mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(plugins): runAndPersistHandoff() + auto-handoff from diff-review bridge
Plan T1 / spec §11.5.1 / §21.5.
apps/daemon/src/plugins/atoms/handoff.ts gains the on-disk shell
runAndPersistHandoff({ cwd, manifestSeed?, exportTarget?, ... })
that round-trips `<cwd>/handoff/manifest.json`:
1. Read existing manifest (if any) so promotions stay monotonic.
Fall back to manifestSeed; final fallback is a minimal default.
2. Call runHandoffAtom() with the chosen seed + caller's targets.
3. Write the updated manifest back ONLY when something changed.
persistMode in the result distinguishes 'created' / 'updated' /
'skipped' so callers can decide whether to broadcast an event.
The diff-review GenUI bridge (R1) now auto-invokes this helper
after recording the user's decision. The /api/runs/:id/genui/...
respond endpoint therefore writes BOTH:
<cwd>/review/decision.json (R1)
<cwd>/handoff/manifest.json (T1 — auto-promotes from the new
decision + any prior build-test
signals)
The handoff bridge is best-effort: a failure surfaces on the
ApplyDiffReviewDecisionResult as `handoffError` without failing
the diff-review write.
Phase 8 promotion ladder now closes end-to-end without an agent
turn:
user clicks 'Accept all' in the web composer's diff-review
surface
\u2192 POST /api/runs/:id/genui/__auto_diff_review_<stage>/respond
\u2192 daemon writes review/decision.json
\u2192 daemon writes handoff/manifest.json with handoffKind set
according to the reject/accept + build-test ladder
\u2192 follow-up od plugin export reads handoff/manifest.json and
ships the artefact with the right provenance fields.
Daemon tests: 1641 \u2192 1647 (+6 cases on plugins-handoff-persist:
created from seed, round-trips existing manifest with monotonic
promotion, no-op skip on second run, deployable-app rung with
docker export, diff-review bridge auto-creates handoff/manifest.json
on accept, reject auto-stamps design-only, build-test signals
forward through the bridge into 'patch' tier).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
co-authored by
Tom Huang
parent
4c3423a500
commit
97c3b77f51
@@ -26,6 +26,7 @@
|
||||
// downstream review/decision.json + receipts.
|
||||
|
||||
import { runDiffReview, type DiffReviewer, type DiffReviewReport } from './diff-review.js';
|
||||
import { runAndPersistHandoff, type RunAndPersistHandoffResult } from './handoff.js';
|
||||
|
||||
const DIFF_REVIEW_SURFACE_PREFIX = '__auto_diff_review_';
|
||||
|
||||
@@ -69,6 +70,13 @@ export interface ApplyDiffReviewDecisionInput {
|
||||
export interface ApplyDiffReviewDecisionResult {
|
||||
ok: true;
|
||||
report: DiffReviewReport;
|
||||
// Plan §3.T1 — when the diff-review write succeeds, we also run the
|
||||
// handoff atom against the project cwd so `<cwd>/handoff/manifest.json`
|
||||
// tracks the promotion ladder live. Best-effort: a failure here
|
||||
// doesn't fail the overall bridge call (the diff-review write
|
||||
// already succeeded).
|
||||
handoff?: RunAndPersistHandoffResult;
|
||||
handoffError?: string;
|
||||
}
|
||||
export interface ApplyDiffReviewDecisionFailure {
|
||||
ok: false;
|
||||
@@ -89,7 +97,15 @@ export async function applyDiffReviewDecisionToCwd(
|
||||
if (parsed.rejected_files) decision.rejected_files = parsed.rejected_files;
|
||||
if (parsed.reason) decision.reason = parsed.reason;
|
||||
const report = await runDiffReview({ cwd: input.cwd, decision });
|
||||
return { ok: true, report };
|
||||
// Auto-promote `<cwd>/handoff/manifest.json` from the new
|
||||
// decision + any prior build-test signals. Best-effort.
|
||||
const result: ApplyDiffReviewDecisionResult = { ok: true, report };
|
||||
try {
|
||||
result.handoff = await runAndPersistHandoff({ cwd: input.cwd });
|
||||
} catch (err) {
|
||||
result.handoffError = (err as Error).message;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { ok: false, error: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -232,6 +232,90 @@ async function readDiffReviewDecision(cwd: string): Promise<'accept' | 'reject'
|
||||
}
|
||||
}
|
||||
|
||||
// Plan §3.T1 — `<cwd>/handoff/manifest.json` round-trip.
|
||||
//
|
||||
// runAndPersistHandoff() is the on-disk shell around runHandoffAtom():
|
||||
//
|
||||
// 1. Read `<cwd>/handoff/manifest.json` (or fall back to
|
||||
// `manifestSeed`, or finally a minimal default) so promotions
|
||||
// stay monotonic across re-runs.
|
||||
// 2. Call runHandoffAtom() with the chosen seed + the caller's
|
||||
// explicit export/deploy targets.
|
||||
// 3. Write the updated manifest back to
|
||||
// `<cwd>/handoff/manifest.json`.
|
||||
// 4. Return the report shape the caller can forward to SSE / CLI
|
||||
// audit logs.
|
||||
//
|
||||
// The bridge fires from the diff-review GenUI flow and from any
|
||||
// pipeline runner that wants the manifest computed declaratively.
|
||||
|
||||
export interface RunAndPersistHandoffInput {
|
||||
cwd: string;
|
||||
// When the manifest file is missing AND no exportTargets are
|
||||
// declared, fall back to this seed. Useful for the first pipeline
|
||||
// run: the agent hasn't produced a manifest yet but we still want
|
||||
// to record the promotion ladder progress.
|
||||
manifestSeed?: ArtifactManifest;
|
||||
exportTarget?: ArtifactExportTarget;
|
||||
exportTargets?: ArtifactExportTarget[];
|
||||
deployTarget?: ArtifactDeployTarget;
|
||||
deployTargets?: ArtifactDeployTarget[];
|
||||
}
|
||||
|
||||
export interface RunAndPersistHandoffResult extends RunHandoffAtomResult {
|
||||
manifestPath: string;
|
||||
// 'created' = no on-disk manifest existed; 'updated' = round-tripped;
|
||||
// 'skipped' = nothing changed (re-run was a no-op).
|
||||
persistMode: 'created' | 'updated' | 'skipped';
|
||||
}
|
||||
|
||||
const DEFAULT_MANIFEST_SEED: ArtifactManifest = {
|
||||
version: 1,
|
||||
kind: 'react-component',
|
||||
title: 'Pipeline output',
|
||||
entry: '',
|
||||
renderer: 'react-component',
|
||||
exports: [],
|
||||
};
|
||||
|
||||
export async function runAndPersistHandoff(
|
||||
input: RunAndPersistHandoffInput,
|
||||
): Promise<RunAndPersistHandoffResult> {
|
||||
const cwd = path.resolve(input.cwd);
|
||||
const manifestPath = path.join(cwd, 'handoff', 'manifest.json');
|
||||
const existing = await readManifestFile(manifestPath);
|
||||
const seed: ArtifactManifest = existing ?? input.manifestSeed ?? DEFAULT_MANIFEST_SEED;
|
||||
|
||||
const handoffInput: RunHandoffAtomInput = { cwd, manifest: seed };
|
||||
if (input.exportTarget) handoffInput.exportTarget = input.exportTarget;
|
||||
if (input.exportTargets) handoffInput.exportTargets = input.exportTargets;
|
||||
if (input.deployTarget) handoffInput.deployTarget = input.deployTarget;
|
||||
if (input.deployTargets) handoffInput.deployTargets = input.deployTargets;
|
||||
const report = await runHandoffAtom(handoffInput);
|
||||
|
||||
let persistMode: RunAndPersistHandoffResult['persistMode'];
|
||||
if (!existing) {
|
||||
await fsp.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fsp.writeFile(manifestPath, JSON.stringify(report.manifest, null, 2) + '\n', 'utf8');
|
||||
persistMode = 'created';
|
||||
} else if (report.changed.length > 0) {
|
||||
await fsp.writeFile(manifestPath, JSON.stringify(report.manifest, null, 2) + '\n', 'utf8');
|
||||
persistMode = 'updated';
|
||||
} else {
|
||||
persistMode = 'skipped';
|
||||
}
|
||||
return { ...report, manifestPath, persistMode };
|
||||
}
|
||||
|
||||
async function readManifestFile(p: string): Promise<ArtifactManifest | undefined> {
|
||||
try {
|
||||
const raw = await fsp.readFile(p, 'utf8');
|
||||
return JSON.parse(raw) as ArtifactManifest;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBuildTestSignals(cwd: string): Promise<{ buildPassing: boolean; testsPassing: boolean } | undefined> {
|
||||
const p = path.join(cwd, 'critique', 'build-test.json');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Phase 8 entry slice — runAndPersistHandoff() round-trip + auto-bridge.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ArtifactManifest } from '@open-design/contracts';
|
||||
import { runAndPersistHandoff } from '../src/plugins/atoms/handoff.js';
|
||||
import { applyDiffReviewDecisionToCwd } from '../src/plugins/atoms/diff-review-genui-bridge.js';
|
||||
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-handoff-persist-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeDecision(decision: 'accept' | 'reject' | 'partial') {
|
||||
await mkdir(path.join(cwd, 'review'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(cwd, 'review', 'decision.json'),
|
||||
JSON.stringify({ decision, accepted_files: [], rejected_files: [], reviewer: 'user', decidedAt: new Date().toISOString() }),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeBuildTest(buildPassing: boolean, testsPassing: boolean) {
|
||||
await mkdir(path.join(cwd, 'critique'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(cwd, 'critique', 'build-test.json'),
|
||||
JSON.stringify({
|
||||
signals: {
|
||||
'build.passing': buildPassing,
|
||||
'tests.passing': testsPassing,
|
||||
'critique.score': buildPassing && testsPassing ? 5 : 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeReceipts() {
|
||||
await mkdir(path.join(cwd, 'plan', 'receipts'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(cwd, 'plan', 'steps.json'),
|
||||
JSON.stringify([
|
||||
{ id: 'rewrite-button', files: [], rationale: '', risk: 'low', status: 'completed' },
|
||||
]),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, 'plan', 'receipts', 'step-rewrite-button.json'),
|
||||
JSON.stringify({
|
||||
step: 'rewrite-button',
|
||||
files: ['Button.tsx'],
|
||||
added: 1, removed: 1,
|
||||
rationale: '',
|
||||
completedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('runAndPersistHandoff', () => {
|
||||
it('creates handoff/manifest.json from the seed when no manifest exists', async () => {
|
||||
await writeDecision('accept');
|
||||
const result = await runAndPersistHandoff({
|
||||
cwd,
|
||||
manifestSeed: {
|
||||
version: 1, kind: 'react-component', title: 'Button',
|
||||
entry: 'Button.tsx', renderer: 'react-component', exports: [],
|
||||
},
|
||||
});
|
||||
expect(result.persistMode).toBe('created');
|
||||
expect(result.manifest.handoffKind).toBe('implementation-plan');
|
||||
const onDisk = JSON.parse(await readFile(result.manifestPath, 'utf8'));
|
||||
expect(onDisk.handoffKind).toBe('implementation-plan');
|
||||
});
|
||||
|
||||
it('round-trips an existing manifest + advances handoffKind monotonically', async () => {
|
||||
await writeDecision('accept');
|
||||
const initial: ArtifactManifest = {
|
||||
version: 1, kind: 'react-component', title: 'Button',
|
||||
entry: 'Button.tsx', renderer: 'react-component', exports: [],
|
||||
handoffKind: 'design-only',
|
||||
};
|
||||
await mkdir(path.join(cwd, 'handoff'), { recursive: true });
|
||||
await writeFile(path.join(cwd, 'handoff', 'manifest.json'), JSON.stringify(initial));
|
||||
|
||||
const a = await runAndPersistHandoff({ cwd });
|
||||
expect(a.persistMode).toBe('updated');
|
||||
expect(a.manifest.handoffKind).toBe('implementation-plan');
|
||||
|
||||
// Re-run is a no-op (the inputs didn't change).
|
||||
const b = await runAndPersistHandoff({ cwd });
|
||||
expect(b.persistMode).toBe('skipped');
|
||||
expect(b.manifest.handoffKind).toBe('implementation-plan');
|
||||
});
|
||||
|
||||
it('promotes to deployable-app when build/test signals + docker export combine', async () => {
|
||||
await writeDecision('accept');
|
||||
await writeBuildTest(true, true);
|
||||
const result = await runAndPersistHandoff({
|
||||
cwd,
|
||||
manifestSeed: {
|
||||
version: 1, kind: 'react-component', title: 'Button',
|
||||
entry: 'Button.tsx', renderer: 'react-component', exports: [],
|
||||
},
|
||||
exportTarget: { surface: 'docker', target: 'ghcr.io/od/x:1', exportedAt: 1 },
|
||||
});
|
||||
expect(result.manifest.handoffKind).toBe('deployable-app');
|
||||
expect(result.signals.deployable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDiffReviewDecisionToCwd \u2192 auto-handoff bridge', () => {
|
||||
it('writes BOTH review/decision.json AND handoff/manifest.json on accept', async () => {
|
||||
await writeReceipts();
|
||||
const result = await applyDiffReviewDecisionToCwd({
|
||||
cwd,
|
||||
reviewer: 'user',
|
||||
value: { decision: 'accept' },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.report.decision?.decision).toBe('accept');
|
||||
expect(result.handoff?.manifest.handoffKind).toBe('implementation-plan');
|
||||
expect(result.handoff?.persistMode).toBe('created');
|
||||
}
|
||||
const onDisk = JSON.parse(await readFile(path.join(cwd, 'handoff', 'manifest.json'), 'utf8'));
|
||||
expect(onDisk.handoffKind).toBe('implementation-plan');
|
||||
});
|
||||
|
||||
it('forwards build-test signals through into the auto-handoff manifest', async () => {
|
||||
await writeReceipts();
|
||||
await writeBuildTest(true, true);
|
||||
const result = await applyDiffReviewDecisionToCwd({
|
||||
cwd,
|
||||
reviewer: 'user',
|
||||
value: { decision: 'accept' },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
// Without a docker/cli export target the rung tops out at 'patch'.
|
||||
expect(result.handoff?.manifest.handoffKind).toBe('patch');
|
||||
expect(result.handoff?.signals.buildPassing).toBe(true);
|
||||
expect(result.handoff?.signals.testsPassing).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("a 'reject' decision auto-stamps handoffKind='design-only'", async () => {
|
||||
await writeReceipts();
|
||||
const result = await applyDiffReviewDecisionToCwd({
|
||||
cwd,
|
||||
reviewer: 'user',
|
||||
value: { decision: 'reject' },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.handoff?.manifest.handoffKind).toBe('design-only');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user