Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@dspack-studio/contracts": "workspace:*",
"@dspack-studio/replay": "workspace:*",
"@aestheticfunction/dspack-emit": "^0.4.1",
"@aestheticfunction/dspack-export": "^0.4.0",
"@aestheticfunction/dspack-export": "^0.5.0",
"@aestheticfunction/dspack-spec": "^0.4.2",
"@dspack-studio/composer-core": "workspace:*"
},
Expand Down
102 changes: 98 additions & 4 deletions apps/agent/src/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,110 @@ describe("rediscover", () => {
);
const { status, payload } = await call("rediscover", { path: root });
expect(status).toBe(200);
expect(payload.report.addedComponents).toContain("spark-line");
// Human-owned components section preserved: enrichment survives the merge.
expect(payload.report.preservedHumanOwned).toContain("components");
// The demo project ships a v1 ledger: this first rediscovery migrates it
// (human-owned components section -> entries unattributed, byte-identical
// ones re-adopted as tool-owned). Migration cannot distinguish
// "hand-deleted" from "new since the snapshot", so the fresh-only id
// ASKS instead of silently adding.
expect(payload.report.migration).toBe("human-owned");
expect(payload.contract.metadata["x-bootstrap"].ledger).toBe("2");
expect(payload.report.components.added).toEqual([]);
expect(payload.report.components.deletedAwaitingDecision).toContain("spark-line");
expect(payload.contract.components["spark-line"]).toBeUndefined();
// Human-owned entries preserved verbatim: enrichment survives the merge.
expect(payload.contract.components["action-button"].props.label.required).toBe(true);
expect(payload.contract.components["action-button"].whenToUse).toBeTruthy();
// Governance carried over verbatim.
expect(payload.contract.rules.length).toBeGreaterThan(0);
// Ledger still reports the section human-owned after the merge.
// Section state derives from entries under v2: enrichment keeps it human-owned.
const byName = Object.fromEntries(payload.ledger.sections.map((s: any) => [s.section, s.state]));
expect(byName.components).toBe("human-owned");
expect(payload.ledger.entryLevel).toBe(true);
// Restoring the genuinely-new component is one explicit decision.
const restored = await call("rediscover", { path: root, restoreTopLevel: ["spark-line"] });
expect(restored.status).toBe(200);
expect(restored.payload.report.components.restoredTopLevel).toEqual([{ id: "spark-line" }]);
expect(restored.payload.contract.components["spark-line"]).toBeDefined();
const entries = Object.fromEntries(restored.payload.ledger.componentEntries.map((e: any) => [e.id, e.state]));
expect(entries["spark-line"]).toBe("tool-owned"); // restored tool-owned
expect(["human-owned", "unattributed"]).toContain(entries["action-button"]); // enriched, yours
});

it("refuses a malformed restoreTopLevel with 400 before touching the project", async () => {
const bad = await call("rediscover", { path: root, restoreTopLevel: [42] });
expect(bad.status).toBe(400);
expect(bad.payload.error).toContain("array of component id strings");
});

it("skip-and-ask: a hand-deleted entry is never silently restored; tombstoning ends the asking", async () => {
const { writeFileSync } = await import("node:fs");
// Hand-delete spark-line from the document (the ledger hash remains).
const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8"));
delete contract.components["spark-line"];
writeFileSync(join(root, "acme-ui.dspack.json"), JSON.stringify(contract, null, 2) + "\n");

// Rediscovery: the source still has spark-line, but restoration is skipped.
const first = await call("rediscover", { path: root });
expect(first.status).toBe(200);
expect(first.payload.contract.components["spark-line"]).toBeUndefined();
expect(first.payload.report.components.deletedAwaitingDecision).toContain("spark-line");
const orphan = first.payload.ledger.componentEntries.find((e: any) => e.id === "spark-line");
expect(orphan.state).toBe("orphaned"); // deletion memory survives the merge

// Decide: tombstone it (what the composer's "Never rediscover" button saves).
const decided = structuredClone(first.payload.contract);
decided.metadata["x-bootstrap"].doNotRediscover = ["spark-line"];
delete decided.metadata["x-bootstrap"].components["spark-line"];
const saved = await call("save", { path: root, kind: "contract", document: decided });
expect(saved.payload.ok).toBe(true);

// Rediscovery now skips it unambiguously, and keeps skipping it.
const second = await call("rediscover", { path: root });
expect(second.status).toBe(200);
expect(second.payload.report.components.suppressed).toContain("spark-line");
expect(second.payload.report.components.deletedAwaitingDecision).not.toContain("spark-line");
expect(second.payload.contract.components["spark-line"]).toBeUndefined();
expect(second.payload.ledger.componentEntries.find((e: any) => e.id === "spark-line").state).toBe("tombstoned");
});

