From 9d6c4a2445d87af6a00a38c3e81487ae1b550a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 21:40:00 +0800 Subject: [PATCH 1/2] feat: record local script invocations --- scripts/invocation_observation.py | 191 +++++++++++++++++++++ scripts/keep-summarizing/dispatcher.py | 8 + scripts/ks.py | 18 +- scripts/orch.py | 18 +- scripts/orchestration/dispatcher.py | 9 + scripts/wb.py | 18 +- scripts/work-bundle/dispatcher.py | 77 ++++++--- tests/conftest.py | 9 + tests/test_invocation_observation.py | 221 +++++++++++++++++++++++++ 9 files changed, 534 insertions(+), 35 deletions(-) create mode 100644 scripts/invocation_observation.py create mode 100644 tests/conftest.py create mode 100644 tests/test_invocation_observation.py diff --git a/scripts/invocation_observation.py b/scripts/invocation_observation.py new file mode 100644 index 0000000..5d92442 --- /dev/null +++ b/scripts/invocation_observation.py @@ -0,0 +1,191 @@ +"""Best-effort, privacy-safe observation for public WorkBundle script invocations.""" + +from __future__ import annotations + +import os +import sqlite3 +import time +from collections.abc import Callable, Collection, Sequence +from contextlib import closing +from datetime import UTC, datetime +from pathlib import Path +from typing import TypeVar + + +DISABLE_ENV = "WORK_BUNDLE_INVOCATION_LOG" +CONFIG_ROOT_ENV = "WB_CONFIG_ROOT" +NO_COMMAND = "__no_command__" +UNKNOWN_COMMAND = "__unknown__" +SCHEMA_VERSION = 1 +_T = TypeVar("_T") + + +def extract_command( + surface: str, + argv: Sequence[str], + recognized_commands: Collection[str], +) -> str: + """Project argv to an allowlisted command without retaining arbitrary values.""" + if surface != "orch": + if not argv or argv[0] in {"-h", "--help"}: + return NO_COMMAND + return argv[0] if argv[0] in recognized_commands else UNKNOWN_COMMAND + + index = 0 + while index < len(argv): + token = argv[index] + if token in {"-h", "--help"}: + return NO_COMMAND + if token == "--project-root": + if index + 1 >= len(argv): + return NO_COMMAND + index += 2 + continue + if token.startswith("--project-root="): + index += 1 + continue + if token.startswith("-"): + return UNKNOWN_COMMAND + return token if token in recognized_commands else UNKNOWN_COMMAND + return NO_COMMAND + + +def _database_path() -> Path: + configured = os.environ.get(CONFIG_ROOT_ENV) + root = Path(configured).expanduser() if configured else Path.home() / ".work-bundle" + return root / "usage" / "invocations.sqlite3" + + +def _protect(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except OSError: + pass + + +def _connect(database: Path) -> sqlite3.Connection: + database.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + _protect(database.parent, 0o700) + connection = sqlite3.connect(database, timeout=0.05) + connection.execute("PRAGMA busy_timeout = 50") + connection.execute("PRAGMA journal_mode = WAL") + version = int(connection.execute("PRAGMA user_version").fetchone()[0]) + if version not in {0, SCHEMA_VERSION}: + raise sqlite3.DatabaseError(f"unsupported invocation schema version: {version}") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS invocation ( + id INTEGER PRIMARY KEY, + started_at_utc TEXT NOT NULL, + surface TEXT NOT NULL, + command TEXT NOT NULL, + state TEXT NOT NULL, + exit_code INTEGER, + duration_ms INTEGER + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS invocation_surface_command_started + ON invocation(surface, command, started_at_utc) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS invocation_state ON invocation(state)" + ) + if version == 0: + connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + connection.commit() + _protect(database, 0o600) + for suffix in ("-wal", "-shm"): + sidecar = Path(f"{database}{suffix}") + if sidecar.exists(): + _protect(sidecar, 0o600) + return connection + + +def _begin(surface: str, command: str) -> tuple[Path, int] | None: + if os.environ.get(DISABLE_ENV) == "0": + return None + database = _database_path() + try: + with closing(_connect(database)) as connection: + with connection: + cursor = connection.execute( + """ + INSERT INTO invocation(started_at_utc, surface, command, state) + VALUES (?, ?, ?, ?) + """, + (datetime.now(UTC).isoformat(), surface, command, "started"), + ) + row_id = int(cursor.lastrowid) + return database, row_id + except (OSError, sqlite3.Error): + return None + + +def _finish( + token: tuple[Path, int] | None, + *, + state: str, + exit_code: int, + started: float, +) -> None: + if token is None: + return + database, row_id = token + duration_ms = max(0, round((time.monotonic() - started) * 1000)) + try: + with closing(_connect(database)) as connection: + with connection: + connection.execute( + """ + UPDATE invocation + SET state = ?, exit_code = ?, duration_ms = ? + WHERE id = ? + """, + (state, exit_code, duration_ms, row_id), + ) + except (OSError, sqlite3.Error): + pass + + +def _system_exit_code(code: object) -> int: + if code is None: + return 0 + return code if isinstance(code, int) else 1 + + +def invoke_observed( + surface: str, + argv: Sequence[str], + recognized_commands: Collection[str], + dispatch: Callable[[], _T], +) -> _T: + """Invoke dispatch while recording a lower-bound local lifecycle row.""" + command = extract_command(surface, argv, recognized_commands) + started = time.monotonic() + token = _begin(surface, command) + try: + result = dispatch() + except SystemExit as exc: + exit_code = _system_exit_code(exc.code) + _finish( + token, + state="completed" if exit_code == 0 else "failed", + exit_code=exit_code, + started=started, + ) + raise + except BaseException: + _finish(token, state="failed", exit_code=1, started=started) + raise + exit_code = result if isinstance(result, int) else 0 + _finish( + token, + state="completed" if exit_code == 0 else "failed", + exit_code=exit_code, + started=started, + ) + return result diff --git a/scripts/keep-summarizing/dispatcher.py b/scripts/keep-summarizing/dispatcher.py index bcc60ad..e83b4f8 100644 --- a/scripts/keep-summarizing/dispatcher.py +++ b/scripts/keep-summarizing/dispatcher.py @@ -14,6 +14,14 @@ from questions import cmd_add_question, cmd_list_questions, cmd_match_questions, cmd_resolve_question from registry import cmd_list_projects, cmd_register_project, cmd_registry_doctor, cmd_unregister_project +RECOGNIZED_COMMANDS = frozenset({ + "init", "resolve", "write-note", "index", "query", "index-open-questions", + "git", "doctor", "output", "breakdown-design", "add-question", + "list-questions", "match-questions", "resolve-question", "migrate-legacy", + "migrate-v3", "register-project", "unregister-project", "list-projects", + "registry-doctor", +}) + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() diff --git a/scripts/ks.py b/scripts/ks.py index b467974..d9d3dd8 100755 --- a/scripts/ks.py +++ b/scripts/ks.py @@ -20,6 +20,10 @@ from importlib import metadata as importlib_metadata from pathlib import Path +SCRIPT_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_ROOT)) +from invocation_observation import invoke_observed + RUNTIME_DEPENDENCIES = ( ("yaml", "pyyaml"), ("sqlite_vec", "sqlite-vec"), @@ -107,15 +111,15 @@ def _ensure_managed_runtime( raise RuntimeError("uv runtime re-exec returned unexpectedly") -def _load_main(): - module_path = Path(__file__).resolve().parent / "keep-summarizing" / "dispatcher.py" +def _load_dispatcher(): + module_path = SCRIPT_ROOT / "keep-summarizing" / "dispatcher.py" sys.path.insert(0, str(module_path.parent)) spec = importlib.util.spec_from_file_location("keep_summarizing_dispatcher", module_path) if spec is None or spec.loader is None: raise RuntimeError(f"Unable to load keep-summarizing CLI: {module_path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return module.main + return module def main() -> int: @@ -123,7 +127,13 @@ def main() -> int: if not ready: print(error, file=sys.stderr) return 2 - return int(_load_main()()) + dispatcher = _load_dispatcher() + return invoke_observed( + "ks", + sys.argv[1:], + dispatcher.RECOGNIZED_COMMANDS, + lambda: int(dispatcher.main()), + ) if __name__ == "__main__": diff --git a/scripts/orch.py b/scripts/orch.py index e96db71..7a84a32 100755 --- a/scripts/orch.py +++ b/scripts/orch.py @@ -7,20 +7,30 @@ import sys from pathlib import Path +SCRIPT_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_ROOT)) +from invocation_observation import invoke_observed -def _load_main(): - module_path = Path(__file__).resolve().parent / "orchestration" / "dispatcher.py" + +def _load_dispatcher(): + module_path = SCRIPT_ROOT / "orchestration" / "dispatcher.py" sys.path.insert(0, str(module_path.parent)) spec = importlib.util.spec_from_file_location("orchestration_dispatcher", module_path) if spec is None or spec.loader is None: raise RuntimeError(f"Unable to load orchestration CLI: {module_path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return module.main + return module def main() -> int: - return int(_load_main()()) + dispatcher = _load_dispatcher() + return invoke_observed( + "orch", + sys.argv[1:], + dispatcher.RECOGNIZED_COMMANDS, + lambda: int(dispatcher.main()), + ) if __name__ == "__main__": diff --git a/scripts/orchestration/dispatcher.py b/scripts/orchestration/dispatcher.py index 66ef208..3c00efa 100644 --- a/scripts/orchestration/dispatcher.py +++ b/scripts/orchestration/dispatcher.py @@ -13,6 +13,15 @@ from repository_preflight import cmd_repository_preflight from specs import cmd_index_specs, cmd_list_specs, cmd_set_spec_status, cmd_write_spec +RECOGNIZED_COMMANDS = frozenset({ + "init", "doctor", "state", "next-action-candidates", "git-status", + "repository-preflight", "build-task-brief", "build-review-package", + "validate-executor-result", "related", "write-doc", "write-spec", + "list-specs", "set-spec-status", "index-specs", "write-plan", "list-plans", + "set-plan-status", "archive-plan", "index-plans", "write-phase", "write-task", + "write-handoff", "list-handoffs", "set-handoff-status", "index-handoffs", +}) + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() diff --git a/scripts/wb.py b/scripts/wb.py index 800d8c0..f5d38fc 100755 --- a/scripts/wb.py +++ b/scripts/wb.py @@ -7,20 +7,30 @@ import sys from pathlib import Path +SCRIPT_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_ROOT)) +from invocation_observation import invoke_observed -def _load_main(): - module_path = Path(__file__).resolve().parent / "work-bundle" / "dispatcher.py" + +def _load_dispatcher(): + module_path = SCRIPT_ROOT / "work-bundle" / "dispatcher.py" sys.path.insert(0, str(module_path.parent)) spec = importlib.util.spec_from_file_location("work_bundle_dispatcher", module_path) if spec is None or spec.loader is None: raise RuntimeError(f"Unable to load work-bundle CLI: {module_path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return module.main + return module def main() -> int: - return int(_load_main()()) + dispatcher = _load_dispatcher() + return invoke_observed( + "wb", + sys.argv[1:], + dispatcher.RECOGNIZED_COMMANDS, + lambda: int(dispatcher.main()), + ) if __name__ == "__main__": diff --git a/scripts/work-bundle/dispatcher.py b/scripts/work-bundle/dispatcher.py index 4caa073..972b97b 100644 --- a/scripts/work-bundle/dispatcher.py +++ b/scripts/work-bundle/dispatcher.py @@ -33,6 +33,55 @@ ) from registry_layout import cmd_migrate_registered_projects +LEGACY_DEFECT_COMMANDS = { + 'violation-ensure-store': 'defect-ensure-store', + 'violation-create-evidence': 'defect-create-evidence', + 'violation-build-index': 'defect-build-index', + 'violation-write-index': 'defect-write-index', + 'violation-archive-evidence': 'defect-archive-evidence', +} +COMMAND_ALIASES = { + 'apply-project-initialization': 'init-project', + 'apply-repository-model': 'initialize-project', + 'extract-domain-profile': 'generate-project-metadata-profile', + 'merge-registry-entry': 'register-skill', + 'validate-project-initialization': 'validate-project', + 'validate-runtime-artifacts': 'doctor', + 'validate-repository-health': 'repository-health', + 'validate-workflow-branches': 'workflow-branches', +} +EXECUTION_WORKSPACE_COMMANDS = frozenset({ + 'execution-workspace-prepare', + 'execution-workspace-status', + 'execution-workspace-mark-terminal', + 'execution-workspace-cleanup-owned', + 'execution-workspace-doctor-stale', +}) +LIVE_COMMANDS = frozenset({ + 'migrate-work-bundle-config', 'init-project', 'initialize-project', + 'register-project', 'show-project', 'migrate-project', + 'migrate-control-plane', 'migrate-registered-projects', 'init-workspace', + 'publish-control-plane', 'attach-workspace', 'doctor-workspace', + 'add-workspace-member', 'detach-workspace', 'migrate-to-multi-repository', + 'doctor-project', 'provision-member', 'cleanup-member', 'credential-list', + 'instruction-audit', 'session-start', 'inspect-project-initialization', + 'validate-project', 'set-prefer-subagent', 'create-rules', 'validate-rules', + 'defect-ensure-store', 'defect-create-evidence', 'defect-build-index', + 'defect-write-index', 'defect-archive-evidence', 'defect-migrate-store', + 'doctor', 'repository-health', 'validate-directive-wiring', + 'validate-skill-registry', 'validate-work-bundle-rules', + 'render-doctor-report', 'workflow-branches', + 'generate-project-metadata-profile', 'merge-project-metadata-profile', + 'validate-project-metadata-profile', 'inspect-skill', + 'validate-registry-entry', 'register-skill', 'merge-skill-hints', +}) | EXECUTION_WORKSPACE_COMMANDS +RECOGNIZED_COMMANDS = frozenset( + LIVE_COMMANDS + | COMMAND_ALIASES.keys() + | LEGACY_DEFECT_COMMANDS.keys() + | LEGACY_COMMAND_MIGRATIONS.keys() +) + def main() -> int: parser = argparse.ArgumentParser( @@ -45,28 +94,11 @@ def main() -> int: parser.add_argument('args', nargs=argparse.REMAINDER) parsed = parser.parse_args() command = parsed.command - legacy_defect_commands = { - 'violation-ensure-store': 'defect-ensure-store', - 'violation-create-evidence': 'defect-create-evidence', - 'violation-build-index': 'defect-build-index', - 'violation-write-index': 'defect-write-index', - 'violation-archive-evidence': 'defect-archive-evidence', - } - if command in legacy_defect_commands: - return cmd_legacy_command_removed(command, legacy_defect_commands[command]) + if command in LEGACY_DEFECT_COMMANDS: + return cmd_legacy_command_removed(command, LEGACY_DEFECT_COMMANDS[command]) if command in LEGACY_COMMAND_MIGRATIONS: return cmd_legacy_command_removed(command, LEGACY_COMMAND_MIGRATIONS[command]) - aliases = { - 'apply-project-initialization': 'init-project', - 'apply-repository-model': 'initialize-project', - 'extract-domain-profile': 'generate-project-metadata-profile', - 'merge-registry-entry': 'register-skill', - 'validate-project-initialization': 'validate-project', - 'validate-runtime-artifacts': 'doctor', - 'validate-repository-health': 'repository-health', - 'validate-workflow-branches': 'workflow-branches', - } - command = aliases.get(command, command) + command = COMMAND_ALIASES.get(command, command) if command == 'migrate-work-bundle-config': return cmd_migrate_work_bundle_config(parsed.args) if command in {'init-project', 'initialize-project'}: @@ -111,10 +143,9 @@ def main() -> int: except CredentialError as exc: out({'status': 'blocked', 'failure_code': str(exc)}) return 1 - if command.startswith('execution-workspace-'): + if command in EXECUTION_WORKSPACE_COMMANDS: action = command.removeprefix('execution-workspace-') - if action in {'prepare', 'status', 'mark-terminal', 'cleanup-owned', 'doctor-stale'}: - return cmd_execution_workspace(action, parsed.args) + return cmd_execution_workspace(action, parsed.args) if command == 'instruction-audit': return cmd_instruction_audit(parsed.args) if command == 'session-start': diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4767d74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def disable_invocation_observation(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the test suite out of the user's real WorkBundle usage database.""" + monkeypatch.setenv("WORK_BUNDLE_INVOCATION_LOG", "0") diff --git a/tests/test_invocation_observation.py b/tests/test_invocation_observation.py new file mode 100644 index 0000000..496da50 --- /dev/null +++ b/tests/test_invocation_observation.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import importlib.util +import ast +import os +import sqlite3 +import stat +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def load_observation(): + return load_module("invocation_observation_test", SCRIPTS / "invocation_observation.py") + + +def rows(config_root: Path) -> list[tuple[object, ...]]: + database = config_root / "usage" / "invocations.sqlite3" + with sqlite3.connect(database) as connection: + return connection.execute( + "SELECT surface, command, state, exit_code, duration_ms FROM invocation ORDER BY id" + ).fetchall() + + +@pytest.fixture +def enabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + monkeypatch.setenv("WORK_BUNDLE_INVOCATION_LOG", "1") + monkeypatch.setenv("WB_CONFIG_ROOT", str(tmp_path)) + return tmp_path + + +def test_surface_aware_command_extraction_never_persists_option_values() -> None: + observation = load_observation() + recognized = {"doctor", "state"} + + assert observation.extract_command("wb", ["doctor"], recognized) == "doctor" + assert observation.extract_command("ks", [], recognized) == "__no_command__" + assert observation.extract_command("wb", ["--invented", "/secret"], recognized) == "__unknown__" + assert observation.extract_command( + "orch", ["--project-root", "/private/path", "doctor"], recognized + ) == "doctor" + assert observation.extract_command( + "orch", ["--project-root=/private/path", "doctor"], recognized + ) == "doctor" + assert observation.extract_command("orch", ["--help", "doctor"], recognized) == "__no_command__" + assert observation.extract_command("orch", ["--project", "/secret", "doctor"], recognized) == "__unknown__" + + +def test_success_creates_v1_schema_and_privacy_safe_row(enabled: Path) -> None: + observation = load_observation() + secret = "/private/a-secret-path" + + result = observation.invoke_observed("orch", ["doctor", secret], {"doctor"}, lambda: 0) + + assert result == 0 + assert rows(enabled) == [("orch", "doctor", "completed", 0, rows(enabled)[0][4])] + assert isinstance(rows(enabled)[0][4], int) and rows(enabled)[0][4] >= 0 + database = enabled / "usage" / "invocations.sqlite3" + with sqlite3.connect(database) as connection: + assert connection.execute("PRAGMA user_version").fetchone() == (1,) + assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal" + columns = [item[1] for item in connection.execute("PRAGMA table_info(invocation)")] + assert columns == ["id", "started_at_utc", "surface", "command", "state", "exit_code", "duration_ms"] + indexes = {item[1] for item in connection.execute("PRAGMA index_list(invocation)")} + assert {"invocation_surface_command_started", "invocation_state"} <= indexes + assert secret.encode() not in database.read_bytes() + if os.name != "nt": + assert stat.S_IMODE((enabled / "usage").stat().st_mode) == 0o700 + assert stat.S_IMODE(database.stat().st_mode) == 0o600 + + +@pytest.mark.parametrize( + ("code", "state", "stored_code"), + [(None, "completed", 0), (0, "completed", 0), (2, "failed", 2), ("bad", "failed", 1)], +) +def test_system_exit_is_classified_and_reraised_literally( + enabled: Path, code: object, state: str, stored_code: int +) -> None: + observation = load_observation() + original = SystemExit(code) + + def dispatch() -> int: + raise original + + with pytest.raises(SystemExit) as caught: + observation.invoke_observed("wb", ["doctor"], {"doctor"}, dispatch) + + assert caught.value is original + assert rows(enabled)[0][2:4] == (state, stored_code) + + +@pytest.mark.parametrize("error", [RuntimeError("boom"), KeyboardInterrupt()]) +def test_base_exceptions_fail_and_retain_identity(enabled: Path, error: BaseException) -> None: + observation = load_observation() + + def dispatch() -> int: + raise error + + with pytest.raises(BaseException) as caught: + observation.invoke_observed("ks", ["doctor"], {"doctor"}, dispatch) + + assert caught.value is error + assert rows(enabled)[0][2:4] == ("failed", 1) + + +def test_nonzero_return_is_failed(enabled: Path) -> None: + observation = load_observation() + assert observation.invoke_observed("wb", ["doctor"], {"doctor"}, lambda: 3) == 3 + assert rows(enabled)[0][2:4] == ("failed", 3) + + +def test_exact_zero_is_only_disable_value(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + observation = load_observation() + monkeypatch.setenv("WB_CONFIG_ROOT", str(tmp_path)) + monkeypatch.setenv("WORK_BUNDLE_INVOCATION_LOG", "0") + assert observation.invoke_observed("wb", ["doctor"], {"doctor"}, lambda: 0) == 0 + assert not (tmp_path / "usage").exists() + + monkeypatch.setenv("WORK_BUNDLE_INVOCATION_LOG", "false") + assert observation.invoke_observed("wb", ["doctor"], {"doctor"}, lambda: 0) == 0 + assert rows(tmp_path)[0][1] == "doctor" + + +def test_database_failure_is_silent_and_does_not_change_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + observation = load_observation() + blocked_root = tmp_path / "not-a-directory" + blocked_root.write_text("occupied", encoding="utf-8") + monkeypatch.setenv("WORK_BUNDLE_INVOCATION_LOG", "1") + monkeypatch.setenv("WB_CONFIG_ROOT", str(blocked_root)) + + assert observation.invoke_observed("wb", ["doctor"], {"doctor"}, lambda: 7) == 7 + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize(("filename", "surface"), [("wb.py", "wb"), ("orch.py", "orch")]) +def test_public_wrappers_observe_once( + enabled: Path, monkeypatch: pytest.MonkeyPatch, filename: str, surface: str +) -> None: + module = load_module(f"{surface}_wrapper_test", SCRIPTS / filename) + dispatcher = SimpleNamespace(main=lambda: 0, RECOGNIZED_COMMANDS=frozenset({"doctor"})) + monkeypatch.setattr(module, "_load_dispatcher", lambda: dispatcher) + monkeypatch.setattr(sys, "argv", [filename, "doctor"]) + + assert module.main() == 0 + assert rows(enabled) == [(surface, "doctor", "completed", 0, rows(enabled)[0][4])] + + +def test_ks_logs_only_after_runtime_readiness(enabled: Path, monkeypatch: pytest.MonkeyPatch) -> None: + module = load_module("ks_wrapper_test", SCRIPTS / "ks.py") + dispatcher = SimpleNamespace(main=lambda: 0, RECOGNIZED_COMMANDS=frozenset({"doctor"})) + monkeypatch.setattr(module, "_load_dispatcher", lambda: dispatcher) + monkeypatch.setattr(sys, "argv", ["ks.py", "doctor"]) + monkeypatch.setattr(module, "_ensure_managed_runtime", lambda: (False, "not ready")) + + assert module.main() == 2 + assert not (enabled / "usage").exists() + + monkeypatch.setattr(module, "_ensure_managed_runtime", lambda: (True, None)) + assert module.main() == 0 + assert len(rows(enabled)) == 1 + + +def test_recognized_vocabulary_is_exact() -> None: + wb = (SCRIPTS / "work-bundle" / "dispatcher.py").read_text(encoding="utf-8") + for command in ( + "execution-workspace-prepare", + "execution-workspace-status", + "execution-workspace-mark-terminal", + "execution-workspace-cleanup-owned", + "execution-workspace-doctor-stale", + ): + assert repr(command) in wb + assert "violation-list" not in wb + + +@pytest.mark.parametrize( + "path", + [SCRIPTS / "keep-summarizing" / "dispatcher.py", SCRIPTS / "orchestration" / "dispatcher.py"], +) +def test_subparser_vocabulary_matches_exported_recognized_set(path: Path) -> None: + tree = ast.parse(path.read_text(encoding="utf-8")) + routed = { + call.args[0].value + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "add_parser" + and call.args + and isinstance(call.args[0], ast.Constant) + and isinstance(call.args[0].value, str) + } + exported: set[str] | None = None + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(target, ast.Name) and target.id == "RECOGNIZED_COMMANDS" for target in node.targets): + continue + assert isinstance(node.value, ast.Call) and node.value.args + assert isinstance(node.value.args[0], ast.Set) + exported = { + item.value + for item in node.value.args[0].elts + if isinstance(item, ast.Constant) and isinstance(item.value, str) + } + assert exported == routed From 46a093cae107ca3b31d70d25e00ffe586f6d2f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Sun, 30 Aug 2026 21:57:19 +0800 Subject: [PATCH 2/2] fix: preserve telemetry failure isolation --- scripts/invocation_observation.py | 9 +++++++-- tests/test_invocation_observation.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/invocation_observation.py b/scripts/invocation_observation.py index 5d92442..85f70ac 100644 --- a/scripts/invocation_observation.py +++ b/scripts/invocation_observation.py @@ -39,6 +39,11 @@ def extract_command( if token == "--project-root": if index + 1 >= len(argv): return NO_COMMAND + value = argv[index + 1] + if value in {"-h", "--help"}: + return NO_COMMAND + if value.startswith("-"): + return UNKNOWN_COMMAND index += 2 continue if token.startswith("--project-root="): @@ -108,8 +113,8 @@ def _connect(database: Path) -> sqlite3.Connection: def _begin(surface: str, command: str) -> tuple[Path, int] | None: if os.environ.get(DISABLE_ENV) == "0": return None - database = _database_path() try: + database = _database_path() with closing(_connect(database)) as connection: with connection: cursor = connection.execute( @@ -121,7 +126,7 @@ def _begin(surface: str, command: str) -> tuple[Path, int] | None: ) row_id = int(cursor.lastrowid) return database, row_id - except (OSError, sqlite3.Error): + except (OSError, RuntimeError, sqlite3.Error): return None diff --git a/tests/test_invocation_observation.py b/tests/test_invocation_observation.py index 496da50..67b6f63 100644 --- a/tests/test_invocation_observation.py +++ b/tests/test_invocation_observation.py @@ -57,6 +57,12 @@ def test_surface_aware_command_extraction_never_persists_option_values() -> None "orch", ["--project-root=/private/path", "doctor"], recognized ) == "doctor" assert observation.extract_command("orch", ["--help", "doctor"], recognized) == "__no_command__" + assert observation.extract_command( + "orch", ["--project-root", "--help", "doctor"], recognized + ) == "__no_command__" + assert observation.extract_command( + "orch", ["--project-root", "--unknown", "doctor"], recognized + ) == "__unknown__" assert observation.extract_command("orch", ["--project", "/secret", "doctor"], recognized) == "__unknown__" @@ -148,6 +154,17 @@ def test_database_failure_is_silent_and_does_not_change_dispatch( assert capsys.readouterr() == ("", "") +def test_config_root_resolution_failure_is_silent_and_does_not_change_dispatch( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + observation = load_observation() + monkeypatch.setenv("WORK_BUNDLE_INVOCATION_LOG", "1") + monkeypatch.setenv("WB_CONFIG_ROOT", "~workbundle-user-that-does-not-exist/usage") + + assert observation.invoke_observed("wb", ["doctor"], {"doctor"}, lambda: 7) == 7 + assert capsys.readouterr() == ("", "") + + @pytest.mark.parametrize(("filename", "surface"), [("wb.py", "wb"), ("orch.py", "orch")]) def test_public_wrappers_observe_once( enabled: Path, monkeypatch: pytest.MonkeyPatch, filename: str, surface: str