refactor(mcp): extract order services into independent project
Remove Kingdee sync, business CLI/MCP tools, and internal order-query routing now owned by the separate kingdee-orders-mcp project. Keep Wiki retrieval, file assets and ingest available in code while its deployed service is off. Retain legacy response fields with business access disabled, and document migration ownership and rollback without exposing credentials or data. Validation: 174 Wiki tests pass. Independent order service verified with 36 template parity checks, authenticated HTTP queries, and a real sync.
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/obsidian-wiki-ops.py" timer "$@"
|
||||
@@ -1,292 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manage the user-level Kingdee business fact sync timer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from obsidian_wiki.config import resolve_vault_path
|
||||
|
||||
|
||||
SERVICE_NAME = "obsidian-wiki-business-sync.service"
|
||||
TIMER_NAME = "obsidian-wiki-business-sync.timer"
|
||||
DEFAULT_CONFIG_RELATIVE_PATH = Path(".runtime") / "kingdee.env"
|
||||
DEFAULT_ON_CALENDAR = "*-*-* 07:10:00"
|
||||
DEFAULT_RANDOMIZED_DELAY_SEC = "15m"
|
||||
DEFAULT_LOOKBACK_HOURS = 48
|
||||
DEFAULT_INITIAL_DAYS = 7
|
||||
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BusinessSyncServicePaths:
|
||||
project_root: Path
|
||||
service_unit_path: Path
|
||||
timer_unit_path: Path
|
||||
config_path: Path
|
||||
|
||||
|
||||
def systemd_quote(value: str | Path) -> str:
|
||||
return shlex.quote(str(value))
|
||||
|
||||
|
||||
def resolve_service_paths(project_root: Path, config_home: Path | None = None) -> BusinessSyncServicePaths:
|
||||
root = project_root.expanduser().resolve()
|
||||
base_config = config_home or Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
unit_dir = base_config.expanduser() / "systemd" / "user"
|
||||
return BusinessSyncServicePaths(
|
||||
project_root=root,
|
||||
service_unit_path=unit_dir / SERVICE_NAME,
|
||||
timer_unit_path=unit_dir / TIMER_NAME,
|
||||
config_path=root / DEFAULT_CONFIG_RELATIVE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def resolve_vault_for_service(project_root: Path, vault_path: Path | str | None = None) -> Path:
|
||||
if vault_path is not None:
|
||||
return Path(vault_path).expanduser().resolve()
|
||||
configured = resolve_vault_path(None, start=project_root)
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
return project_root.expanduser().resolve()
|
||||
|
||||
|
||||
def render_service_unit(
|
||||
*,
|
||||
project_root: Path,
|
||||
vault_path: Path,
|
||||
config_path: Path,
|
||||
python_path: Path | None = None,
|
||||
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
||||
initial_days: int = DEFAULT_INITIAL_DAYS,
|
||||
) -> str:
|
||||
resolved_python = python_path or Path(sys.executable)
|
||||
command = " ".join(
|
||||
[
|
||||
systemd_quote(resolved_python),
|
||||
"-m",
|
||||
"obsidian_wiki",
|
||||
"business",
|
||||
"sync",
|
||||
"kingdee",
|
||||
"--vault",
|
||||
systemd_quote(vault_path),
|
||||
"--config",
|
||||
systemd_quote(config_path),
|
||||
"--incremental",
|
||||
"--lookback-hours",
|
||||
str(lookback_hours),
|
||||
"--initial-days",
|
||||
str(initial_days),
|
||||
]
|
||||
)
|
||||
return (
|
||||
"[Unit]\n"
|
||||
"Description=obsidian-wiki Kingdee business fact sync\n"
|
||||
"After=network-online.target\n"
|
||||
"Wants=network-online.target\n"
|
||||
"\n"
|
||||
"[Service]\n"
|
||||
"Type=oneshot\n"
|
||||
f"WorkingDirectory={systemd_quote(project_root)}\n"
|
||||
f"EnvironmentFile={systemd_quote(config_path)}\n"
|
||||
f"PassEnvironment={' '.join(PROXY_ENV_NAMES)}\n"
|
||||
f"ExecStart={command}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_timer_unit(
|
||||
*,
|
||||
on_calendar: str = DEFAULT_ON_CALENDAR,
|
||||
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
|
||||
) -> str:
|
||||
return (
|
||||
"[Unit]\n"
|
||||
"Description=Run obsidian-wiki Kingdee business sync on a schedule\n"
|
||||
"\n"
|
||||
"[Timer]\n"
|
||||
f"OnCalendar={on_calendar}\n"
|
||||
f"RandomizedDelaySec={randomized_delay_sec}\n"
|
||||
"Persistent=true\n"
|
||||
f"Unit={SERVICE_NAME}\n"
|
||||
"\n"
|
||||
"[Install]\n"
|
||||
"WantedBy=timers.target\n"
|
||||
)
|
||||
|
||||
|
||||
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(["systemctl", "--user", *args], text=True, check=check)
|
||||
|
||||
|
||||
def write_units(
|
||||
paths: BusinessSyncServicePaths,
|
||||
*,
|
||||
vault_path: Path,
|
||||
python_path: Path | None = None,
|
||||
on_calendar: str = DEFAULT_ON_CALENDAR,
|
||||
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
|
||||
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
||||
initial_days: int = DEFAULT_INITIAL_DAYS,
|
||||
) -> None:
|
||||
paths.service_unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
paths.service_unit_path.write_text(
|
||||
render_service_unit(
|
||||
project_root=paths.project_root,
|
||||
vault_path=vault_path,
|
||||
config_path=paths.config_path,
|
||||
python_path=python_path,
|
||||
lookback_hours=lookback_hours,
|
||||
initial_days=initial_days,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
paths.timer_unit_path.write_text(
|
||||
render_timer_unit(on_calendar=on_calendar, randomized_delay_sec=randomized_delay_sec),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def start_timer(
|
||||
*,
|
||||
project_root: Path,
|
||||
vault_path: Path | str | None = None,
|
||||
python_path: Path | None = None,
|
||||
on_calendar: str = DEFAULT_ON_CALENDAR,
|
||||
randomized_delay_sec: str = DEFAULT_RANDOMIZED_DELAY_SEC,
|
||||
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
||||
initial_days: int = DEFAULT_INITIAL_DAYS,
|
||||
) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
if not paths.config_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Missing Kingdee config file: {paths.config_path}. "
|
||||
"Run `scripts/obsidian-wiki-ops.py kingdee config` first."
|
||||
)
|
||||
resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path)
|
||||
write_units(
|
||||
paths,
|
||||
vault_path=resolved_vault,
|
||||
python_path=python_path,
|
||||
on_calendar=on_calendar,
|
||||
randomized_delay_sec=randomized_delay_sec,
|
||||
lookback_hours=lookback_hours,
|
||||
initial_days=initial_days,
|
||||
)
|
||||
run_systemctl(["daemon-reload"])
|
||||
run_systemctl(["enable", "--now", TIMER_NAME])
|
||||
return (
|
||||
"Business sync timer is enabled.\n"
|
||||
f"Service: {paths.service_unit_path}\n"
|
||||
f"Timer: {paths.timer_unit_path}\n"
|
||||
f"Vault: {resolved_vault}\n"
|
||||
f"Config: {paths.config_path}\n"
|
||||
f"Schedule: {on_calendar} randomized by {randomized_delay_sec}\n"
|
||||
)
|
||||
|
||||
|
||||
def run_now() -> None:
|
||||
run_systemctl(["start", SERVICE_NAME])
|
||||
|
||||
|
||||
def stop_timer() -> None:
|
||||
run_systemctl(["disable", "--now", TIMER_NAME], check=False)
|
||||
|
||||
|
||||
def uninstall_timer(project_root: Path) -> None:
|
||||
paths = resolve_service_paths(project_root)
|
||||
stop_timer()
|
||||
if paths.service_unit_path.exists():
|
||||
paths.service_unit_path.unlink()
|
||||
if paths.timer_unit_path.exists():
|
||||
paths.timer_unit_path.unlink()
|
||||
run_systemctl(["daemon-reload"], check=False)
|
||||
run_systemctl(["reset-failed", SERVICE_NAME], check=False)
|
||||
run_systemctl(["reset-failed", TIMER_NAME], check=False)
|
||||
|
||||
|
||||
def timer_status() -> None:
|
||||
run_systemctl(["status", TIMER_NAME], check=False)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Manage the Kingdee business sync user timer.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
start_parser = subparsers.add_parser("start", help="Install or update the user timer.")
|
||||
start_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
start_parser.add_argument("--vault", dest="vault_path", type=Path, help="Vault path to sync into.")
|
||||
start_parser.add_argument("--python", dest="python_path", type=Path, help="Python executable for the systemd service.")
|
||||
start_parser.add_argument("--on-calendar", default=DEFAULT_ON_CALENDAR)
|
||||
start_parser.add_argument("--randomized-delay-sec", default=DEFAULT_RANDOMIZED_DELAY_SEC)
|
||||
start_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS)
|
||||
start_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS)
|
||||
|
||||
run_now_parser = subparsers.add_parser("run-now", help="Run the sync service immediately.")
|
||||
run_now_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
run_now_parser.add_argument("--vault", dest="vault_path", type=Path)
|
||||
run_now_parser.add_argument("--python", dest="python_path", type=Path)
|
||||
run_now_parser.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS)
|
||||
run_now_parser.add_argument("--initial-days", type=int, default=DEFAULT_INITIAL_DAYS)
|
||||
|
||||
subparsers.add_parser("status", help="Show timer status.")
|
||||
subparsers.add_parser("stop", help="Disable and stop the timer.")
|
||||
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Remove managed timer/service unit files.")
|
||||
uninstall_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "start":
|
||||
print(
|
||||
start_timer(
|
||||
project_root=args.project_root,
|
||||
vault_path=args.vault_path,
|
||||
python_path=args.python_path,
|
||||
on_calendar=args.on_calendar,
|
||||
randomized_delay_sec=args.randomized_delay_sec,
|
||||
lookback_hours=args.lookback_hours,
|
||||
initial_days=args.initial_days,
|
||||
),
|
||||
end="",
|
||||
)
|
||||
return 0
|
||||
if args.command == "run-now":
|
||||
paths = resolve_service_paths(args.project_root)
|
||||
if not paths.service_unit_path.exists():
|
||||
start_timer(
|
||||
project_root=args.project_root,
|
||||
vault_path=args.vault_path,
|
||||
python_path=args.python_path,
|
||||
lookback_hours=args.lookback_hours,
|
||||
initial_days=args.initial_days,
|
||||
)
|
||||
run_now()
|
||||
print(f"Started {SERVICE_NAME}.")
|
||||
return 0
|
||||
if args.command == "status":
|
||||
timer_status()
|
||||
return 0
|
||||
if args.command == "stop":
|
||||
stop_timer()
|
||||
print(f"Stopped {TIMER_NAME}.")
|
||||
return 0
|
||||
if args.command == "uninstall":
|
||||
uninstall_timer(args.project_root)
|
||||
print(f"Uninstalled {TIMER_NAME}.")
|
||||
return 0
|
||||
raise AssertionError(f"unhandled command: {args.command}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -53,8 +53,7 @@ DEFAULT_RUNTIME_CLEANUP_DAYS = 30
|
||||
TOOL_FILE_ASSETS = "file-assets"
|
||||
TOOL_INGEST = "ingest"
|
||||
TOOL_QUERY = "query"
|
||||
TOOL_BUSINESS = "business"
|
||||
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY, TOOL_BUSINESS)
|
||||
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY)
|
||||
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
|
||||
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
|
||||
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
|
||||
@@ -72,8 +71,6 @@ def normalize_tool_name(name: str) -> str:
|
||||
"upload": TOOL_INGEST,
|
||||
"uploads": TOOL_INGEST,
|
||||
"opencode-ingest": TOOL_INGEST,
|
||||
"business": TOOL_BUSINESS,
|
||||
"orders": TOOL_BUSINESS,
|
||||
"query": TOOL_QUERY,
|
||||
"wiki-query": TOOL_QUERY,
|
||||
"ask": TOOL_QUERY,
|
||||
@@ -645,18 +642,12 @@ def render_remote_config(
|
||||
"Remote agents should upload with create_ingest_upload_ticket and X-Obsidian-Wiki-Upload-Token.\n"
|
||||
"Bearer upload is for local/admin use. POST multipart/form-data with one or more `files` fields.\n"
|
||||
)
|
||||
business_section = ""
|
||||
if enabled_tools is not None and TOOL_BUSINESS in normalize_enabled_tools(enabled_tools):
|
||||
business_section = (
|
||||
"\nBusiness query tools (direct SQLite, no OpenCode):\n"
|
||||
"Use get_business_schema, query_business_template, execute_business_sql, and get_business_status.\n"
|
||||
)
|
||||
query_section = ""
|
||||
if enabled_tools is not None and TOOL_QUERY in normalize_enabled_tools(enabled_tools):
|
||||
query_section = (
|
||||
"\n"
|
||||
"Wiki query tools:\n"
|
||||
"Use ask_wiki for ordinary wiki, business facts, or hybrid questions.\n"
|
||||
"Use ask_wiki for wiki questions.\n"
|
||||
"For long-running jobs, use start_wiki_query, get_wiki_query_status, "
|
||||
"get_wiki_query_result, cancel_wiki_query, and check_query_opencode_readiness.\n"
|
||||
)
|
||||
@@ -670,7 +661,6 @@ def render_remote_config(
|
||||
f"{enabled_section}"
|
||||
f"{ingest_upload_section}"
|
||||
f"{query_section}"
|
||||
f"{business_section}"
|
||||
"\n"
|
||||
"Use this header:\n"
|
||||
f"Authorization: Bearer {token}\n"
|
||||
@@ -1278,7 +1268,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
tools_disable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
|
||||
tools_disable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
tools_set_parser = tools_subparsers.add_parser("set", help="Replace enabled groups and restart once; e.g. tools set business.")
|
||||
tools_set_parser = tools_subparsers.add_parser("set", help="Replace enabled groups and restart once; e.g. tools set file-assets.")
|
||||
tools_set_parser.add_argument("tools", nargs="+", choices=AVAILABLE_TOOL_GROUPS)
|
||||
tools_set_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ for path in (REPO_ROOT, SCRIPT_DIR):
|
||||
|
||||
from obsidian_wiki.cli import main as obsidian_cli_main
|
||||
|
||||
import manage_business_sync
|
||||
import manage_mcp_service
|
||||
|
||||
|
||||
@@ -31,18 +30,6 @@ Usage:
|
||||
scripts/obsidian-wiki-ops.py info
|
||||
scripts/obsidian-wiki-ops.py file-index rebuild [args...]
|
||||
|
||||
scripts/obsidian-wiki-ops.py kingdee config [args...]
|
||||
scripts/obsidian-wiki-ops.py kingdee auth-test [args...]
|
||||
scripts/obsidian-wiki-ops.py kingdee sync [args...]
|
||||
scripts/obsidian-wiki-ops.py kingdee schema --json
|
||||
scripts/obsidian-wiki-ops.py kingdee template order_count --vault /path/to/vault --year 2026 --month 6 --json
|
||||
scripts/obsidian-wiki-ops.py kingdee sql "SELECT COUNT(*) FROM orders" [args...]
|
||||
|
||||
scripts/obsidian-wiki-ops.py timer install [args...]
|
||||
scripts/obsidian-wiki-ops.py timer run-now [args...]
|
||||
scripts/obsidian-wiki-ops.py timer status
|
||||
scripts/obsidian-wiki-ops.py timer stop
|
||||
scripts/obsidian-wiki-ops.py timer uninstall [args...]
|
||||
|
||||
scripts/obsidian-wiki-ops.py mcp start [args...]
|
||||
scripts/obsidian-wiki-ops.py mcp tools list
|
||||
@@ -53,7 +40,6 @@ Usage:
|
||||
scripts/obsidian-wiki-ops.py daily-update [args...]
|
||||
|
||||
Notes:
|
||||
timer install is an alias for installing and starting the Kingdee business sync timer.
|
||||
Use --help after any forwarded command to see that command's detailed options.
|
||||
"""
|
||||
|
||||
@@ -63,7 +49,6 @@ CLI_COMMANDS = {
|
||||
"list",
|
||||
"info",
|
||||
"file-index",
|
||||
"business",
|
||||
"mcp-server",
|
||||
"bot-view",
|
||||
}
|
||||
@@ -80,49 +65,6 @@ def run_script(script_name: str, args: Sequence[str]) -> int:
|
||||
return int(completed.returncode)
|
||||
|
||||
|
||||
def route_kingdee(args: Sequence[str]) -> int:
|
||||
if not args:
|
||||
return print_help()
|
||||
action, *rest = args
|
||||
if action == "config":
|
||||
return obsidian_cli_main(["business", "config", "kingdee", *rest])
|
||||
if action == "auth-test":
|
||||
return obsidian_cli_main(["business", "sync", "kingdee", "--dry-run-auth", *rest])
|
||||
if action == "sync":
|
||||
return obsidian_cli_main(["business", "sync", "kingdee", *rest])
|
||||
if action == "schema":
|
||||
return obsidian_cli_main(["business", "schema", *rest])
|
||||
if action == "template":
|
||||
return obsidian_cli_main(["business", "template", *rest])
|
||||
if action == "query":
|
||||
return obsidian_cli_main(["business", "query", *rest])
|
||||
if action == "sql":
|
||||
return obsidian_cli_main(["business", "sql", *rest])
|
||||
print(f"Unknown kingdee action: {action}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def route_timer(args: Sequence[str]) -> int:
|
||||
if not args:
|
||||
return print_help()
|
||||
action, *rest = args
|
||||
action_aliases = {
|
||||
"install": "start",
|
||||
"start": "start",
|
||||
"run": "run-now",
|
||||
"now": "run-now",
|
||||
"run-now": "run-now",
|
||||
"status": "status",
|
||||
"stop": "stop",
|
||||
"uninstall": "uninstall",
|
||||
}
|
||||
mapped = action_aliases.get(action)
|
||||
if mapped is None:
|
||||
print(f"Unknown timer action: {action}", file=sys.stderr)
|
||||
return 2
|
||||
return manage_business_sync.main([mapped, *rest])
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args or args[0] in {"help", "-h", "--help"}:
|
||||
@@ -131,10 +73,6 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
command, *rest = args
|
||||
if command in CLI_COMMANDS:
|
||||
return obsidian_cli_main([command, *rest])
|
||||
if command == "kingdee":
|
||||
return route_kingdee(rest)
|
||||
if command == "timer":
|
||||
return route_timer(rest)
|
||||
if command == "mcp":
|
||||
return manage_mcp_service.main(rest)
|
||||
if command == "daily-update":
|
||||
|
||||
Reference in New Issue
Block a user