it("restoredConflict: authored sub-component blocks re-add until the explicit restore-top-level intent", async () => {
// Undo the tombstone and author spark-line as a sub-component of
// action-button (the #13 restructure shape, through the real routes).
const contract = JSON.parse(readFileSync(join(root, "acme-ui.dspack.json"), "utf8"));
contract.metadata["x-bootstrap"].doNotRediscover = [];
contract.components["action-button"].composition = {
subComponents: [{ id: "spark-line", name: "SparkLine", description: "Inline trend inside the button." }],
};
const saved = await call("save", { path: root, kind: "contract", document: contract });
expect(saved.payload.ok).toBe(true);

// Outcome 3 first (leave unresolved): reported, never re-added.
const unresolved = await call("rediscover", { path: root });
expect(unresolved.status).toBe(200);
// The shipped demo project carries its own #13-shaped conflicts
// (info-card sub-vocabulary discovered top-level in source), so assert
// on spark-line specifically rather than the whole list.
expect(unresolved.payload.report.components.restoredConflict).toContainEqual({ id: "spark-line", parent: "action-button" });
expect(unresolved.payload.contract.components["spark-line"]).toBeUndefined();

// A contradictory intent refuses with the tool's words (nothing partial).
const contradicted = await call("rediscover", { path: root, restoreTopLevel: ["not-in-source"] });
expect(contradicted.status).toBe(409);
expect(contradicted.payload.error).toContain("not-in-source");

// Outcome 2: the explicit intent restores tool-owned, nested preserved.
const restored = await call("rediscover", { path: root, restoreTopLevel: ["spark-line"] });
expect(restored.status).toBe(200);
expect(restored.payload.report.components.restoredTopLevel).toEqual([{ id: "spark-line", parent: "action-button" }]);
expect(restored.payload.report.components.restoredConflict.map((x: any) => x.id)).not.toContain("spark-line");
expect(restored.payload.contract.components["spark-line"]).toBeDefined();
expect(restored.payload.contract.components["action-button"].composition.subComponents[0].id).toBe("spark-line");
expect(restored.payload.ledger.componentEntries.find((e: any) => e.id === "spark-line").state).toBe("tool-owned");

// Subsequent runs treat it as ordinary tool-owned; the conflict is gone.
const after = await call("rediscover", { path: root });
expect(after.payload.report.components.restoredConflict.map((x: any) => x.id)).not.toContain("spark-line");
expect(after.payload.report.components.unchanged).toContain("spark-line");
});
});

