diff --git a/backend/src/workspaces/runtime-config-lease.ts b/backend/src/workspaces/runtime-config-lease.ts index 01352c02..e8cc4a77 100644 --- a/backend/src/workspaces/runtime-config-lease.ts +++ b/backend/src/workspaces/runtime-config-lease.ts @@ -240,7 +240,7 @@ function readSnapshotWorkspace(snapshotPath: string): { } } -function runtimePaths(dataRoot: string, workspaceId: string): RuntimePaths { +function runtimePaths(dataRoot: string, workspaceId: string, workspaceRevision?: string): RuntimePaths { if (!isAbsolute(dataRoot)) throw new Error("registry workspace runtime requires an absolute data root"); const root = join(dataRoot, "sessions", workspaceId); return { @@ -248,6 +248,9 @@ function runtimePaths(dataRoot: string, workspaceId: string): RuntimePaths { artifacts: join(root, "artifacts"), indexes: join(root, "indexes"), memory: join(root, "memory"), + ...(workspaceRevision === undefined + ? {} + : { annotations_root: join(dataRoot, "sessions", workspaceId, "revisions", workspaceRevision, "artifacts") }), }; } @@ -308,7 +311,7 @@ function renderWorkspaceRuntimeFromWorkspace(options: { workspaceId: options.workspaceId, workspaceRevision: options.workspaceRevision, revisionContentRoot: options.revisionContentRoot, - runtimePaths: runtimePaths(options.dataRoot, options.workspaceId), + runtimePaths: runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision), installationOverlay: overlay, bindings, bindingDigest: stableBindingDigest(bindings), @@ -316,7 +319,7 @@ function renderWorkspaceRuntimeFromWorkspace(options: { renderedConfig: renderRuntimeConfig( options.workspace, bindings, - runtimePaths(options.dataRoot, options.workspaceId), + runtimePaths(options.dataRoot, options.workspaceId, options.workspaceRevision), context, overlay, options.semanticRuntime, diff --git a/backend/src/workspaces/runtime-renderer.ts b/backend/src/workspaces/runtime-renderer.ts index a982e698..23db59a1 100644 --- a/backend/src/workspaces/runtime-renderer.ts +++ b/backend/src/workspaces/runtime-renderer.ts @@ -10,6 +10,8 @@ export interface RuntimePaths { artifacts: string; indexes: string; memory: string; + /** Revision-qualified root for curated FK annotations (P5); optional for legacy callers. */ + annotations_root?: string; } export interface RuntimeIdentity { diff --git a/backend/test/workspace-runtime-config-lease.test.ts b/backend/test/workspace-runtime-config-lease.test.ts index 1512797e..f7bae6af 100644 --- a/backend/test/workspace-runtime-config-lease.test.ts +++ b/backend/test/workspace-runtime-config-lease.test.ts @@ -170,6 +170,27 @@ test("active workspace rendering is byte-identical to direct snapshot rendering" expect(active.catalogBlob).toMatch(/^sha256:[0-9a-f]{64}$/); }); +test("renders a revision-qualified annotations root for the active revision", async () => { + const f = await fixture(); + const active = await renderActiveWorkspaceRuntime({ + workspaceId: "psd-clinical", + registry: f.registry, + registryConfig: f.registryConfig, + harnessDir: f.harnessDir, + configPath: "config/tht.yaml", + dataRoot: f.dataRoot, + secretRoots: f.registryConfig.secretRoots, + semanticRuntime, + }); + const rendered = parse(active.renderedConfig) as Record; + + expect(rendered.paths.annotations_root).toBe( + join(f.dataRoot, "sessions", "psd-clinical", "revisions", f.revision.commit, "artifacts"), + ); + expect(rendered.roots.annotations_root).toBe(rendered.paths.annotations_root); + expect(rendered.paths.artifacts).toBe(join(f.dataRoot, "sessions", "psd-clinical", "artifacts")); +}); + test("deterministic operator leases are keyed by logical identity and stable across calls", async () => { const f = await fixture(); const first = await publishDeterministicRuntimeConfigLease({ diff --git a/harness/tests/test_annotations_root.py b/harness/tests/test_annotations_root.py new file mode 100644 index 00000000..06f6bf6e --- /dev/null +++ b/harness/tests/test_annotations_root.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from tht.cli.schema_cmd import annotations_path +from tht.config import load_config + +REVISION = "a" * 40 + + +def _config(tmp_path: Path, *, with_annotations_root: bool = True) -> Path: + annotations = tmp_path / "revisions" / REVISION / "artifacts" + roots = f""" +roots: + sessions: {tmp_path / 'sessions'} + artifacts: {tmp_path / 'artifacts'} + indexes: {tmp_path / 'indexes'} + memory: {tmp_path / 'memory'} +""" + if with_annotations_root: + roots += f" annotations_root: {annotations}\n" + path = tmp_path / "runtime.yaml" + path.write_text(f""" +runtime_identity: + workspace_id: demo + workspace_revision: {REVISION} + source_identity: workspace://demo +dwh: + type: postgres_direct + connection: {{database: warehouse, schema: analytics, user: reader, password: secret}} +{roots} +""") + return path + + +def test_annotations_path_prefers_revision_root(tmp_path, monkeypatch): + monkeypatch.delenv("THT_HOME", raising=False) + monkeypatch.delenv("THT_DATA_ROOT", raising=False) + cfg = load_config(_config(tmp_path)) + + assert cfg.paths.annotations_root == tmp_path / "revisions" / REVISION / "artifacts" + assert annotations_path(cfg) == ( + tmp_path / "revisions" / REVISION / "artifacts" / "mschema" / "annotations.yaml" + ) + + +def test_annotations_path_falls_back_to_legacy_root(tmp_path, monkeypatch): + monkeypatch.delenv("THT_HOME", raising=False) + monkeypatch.delenv("THT_DATA_ROOT", raising=False) + cfg = load_config(_config(tmp_path, with_annotations_root=False)) + + assert cfg.paths.annotations_root is None + assert annotations_path(cfg) == tmp_path / "artifacts" / "mschema" / "annotations.yaml" + + +def test_load_config_preserves_annotations_root_with_data_root(tmp_path, monkeypatch): + monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) + monkeypatch.delenv("THT_HOME", raising=False) + cfg = load_config(_config(tmp_path)) + + assert cfg.paths.annotations_root == tmp_path / "revisions" / REVISION / "artifacts" + assert annotations_path(cfg) == ( + tmp_path / "revisions" / REVISION / "artifacts" / "mschema" / "annotations.yaml" + ) diff --git a/harness/tht/cli/schema_cmd.py b/harness/tht/cli/schema_cmd.py index 742ae146..3adda3aa 100644 --- a/harness/tht/cli/schema_cmd.py +++ b/harness/tht/cli/schema_cmd.py @@ -49,6 +49,8 @@ def physical_path(cfg) -> Path: def annotations_path(cfg) -> Path: + if cfg.paths.annotations_root is not None: + return cfg.paths.annotations_root / "mschema" / "annotations.yaml" return cfg.paths.artifacts / "mschema" / "annotations.yaml" diff --git a/harness/tht/config.py b/harness/tht/config.py index 3418a529..4899078d 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -324,6 +324,9 @@ class PathsConfig(BaseModel): # Explicit workspace-global memory root (P3). When absent, legacy `artifacts/memory` is used # only through the documented migration path. memory: Path | None = None + # Revision-qualified curated FK annotations root (P5). When absent, legacy + # `artifacts/mschema/annotations.yaml` remains the annotations source. + annotations_root: Path | None = None class RuntimeIdentityConfig(BaseModel): @@ -669,6 +672,8 @@ def load_config(path: Path) -> Config: sessions=resolved.sessions, artifacts=resolved.artifacts, indexes=resolved.indexes, + memory=cfg.paths.memory, + annotations_root=cfg.paths.annotations_root, ) } )