feat: operator schema accept command for curated FK review (P5)

This commit is contained in:
2026-08-13 05:06:23 +02:00
parent 5249798c03
commit 0459a6cd3e
9 changed files with 293 additions and 1 deletions
@@ -172,3 +172,38 @@ export function syncAnnotations(input: AnnotationsSyncInput): AnnotationsSyncRes
writeAtomicFile(manifestPath, `${JSON.stringify(manifest)}\n`, 0o600);
return { path, manifestPath, contentDigest };
}
export interface SyncedAnnotations {
blobId: string;
contents: Buffer;
contentDigest: string;
manifestPath: string;
}
/** Read the synced annotations and verify their ownership manifest; undefined when not yet synced. */
export function readAnnotationsSync(
dataRoot: string,
workspaceId: string,
commit: string,
): SyncedAnnotations | undefined {
const directory = join(annotationsSyncRoot(dataRoot, workspaceId, commit), "mschema");
const path = join(directory, "annotations.yaml");
const manifestPath = join(directory, "annotations.ownership.json");
let contents: Buffer;
try {
contents = readTrustedFile(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
const manifest = parseManifest(readTrustedFile(manifestPath).toString("utf8"));
const contentDigest = sha256(contents);
if (
manifest.workspace !== workspaceId
|| manifest.commit !== commit
|| manifest.contentDigest !== contentDigest
) {
throw new Error("annotations ownership manifest does not match the pinned revision");
}
return { blobId: manifest.blobId, contents, contentDigest, manifestPath };
}
@@ -10,6 +10,7 @@ import {
type SessionInventoryRow,
} from "./preprocessing-state.js";
import type { DeterministicRuntimeConfigLease } from "./runtime-config-lease.js";
import { readAnnotationsSync } from "./annotations-sync.js";
export interface WorkspaceOperationResult {
schemaVersion: 1;
@@ -262,6 +263,62 @@ export class WorkspacePreprocessingService {
});
}
async acceptSchema(options: { workspaceId: string; runId: string; yes?: boolean }): Promise<WorkspaceOperationResult> {
const runtime = await this.deps.acquireActiveRuntime(options.workspaceId);
const state = this.state(runtime.workspaceId);
if (options.yes !== true) {
return baseResult(runtime, "schema accept", "failed", "annotation_invalid", {
runId: options.runId,
warnings: ["accept requires --yes"],
});
}
if (!/^[0-9a-f]{32}$/.test(options.runId)) {
return baseResult(runtime, "schema accept", "failed", "annotation_invalid");
}
const candidate = state.readFkCandidates(options.runId);
if (candidate === undefined) {
return baseResult(runtime, "schema accept", "failed", "annotation_invalid", {
runId: options.runId,
warnings: ["candidate run is unavailable"],
});
}
const synced = readAnnotationsSync(this.deps.dataRoot, runtime.workspaceId, runtime.workspaceRevision);
if (synced === undefined || synced.contents.toString("utf8").trim() === "") {
return baseResult(runtime, "schema accept", "failed", "annotation_invalid", {
runId: options.runId,
warnings: ["curated annotations are not synchronized"],
});
}
// The harness parser validates the curated blob against the physical schema; the recorded
// candidate digest must round-trip and the blob digest must match the synced destination.
const payload = await this.runJsonStage(runtime, [
"schema", "check", "--reviewed-candidates", candidate.digest, "--json", "-c", "/dev/fd/3",
]);
if (payload.annotations_digest !== synced.contentDigest
|| payload.reviewed_candidates_digest !== candidate.digest
|| Number(payload.orphan_count ?? 0) !== 0) {
return baseResult(runtime, "schema accept", "failed", "annotation_invalid", { runId: options.runId });
}
const review = state.writeFkReview(options.runId, {
reviewedCandidatesDigest: candidate.digest,
annotationsDigest: synced.contentDigest,
workspaceRevision: runtime.workspaceRevision,
blobId: synced.blobId,
});
const job = state.readJob(options.runId);
job.reviewDigest = review.digest;
if (!job.completedStages.includes("fk_review")) job.completedStages.push("fk_review");
state.writeJob(job);
return baseResult(runtime, "schema accept", "succeeded", "ok", {
runId: options.runId,
completedStages: [...job.completedStages],
artifactIdentities: [
{ kind: "fk_review", digest: review.digest },
{ kind: "annotations", digest: synced.contentDigest },
],
});
}
async indexSchema(options: { workspaceId: string; resumeRunId?: string }): Promise<WorkspaceOperationResult> {
const scope = await this.startRun(options.workspaceId, "index-schema", options.resumeRunId);
const semantic = await this.deps.semanticPreflight(scope.runtime.workspace);
@@ -68,6 +68,8 @@ export interface FkReviewRecord {
reviewedCandidatesDigest: string;
annotationsDigest: string;
workspaceRevision: string;
/** Curated Git blob id accepted at review time (P5); absent for legacy host-file reviews. */
blobId?: string;
}
function sha256(value: string | Buffer): string {
@@ -171,6 +173,7 @@ function decodeReview(value: unknown): FkReviewRecord {
typeof record.reviewedCandidatesDigest !== "string"
|| typeof record.annotationsDigest !== "string"
|| typeof record.workspaceRevision !== "string"
|| (record.blobId !== undefined && typeof record.blobId !== "string")
) throw new Error("preprocessing review state is invalid");
return record as unknown as FkReviewRecord;
}