From 8f5e0ccbd5728df6d69a0389d0b65ead1a9d9ac9 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Thu, 28 May 2026 10:29:46 -0700 Subject: [PATCH 1/2] feat(deliverables): git-first artifact write-path + verify endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where writeDeliverable() existed but (a) had zero callers and (b) wrote a loose JSON envelope with no verifiability. Now every deliverable is committed to a per-company git repo, so "the fleet ran" becomes "here is the commit you can inspect." Slice 1 — verifiable write-path: - db: add commit_sha + git_ref columns to deliverables (migration 0012). - shared: Deliverable gains commitSha/gitRef; DeliverableStatus gains verified/failed (matches the cloud deliverable_ledger vocabulary); new deliverable_verified activity event. - deliverables.ts: commitArtifact() lazily `git init`s the deliverables dir, commits the artifact file, returns the SHA. writeDeliverable() calls it and persists commit_sha/git_ref. Best-effort — a git failure leaves commit_sha empty and the DB row is still written. - route: POST /api/mission-control/deliverable — board-auth producer endpoint that records an agent run's output as a committed artifact. Slice 2 — verify: - verifyDeliverable() resolves the recorded commit (git cat-file -e) AND re-hashes the on-disk file against contentHash; sets verified/failed, stamps reviewedAt, emits deliverable_verified. - route: POST /api/mission-control/deliverable/:id/verify. Tests: 3 new (commit resolves via git; clean → verified; tampered → failed) alongside the 3 existing write/query tests — all 6 green. Boundary preserved: the write-path is local only (local git + @wavex-os/db). The Liaison's existing mirror carries commit_sha up to Supabase deliverable_ledger.artifacts — the server never writes Supabase directly. Follow-up (Slice 3, separate PR): auto-producer reconciler that detects completed issues carrying a wavex-artifact block + the agent-side DELIVERABLE_EMIT skill. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../db/migrations/0012_deliverable_git.sql | 2 + packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema/deliverables.ts | 6 + packages/shared/src/types/mission-control.ts | 13 +- .../src/mission-control/deliverables.ts | 161 +++++++++++++++++- .../src/routes/mission-control.ts | 94 ++++++++++ .../test/mission-control-deliverables.test.ts | 84 ++++++++- 7 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 packages/db/migrations/0012_deliverable_git.sql diff --git a/packages/db/migrations/0012_deliverable_git.sql b/packages/db/migrations/0012_deliverable_git.sql new file mode 100644 index 0000000..2fe5182 --- /dev/null +++ b/packages/db/migrations/0012_deliverable_git.sql @@ -0,0 +1,2 @@ +ALTER TABLE "deliverables" ADD COLUMN "commit_sha" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "deliverables" ADD COLUMN "git_ref" text DEFAULT '' NOT NULL; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 61daf5d..6814dfa 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1747500005000, "tag": "0011_kpi_snapshots", "breakpoints": false + }, + { + "idx": 9, + "version": "7", + "when": 1747500006000, + "tag": "0012_deliverable_git", + "breakpoints": false } ] } diff --git a/packages/db/src/schema/deliverables.ts b/packages/db/src/schema/deliverables.ts index bbbb698..68918b2 100644 --- a/packages/db/src/schema/deliverables.ts +++ b/packages/db/src/schema/deliverables.ts @@ -46,6 +46,12 @@ export const deliverables = pgTable( sizeBytes: bigint("size_bytes", { mode: "number" }).notNull().default(0), contentHash: text("content_hash").notNull().default(""), + // Git-first verifiability — the artifact is committed to a per-company + // deliverables repo so `git -C show ` is the proof. + // commitSha is the canonical content-address; gitRef is the branch/ref. + commitSha: text("commit_sha").notNull().default(""), + gitRef: text("git_ref").notNull().default(""), + // What it is title: text("title").notNull(), description: text("description").notNull().default(""), diff --git a/packages/shared/src/types/mission-control.ts b/packages/shared/src/types/mission-control.ts index e177ef1..07d4faa 100644 --- a/packages/shared/src/types/mission-control.ts +++ b/packages/shared/src/types/mission-control.ts @@ -177,6 +177,11 @@ export interface Deliverable { sizeBytes: number; contentHash: string; + // Git-first verifiability — the artifact is committed to a per-company + // deliverables repo; commitSha is the proof, gitRef the branch/ref. + commitSha?: string; + gitRef?: string; + // What it is title: string; description: string; @@ -204,7 +209,12 @@ export type DeliverableStatus = | "in_review" | "approved" | "rejected" - | "published"; + | "published" + // Git-artifact integrity outcomes (matches the cloud deliverable_ledger + // vocabulary): `verified` = commit resolves + content hash matches; + // `failed` = verification could not confirm the artifact. + | "verified" + | "failed"; export type DeliverableKind = | "document" @@ -349,6 +359,7 @@ export type ActivityEventKind = | "deliverable_revised" | "deliverable_approved" | "deliverable_published" + | "deliverable_verified" // Node lifecycle | "node_paused" | "node_resumed" diff --git a/packages/wavex-os-server/src/mission-control/deliverables.ts b/packages/wavex-os-server/src/mission-control/deliverables.ts index bd74580..c567c6e 100644 --- a/packages/wavex-os-server/src/mission-control/deliverables.ts +++ b/packages/wavex-os-server/src/mission-control/deliverables.ts @@ -15,9 +15,13 @@ * knowing the layout; `writeDeliverable()` computes the path. */ import { randomUUID, createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile, readFile, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { dirname, join } from "node:path"; import { and, desc, eq } from "drizzle-orm"; + +const execFileAsync = promisify(execFile); import { type Db, deliverables, @@ -89,6 +93,53 @@ function deliverableActionForKind(kind: DeliverableKind): string { } const PRODUCED_EVENT: ActivityEventKind = "deliverable_produced"; +const VERIFIED_EVENT: ActivityEventKind = "deliverable_verified"; + +// Inline committer identity so we never depend on the operator's global +// git config (which may be unset on a fresh box). Local-only repo. +const GIT_IDENT = [ + "-c", "user.email=deliverables@wavex-os.local", + "-c", "user.name=WaveX Deliverables", +]; + +async function gitDir(dir: string): Promise { + try { + await stat(join(dir, ".git")); + return true; + } catch { + return false; + } +} + +/** Commit one artifact file into the per-company deliverables git repo so + * it is verifiable via `git -C show `. Lazily `git init`s the + * repo on first write. Best-effort: returns null on any git failure so + * the caller still records the DB row (the row is the source of truth; + * the commit is the proof layer). */ +async function commitArtifact( + dir: string, + relPath: string, + message: string, +): Promise<{ commitSha: string; gitRef: string } | null> { + try { + if (!(await gitDir(dir))) { + await execFileAsync("git", ["init", "-q"], { cwd: dir }); + // Pin a deterministic default branch — older git defaults to master. + await execFileAsync("git", ["symbolic-ref", "HEAD", "refs/heads/main"], { cwd: dir }).catch(() => {}); + } + await execFileAsync("git", ["add", "--", relPath], { cwd: dir }); + await execFileAsync( + "git", + [...GIT_IDENT, "commit", "-q", "-m", message, "--", relPath], + { cwd: dir }, + ); + const { stdout: sha } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: dir }); + const { stdout: ref } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir }); + return { commitSha: sha.trim(), gitRef: ref.trim() }; + } catch { + return null; + } +} /** Write a Deliverable row + JSON envelope to disk + emit MC event. * Returns the canonical Deliverable shape. Best-effort disk write — a @@ -126,9 +177,22 @@ export async function writeDeliverable( const contentHash = createHash("sha256").update(envelope).digest("hex"); const sizeBytes = Buffer.byteLength(envelope, "utf8"); + let commitSha = ""; + let gitRef = ""; try { await mkdir(dir, { recursive: true }); await writeFile(diskPath, envelope, "utf8"); + // Git-first: commit the artifact so it is verifiable. Best-effort — + // a git failure leaves commitSha empty and the row is still written. + const committed = await commitArtifact( + dir, + filename, + `deliverable(${input.kind}): ${input.title} [${input.taskRefType}:${input.taskRefId}]`, + ); + if (committed) { + commitSha = committed.commitSha; + gitRef = committed.gitRef; + } } catch { // Disk write is best-effort — the DB row is the canonical record. } @@ -145,6 +209,8 @@ export async function writeDeliverable( relPath: filename, sizeBytes, contentHash, + commitSha, + gitRef, title: input.title, description, previewText: input.previewText ?? null, @@ -242,6 +308,8 @@ function rowToDeliverable(row: typeof deliverables.$inferSelect): DeliverableSha relPath: row.relPath, sizeBytes: row.sizeBytes, contentHash: row.contentHash, + commitSha: row.commitSha || undefined, + gitRef: row.gitRef || undefined, title: row.title, description: row.description, previewText: row.previewText ?? undefined, @@ -273,3 +341,94 @@ export async function deliverableFolder( if (!d || !d.diskPath) return null; return { folder: dirname(d.diskPath), diskPath: d.diskPath }; } + +export interface VerifyResult { + ok: boolean; + /** Final status written to the row: "verified" on success, "failed" otherwise. */ + status: DeliverableStatus; + /** Human-readable reason when verification fails. */ + reason?: string; + deliverable: DeliverableShape; +} + +/** Verify a deliverable's git artifact: (1) the recorded commit resolves + * in the company's deliverables repo, and (2) the on-disk file still + * hashes to the recorded contentHash. Sets status `verified` / `failed`, + * stamps reviewedAt, and emits a `deliverable_verified` event. Returns + * null if the deliverable id is unknown. */ +export async function verifyDeliverable( + id: string, + reviewerNodeId: string | undefined, + db?: Db, +): Promise { + const resolved = db ?? (await getDb()); + const rows = await resolved + .select() + .from(deliverables) + .where(eq(deliverables.id, id)) + .limit(1); + const row = rows[0]; + if (!row) return null; + + const dir = dirname(row.diskPath); + const failures: string[] = []; + + // (1) Commit must resolve in the deliverables repo. + if (row.commitSha) { + try { + await execFileAsync("git", ["cat-file", "-e", `${row.commitSha}^{commit}`], { cwd: dir }); + } catch { + failures.push(`commit ${row.commitSha.slice(0, 10)} not found in deliverables repo`); + } + } else { + failures.push("no commit_sha recorded (artifact was not git-committed)"); + } + + // (2) On-disk content must still match the recorded hash. + try { + const buf = await readFile(row.diskPath); + const actual = createHash("sha256").update(buf).digest("hex"); + if (actual !== row.contentHash) { + failures.push("content hash mismatch — artifact changed since it was recorded"); + } + } catch { + failures.push("artifact file missing on disk"); + } + + const status: DeliverableStatus = failures.length === 0 ? "verified" : "failed"; + const reason = failures.length ? failures.join("; ") : undefined; + const reviewedAt = new Date(); + + const updated = await resolved + .update(deliverables) + .set({ + status, + reviewedByNodeId: reviewerNodeId ?? null, + reviewedAt, + reviewNotes: reason ?? null, + }) + .where(eq(deliverables.id, id)) + .returning(); + const stored = updated[0] ?? row; + const deliverable = rowToDeliverable(stored); + + const modeContext: PaperclipMode = row.diskPath.includes("/avatars/") + ? "avatar" + : "solo_founder"; + await logMissionControlActivity({ + companyId: row.companyId, + instanceId: row.instanceId, + kind: VERIFIED_EVENT, + modeContext, + actorNodeId: reviewerNodeId ?? row.producedByNodeId, + action: `deliverable.${row.kind}.${status}`, + subjectRef: { kind: "deliverable", id, title: row.title }, + deliverableRef: { id, title: row.title, kind: row.kind as DeliverableKind }, + plainLanguageSentence: + status === "verified" + ? `Verified deliverable "${row.title}" — commit resolves and content is intact.` + : `Could not verify deliverable "${row.title}": ${reason}.`, + }); + + return { ok: status === "verified", status, reason, deliverable }; +} diff --git a/packages/wavex-os-server/src/routes/mission-control.ts b/packages/wavex-os-server/src/routes/mission-control.ts index 6ce190e..69d23c7 100644 --- a/packages/wavex-os-server/src/routes/mission-control.ts +++ b/packages/wavex-os-server/src/routes/mission-control.ts @@ -25,7 +25,10 @@ import { deliverableFolder, getDeliverable, queryDeliverables, + writeDeliverable, + verifyDeliverable, type QueryDeliverablesInput, + type WriteDeliverableInput, } from "../mission-control/deliverables.js"; import { announceDueImpacts, @@ -542,6 +545,97 @@ export function registerMissionControlRoutes(app: FastifyInstance): void { } }); + // ── POST /api/mission-control/deliverable ───────────────────────────── + // Producer write-path: an agent run records its output as a git-committed, + // verifiable artifact. Body must carry the task linkage + the artifact + // (payload). writeDeliverable() commits it to the company's deliverables + // repo and returns the row + commit_sha. + app.post("/api/mission-control/deliverable", async (req, reply) => { + const ar = authReq(req); + try { + assertBoard(ar); + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + throw e; + } + const body = (req.body ?? {}) as Partial; + const missing = (["instanceId", "taskRefId", "producedByNodeId", "kind", "title"] as const).filter( + (k) => !body[k], + ); + if (missing.length) { + return reply.status(400).send({ ok: false, error: `missing required fields: ${missing.join(", ")}` }); + } + assertCompanyAccess(ar, body.instanceId as string); + await ensureBootstrap(); + try { + const deliverable = await writeDeliverable({ + companyId: body.companyId ?? (body.instanceId as string), + instanceId: body.instanceId as string, + modeContext: body.modeContext ?? "solo_founder", + taskRefType: body.taskRefType ?? "issue", + taskRefId: body.taskRefId as string, + producedByNodeId: body.producedByNodeId as string, + kind: body.kind as WriteDeliverableInput["kind"], + title: body.title as string, + description: body.description, + previewText: body.previewText, + mimeType: body.mimeType, + payload: body.payload, + templateUsed: body.templateUsed, + promptUsedRef: body.promptUsedRef, + expectedKpiImpactId: body.expectedKpiImpactId, + filename: body.filename, + taskRef: body.taskRef, + plainLanguageSentence: body.plainLanguageSentence, + }); + return { ok: true, deliverable, commit_sha: deliverable.commitSha ?? null }; + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + return reply.status(503).send({ ok: false, error: e instanceof Error ? e.message : String(e) }); + } + }); + + // ── POST /api/mission-control/deliverable/:id/verify ────────────────── + // Confirms the recorded commit resolves in the deliverables repo AND the + // on-disk artifact still hashes to contentHash. Flips status verified / + // failed. This is what turns "the fleet ran" into "the output is real". + app.post("/api/mission-control/deliverable/:id/verify", async (req, reply) => { + const ar = authReq(req); + try { + assertBoard(ar); + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + throw e; + } + const { id } = req.params as { id: string }; + const body = (req.body ?? {}) as { reviewerNodeId?: string }; + await ensureBootstrap(); + try { + const existing = await getDeliverable(id); + if (!existing) { + return reply.status(404).send({ ok: false, error: "not found" }); + } + assertCompanyAccess(ar, existing.instanceId); + const result = await verifyDeliverable(id, body.reviewerNodeId); + if (!result) { + return reply.status(404).send({ ok: false, error: "not found" }); + } + return { + ok: result.ok, + status: result.status, + reason: result.reason ?? null, + deliverable: result.deliverable, + }; + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + return reply.status(503).send({ ok: false, error: e instanceof Error ? e.message : String(e) }); + } + }); + // ── KPI impacts ─────────────────────────────────────────────────────── app.post( "/api/mission-control/:companyId/kpi-impacts", diff --git a/packages/wavex-os-server/test/mission-control-deliverables.test.ts b/packages/wavex-os-server/test/mission-control-deliverables.test.ts index 63cc110..c6ea094 100644 --- a/packages/wavex-os-server/test/mission-control-deliverables.test.ts +++ b/packages/wavex-os-server/test/mission-control-deliverables.test.ts @@ -1,7 +1,9 @@ /** Mission Control Deliverables — write + query round-trip. */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { dirname } from "node:path"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { _resetDbCache, runMigrations } from "@wavex-os/db"; @@ -9,6 +11,7 @@ import { writeDeliverable, queryDeliverables, deliverableFolder, + verifyDeliverable, } from "../src/mission-control/deliverables.js"; import { _resetActivityBusForTesting, @@ -117,3 +120,82 @@ describe("writeDeliverable", () => { expect(f!.folder).toMatch(/deliverables$/); }); }); + +describe("git-first deliverable artifact", () => { + it("commits the artifact and the commit resolves via git", async () => { + const d = await writeDeliverable({ + companyId: "co-git", + instanceId: "co-git", + modeContext: "solo_founder", + taskRefType: "issue", + taskRefId: "WAV-100", + producedByNodeId: "agent-cmo", + kind: "document", + title: "Q3 campaign brief", + payload: { body: "Launch the autumn line via IG + email." }, + }); + + // commit_sha is recorded (git sha1 = 40 hex, sha256 = 64 hex). + expect(d.commitSha).toMatch(/^[a-f0-9]{40,64}$/); + expect(d.gitRef).toBe("main"); + + // The recorded commit resolves in the deliverables repo, and the file + // is part of that commit. + const dir = dirname(d.diskPath); + expect(() => + execFileSync("git", ["cat-file", "-e", `${d.commitSha}^{commit}`], { cwd: dir }), + ).not.toThrow(); + const tree = execFileSync("git", ["show", "--stat", "--oneline", d.commitSha], { + cwd: dir, + encoding: "utf8", + }); + expect(tree).toContain(d.relPath); + }); + + it("verifyDeliverable flips a clean artifact to verified", async () => { + const received: string[] = []; + subscribeMissionControlEvents((e) => received.push(e.kind)); + + const d = await writeDeliverable({ + companyId: "co-v", + instanceId: "co-v", + modeContext: "solo_founder", + taskRefType: "issue", + taskRefId: "WAV-200", + producedByNodeId: "agent-strategy", + kind: "document", + title: "GTM plan", + payload: { body: "Plan body." }, + }); + + const result = await verifyDeliverable(d.id, "reviewer-cto"); + expect(result).not.toBeNull(); + expect(result!.ok).toBe(true); + expect(result!.status).toBe("verified"); + expect(result!.deliverable.status).toBe("verified"); + expect(result!.deliverable.reviewedByNodeId).toBe("reviewer-cto"); + expect(received).toContain("deliverable_verified"); + }); + + it("verifyDeliverable fails when the on-disk artifact is tampered", async () => { + const d = await writeDeliverable({ + companyId: "co-t", + instanceId: "co-t", + modeContext: "solo_founder", + taskRefType: "issue", + taskRefId: "WAV-300", + producedByNodeId: "agent-x", + kind: "document", + title: "Tamper test", + payload: { body: "original" }, + }); + + // Mutate the artifact on disk after it was recorded + committed. + writeFileSync(d.diskPath, JSON.stringify({ tampered: true }), "utf8"); + + const result = await verifyDeliverable(d.id, undefined); + expect(result!.ok).toBe(false); + expect(result!.status).toBe("failed"); + expect(result!.reason).toMatch(/hash mismatch/i); + }); +}); From da8ed18a9668d24ed9e699416d877b140d521de1 Mon Sep 17 00:00:00 2001 From: aimerdoux Date: Fri, 29 May 2026 05:42:15 -0700 Subject: [PATCH 2/2] fix(test): assert commitSha non-null in execFileSync arg commitSha is optional on the Deliverable shape (string | undefined); execFileSync's args array requires string. The test asserts it matches the hex regex just above, so `!` is safe. Fixes a tsc overload error. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wavex-os-server/test/mission-control-deliverables.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wavex-os-server/test/mission-control-deliverables.test.ts b/packages/wavex-os-server/test/mission-control-deliverables.test.ts index c6ea094..6d6ffb6 100644 --- a/packages/wavex-os-server/test/mission-control-deliverables.test.ts +++ b/packages/wavex-os-server/test/mission-control-deliverables.test.ts @@ -145,7 +145,7 @@ describe("git-first deliverable artifact", () => { expect(() => execFileSync("git", ["cat-file", "-e", `${d.commitSha}^{commit}`], { cwd: dir }), ).not.toThrow(); - const tree = execFileSync("git", ["show", "--stat", "--oneline", d.commitSha], { + const tree = execFileSync("git", ["show", "--stat", "--oneline", d.commitSha!], { cwd: dir, encoding: "utf8", });