Expand Down
20 changes: 17 additions & 3 deletions apps/agent/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,21 +229,35 @@ async function discover(ctx: ProjectContext) {
* Section-scoped rediscovery: fresh extraction merged at the ledger's
* granularity (dspack-export regenerateSections) — tool-owned refreshes,
* human-owned and governance preserved, new components added.
* `restoreTopLevel` passes explicit owner intents through (the ratified
* "Restore top-level" conflict resolution); the tool's refusals are
* returned verbatim.
*/
async function rediscover(ctx: ProjectContext) {
async function rediscover(ctx: ProjectContext, body: Record<string, unknown>) {
const config = exportConfig(ctx);
if (!existsSync(ctx.contractPath)) {
throw new ProjectError(409, "no contract exists yet; run discovery first");
}
const restoreTopLevel = body.restoreTopLevel;
if (restoreTopLevel !== undefined && (!Array.isArray(restoreTopLevel) || restoreTopLevel.some((id) => typeof id !== "string"))) {
throw new ProjectError(400, "restoreTopLevel must be an array of component id strings");
}
const existing = readJson(ctx.contractPath) as Parameters<typeof regenerateSections>[0];
let fresh;
try {
fresh = exportProject(config).document;
} catch (e) {
throw new ProjectError(409, (e instanceof Error ? e.message : String(e)).trim());
}
const result = regenerateSections(existing, fresh);
const result = regenerateSections(existing, fresh, restoreTopLevel ? { restoreTopLevel: restoreTopLevel as string[] } : undefined);
if (!result.ok) throw new ProjectError(409, result.reason);
// One-validator principle: every contract write passes the same harness
// gate as /project/save — a merge that produced an invalid document is
// refused, not persisted.
const report = documentReport(result.document as unknown as Record<string, unknown>, specValidators());
if (!report.valid) {
throw new ProjectError(409, `rediscovery produced a document the harness rejects; nothing was written: ${report.errors.join("; ")}`);
}
atomicWriteJson(ctx.contractPath, result.document);
const contract = result.document as unknown as Record<string, unknown>;
return { ok: true, contract, ledger: await ledgerStatus(contract), report: result.report };
Expand Down Expand Up @@ -473,7 +487,7 @@ export async function handleProjectRoute(
json(res, 200, await discover(ctx), cors);
return true;
case "rediscover":
json(res, 200, await rediscover(ctx), cors);
json(res, 200, await rediscover(ctx, body), cors);
return true;
case "emit":
json(res, 200, emit(ctx), cors);
Expand Down
28 changes: 25 additions & 3 deletions apps/composer/app/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,39 @@ export async function probeAgent(): Promise<boolean> {
}
}

/**
* dspack-export 0.5.0's RegenerateReport (shape owned there). The entry-
* level classes are the ratified regeneration-state table; every one is
* rendered, none is acted on without an explicit human decision.
*/
export interface RediscoverReport {
refreshed: string[];
preservedHumanOwned: string[];
keptMissingInFresh: string[];
addedComponents: string[];
migration?: "tool-owned" | "human-owned";
components: {
added: string[];
refreshed: string[];
unchanged: string[];
readopted: string[];
preservedEnriched: Array<{ id: string; freshDelta: Array<{ path: string; fresh: unknown }> }>;
removedWithSource: string[];
keptMissingInFresh: string[];
deletedAwaitingDecision: string[];
suppressed: string[];
suppressedButPresent: string[];
restoredConflict: Array<{ id: string; parent: string }>;
restoredTopLevel: Array<{ id: string; parent?: string }>;
};
}

export const agentConnect = (path: string) => post<ConnectPayload>("/project/connect", { path });
export const agentDiscover = (path: string) => post<{ ok: boolean; log: string; contract: Record<string, unknown>; ledger: LedgerStatus }>("/project/discover", { path });
export const agentRediscover = (path: string) =>
post<{ ok: boolean; contract: Record<string, unknown>; ledger: LedgerStatus; report: RediscoverReport }>("/project/rediscover", { path });
export const agentRediscover = (path: string, restoreTopLevel?: string[]) =>
post<{ ok: boolean; contract: Record<string, unknown>; ledger: LedgerStatus; report: RediscoverReport }>(
"/project/rediscover",
restoreTopLevel ? { path, restoreTopLevel } : { path },
);
export const agentEmit = (path: string) => post<EmitPayload>("/project/emit", { path });
export const agentValidate = (path: string) => post<ValidatePayload>("/project/validate", { path });
export const agentSave = (path: string, kind: "contract" | "profile", document: unknown) =>
Expand Down
Loading
Loading