From 8a27ff782634da3227cf0a492e593d27b37aa26a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Fri, 28 Aug 2026 16:47:15 +0800 Subject: [PATCH 1/5] fix(toolkit): harden reliability and CI hygiene --- .github/workflows/ci.yml | 14 +-- scripts/keep-summarizing/indexes.py | 26 +++++ scripts/orchestration/repository_preflight.py | 72 +++++++++--- scripts/work-bundle/control_plane.py | 11 ++ scripts/work-bundle/migration.py | 49 ++++++++- scripts/work-bundle/project.py | 11 +- tests/test_control_plane_v4.py | 30 ++++- tests/test_keep_summarizing_query.py | 36 ++++++ ...test_orchestration_repository_preflight.py | 103 ++++++++++++++++++ tests/test_workspace_migration.py | 52 +++++++++ 10 files changed, 374 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b60332..0ef7bf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,27 +23,19 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v9 with: python-version: "3.13" + enable-cache: false - name: Run full tests - if: runner.os == 'Linux' run: >- uvx --python 3.13 --from pytest==9.1.1 --with pyyaml==6.0.3 --with sqlite-vec==0.1.9 --with fastembed==0.8.0 pytest -q - - name: Run macOS portable tests - if: runner.os == 'macOS' - run: >- - uvx --python 3.13 --from pytest==9.1.1 - --with pyyaml==6.0.3 --with sqlite-vec==0.1.9 - --with fastembed==0.8.0 - pytest -q -k "not test_production_index_rebuild_keeps_proposed_notes_in_vector_discovery" - - name: Validate skill packages run: bin/work-bundle-skill validate diff --git a/scripts/keep-summarizing/indexes.py b/scripts/keep-summarizing/indexes.py index 9f03686..77236a3 100644 --- a/scripts/keep-summarizing/indexes.py +++ b/scripts/keep-summarizing/indexes.py @@ -21,6 +21,32 @@ def install_sqlite_vec() -> tuple[object | None, str | None]: return None, f"sqlite-vec unavailable in the uv-managed environment: {exc}" +def sqlite_vec_availability_probe() -> dict[str, object]: + """Probe import and extension loading independently from a production rebuild.""" + sqlite_vec, _ = install_sqlite_vec() + if sqlite_vec is None: + return { + "status": "unavailable", + "reason": "sqlite-vec probe unavailable: import failed", + } + conn = sqlite3.connect(":memory:") + try: + conn.enable_load_extension(True) + sqlite_vec.load(conn) + return {"status": "available", "reason": None} + except Exception: + return { + "status": "unavailable", + "reason": "sqlite-vec probe unavailable: temporary load failed", + } + finally: + try: + conn.enable_load_extension(False) + except Exception: + pass + conn.close() + + def load_sqlite_vec(conn: sqlite3.Connection) -> tuple[object | None, str | None]: sqlite_vec, error = install_sqlite_vec() if sqlite_vec is None: diff --git a/scripts/orchestration/repository_preflight.py b/scripts/orchestration/repository_preflight.py index 37e31d8..8915d35 100644 --- a/scripts/orchestration/repository_preflight.py +++ b/scripts/orchestration/repository_preflight.py @@ -171,19 +171,20 @@ def _v4_metadata_repository_entries(root: Path, text: str) -> list[dict[str, obj resolved: list[dict[str, object]] = [] for entry in entries: repository_id = str(entry.get("id") or "") - binding = local.get(repository_id) - if not binding or not binding.get("project_root"): - continue + binding = local.get(repository_id) or {} entry.update(binding) - entry["project_root"] = binding["project_root"] - entry["path"] = binding["project_root"] entry["git_repository"] = True - entry["expected_branch"] = entry.get("default_branch") or binding.get("observed_branch") or "" - # A device observation is current evidence, never the expected task baseline. - entry["observed_head"] = "" + entry["expected_branch"] = entry.get("default_branch") or "" + entry["observed_branch"] = binding.get("observed_branch") or "" + entry["observed_head"] = binding.get("observed_head") or "" + project_root = str(binding.get("project_root") or "") + entry["observation_project_root_status"] = "present" if project_root else "missing" entry["baseline_status"] = "local-observation" - project_path = Path(str(binding["project_root"])).expanduser().resolve() - codegraph_present = (project_path / ".codegraph").is_dir() + if project_root: + entry["project_root"] = project_root + entry["path"] = project_root + project_path = Path(project_root).expanduser().resolve() if project_root else None + codegraph_present = bool(project_path and (project_path / ".codegraph").is_dir()) entry["codegraph"] = { "supported": codegraph_present, "index_present": codegraph_present, @@ -259,6 +260,15 @@ def _metadata_repositories(root: Path) -> list[Path]: return repositories +def _v4_metadata_targets(root: Path) -> list[dict[str, object]]: + targets: list[dict[str, object]] = [] + for entry in _metadata_repository_entries(root): + raw = entry.get("project_root") or entry.get("path") + path = Path(str(raw)).expanduser().resolve() if raw else root.resolve() + targets.append({"path": str(path), "source": "project-metadata", "metadata": entry}) + return targets + + def _enrich_with_metadata(root: Path, targets: list[dict[str, str]]) -> list[dict[str, object]]: entries: dict[str, dict[str, object]] = {} for entry in _metadata_repository_entries(root): @@ -337,6 +347,11 @@ def resolve_target_repositories( resolved = _resolve_candidates(root, references) if resolved: return _enrich_with_metadata(root, resolved) + metadata_path = root / ".work-bundle" / "project.yaml" + if metadata_path.is_file() and _metadata_scalar( + metadata_path.read_text(encoding="utf-8"), "metadata_version" + ) == "4": + return _v4_metadata_targets(root) return _enrich_with_metadata( root, _resolve_candidates(root, ((path, "project-metadata") for path in _metadata_repositories(root))), @@ -478,6 +493,23 @@ def inspect_repository_state( "baseline": baseline, "changes": [], } + if metadata and metadata.get("observation_project_root_status") == "missing": + result["status"] = "missing-observation" + result["failure_code"] = "WB_REPOSITORY_OBSERVATION_PROJECT_ROOT_MISSING" + result["metadata"] = { + "repository_id": metadata.get("id"), + "expected_branch": metadata.get("expected_branch") or None, + "actual_branch": None, + "branch_status": "not-observed", + "expected_commit": metadata.get("observed_head") or None, + "actual_commit": None, + "commit_status": "not-observed", + "observation_branch_status": "missing", + "observation_head_status": "missing", + "baseline_status": metadata.get("baseline_status"), + "codegraph": metadata.get("codegraph") or {}, + } + return result if not path.exists(): result["status"] = "inaccessible" return result @@ -508,13 +540,13 @@ def inspect_repository_state( actual_head = _run_git(path, "rev-parse", "HEAD").stdout.strip() expected_branch = str(metadata.get("expected_branch") or metadata.get("working_branch") or "") expected_head = str(metadata.get("observed_head") or metadata.get("last_commit_id") or "") + observed_branch = str(metadata.get("observed_branch") or "") + device_observation = "observation_project_root_status" in metadata branch_status = "not-applicable" commit_status = "not-applicable" if bool(metadata.get("git_repository")): branch_status = "matched" if expected_branch == actual_branch else "mismatch" - if str(metadata.get("baseline_status") or "") == "local-observation": - commit_status = "current-observation" - elif expected_head: + if expected_head: commit_status = "matched" if expected_head == actual_head else "stale" elif str(metadata.get("baseline_status") or "") == "unborn": commit_status = "unborn" @@ -531,6 +563,12 @@ def inspect_repository_state( "actual_commit": actual_head or None, "commit_status": commit_status, "baseline_status": metadata.get("baseline_status"), + "observation_branch_status": ( + "matched" if observed_branch == actual_branch else "stale" + ) if device_observation and observed_branch else ("missing" if device_observation else "not-applicable"), + "observation_head_status": ( + "matched" if expected_head == actual_head else "stale" + ) if device_observation and expected_head else ("missing" if device_observation else "not-applicable"), "codegraph": { "supported": bool(codegraph_metadata.get("supported")), "index_present": bool(codegraph_metadata.get("index_present")), @@ -543,6 +581,14 @@ def inspect_repository_state( if branch_status == "mismatch": result["status"] = "branch-mismatch" return result + if device_observation and (not observed_branch or not expected_head): + result["status"] = "missing-observation" + result["failure_code"] = "WB_REPOSITORY_OBSERVATION_MISSING" + return result + if device_observation and (observed_branch != actual_branch or expected_head != actual_head): + result["status"] = "stale-observation" + result["failure_code"] = "WB_REPOSITORY_OBSERVATION_STALE" + return result if commit_status in {"stale", "missing"}: result["status"] = "stale-baseline" return result diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index f8ee8cf..4008637 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -856,6 +856,7 @@ def _portable_failures(text: str) -> list[str]: seen_member_names: set[str] = set() seen_member_paths: set[str] = set() root_bindings = 0 + composite_member_bindings = 0 for repository in repositories: repository_id = str(repository.get("id") or "") if not repository.get("id"): @@ -884,21 +885,29 @@ def _portable_failures(text: str) -> list[str]: failures.append(f"WB_CONTROL_PLANE_MEMBER_BINDING_DUPLICATE:{member_name}") seen_member_names.add(member_name) elif mode == "composite": + valid_composite_member = True if not member_name: failures.append(f"WB_CONTROL_PLANE_MEMBER_BINDING_INVALID:{repository_id}") + valid_composite_member = False elif member_name in seen_member_names: failures.append(f"WB_CONTROL_PLANE_MEMBER_BINDING_DUPLICATE:{member_name}") + valid_composite_member = False seen_member_names.add(member_name) if not member_path: failures.append(f"WB_CONTROL_PLANE_MEMBER_BINDING_INVALID:{repository_id}") + valid_composite_member = False else: try: _validate_member_path(member_path) except ControlPlaneError as exc: failures.append(f"{exc.code}:{repository_id}") + valid_composite_member = False if member_path in seen_member_paths: failures.append(f"WB_CONTROL_PLANE_MEMBER_PATH_DUPLICATE:{member_path}") + valid_composite_member = False seen_member_paths.add(member_path) + if valid_composite_member: + composite_member_bindings += 1 else: failures.append(f"WB_CONTROL_PLANE_MEMBER_BINDING_INVALID:{repository_id}") if member_name: @@ -909,6 +918,8 @@ def _portable_failures(text: str) -> list[str]: failures.append("WB_CONTROL_PLANE_SINGLE_REPOSITORY_BINDING_INVALID") if mode == "composite" and root_bindings != 1: failures.append("WB_CONTROL_PLANE_COMPOSITE_ROOT_BINDING_INVALID") + if mode == "composite" and composite_member_bindings == 0: + failures.append("WB_CONTROL_PLANE_COMPOSITE_MEMBER_REQUIRED") return failures diff --git a/scripts/work-bundle/migration.py b/scripts/work-bundle/migration.py index 01e0480..220a1e9 100644 --- a/scripts/work-bundle/migration.py +++ b/scripts/work-bundle/migration.py @@ -30,9 +30,16 @@ class MigrationError(Exception): - def __init__(self, code: str, transaction_record: Path | None = None) -> None: + def __init__( + self, + code: str, + transaction_record: Path | None = None, + *, + result: dict[str, object] | None = None, + ) -> None: self.code = code self.transaction_record = transaction_record + self.result = result or {} super().__init__(code) @@ -57,6 +64,43 @@ def _run_git(root: Path, *args: str) -> subprocess.CompletedProcess[str]: ) +def validate_migration_proposal(origin: Path, branch: str, base_ref: str) -> dict[str, object]: + """Validate origin-local worktree and ref availability without writing Git state.""" + origin = origin.resolve() + common_dir = _run_git(origin, 'rev-parse', '--path-format=absolute', '--git-common-dir') + if common_dir.returncode: + raise MigrationError('WB_MIGRATION_ORIGIN_GIT_UNAVAILABLE') + evidence: dict[str, object] = { + 'working_branch': branch, + 'base_ref': base_ref, + 'origin_git_common_dir': str(Path(common_dir.stdout.strip()).resolve()), + 'changed_files': [], + 'git_actions': [], + } + worktrees = _run_git(origin, 'worktree', 'list', '--porcelain') + if worktrees.returncode: + raise MigrationError('WB_MIGRATION_ORIGIN_GIT_UNAVAILABLE', result=evidence) + if f'branch refs/heads/{branch}' in worktrees.stdout.splitlines(): + raise MigrationError('WB_WORKTREE_BRANCH_CONFLICT', result=evidence) + + resolved = _run_git(origin, 'rev-parse', '--verify', '--quiet', f'{base_ref}^{{commit}}') + if resolved.returncode: + local_branch_available = False + if base_ref.startswith('origin/') and len(base_ref) > len('origin/'): + local_name = base_ref.removeprefix('origin/') + local = _run_git(origin, 'show-ref', '--verify', '--quiet', f'refs/heads/{local_name}') + local_branch_available = local.returncode == 0 + evidence['local_branch_available'] = local_branch_available + code = ( + 'WB_MIGRATION_LOCAL_ORIGIN_BASE_REF_UNAVAILABLE' + if local_branch_available + else 'WB_MIGRATION_BASE_REF_UNAVAILABLE' + ) + raise MigrationError(code, result=evidence) + evidence['resolved_base_commit'] = resolved.stdout.strip() + return evidence + + def source_git_state(root: Path) -> dict[str, object]: """Return bounded Git facts without modifying the repository.""" resolved = root.resolve() @@ -201,7 +245,9 @@ def propose_migration( repository_name: str | None = None, additional_repository_origins: list[dict[str, object]] | None = None, ) -> dict[str, object]: + origin = (origin or source).resolve() inspection = inspect_migration(source, target, origin) + proposal_validation = validate_migration_proposal(origin, branch, base_ref) slug = workspace_slug or target.name name = repository_name or repository_id return { @@ -215,6 +261,7 @@ def propose_migration( 'dry_run': True, 'changed_files': [], 'git_actions': [], + 'proposal_validation': proposal_validation, 'credential_action': 'create-empty-protected-store', 'apply_requires_accepted_baseline': bool( inspection['source_repository_git']['dirty'] diff --git a/scripts/work-bundle/project.py b/scripts/work-bundle/project.py index 601c32d..1dcc71b 100644 --- a/scripts/work-bundle/project.py +++ b/scripts/work-bundle/project.py @@ -2547,7 +2547,16 @@ def cmd_migrate_to_multi_repository(args: list[str]) -> int: additional_repository_origins=additional_origins, ) except (MigrationError, ValueError, RuntimeError) as exc: - out({'command':'migrate-to-multi-repository','status':'issues-found','failure_code':str(exc)}) + payload = { + 'command': 'migrate-to-multi-repository', + 'status': 'issues-found', + 'failure_code': exc.code if isinstance(exc, MigrationError) else str(exc), + } + if isinstance(exc, MigrationError) and exc.result: + payload['result'] = exc.result + payload['changed_files'] = exc.result.get('changed_files', []) + payload['git_actions'] = exc.result.get('git_actions', []) + out(payload) return 1 out({'command':'migrate-to-multi-repository','status':'passed','result':result}) return 0 diff --git a/tests/test_control_plane_v4.py b/tests/test_control_plane_v4.py index de7af95..0da6b4e 100644 --- a/tests/test_control_plane_v4.py +++ b/tests/test_control_plane_v4.py @@ -1228,7 +1228,7 @@ def test_migration_rejects_tracked_protected_control_plane_paths(tmp_path: Path) assert json.loads(blocked.stdout)["failure_code"] == "WB_CONTROL_PLANE_PROTECTED_PATH_TRACKED" -def test_orchestration_uses_current_local_head_as_observation_not_expected_baseline(tmp_path: Path) -> None: +def test_orchestration_blocks_when_current_local_head_outgrows_device_observation(tmp_path: Path) -> None: config = config_root(tmp_path / "config-root") workspace, remote, _ = make_v3_workspace(tmp_path / "fixture") migrate(config, workspace) @@ -1245,13 +1245,20 @@ def test_orchestration_uses_current_local_head_as_observation_not_expected_basel "--apply", ) assert attached.returncode == 0 + registry = config / "registry/projects.yaml" + before = registry.read_text(encoding="utf-8") (checkout / "later.txt").write_text("later\n", encoding="utf-8") git(checkout, "add", "later.txt") git(checkout, "commit", "-q", "-m", "later local commit") preflight = run_orch(config, "repository-preflight", "--project-root", str(workspace)) - row = json.loads(preflight.stdout)["repository_preflight"]["repositories"][0] - assert row["status"] == "clean" - assert row["metadata"]["expected_commit"] is None + payload = json.loads(preflight.stdout)["repository_preflight"] + row = payload["repositories"][0] + assert payload["status"] == "blocked" + assert row["status"] == "stale-observation" + assert row["failure_code"] == "WB_REPOSITORY_OBSERVATION_STALE" + assert row["metadata"]["commit_status"] == "stale" + assert row["metadata"]["observation_head_status"] == "stale" + assert registry.read_text(encoding="utf-8") == before def test_attach_materializes_missing_repository_idempotently_without_portable_mutation(tmp_path: Path) -> None: @@ -1724,6 +1731,21 @@ def test_v4_composite_schema_requires_exactly_one_named_root(self) -> None: failures = json.loads(doctor.stdout)["portable"]["failures"] self.assertIn("WB_CONTROL_PLANE_COMPOSITE_ROOT_BINDING_INVALID", failures) + def test_v4_composite_schema_requires_at_least_one_named_member(self) -> None: + config, workspace, _, _ = init_single_v4(self.tmp_path) + metadata = workspace / ".work-bundle/project.yaml" + metadata.write_text( + metadata.read_text(encoding="utf-8").replace( + " mode: single-repository", " mode: composite" + ), + encoding="utf-8", + ) + + doctor = run_wb(config, "doctor-workspace", str(workspace)) + failures = json.loads(doctor.stdout)["portable"]["failures"] + + self.assertIn("WB_CONTROL_PLANE_COMPOSITE_MEMBER_REQUIRED", failures) + def test_v4_composite_schema_rejects_duplicate_member_paths(self) -> None: config, workspace, _, _ = init_single_v4(self.tmp_path) write_composite_metadata(workspace) diff --git a/tests/test_keep_summarizing_query.py b/tests/test_keep_summarizing_query.py index d7d1c92..6d8b1e9 100644 --- a/tests/test_keep_summarizing_query.py +++ b/tests/test_keep_summarizing_query.py @@ -324,9 +324,45 @@ def test_query_trace_reports_vector_unavailable_status( assert trace["sources"]["vector"] == "unavailable" +def test_sqlite_vec_availability_probe_reports_import_unavailable_stably( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + indexes, + "install_sqlite_vec", + lambda: (None, "sqlite-vec unavailable in the uv-managed environment: missing"), + ) + + assert indexes.sqlite_vec_availability_probe() == { + "status": "unavailable", + "reason": "sqlite-vec probe unavailable: import failed", + } + + +def test_sqlite_vec_availability_probe_reports_temporary_load_unavailable_stably( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class UnloadableSqliteVec: + @staticmethod + def load(_connection: object) -> None: + raise RuntimeError("runner cannot load extension") + + monkeypatch.setattr(indexes, "install_sqlite_vec", lambda: (UnloadableSqliteVec(), None)) + + assert indexes.sqlite_vec_availability_probe() == { + "status": "unavailable", + "reason": "sqlite-vec probe unavailable: temporary load failed", + } + + def test_production_index_rebuild_keeps_proposed_notes_in_vector_discovery( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: + probe = indexes.sqlite_vec_availability_probe() + if probe["status"] == "unavailable": + pytest.skip(str(probe["reason"])) + assert probe == {"status": "available", "reason": None} + root = tmp_path / ".work-bundle" / "knowledge" note = root / "notes" / "development-design" / "architecture" / "source-of-truth" / "vector-index.md" note.parent.mkdir(parents=True) diff --git a/tests/test_orchestration_repository_preflight.py b/tests/test_orchestration_repository_preflight.py index 906c75c..9e4eb76 100644 --- a/tests/test_orchestration_repository_preflight.py +++ b/tests/test_orchestration_repository_preflight.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] ORCHESTRATION = REPO_ROOT / "scripts" / "orchestration" @@ -15,6 +17,7 @@ repository_preflight, resolve_target_repositories, ) +import repository_preflight as preflight_module # noqa: E402 def git(path: Path, *args: str) -> str: @@ -99,6 +102,106 @@ def write_workspace_metadata_v3(workspace: Path, repo: Path) -> None: ) +def write_workspace_metadata_v4(workspace: Path, workspace_id: str = "wb-test") -> None: + (workspace / ".work-bundle").mkdir(parents=True, exist_ok=True) + (workspace / ".work-bundle" / "project.yaml").write_text( + "\n".join( + [ + "metadata_version: 4", + "authority: canonical", + "workspace:", + f" id: {workspace_id}", + " slug: test", + " mode: multi-repository", + "source_repositories:", + " - id: repo-main", + " role: source", + " remote:", + ' canonical: "https://example.com/repo.git"', + " default_branch: main", + " workspace_binding:", + " type: member", + " name: repo-main", + " materialization:", + " required: true", + "", + ] + ), + encoding="utf-8", + ) + + +def write_v4_registry( + path: Path, + repo: Path | None, + *, + workspace_id: str = "wb-test", + observed_branch: str = "main", + observed_head: str = "", +) -> None: + repository_lines = [" repo-main:"] + if repo is not None: + repository_lines.append(f" project_root: {repo.resolve()}") + if observed_branch: + repository_lines.append(f" observed_branch: {observed_branch}") + if observed_head: + repository_lines.append(f" observed_head: {observed_head}") + path.write_text( + "\n".join( + [ + "metadata_version: 4", + "device_bindings:", + f" {workspace_id}:", + " repositories:", + *repository_lines, + "", + ] + ), + encoding="utf-8", + ) + + +def test_v4_preflight_keeps_missing_device_observation_as_typed_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + registry = tmp_path / "projects.yaml" + write_workspace_metadata_v4(workspace) + write_v4_registry(registry, None) + monkeypatch.setattr(preflight_module, "project_registry_path", lambda: registry) + + result = repository_preflight(resolve_target_repositories(workspace)) + + assert result["repository_preflight"]["status"] == "blocked" + row = result["repository_preflight"]["repositories"][0] + assert row["status"] == "missing-observation" + assert row["failure_code"] == "WB_REPOSITORY_OBSERVATION_PROJECT_ROOT_MISSING" + assert row["metadata"]["repository_id"] == "repo-main" + + +def test_v4_preflight_detects_stale_device_head_without_refreshing_registry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + repo = repository(tmp_path) + registry = tmp_path / "projects.yaml" + write_workspace_metadata_v4(workspace) + write_v4_registry(registry, repo, observed_head="0" * 40) + before = registry.read_text(encoding="utf-8") + monkeypatch.setattr(preflight_module, "project_registry_path", lambda: registry) + + result = repository_preflight(resolve_target_repositories(workspace)) + + assert result["repository_preflight"]["status"] == "blocked" + row = result["repository_preflight"]["repositories"][0] + assert row["status"] == "stale-observation" + assert row["failure_code"] == "WB_REPOSITORY_OBSERVATION_STALE" + assert row["metadata"]["observation_head_status"] == "stale" + assert registry.read_text(encoding="utf-8") == before + + def test_clean_repository_passes(tmp_path: Path) -> None: repo = repository(tmp_path) result = inspect_repository_state(repo) diff --git a/tests/test_workspace_migration.py b/tests/test_workspace_migration.py index f182e88..112a070 100644 --- a/tests/test_workspace_migration.py +++ b/tests/test_workspace_migration.py @@ -154,6 +154,58 @@ def test_proposal_reports_complete_inputs_and_separate_dirty_states(tmp_path: Pa assert dry_run['changed_files'] == [] and not target.exists() +def test_proposal_rejects_working_branch_checked_out_in_origin_common_dir(tmp_path: Path) -> None: + source, target, occupied = tmp_path / 'source', tmp_path / 'target', tmp_path / 'occupied' + seed(source, dirty_source=False, dirty_nested=False) + subprocess.run( + ['git', '-C', str(source), 'worktree', 'add', '-q', '-b', 'feature/occupied', str(occupied), 'HEAD'], + check=True, + ) + + with pytest.raises(MigrationError) as raised: + propose_migration(source, target, 'repo-one', 'feature/occupied', 'HEAD') + + assert raised.value.code == 'WB_WORKTREE_BRANCH_CONFLICT' + assert raised.value.result['changed_files'] == [] + assert raised.value.result['working_branch'] == 'feature/occupied' + assert not target.exists() + + +def test_proposal_distinguishes_missing_origin_main_from_local_main(tmp_path: Path) -> None: + source, target = tmp_path / 'source', tmp_path / 'target' + seed(source, dirty_source=False, dirty_nested=False) + + with pytest.raises(MigrationError) as raised: + propose_migration(source, target, 'repo-one', 'feature/workspace', 'origin/main') + + assert raised.value.code == 'WB_MIGRATION_LOCAL_ORIGIN_BASE_REF_UNAVAILABLE' + assert raised.value.result['changed_files'] == [] + assert raised.value.result['base_ref'] == 'origin/main' + assert raised.value.result['local_branch_available'] is True + assert not target.exists() + + valid = propose_migration(source, target, 'repo-one', 'feature/workspace', 'main') + assert valid['changed_files'] == [] + assert not target.exists() + + +def test_invalid_proposal_prevents_apply_from_reaching_member_provisioning( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source, target = tmp_path / 'source', tmp_path / 'target' + seed(source, dirty_source=False, dirty_nested=False) + monkeypatch.setattr( + migration, + 'provision_member', + lambda *_args, **_kwargs: pytest.fail('provision_member must not run'), + ) + + with pytest.raises(MigrationError, match='LOCAL_ORIGIN_BASE_REF_UNAVAILABLE'): + apply_migration(source, target, 'repo-one', 'feature/workspace', 'origin/main') + + assert not target.exists() + + def test_dirty_apply_requires_exact_accepted_baseline(tmp_path: Path) -> None: source, target, registry_path = tmp_path / 'source', tmp_path / 'target', tmp_path / 'config/projects.yaml' seed(source) From 9b0d1a8ccaf192207f4db4bb7cd6309eed15a1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Fri, 28 Aug 2026 16:51:32 +0800 Subject: [PATCH 2/5] ci: pin setup-uv to published v9 tag --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ef7bf4..e2a0c22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v5 - name: Set up uv - uses: astral-sh/setup-uv@v9 + uses: astral-sh/setup-uv@v9.0.0 with: python-version: "3.13" enable-cache: false From 8d5fc489ac1f8f178227c4efbcfbc9e9f6fad824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Fri, 28 Aug 2026 17:05:23 +0800 Subject: [PATCH 3/5] test(vector): keep availability probe independent --- scripts/keep-summarizing/indexes.py | 5 ++-- tests/test_keep_summarizing_query.py | 36 +++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/scripts/keep-summarizing/indexes.py b/scripts/keep-summarizing/indexes.py index 77236a3..76c0152 100644 --- a/scripts/keep-summarizing/indexes.py +++ b/scripts/keep-summarizing/indexes.py @@ -23,8 +23,9 @@ def install_sqlite_vec() -> tuple[object | None, str | None]: def sqlite_vec_availability_probe() -> dict[str, object]: """Probe import and extension loading independently from a production rebuild.""" - sqlite_vec, _ = install_sqlite_vec() - if sqlite_vec is None: + try: + sqlite_vec = __import__(SQLITE_VEC_IMPORT) + except ImportError: return { "status": "unavailable", "reason": "sqlite-vec probe unavailable: import failed", diff --git a/tests/test_keep_summarizing_query.py b/tests/test_keep_summarizing_query.py index 6d8b1e9..5687bd5 100644 --- a/tests/test_keep_summarizing_query.py +++ b/tests/test_keep_summarizing_query.py @@ -327,11 +327,14 @@ def test_query_trace_reports_vector_unavailable_status( def test_sqlite_vec_availability_probe_reports_import_unavailable_stably( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - indexes, - "install_sqlite_vec", - lambda: (None, "sqlite-vec unavailable in the uv-managed environment: missing"), - ) + real_import = __import__ + + def import_without_sqlite_vec(name: str, *args: object, **kwargs: object) -> object: + if name == indexes.SQLITE_VEC_IMPORT: + raise ImportError("missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", import_without_sqlite_vec) assert indexes.sqlite_vec_availability_probe() == { "status": "unavailable", @@ -347,7 +350,14 @@ class UnloadableSqliteVec: def load(_connection: object) -> None: raise RuntimeError("runner cannot load extension") - monkeypatch.setattr(indexes, "install_sqlite_vec", lambda: (UnloadableSqliteVec(), None)) + real_import = __import__ + + def import_unloadable_sqlite_vec(name: str, *args: object, **kwargs: object) -> object: + if name == indexes.SQLITE_VEC_IMPORT: + return UnloadableSqliteVec() + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", import_unloadable_sqlite_vec) assert indexes.sqlite_vec_availability_probe() == { "status": "unavailable", @@ -355,6 +365,20 @@ def load(_connection: object) -> None: } +def test_sqlite_vec_availability_probe_does_not_call_production_import_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_if_called() -> tuple[object | None, str | None]: + raise AssertionError("availability probe delegated to production import helper") + + monkeypatch.setattr(indexes, "install_sqlite_vec", fail_if_called) + + assert indexes.sqlite_vec_availability_probe() == { + "status": "available", + "reason": None, + } + + def test_production_index_rebuild_keeps_proposed_notes_in_vector_discovery( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From 3934a05f984c8494b015bfd793b96af9e0a442f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Fri, 28 Aug 2026 17:10:36 +0800 Subject: [PATCH 4/5] test(vector): isolate probe capability fixture --- tests/test_keep_summarizing_query.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_keep_summarizing_query.py b/tests/test_keep_summarizing_query.py index 5687bd5..8c5fa9a 100644 --- a/tests/test_keep_summarizing_query.py +++ b/tests/test_keep_summarizing_query.py @@ -368,10 +368,23 @@ def import_unloadable_sqlite_vec(name: str, *args: object, **kwargs: object) -> def test_sqlite_vec_availability_probe_does_not_call_production_import_helper( monkeypatch: pytest.MonkeyPatch, ) -> None: + class LoadableSqliteVec: + @staticmethod + def load(_connection: object) -> None: + return None + def fail_if_called() -> tuple[object | None, str | None]: raise AssertionError("availability probe delegated to production import helper") + real_import = __import__ + + def import_loadable_sqlite_vec(name: str, *args: object, **kwargs: object) -> object: + if name == indexes.SQLITE_VEC_IMPORT: + return LoadableSqliteVec() + return real_import(name, *args, **kwargs) + monkeypatch.setattr(indexes, "install_sqlite_vec", fail_if_called) + monkeypatch.setattr("builtins.__import__", import_loadable_sqlite_vec) assert indexes.sqlite_vec_availability_probe() == { "status": "available", From d3e44341cbe858b55bb94f4e075b04d778014e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Fri, 28 Aug 2026 17:16:48 +0800 Subject: [PATCH 5/5] test(vector): stub temporary probe connection --- tests/test_keep_summarizing_query.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_keep_summarizing_query.py b/tests/test_keep_summarizing_query.py index 8c5fa9a..dc52dcf 100644 --- a/tests/test_keep_summarizing_query.py +++ b/tests/test_keep_summarizing_query.py @@ -373,6 +373,13 @@ class LoadableSqliteVec: def load(_connection: object) -> None: return None + class TemporaryConnection: + def enable_load_extension(self, _enabled: bool) -> None: + return None + + def close(self) -> None: + return None + def fail_if_called() -> tuple[object | None, str | None]: raise AssertionError("availability probe delegated to production import helper") @@ -385,6 +392,7 @@ def import_loadable_sqlite_vec(name: str, *args: object, **kwargs: object) -> ob monkeypatch.setattr(indexes, "install_sqlite_vec", fail_if_called) monkeypatch.setattr("builtins.__import__", import_loadable_sqlite_vec) + monkeypatch.setattr(indexes.sqlite3, "connect", lambda _path: TemporaryConnection()) assert indexes.sqlite_vec_availability_probe() == { "status": "available",