feat: operator schema accept command for curated FK review (P5)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user