diff --git a/docs/plans/README.md b/docs/plans/README.md index ece5b40c..0780e106 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -14,6 +14,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Compaction](compaction-plan.md) | Partial | Pi-native checkpoints, lifecycle/crash recovery, and exact audited-upstream compatibility ship; durable memory and provider-native paths remain open. | | [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.84.4. | | [Generative UI Artifacts](generative-ui-artifacts-plan.md) | Active | Phases 0–6 shipped: chat-scoped `render_artifact`, strict sandboxed preview/export hosts, verified vendored Chart.js/Plotly/KaTeX, permission-aware `/visualize`, crash-recoverable authoritative storage/copies, descriptor-relative workspace reads, one-iframe handoff/expansion, visible failure states, and route-stable Responding/Visualizing activity. Three-agent PR review findings are remediated with focused regression coverage. | +| [Stitch-Inspired Design Studio](stitch-inspired-design-studio-plan.md) | Active | Phase 1 is complete: selection, exact revision navigation, canvas recovery, inspector layout, workflow terminology, and responsive Electron acceptance are green. Phase 2 is in progress: Project V2, durable title policy, per-Screen surface semantics, and migration. | | [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | | [Logging and Diagnostics Upgrade](logging-and-diagnostics-upgrade-plan.md) | Implemented | Phases 0–7 are implemented: bounded typed desktop journals, main-owned renderer evidence, local support export/delete, native categorical parity, and CI/release gates. Signed/notarized `v0.35.0` passed packaged diagnostics acceptance; physical-device termination receipts remain. | | [Long-thread payload upgrades](long-thread-payload-upgrade-plan.md) | Partial | Investigation complete: T3’s O(N²) stdout store does not exist here. No-op `toolRunning` timeline republish is skipped; Remote gzip, stream-journal debounce, chat JSON/attachments, and transcript windowing remain planned. | diff --git a/docs/plans/stitch-inspired-design-studio-plan.md b/docs/plans/stitch-inspired-design-studio-plan.md new file mode 100644 index 00000000..15d8346a --- /dev/null +++ b/docs/plans/stitch-inspired-design-studio-plan.md @@ -0,0 +1,130 @@ +# Stitch-Inspired Design Studio Plan + +Status: Active — Phases 1–2 complete; Phase 3 implementation in progress +Date: 2026-09-03 +Branch: `feature/stitch-design-studio` +Predecessor: [Design Workspace Durable Projects and Handoff](completed/design-workspace-claude-alignment-plan.md) +Research notes: [Stitch product and interaction findings](../../stitch-ideas.md) + +## Objective + +Evolve Aiden Design from a conversation-attached canvas into a local-first, screen-centered studio: + +```text +Brief → Explore → Choose → Refine → Prototype → Inspect → Export / Build +``` + +Preserve Aiden's main-owned storage, immutable revisions, semantic compare-and-swap publication, restart reconciliation, sandboxed previews, deterministic export, and permission-preserving engineering handoff. Conversation remains a contextual work surface rather than the product's organizing center. + +## Delivery status + +- [x] Phase 1 — coherent selection, canvas recovery, inspector layout, terminology, and responsive workbench +- [x] Phase 2 — Project V2, title policy, per-screen surface semantics, and migration +- [ ] Phase 3 — durable generation intents, Explore, single-screen Refine, direction sets, and cancellation recovery +- [ ] Phase 4 — project-local Design Language and hardened deterministic `DESIGN.md` +- [ ] Phase 5 — bounded prototype graph and host verification +- [ ] Phase 6 — project export and handoff V2 +- [ ] Final — combined review, full CI, packaged acceptance, and PR delivery + +Each phase requires focused implementation tests, a fresh correctness/edge-case review, a UI/UX review where applicable, remediation, and rerun verification before the next phase begins. + +## Product and vocabulary contract + +- **Project** is the durable local Design object. +- **Screen** is the user-facing name for one stable generated lineage. +- **Revision** is one immutable media artifact within a Screen. +- **Direction set** groups one Explore operation's alternatives. +- **Chosen direction** is a reversible project decision and never deletes alternatives. +- **Prototype** is a bounded, verified graph between exact Screen revisions. +- **Connected preview** remains distinct from an Aiden-owned Screen. +- **Design Language** is a project-local semantic system with exact revision and content hash. + +Existing persisted `artboard`, `lineageId`, `mediaId`, and `canonicalOrigin` identifiers remain unchanged. Renderer selection, conversation content, imported prose, and connection state are context rather than authority. + +## Phase 1 — Workbench coherence + +- Introduce one ephemeral selection projection shared by canvas, transcript artifacts, navigator, composer, History, Comments, and inspector. +- Make **Show on canvas** select, center, activate the requested preview revision, and restore canvas focus. +- Separate historical preview from the durable active revision; add explicit **Make current** and **Refine from this** actions. +- Keep every durable Screen visible as a preview, loading placeholder, or explicit error state; expose Fit recovery when the saved viewport is offscreen. +- Repair the Code rail so long source scrolls internally and cannot displace the inspector. +- Replace ambiguous creation controls with named Explore, Refine, Prototype, Inspect, and Export actions while keeping Select, Hand, Zoom, and Fit as canvas mechanics. +- Separate preview dimensions from surface translation. +- Measure workbench width and switch the context surface from inline rail to contained overlay/full-width sheet without hiding its close control. + +Acceptance: every entry point resolves to one exact Screen/revision; reopening never appears to lose durable Screens; History inspection cannot silently mutate `activeMediaId`; narrow layouts pass keyboard, focus, and pointer-interception checks. + +## Phase 2 — Project V2 and semantic ownership + +- Add dual readers and atomic V1→V2 migration. +- Add durable title policy (`auto-eligible`, `auto-applied`, `manual`) so manual names always win. +- Create blank projects immediately and apply a title only after the first successful Screen publication. +- Add per-Screen surface/frame facts while retaining the project viewport as a preview preference during migration. +- Restrict the generic update endpoint to layout facts; use dedicated main-owned CAS operations for active revision, title, direction, language, prototype, and connection changes. +- Add optional versioned generation provenance before introducing Explore. + +Acceptance: old projects preserve every ID, artifact byte, canvas position, timestamp, origin, reference, connection fact, and nonterminal recovery operation; renderer-forged semantic fields fail closed. + +## Phase 3 — Explore and Refine + +- Persist a strict main-resolved generation intent with the user turn. +- Explore creates 2–4 new lineages from a blank brief or one exact base revision. +- Refine advances exactly one selected lineage from an exact immutable base. +- Persist Direction sets with requested/actual counts, creative range, selected aspects, members, source revision, chosen direction, archive presentation state, and partial/complete status. +- Choose direction never deletes siblings; archive does not recover artifact quota. +- A cancelled user generation with usable candidates asks **Keep draft** or **Discard**. A kept partial Explore remains visibly incomplete and can retry missing members. +- Extend publication ownership and startup reconciliation so artifact and Direction-set stages converge idempotently. + +Acceptance: stale bases cannot publish; Explore never advances the selected lineage; partial/cancel/restart cannot create phantom or duplicate members; each new revision exposes exact content-free intent provenance. + +## Phase 4 — Project-local Design Language + +- Add authored, imported, derived, and workspace-snapshot provenance without weakening the existing workspace freshness contract. +- Store bounded immutable normalized snapshots behind a small owner-only project index. +- Support describe, derive, import, export, apply, compare, reviewed merge, refresh, and detach. +- Bind the exact language revision/hash into generation intent and artifact provenance. +- Define a deterministic Aiden `DESIGN.md` subset. Reject unsafe YAML features, raw HTML, embedded resources, executable directives, paths, credentials, unsupported encodings, control characters, and oversized/deep input. +- Treat all imported human guidance as inert untrusted model context. + +Acceptance: prototype-only projects work without Connect App; canonical import/export round trips preserve the normalized hash; malicious fixtures and stale workspace snapshots fail closed. + +## Phase 5 — Prototype graph + +- Store exact Screen/revision nodes and whitelisted interaction edges separately from HTML. +- Allow bounded click, submit, change, and keyboard triggers with validated destinations and transitions. +- Keep static, unverified, verified, broken/stale, and connected-preview states distinct. +- Verify through the existing network-denied preview host, recording exact source hashes and bounded check evidence. +- Make missing states, keyboard/focus, reduced motion, and link validity explicit checks rather than unverified claims. + +Acceptance: no legacy or generated Screen is labeled interactive without verification; stale/remapped revisions visibly invalidate affected edges; the bridge cannot navigate outside its exact project/iframe capability. + +## Phase 6 — Export and handoff V2 + +- Preserve existing single-Screen export. +- Add a versioned deterministic project bundle containing a Project Brief, `DESIGN.md`, exact Screen sources, prototype graph, and bounded references. +- Extend the existing recoverable handoff journal with chosen direction, reviewed Screen subset, Design Language hash, prototype summary, responsive intent, accessibility notes, and exact source hashes. +- Integrate every new project-owned record into duplicate, delete, recovery, and health-check lifecycles. +- Keep export, Connect App, and Continue in Workspace as distinct authority boundaries. + +Acceptance: identical reviewed scope produces byte-identical output; stale preview digests require re-preview; no prompt, transcript, credential, absolute path, implicit permission, or rejected source crosses the boundary without explicit review; all V1 journals remain recoverable. + +## Cross-cutting verification + +- Exact-key parsing, bounds, Unicode, future-version, migration, rollback, and corruption fixtures. +- Store CAS, two-window conflict, crash-boundary, restart reconciliation, duplicate, delete, and feature-disable tests. +- Electron E2E for new project, Show on canvas, History preview, Code rail, offscreen recovery, Explore/cancel, exact-base Refine, Design Language, Prototype, export, relaunch, and preview cleanup. +- Accessibility coverage for roles, names, focus-visible, text-entry focus rules, keyboard-only operation, VoiceOver, reduced motion, high contrast, and forced colors. +- A 20-Screen/10-reference/100-revision fixture with a hard live-iframe cap and truthful offscreen placeholders. +- Full Generative UI, Design recovery, remote exclusion, type-check, lint, build, and exact-head CI gates. Shared chat/remote schema changes trigger iOS and Android contract suites even though the Design canvas remains Mac-only. +- Signed packaged-app acceptance remains separate from source/CI completion. + +## Explicit non-goals + +- Stitch runtime, code, or asset dependency +- Cloud storage, hosted sharing, or multiplayer +- Public MCP, Figma export, QR/LAN preview, or deployment partners +- Heatmaps, animation generation, or marketing/App Store assets +- Multi-Screen Refine before exact per-output attribution exists +- Five alternatives before measured quota and memory changes +- Automatic repository writes or permission escalation +- Exposing Design Projects or executable artifacts to native clients diff --git a/main/handlers/design-project-params.test.ts b/main/handlers/design-project-params.test.ts index 485b0787..1fc3c55e 100644 --- a/main/handlers/design-project-params.test.ts +++ b/main/handlers/design-project-params.test.ts @@ -11,42 +11,18 @@ import { parseDesignProjectStartPreviewParams, } from "./design-project-params.js"; -test("Design Project creation separates Prototype storage from Connected App authority", () => { - assert.deepEqual( - parseDesignProjectCreateParams({ title: " Checkout ", connectionState: "prototype-only" }), - { title: "Checkout", connectionState: "prototype-only" }, - ); - assert.deepEqual( - parseDesignProjectCreateParams({ - title: "Checkout", - connectionState: "connected", - workspaceId: "workspace-1", - }), - { title: "Checkout", connectionState: "connected", workspaceId: "workspace-1" }, - ); - assert.throws( - () => - parseDesignProjectCreateParams({ - title: "Forged Prototype", - connectionState: "prototype-only", - workspaceId: "workspace-1", - }), - /workspace connection/u, - ); - assert.throws( - () => parseDesignProjectCreateParams({ title: "Missing", connectionState: "connected" }), - /workspace connection/u, - ); - assert.throws( - () => - parseDesignProjectCreateParams({ - title: "Legacy authority", - connectionState: "connected", - chatWorkspaceId: "workspace-1", - connectedWorkspaceId: "workspace-1", - }), - /invalid design project request/iu, - ); +test("Design Project creation is a main-owned blank local operation", () => { + assert.deepEqual(parseDesignProjectCreateParams({}), {}); + for (const request of [ + { title: "Checkout" }, + { connectionState: "connected" }, + { workspaceId: "workspace-1" }, + ]) { + assert.throws( + () => parseDesignProjectCreateParams(request), + /invalid design project request/iu, + ); + } }); test("connection and preflight IPC accept identities, never paths", () => { @@ -77,18 +53,20 @@ test("generic content updates reject forged workspace bindings and transitions", id: "project:one", expectedRevision: 2, canvas: { viewport: "desktop", flowViewport: { x: 0, y: 0, zoom: 1 }, nodes: [] }, - referenceAssetIds: [], }; assert.deepEqual(parseDesignProjectContentUpdateEnvelope(update), update); - assert.throws( - () => - parseDesignProjectContentUpdateEnvelope({ - ...update, - connectionState: "connected", - workspaceId: "forged-workspace", - }), - /invalid design project request/iu, - ); + for (const claim of [ + { connectionState: "connected" }, + { workspaceId: "forged-workspace" }, + { referenceAssetIds: ["asset:forged"] }, + { designSystemBinding: { id: "design-system:forged", revision: 1 } }, + { previewScriptId: "forged" }, + ]) { + assert.throws( + () => parseDesignProjectContentUpdateEnvelope({ ...update, ...claim }), + /invalid design project request/iu, + ); + } }); test("source preview IPC is project-bound and rejects renderer workspace claims", () => { diff --git a/main/handlers/design-project-params.ts b/main/handlers/design-project-params.ts index bd1b62b5..85aa7718 100644 --- a/main/handlers/design-project-params.ts +++ b/main/handlers/design-project-params.ts @@ -1,8 +1,4 @@ -import { - isDesignProjectOpaqueId, - normalizeDesignProjectTitle, - type DesignProjectConnectionState, -} from "../services/design-project-contract.js"; +import { isDesignProjectOpaqueId } from "../services/design-project-contract.js"; function exactRecord( value: unknown, @@ -49,34 +45,9 @@ function boundedString(value: unknown, label: string, max: number): string { return value; } -function connectionState(value: unknown): DesignProjectConnectionState { - if (value !== "prototype-only" && value !== "connected") { - throw new Error("Invalid Design Project connection state."); - } - return value; -} - -export function parseDesignProjectCreateParams(value: unknown): { - title: string; - connectionState: DesignProjectConnectionState; - workspaceId?: string; -} { - const input = exactRecord( - value, - new Set(["title", "connectionState", "workspaceId"]), - new Set(["title", "connectionState"]), - ); - const title = normalizeDesignProjectTitle(input.title); - if (!title) throw new Error("Invalid Design Project title."); - const state = connectionState(input.connectionState); - const workspaceId = input.workspaceId === undefined ? undefined : opaqueId(input.workspaceId); - if ( - (state === "prototype-only" && workspaceId !== undefined) || - (state === "connected" && workspaceId === undefined) - ) { - throw new Error("Invalid Design Project workspace connection."); - } - return { title, connectionState: state, ...(workspaceId ? { workspaceId } : {}) }; +export function parseDesignProjectCreateParams(value: unknown): Record { + exactRecord(value, new Set()); + return {}; } export function parseDesignProjectConnectParams(value: unknown): { @@ -84,10 +55,7 @@ export function parseDesignProjectConnectParams(value: unknown): { expectedRevision: number; workspaceId: string; } { - const input = exactRecord( - value, - new Set(["projectId", "expectedRevision", "workspaceId"]), - ); + const input = exactRecord(value, new Set(["projectId", "expectedRevision", "workspaceId"])); return { projectId: opaqueId(input.projectId), expectedRevision: revision(input.expectedRevision), @@ -105,18 +73,7 @@ export function parseDesignProjectPreflightParams(value: unknown): { projectId: * A workspace binding may only be created by designer:connectProject. */ export function parseDesignProjectContentUpdateEnvelope(value: unknown): Record { - return exactRecord( - value, - new Set([ - "id", - "expectedRevision", - "canvas", - "referenceAssetIds", - "designSystemBinding", - "previewScriptId", - ]), - new Set(["id", "expectedRevision", "canvas", "referenceAssetIds"]), - ); + return exactRecord(value, new Set(["id", "expectedRevision", "canvas"])); } export function parseDesignProjectPreviewParams(value: unknown): { projectId: string } { diff --git a/main/handlers/designer.ts b/main/handlers/designer.ts index d2d118d9..ff9a454b 100644 --- a/main/handlers/designer.ts +++ b/main/handlers/designer.ts @@ -26,9 +26,10 @@ import { import { isDesignProjectOpaqueId, normalizeDesignProjectTitle, - parseDesignProjectCanvasV1, - type DesignProjectSnapshotV1, + type DesignProjectSnapshot as DesignProjectSnapshotV1, } from "../services/design-project-contract.js"; +import { parseDesignProjectCanvasForV2Update } from "../services/design-project-contract-v2.js"; +import { normalizeDesignScreenPresentationV2 } from "../services/design-project-v2-policy.js"; import { DesignProjectPublicationUncertainError, DesignProjectRevisionConflictError, @@ -639,38 +640,28 @@ export function registerDesignerHandlers(): void { ipcMain.handle("designer:createProject", async (event, inputValue: unknown) => { const owner = ownerFor(event); - const input = parseDesignProjectCreateParams(inputValue); - const { title } = input; - const state = input.connectionState; - const connectedWorkspaceId = input.workspaceId; - const create = async (signal?: AbortSignal) => { + parseDesignProjectCreateParams(inputValue); + const title = "Untitled Design"; + const create = async () => { const chat = await chatApplicationService.createDesignConversation({ title }, owner); if (!chat) throw new Error("Aiden could not create the Design Project conversation."); try { - if (signal?.aborted) throw signal.reason; return await designProjectLifecycle.runProjectMutation(() => designProjectStore.create({ chatId: chat.id, title, - connectionState: state, - ...(connectedWorkspaceId ? { workspaceId: connectedWorkspaceId } : {}), + titleOrigin: "blank", + connectionState: "prototype-only", }), ); } catch (error) { if (!(error instanceof DesignProjectPublicationUncertainError)) { - await chatApplicationService.remove(chat.id); + await chatApplicationService.remove(chat.id); } throw error; } }; - if (!connectedWorkspaceId) { - return create(); - } - return workspaceEnvironmentApplicationService.run( - owner, - connectedWorkspaceId, - async (_resolved, signal) => create(signal), - ); + return create(); }); ipcMain.handle("designer:connectProject", async (event, inputValue: unknown) => { @@ -697,35 +688,17 @@ export function registerDesignerHandlers(): void { ipcMain.handle("designer:updateProject", async (event, inputValue: unknown) => { ownerFor(event); const input = parseDesignProjectContentUpdateEnvelope(inputValue); - const canvas = parseDesignProjectCanvasV1(input.canvas); - if (!canvas || !Array.isArray(input.referenceAssetIds)) { + const canvas = parseDesignProjectCanvasForV2Update(input.canvas); + if (!canvas) { throw new Error("Invalid Design Project canvas."); } - const referenceAssetIds = input.referenceAssetIds.map(projectId); const requestedProjectId = projectId(input.id); try { const project = await designProjectLifecycle.runProjectMutation(async () => { - await requireStoredReferenceAssets(referenceAssetIds); - const current = await designProjectStore.get(requestedProjectId); - if (!current) throw new Error("Design Project was not found."); return designProjectStore.update({ id: requestedProjectId, expectedRevision: projectRevision(input.expectedRevision), - connectionState: current.connectionState, - ...(current.workspaceId ? { workspaceId: current.workspaceId } : {}), canvas, - referenceAssetIds, - ...(input.designSystemBinding === undefined - ? {} - : { - designSystemBinding: exactRecord( - input.designSystemBinding, - new Set(["id", "revision"]), - ) as unknown as DesignProjectSnapshotV1["designSystemBinding"], - }), - ...(input.previewScriptId === undefined - ? {} - : { previewScriptId: projectId(input.previewScriptId) }), }); }); return { status: "updated" as const, project }; @@ -737,6 +710,79 @@ export function registerDesignerHandlers(): void { } }); + ipcMain.handle("designer:setActiveRevision", async (event, inputValue: unknown) => { + ownerFor(event); + const input = exactRecord( + inputValue, + new Set(["id", "expectedRevision", "lineageId", "mediaId"]), + ); + const requestedProjectId = projectId(input.id); + try { + const project = await designProjectLifecycle.runProjectMutation(() => + designProjectStore.setActiveRevision({ + id: requestedProjectId, + expectedRevision: projectRevision(input.expectedRevision), + lineageId: projectId(input.lineageId), + mediaId: projectId(input.mediaId), + }), + ); + return { status: "updated" as const, project }; + } catch (error) { + if (!(error instanceof DesignProjectRevisionConflictError)) throw error; + const current = await designProjectStore.get(requestedProjectId); + if (!current) throw error; + return { status: "conflict" as const, current }; + } + }); + + ipcMain.handle("designer:setScreenPresentation", async (event, inputValue: unknown) => { + ownerFor(event); + const input = exactRecord( + inputValue, + new Set(["id", "expectedRevision", "lineageId", "presentation"]), + ); + const requestedProjectId = projectId(input.id); + const presentation = normalizeDesignScreenPresentationV2(input.presentation); + if (!presentation) throw new Error("Invalid Design Screen presentation."); + try { + const project = await designProjectLifecycle.runProjectMutation(() => + designProjectStore.setScreenPresentation({ + id: requestedProjectId, + expectedRevision: projectRevision(input.expectedRevision), + lineageId: projectId(input.lineageId), + presentation, + }), + ); + return { status: "updated" as const, project }; + } catch (error) { + if (!(error instanceof DesignProjectRevisionConflictError)) throw error; + const current = await designProjectStore.get(requestedProjectId); + if (!current) throw error; + return { status: "conflict" as const, current }; + } + }); + + ipcMain.handle("designer:setPreviewScript", async (event, inputValue: unknown) => { + ownerFor(event); + const input = exactRecord(inputValue, new Set(["id", "expectedRevision", "previewScriptId"])); + const requestedProjectId = projectId(input.id); + try { + const project = await designProjectLifecycle.runProjectMutation(() => + designProjectStore.setPreviewScript({ + id: requestedProjectId, + expectedRevision: projectRevision(input.expectedRevision), + previewScriptId: projectId(input.previewScriptId), + }), + ); + return { status: "updated" as const, project }; + } catch (error) { + if (!(error instanceof DesignProjectRevisionConflictError)) throw error; + const current = await designProjectStore.get(requestedProjectId); + if (!current) throw error; + return { status: "conflict" as const, current }; + } + }); + ipcMain.handle("designer:renameProject", async (event, inputValue: unknown) => { ownerFor(event); const input = exactRecord(inputValue, new Set(["id", "expectedRevision", "title"])); @@ -1043,14 +1089,10 @@ export function registerDesignerHandlers(): void { ); try { const updated = await designProjectLifecycle.runProjectMutation(() => - designProjectStore.update({ + designProjectStore.setDesignSystemBinding({ id: project.id, expectedRevision: project.revision, - connectionState: project.connectionState, - workspaceId: project.workspaceId, - canvas: project.canvas, - referenceAssetIds: project.referenceAssetIds, - designSystemBinding: { + binding: { id: attachment.attachmentId, revision: attachment.revision, }, @@ -1105,14 +1147,10 @@ export function registerDesignerHandlers(): void { ), ); const updated = await designProjectLifecycle.runProjectMutation(() => - designProjectStore.update({ + designProjectStore.setDesignSystemBinding({ id: project.id, expectedRevision: project.revision, - connectionState: project.connectionState, - workspaceId: project.workspaceId, - canvas: project.canvas, - referenceAssetIds: project.referenceAssetIds, - designSystemBinding: { + binding: { id: attachment.attachmentId, revision: attachment.revision, }, @@ -1134,13 +1172,9 @@ export function registerDesignerHandlers(): void { throw new DesignProjectRevisionConflictError(project.revision); } const updated = await designProjectLifecycle.runProjectMutation(() => - designProjectStore.update({ + designProjectStore.setDesignSystemBinding({ id: project.id, expectedRevision: project.revision, - connectionState: project.connectionState, - workspaceId: project.workspaceId, - canvas: project.canvas, - referenceAssetIds: project.referenceAssetIds, }), ); await designSystemAttachmentService.detach( @@ -1534,6 +1568,35 @@ export function registerDesignerHandlers(): void { }); }); + ipcMain.handle("designer:attachReferenceAsset", async (event, inputValue: unknown) => { + ownerFor(event); + const input = exactRecord( + inputValue, + new Set(["projectId", "expectedRevision", "nodeId", "assetId", "x", "y"]), + ); + const requestedProjectId = projectId(input.projectId); + const assetId = string(input.assetId, "Design reference image identity", 64); + await requireStoredReferenceAssets([assetId]); + try { + const project = await designProjectLifecycle.runProjectMutation(() => + designProjectStore.attachReferenceAsset({ + id: requestedProjectId, + expectedRevision: projectRevision(input.expectedRevision), + nodeId: projectId(input.nodeId), + assetId, + x: input.x as number, + y: input.y as number, + }), + ); + return { status: "updated" as const, project }; + } catch (error) { + if (!(error instanceof DesignProjectRevisionConflictError)) throw error; + const current = await designProjectStore.get(requestedProjectId); + if (!current) throw error; + return { status: "conflict" as const, current }; + } + }); + ipcMain.handle("designer:readReferenceAsset", async (event, assetIdValue: unknown) => { ownerFor(event); const result = await designReferenceAssetStore.read( @@ -1563,11 +1626,11 @@ export function registerDesignerHandlers(): void { owner, project.workspaceId!, async (resolved) => { - const current = await requireConnectedProject(requestedProjectId); - assertSameConnectedDesignProjectBinding(current, project); + const current = await requireConnectedProject(requestedProjectId); + assertSameConnectedDesignProjectBinding(current, project); return sourceDesignPreviewService.state(owner, project.id, resolved.folderPath); }, - ); + ); }); }); @@ -1581,89 +1644,89 @@ export function registerDesignerHandlers(): void { }); ipcMain.handle("designer:startPreview", async (event, inputValue: unknown) => { - const owner = ownerFor(event); - const input = parseDesignProjectStartPreviewParams(inputValue); - return designProjectLifecycle.runProjectMutation(async () => { - const project = await requireConnectedProject(input.projectId); - const workspaceId = project.workspaceId; - if (workspaceMutationGate.isChanging(workspaceId)) { - throw new Error("The workspace is changing. Try again in a moment."); + const owner = ownerFor(event); + const input = parseDesignProjectStartPreviewParams(inputValue); + return designProjectLifecycle.runProjectMutation(async () => { + const project = await requireConnectedProject(input.projectId); + const workspaceId = project.workspaceId; + if (workspaceMutationGate.isChanging(workspaceId)) { + throw new Error("The workspace is changing. Try again in a moment."); + } + const admission = admitOwnedWorkspaceOperation( + workspaceOperationRegistry, + owner, + workspaceId, + ); + try { + if ( + owner.isDestroyed() || + admission.signal.aborted || + workspaceMutationGate.isChanging(workspaceId) + ) { + throw new Error("The workspace changed before the preview could start."); + } + const resolved = await workspaceEnvironmentApplicationService.resolve(workspaceId, true); + if (!resolved) throw new Error("The workspace folder is unavailable."); + const current = await requireConnectedProject(input.projectId); + assertSameConnectedDesignProjectBinding(current, project); + if ( + owner.isDestroyed() || + admission.signal.aborted || + workspaceMutationGate.isChanging(workspaceId) + ) { + throw new Error("The workspace changed before the preview could start."); } - const admission = admitOwnedWorkspaceOperation( - workspaceOperationRegistry, + // The preview service owns and releases this admission for the full + // lifetime of the child process after a successful start. + return await sourceDesignPreviewService.start({ owner, + admission, + projectId: project.id, workspaceId, - ); - try { - if ( - owner.isDestroyed() || - admission.signal.aborted || - workspaceMutationGate.isChanging(workspaceId) - ) { - throw new Error("The workspace changed before the preview could start."); - } - const resolved = await workspaceEnvironmentApplicationService.resolve(workspaceId, true); - if (!resolved) throw new Error("The workspace folder is unavailable."); - const current = await requireConnectedProject(input.projectId); - assertSameConnectedDesignProjectBinding(current, project); - if ( - owner.isDestroyed() || - admission.signal.aborted || - workspaceMutationGate.isChanging(workspaceId) - ) { - throw new Error("The workspace changed before the preview could start."); - } - // The preview service owns and releases this admission for the full - // lifetime of the child process after a successful start. - return await sourceDesignPreviewService.start({ - owner, - admission, - projectId: project.id, - workspaceId, - root: resolved.folderPath, - scriptId: input.scriptId, - }); - } catch (error) { - admission.release(); - throw error; - } + root: resolved.folderPath, + scriptId: input.scriptId, + }); + } catch (error) { + admission.release(); + throw error; + } }); - }); + }); ipcMain.handle("designer:bindSelection", async (event, inputValue: unknown) => { - const owner = ownerFor(event); - const input = parseDesignProjectBindSelectionParams(inputValue); - const descriptor = parseSourceElementDescriptor(input.descriptor); - if (!descriptor) throw new Error("The selected element context is invalid."); - return designProjectLifecycle.runProjectMutation(async () => { - const project = await requireConnectedProject(input.projectId); - return workspaceEnvironmentApplicationService.run(owner, project.workspaceId, async () => { - const current = await requireConnectedProject(input.projectId); - assertSameConnectedDesignProjectBinding(current, project); - return sourceDesignerActionService.bind( - owner, - project.id, - project.workspaceId, - input.sessionId, - descriptor, - ); - }); - }); - }); - - ipcMain.handle("designer:listActions", async (event, inputValue: unknown) => { - const owner = ownerFor(event); - const { projectId: requestedProjectId } = parseDesignProjectPreviewParams(inputValue); - return designProjectLifecycle.runProjectMutation(async () => { - const project = await requireConnectedProject(requestedProjectId); - return sourceDesignerActionService.list( + const owner = ownerFor(event); + const input = parseDesignProjectBindSelectionParams(inputValue); + const descriptor = parseSourceElementDescriptor(input.descriptor); + if (!descriptor) throw new Error("The selected element context is invalid."); + return designProjectLifecycle.runProjectMutation(async () => { + const project = await requireConnectedProject(input.projectId); + return workspaceEnvironmentApplicationService.run(owner, project.workspaceId, async () => { + const current = await requireConnectedProject(input.projectId); + assertSameConnectedDesignProjectBinding(current, project); + return sourceDesignerActionService.bind( owner, project.id, - project.chatId, project.workspaceId, + input.sessionId, + descriptor, ); - }); }); + }); + }); + + ipcMain.handle("designer:listActions", async (event, inputValue: unknown) => { + const owner = ownerFor(event); + const { projectId: requestedProjectId } = parseDesignProjectPreviewParams(inputValue); + return designProjectLifecycle.runProjectMutation(async () => { + const project = await requireConnectedProject(requestedProjectId); + return sourceDesignerActionService.list( + owner, + project.id, + project.chatId, + project.workspaceId, + ); + }); + }); ipcMain.handle("designer:listMultifileActions", async (event, projectIdValue: unknown) => { ownerFor(event); @@ -1807,28 +1870,28 @@ export function registerDesignerHandlers(): void { ); ipcMain.handle("designer:applyAction", async (event, inputValue: unknown) => { - const owner = ownerFor(event); - const input = parseDesignProjectActionParams(inputValue); - return designProjectLifecycle.runProjectMutation(async () => { - const project = await requireConnectedProject(input.projectId); - requireProjectAction(owner, project, input.actionId); - return workspaceEnvironmentApplicationService.run( - owner, - project.workspaceId, - async (resolved, signal) => { - const current = await requireConnectedProject(input.projectId); - assertSameConnectedDesignProjectBinding(current, project); - requireProjectAction(owner, current, input.actionId); - return sourceDesignerActionService.apply( - owner, - input.actionId, - resolved.folderPath, - signal, - ); - }, - ); + const owner = ownerFor(event); + const input = parseDesignProjectActionParams(inputValue); + return designProjectLifecycle.runProjectMutation(async () => { + const project = await requireConnectedProject(input.projectId); + requireProjectAction(owner, project, input.actionId); + return workspaceEnvironmentApplicationService.run( + owner, + project.workspaceId, + async (resolved, signal) => { + const current = await requireConnectedProject(input.projectId); + assertSameConnectedDesignProjectBinding(current, project); + requireProjectAction(owner, current, input.actionId); + return sourceDesignerActionService.apply( + owner, + input.actionId, + resolved.folderPath, + signal, + ); + }, + ); }); - }); + }); ipcMain.handle("designer:rejectAction", async (event, inputValue: unknown) => { const owner = ownerFor(event); @@ -1841,26 +1904,26 @@ export function registerDesignerHandlers(): void { }); ipcMain.handle("designer:undoAction", async (event, inputValue: unknown) => { - const owner = ownerFor(event); - const input = parseDesignProjectActionParams(inputValue); - return designProjectLifecycle.runProjectMutation(async () => { - const project = await requireConnectedProject(input.projectId); - requireProjectAction(owner, project, input.actionId); - return workspaceEnvironmentApplicationService.run( - owner, - project.workspaceId, - async (resolved, signal) => { - const current = await requireConnectedProject(input.projectId); - assertSameConnectedDesignProjectBinding(current, project); - requireProjectAction(owner, current, input.actionId); - return sourceDesignerActionService.undo( - owner, - input.actionId, - resolved.folderPath, - signal, - ); - }, - ); + const owner = ownerFor(event); + const input = parseDesignProjectActionParams(inputValue); + return designProjectLifecycle.runProjectMutation(async () => { + const project = await requireConnectedProject(input.projectId); + requireProjectAction(owner, project, input.actionId); + return workspaceEnvironmentApplicationService.run( + owner, + project.workspaceId, + async (resolved, signal) => { + const current = await requireConnectedProject(input.projectId); + assertSameConnectedDesignProjectBinding(current, project); + requireProjectAction(owner, current, input.actionId); + return sourceDesignerActionService.undo( + owner, + input.actionId, + resolved.folderPath, + signal, + ); + }, + ); }); - }); + }); } diff --git a/main/services/chat-workspace-authority.test.ts b/main/services/chat-workspace-authority.test.ts index dce1bc1a..995814cc 100644 --- a/main/services/chat-workspace-authority.test.ts +++ b/main/services/chat-workspace-authority.test.ts @@ -142,10 +142,7 @@ test("Design generation rejects forged chats, stale renderer roots, and invalid test("persisted Design ownership cannot be downgraded or renderer-forged", () => { const project = designProject(); - assert.equal( - authoritativeChatDesignMode(DESIGN_PROJECT_CHAT_WORKSPACE_ID, true, project), - true, - ); + assert.equal(authoritativeChatDesignMode(DESIGN_PROJECT_CHAT_WORKSPACE_ID, true, project), true); assert.equal(authoritativeChatDesignMode("legacy-workspace", true, project), true); assert.throws( () => authoritativeChatDesignMode(DESIGN_PROJECT_CHAT_WORKSPACE_ID, undefined, project), diff --git a/main/services/chat-workspace-authority.ts b/main/services/chat-workspace-authority.ts index 4412950e..20e412a2 100644 --- a/main/services/chat-workspace-authority.ts +++ b/main/services/chat-workspace-authority.ts @@ -4,7 +4,7 @@ import { } from "../../renderer/shared/chat-workspace.js"; import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; import { DESIGN_PROJECT_CHAT_WORKSPACE_ID } from "../../renderer/shared/design-projects.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import type { ChatStartParams } from "./types.js"; export { persistedChatWorkspaceId }; @@ -84,14 +84,10 @@ export function authoritativeChatGenerationMode( persistedWorkspaceId: string | undefined, requestedMode: ChatStartParams["mode"], ): ChatStartParams["mode"] { - if ( - requestedMode === "assistant-unattended" || - requestedMode === "assistant-automation" - ) { + if (requestedMode === "assistant-unattended" || requestedMode === "assistant-automation") { return requestedMode; } - const isAssistantChat = - persistedChatWorkspaceId(persistedWorkspaceId) === ASSISTANT_WORKSPACE_ID; + const isAssistantChat = persistedChatWorkspaceId(persistedWorkspaceId) === ASSISTANT_WORKSPACE_ID; if (isAssistantChat) return "assistant"; if (requestedMode === "assistant") { throw new Error("This chat is not an Aiden Assistant chat."); diff --git a/main/services/design-artifact-recovery.test.ts b/main/services/design-artifact-recovery.test.ts index 7250df88..7e36f58e 100644 --- a/main/services/design-artifact-recovery.test.ts +++ b/main/services/design-artifact-recovery.test.ts @@ -471,7 +471,11 @@ test("an exact eligible active revision finalizes a post-project publication ide const result = await service.recover("project:recovery", plan.expectedRevision); assert.equal(result.status, "recovered"); assert.equal(source.designPublication, "published"); - assert.deepEqual(setup.getProject(), before, "the already-published project snapshot is unchanged"); + assert.deepEqual( + setup.getProject(), + before, + "the already-published project snapshot is unchanged", + ); const retry = await service.recover("project:recovery", plan.expectedRevision); assert.equal(retry.status, "recovered"); assert.deepEqual(setup.getProject(), before); @@ -511,8 +515,7 @@ for (const metadataDamage of ["suppressed", "ownership-mismatch"] as const) { source.designOwnership = { version: 1, kind: "revision", - projectId: - metadataDamage === "ownership-mismatch" ? "project:other" : setup.getProject().id, + projectId: metadataDamage === "ownership-mismatch" ? "project:other" : setup.getProject().id, lineageId: "lineage:recovery", baseMediaId: "design:base", }; @@ -855,7 +858,9 @@ test("generation reconciliation preserves a quota-pruned exact recovery candidat artifacts: { ...setup.dependencies.artifacts, async discardPending() { - assert.fail("an exact recovery candidate must not be discarded by generation reconciliation"); + assert.fail( + "an exact recovery candidate must not be discarded by generation reconciliation", + ); }, }, projects: { @@ -881,7 +886,10 @@ test("generation reconciliation preserves a quota-pruned exact recovery candidat assert.equal(retryPlan.status, "recoverable"); const result = await restarted.recover("project:recovery", retryPlan.expectedRevision); assert.equal(result.status, "recovered"); - assert.equal(setup.records.get(setup.getProject().canvas.nodes[0]!.activeMediaId!)?.html, VALID_HTML); + assert.equal( + setup.records.get(setup.getProject().canvas.nodes[0]!.activeMediaId!)?.html, + VALID_HTML, + ); }); test("a completed recovery for artboard A never shadows a later recovery for broken artboard B", async () => { diff --git a/main/services/design-artifact-recovery.ts b/main/services/design-artifact-recovery.ts index 90267dd8..7513602c 100644 --- a/main/services/design-artifact-recovery.ts +++ b/main/services/design-artifact-recovery.ts @@ -8,10 +8,11 @@ import type { } from "../../renderer/shared/design-projects.js"; import { HTML_ARTIFACT_MIME_TYPE } from "../../renderer/shared/generative-ui.js"; import { DESIGN_ARTIFACT_MEDIA_ID_PREFIX } from "../../renderer/shared/design-workspace.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { DesignProjectRevisionConflictError, - type DesignProjectStore, + type RemoveMissingGeneratedArtboardInput, + type RemoveMissingGeneratedRevisionInput, } from "./design-project-store.js"; import { DESIGN_ARTIFACT_RECOVERY_GENERATION_PREFIX, @@ -40,10 +41,15 @@ export interface DesignArtifactRecoverySource { } export interface DesignArtifactRecoveryDependencies { - projects: Pick< - DesignProjectStore, - "get" | "removeMissingGeneratedArtboard" | "removeMissingGeneratedRevision" - >; + projects: { + get(id: string): Promise; + removeMissingGeneratedArtboard( + input: RemoveMissingGeneratedArtboardInput, + ): Promise; + removeMissingGeneratedRevision( + input: RemoveMissingGeneratedRevisionInput, + ): Promise; + }; artifacts: Pick< GenerativeUiArtifactStore, | "stage" @@ -354,7 +360,9 @@ export class DesignArtifactRecoveryService { })), ); const valid = new Set( - records.filter(({ source }) => sourceIsUsable(project, source)).map(({ mediaId }) => mediaId), + records + .filter(({ source }) => sourceIsUsable(project, source)) + .map(({ mediaId }) => mediaId), ); const repaired = new Set(); let changed = true; @@ -381,7 +389,10 @@ export class DesignArtifactRecoveryService { : source ? "corrupt-artifact" : "missing-artifact"; - if (sourceIsUsable(project, source) || (repaired.has(mediaId) && mediaId !== active?.mediaId)) { + if ( + sourceIsUsable(project, source) || + (repaired.has(mediaId) && mediaId !== active?.mediaId) + ) { continue; } return { diff --git a/main/services/design-artifact-source-authority.ts b/main/services/design-artifact-source-authority.ts index 8f707c16..4ba0492c 100644 --- a/main/services/design-artifact-source-authority.ts +++ b/main/services/design-artifact-source-authority.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { ChatHtmlArtifactV1 } from "../../renderer/shared/chat-artifacts.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { validateGenerativeUiHtml } from "./generative-ui-html.js"; import type { CommittedGenerativeUiRecoverySource, @@ -97,5 +97,7 @@ export function isUsableLiveDesignCandidateSource( project: DesignProjectSnapshotV1 | undefined, source: StagedHtmlArtifact | undefined, ): source is StagedHtmlArtifact { - return isValidDesignArtifactSource(source) && projectOwnsLiveDesignCandidateSource(project, source); + return ( + isValidDesignArtifactSource(source) && projectOwnsLiveDesignCandidateSource(project, source) + ); } diff --git a/main/services/design-direct-edit-main-adapters.ts b/main/services/design-direct-edit-main-adapters.ts index f2fc4a57..f149977a 100644 --- a/main/services/design-direct-edit-main-adapters.ts +++ b/main/services/design-direct-edit-main-adapters.ts @@ -1,5 +1,5 @@ import { chatStore } from "./chat-store.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { createDesignDirectEditMessagePort } from "./design-direct-edit-message-port.js"; import { DesignDirectEditService } from "./design-direct-edit-service.js"; import { designProjectStore } from "./design-project-store-main.js"; diff --git a/main/services/design-direct-edit-service.ts b/main/services/design-direct-edit-service.ts index 0d46b308..b66a25b2 100644 --- a/main/services/design-direct-edit-service.ts +++ b/main/services/design-direct-edit-service.ts @@ -16,7 +16,8 @@ import { transformConnectedDirectEdit, transformPrototypeDirectEdit, } from "./design-direct-edit-transforms.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshotV2 } from "./design-project-contract-v2.js"; import { DesignProjectConflictError, DesignProjectRevisionConflictError, @@ -236,14 +237,14 @@ export class DesignDirectEditService { } private async completePrototypePublication(input: { - project: DesignProjectSnapshotV1; + project: DesignProjectSnapshotV2; generationId: string; artifact: ChatHtmlArtifactV1; html: string; lineageId: string; baseMediaId: string; model?: string; - }): Promise { + }): Promise { let record = await this.publicationRecord({ chatId: input.project.chatId, generationId: input.generationId, @@ -280,6 +281,7 @@ export class DesignDirectEditService { { mediaId: input.artifact.mediaId, ownership: record.designOwnership!, + candidateTitle: input.artifact.title, }, ], }); @@ -383,12 +385,12 @@ export class DesignDirectEditService { }); } else { // Compatibility for revisions published by the legacy coordinator. - await this.dependencies.messages.ensureArtifactMessage({ - chatId: project.chatId, - artifact, - createdAt: this.now(), - }); - await this.dependencies.artifacts.commit(project.chatId, [mediaId]); + await this.dependencies.messages.ensureArtifactMessage({ + chatId: project.chatId, + artifact, + createdAt: this.now(), + }); + await this.dependencies.artifacts.commit(project.chatId, [mediaId]); } return { kind: "prototype-revision", @@ -524,12 +526,12 @@ export class DesignDirectEditService { baseMediaId: input.editedMediaId, }); } else { - await this.dependencies.messages.ensureArtifactMessage({ - chatId: project.chatId, - artifact, - createdAt: this.now(), - }); - await this.dependencies.artifacts.commit(project.chatId, [mediaId]); + await this.dependencies.messages.ensureArtifactMessage({ + chatId: project.chatId, + artifact, + createdAt: this.now(), + }); + await this.dependencies.artifacts.commit(project.chatId, [mediaId]); } return { kind: "prototype-revision", proposalId, undoId: input.undoId, artifact, project }; } diff --git a/main/services/design-generated-revision-contract.ts b/main/services/design-generated-revision-contract.ts index 17dfd561..54ab9668 100644 --- a/main/services/design-generated-revision-contract.ts +++ b/main/services/design-generated-revision-contract.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; import type { ChatHtmlArtifactV1 } from "../../renderer/shared/chat-artifacts.js"; import { isDesignProjectOpaqueId } from "./design-project-contract.js"; +import { + DEFAULT_NEW_DESIGN_SCREEN_PRESENTATION, + normalizeDesignScreenPresentationV2, + type DesignScreenPresentationV2, +} from "./design-project-v2-policy.js"; export const DESIGN_GENERATED_REVISION_OWNERSHIP_VERSION = 1 as const; export const DESIGN_ARTIFACT_RECOVERY_GENERATION_PREFIX = "journal-recovery:" as const; @@ -22,6 +27,7 @@ export type DesignGeneratedRevisionOwnershipV1 = kind: "new-artboard"; projectId: string; lineageId: string; + presentation?: DesignScreenPresentationV2; } | { version: typeof DESIGN_GENERATED_REVISION_OWNERSHIP_VERSION; @@ -34,6 +40,8 @@ export type DesignGeneratedRevisionOwnershipV1 = export interface OwnedDesignGeneratedRevisionV1 { mediaId: string; ownership: DesignGeneratedRevisionOwnershipV1; + /** Main-validated artifact title candidate; never used as identity or ownership. */ + candidateTitle?: string; } function digest(parts: readonly string[]): string { @@ -53,12 +61,14 @@ export function generatedDesignNodeId(projectId: string, lineageId: string): str export function newArtboardOwnership( projectId: string, mediaId: string, + presentation: DesignScreenPresentationV2 = DEFAULT_NEW_DESIGN_SCREEN_PRESENTATION, ): DesignGeneratedRevisionOwnershipV1 { return { version: DESIGN_GENERATED_REVISION_OWNERSHIP_VERSION, kind: "new-artboard", projectId, lineageId: generatedDesignLineageId(projectId, mediaId), + presentation, }; } @@ -79,9 +89,16 @@ export function parseDesignGeneratedRevisionOwnershipV1( return undefined; } if (record.kind === "new-artboard") { + const presentation = + record.presentation === undefined + ? undefined + : normalizeDesignScreenPresentationV2(record.presentation); if ( - keys.length !== 4 || - keys.some((key) => !["version", "kind", "projectId", "lineageId"].includes(key)) || + (keys.length !== 4 && keys.length !== 5) || + keys.some( + (key) => !["version", "kind", "projectId", "lineageId", "presentation"].includes(key), + ) || + (record.presentation !== undefined && !presentation) || artifact.revisionOfMediaId !== undefined || record.lineageId !== generatedDesignLineageId(record.projectId, artifact.mediaId) ) { @@ -92,6 +109,7 @@ export function parseDesignGeneratedRevisionOwnershipV1( kind: "new-artboard", projectId: record.projectId, lineageId: record.lineageId, + ...(presentation ? { presentation } : {}), }; } if ( diff --git a/main/services/design-generated-revision-service.ts b/main/services/design-generated-revision-service.ts index a8498835..839f1d4c 100644 --- a/main/services/design-generated-revision-service.ts +++ b/main/services/design-generated-revision-service.ts @@ -168,6 +168,7 @@ export class DesignGeneratedRevisionService { revisions: records.map((record) => ({ mediaId: record.artifact.mediaId, ownership: record.designOwnership!, + candidateTitle: record.artifact.title, })), }); await this.dependencies.artifacts.setDesignPublicationState( @@ -261,14 +262,10 @@ export class DesignGeneratedRevisionService { chatId: chat.id, }); if (this.dependencies.isChatGenerationActive?.(chat.id)) return {}; - if ( - unresolved.some((record) => !this.dependencies.isGenerationActive?.(record.generationId)) - ) { + if (unresolved.some((record) => !this.dependencies.isGenerationActive?.(record.generationId))) { return { designPublication: "retryable" }; } - return reconciliation.semanticConflictSuppressed - ? { designPublication: "suppressed" } - : {}; + return reconciliation.semanticConflictSuppressed ? { designPublication: "suppressed" } : {}; } async reconcileAtStartup(chats: readonly DesignRevisionRecoveryChat[]): Promise { diff --git a/main/services/design-generation-context.test.ts b/main/services/design-generation-context.test.ts index e0ab3c4e..3bdf1d7c 100644 --- a/main/services/design-generation-context.test.ts +++ b/main/services/design-generation-context.test.ts @@ -139,7 +139,10 @@ test("live candidate preview authority is pending-only and exact to its project }; assert.equal(isUsableLiveDesignCandidateSource(project(), candidate), true); - assert.equal(isUsableLiveDesignCandidateSource(project(), { ...candidate, committed: true }), false); + assert.equal( + isUsableLiveDesignCandidateSource(project(), { ...candidate, committed: true }), + false, + ); assert.equal( isUsableLiveDesignCandidateSource(project(), { ...candidate, @@ -188,10 +191,7 @@ test("legacy Design rows fall back only when exact project membership is unannot test("provider Design context requires committed exact and semantically valid bytes", () => { const expected = artifact("design:owned"); - assert.equal( - requireCommittedDesignContextHtml(expected, recoverySource(expected)), - HTML, - ); + assert.equal(requireCommittedDesignContextHtml(expected, recoverySource(expected)), HTML); assert.throws( () => requireCommittedDesignContextHtml(expected, undefined), /no longer available/iu, @@ -218,10 +218,7 @@ test("provider Design context rejects exact committed bytes until project public const expected = artifact("design:owned"); const eligible = recoverySource(expected, "eligible"); const owner = project(expected.mediaId, eligible.designOwnership!.lineageId); - assert.throws( - () => requireCommittedDesignContextHtml(expected, eligible, owner), - /damaged/iu, - ); + assert.throws(() => requireCommittedDesignContextHtml(expected, eligible, owner), /damaged/iu); assert.equal( requireCommittedDesignContextHtml(expected, recoverySource(expected, "published"), owner), HTML, diff --git a/main/services/design-generation-context.ts b/main/services/design-generation-context.ts index 48ad5bed..98926f9b 100644 --- a/main/services/design-generation-context.ts +++ b/main/services/design-generation-context.ts @@ -3,7 +3,7 @@ import { type ChatHtmlArtifactV1, } from "../../renderer/shared/chat-artifacts.js"; import { isDesignHtmlArtifact } from "../../renderer/shared/design-workspace.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { isValidDesignArtifactSource, projectOwnsPublishedDesignSource, diff --git a/main/services/design-project-connection-service.ts b/main/services/design-project-connection-service.ts index 07e01f35..05696608 100644 --- a/main/services/design-project-connection-service.ts +++ b/main/services/design-project-connection-service.ts @@ -1,4 +1,4 @@ -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { DesignProjectConflictError, DesignProjectNotFoundError, @@ -31,19 +31,13 @@ export interface DesignProjectConnectionDependencies { run( owner: WorkspaceOperationDocumentOwner, workspaceId: string, - operation: ( - resolved: WorkspaceEnvironmentDirectory, - signal: AbortSignal, - ) => Promise, + operation: (resolved: WorkspaceEnvironmentDirectory, signal: AbortSignal) => Promise, ): Promise; }; runProjectMutation(operation: () => Promise): Promise; chatWorkspaceId(chatId: string): Promise; isChatBusy?(chatId: string): boolean; - prepareRebind?( - owner: RendererDocumentOwner, - current: DesignProjectSnapshotV1, - ): Promise; + prepareRebind?(owner: RendererDocumentOwner, current: DesignProjectSnapshotV1): Promise; finalizeRebind?( previous: DesignProjectSnapshotV1, connected: DesignProjectSnapshotV1, @@ -77,9 +71,7 @@ export function assertDesignProjectGenerationClaim( return current; } -function requireProject( - project: DesignProjectSnapshotV1 | undefined, -): DesignProjectSnapshotV1 { +function requireProject(project: DesignProjectSnapshotV1 | undefined): DesignProjectSnapshotV1 { if (!project) throw new DesignProjectNotFoundError(); return project; } diff --git a/main/services/design-project-contract-v2.test.ts b/main/services/design-project-contract-v2.test.ts new file mode 100644 index 00000000..581413c8 --- /dev/null +++ b/main/services/design-project-contract-v2.test.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + MAX_DESIGN_PROJECT_NODES, + MAX_DESIGN_PROJECT_SNAPSHOT_BYTES, + MAX_DESIGN_PROJECT_STORE_BYTES, + parseDesignProjectDatabaseV1, + parseDesignProjectSnapshotV1, + type DesignProjectSnapshotV1, +} from "./design-project-contract.js"; +import { + designProjectDatabaseV2StorePolicy, + emptyDesignProjectDatabaseV2, + migrateDesignProjectDatabaseV1ToV2, + migrateDesignProjectSnapshotV1ToV2, + parseDesignProjectDatabaseV2, + parseDesignProjectSnapshotV2, + readDesignProjectDatabaseV2, + type DesignProjectV2MigrationPolicy, +} from "./design-project-contract-v2.js"; +import { DataStore } from "./data-store.js"; + +const migrationPolicy: DesignProjectV2MigrationPolicy = { + titlePolicy: () => ({ state: "manual" }), + screenPresentation: (viewport) => + viewport === "desktop" + ? { surface: "web", frame: { preset: "desktop", width: 1_200, height: 760 } } + : viewport === "tablet" + ? { surface: "app", frame: { preset: "tablet", width: 768, height: 900 } } + : { surface: "app", frame: { preset: "phone", width: 390, height: 844 } }, +}; + +function legacySnapshot(): DesignProjectSnapshotV1 { + return { + version: 1, + id: "project:one", + revision: 7, + title: "Manually named project", + chatId: "chat:one", + workspaceId: "workspace:one", + connectionState: "connected", + createdAt: 10, + updatedAt: 20, + canvas: { + viewport: "phone", + flowViewport: { x: 12.5, y: -3, zoom: 0.75 }, + nodes: [ + { + id: "node:screen", + kind: "artboard", + canonicalOrigin: "generated-artifact", + x: 100, + y: 200, + lineageId: "lineage:screen", + artifactMediaIds: ["design:one", "design:two"], + activeMediaId: "design:two", + }, + { + id: "node:reference", + kind: "reference-image", + canonicalOrigin: "reference-asset", + x: 300, + y: 400, + assetId: "asset:one", + }, + { + id: "node:source", + kind: "source-preview", + canonicalOrigin: "connected-app", + x: 500, + y: 600, + }, + ], + }, + referenceAssetIds: ["asset:one"], + designSystemBinding: { id: "design-system:one", revision: 4 }, + previewScriptId: "script:dev", + }; +} + +function nearLimitLegacySnapshot(projectIndex: number): DesignProjectSnapshotV1 { + const nodes = Array.from({ length: MAX_DESIGN_PROJECT_NODES }, (_, nodeIndex) => { + const artifactMediaIds = Array.from( + { length: 6 }, + (_, revisionIndex) => + `design:${projectIndex}:${nodeIndex}:${revisionIndex}:${"x".repeat(100)}`, + ); + return { + id: `node:${projectIndex}:${nodeIndex}`, + kind: "artboard" as const, + canonicalOrigin: "generated-artifact" as const, + x: nodeIndex, + y: projectIndex, + lineageId: `lineage:${projectIndex}:${nodeIndex}`, + artifactMediaIds, + activeMediaId: artifactMediaIds[artifactMediaIds.length - 1]!, + }; + }); + return { + version: 1, + id: `project:${projectIndex}`, + revision: 1, + title: `Legacy project ${projectIndex}`, + chatId: `chat:${projectIndex}`, + connectionState: "prototype-only", + createdAt: 1, + updatedAt: 1, + canvas: { + viewport: "desktop", + flowViewport: { x: 0, y: 0, zoom: 1 }, + nodes, + }, + referenceAssetIds: [], + }; +} + +test("V1 to V2 migration preserves every durable fact and adds conservative semantics", () => { + const legacy = legacySnapshot(); + const before = structuredClone(legacy); + const migrated = migrateDesignProjectSnapshotV1ToV2(legacy, migrationPolicy); + assert.ok(migrated); + assert.deepEqual(legacy, before, "migration must not mutate the source snapshot"); + assert.equal(migrated.version, 2); + assert.equal(migrated.revision, legacy.revision); + assert.equal(migrated.title, legacy.title); + assert.deepEqual(migrated.titlePolicy, { state: "manual" }); + assert.deepEqual(migrated.canvas.nodes[0], { + ...legacy.canvas.nodes[0], + presentation: { + surface: "app", + frame: { preset: "phone", width: 390, height: 844 }, + }, + }); + assert.deepEqual(migrated.canvas.nodes.slice(1), legacy.canvas.nodes.slice(1)); + const { version: _legacyVersion, canvas: _legacyCanvas, ...legacyRest } = legacy; + const { + version: _v2Version, + canvas: _v2Canvas, + titlePolicy: _titlePolicy, + ...migratedRest + } = migrated; + assert.deepEqual(migratedRest, legacyRest); + assert.deepEqual(migrated.canvas.flowViewport, legacy.canvas.flowViewport); + assert.equal(migrated.canvas.viewport, legacy.canvas.viewport); +}); + +test("V2 parser is exact, bounded, and requires presentation only on Screens", () => { + const valid = migrateDesignProjectSnapshotV1ToV2(legacySnapshot(), migrationPolicy)!; + assert.deepEqual(parseDesignProjectSnapshotV2(valid), valid); + assert.equal(parseDesignProjectSnapshotV2({ ...valid, prompt: "hidden" }), undefined); + assert.equal(parseDesignProjectSnapshotV2({ ...valid, version: 3 }), undefined); + const inheritedTitle = Object.assign( + Object.create({ title: valid.title }) as Record, + (({ title: _title, ...rest }) => rest)(valid), + ); + assert.equal( + parseDesignProjectSnapshotV2(inheritedTitle), + undefined, + "required properties must be owned rather than inherited", + ); + assert.equal( + parseDesignProjectSnapshotV2({ + ...valid, + title: "Named project", + titlePolicy: { state: "auto-eligible" }, + }), + undefined, + "auto eligibility is valid only for the blank placeholder title", + ); + assert.equal( + parseDesignProjectSnapshotV2({ + ...valid, + canvas: { + ...valid.canvas, + nodes: valid.canvas.nodes.map((node) => + node.kind === "artboard" + ? (({ presentation: _presentation, ...rest }) => rest)(node) + : node, + ), + }, + }), + undefined, + ); + assert.equal( + parseDesignProjectSnapshotV2({ + ...valid, + canvas: { + ...valid.canvas, + nodes: valid.canvas.nodes.map((node) => + node.kind === "reference-image" + ? { + ...node, + presentation: { + surface: "web", + frame: { preset: "desktop", width: 1200, height: 760 }, + }, + } + : node, + ), + }, + }), + undefined, + ); + const malformedFrame = structuredClone(valid); + const screen = malformedFrame.canvas.nodes[0]!; + if (screen.kind !== "artboard") throw new Error("Fixture Screen missing."); + screen.presentation.frame.width = 16_385; + assert.equal(parseDesignProjectSnapshotV2(malformedFrame), undefined); + + assert.ok( + parseDesignProjectSnapshotV2({ + ...valid, + titlePolicy: { + state: "auto-applied", + sourceLineageId: "lineage:screen", + sourceMediaId: "design:two", + }, + }), + ); + assert.equal( + parseDesignProjectSnapshotV2({ + ...valid, + titlePolicy: { + state: "auto-applied", + sourceLineageId: "lineage:screen", + sourceMediaId: "design:unowned", + }, + }), + undefined, + "automatic title provenance must resolve to one owned Screen revision", + ); +}); + +test("migration headroom accepts every near-limit V1 snapshot and store without data loss", () => { + const one = nearLimitLegacySnapshot(0); + const oneBytes = Buffer.byteLength(JSON.stringify(one), "utf8"); + assert.ok(oneBytes > MAX_DESIGN_PROJECT_SNAPSHOT_BYTES - 64 * 1024); + assert.ok(oneBytes <= MAX_DESIGN_PROJECT_SNAPSHOT_BYTES); + assert.ok(parseDesignProjectSnapshotV1(one)); + const migratedOne = migrateDesignProjectSnapshotV1ToV2(one); + assert.ok(migratedOne); + assert.ok(Buffer.byteLength(JSON.stringify(migratedOne), "utf8") > oneBytes); + assert.equal(migratedOne.canvas.nodes.length, one.canvas.nodes.length); + + const projects: DesignProjectSnapshotV1[] = []; + let encodedBytes = Buffer.byteLength( + JSON.stringify({ version: 1, revision: 1, projects: [] }), + "utf8", + ); + for (let index = 0; index < 250; index += 1) { + const project = nearLimitLegacySnapshot(index); + const projectBytes = + Buffer.byteLength(JSON.stringify(project), "utf8") + (projects.length === 0 ? 0 : 1); + if (encodedBytes + projectBytes > MAX_DESIGN_PROJECT_STORE_BYTES) break; + projects.push(project); + encodedBytes += projectBytes; + } + const legacyDatabase = { version: 1 as const, revision: 1, projects }; + assert.ok(encodedBytes > MAX_DESIGN_PROJECT_STORE_BYTES - oneBytes * 2); + assert.ok(parseDesignProjectDatabaseV1(legacyDatabase)); + const migratedDatabase = migrateDesignProjectDatabaseV1ToV2(legacyDatabase); + assert.ok(migratedDatabase); + assert.equal(migratedDatabase.projects.length, projects.length); + assert.equal(migratedDatabase.projects[0]?.canvas.nodes.length, MAX_DESIGN_PROJECT_NODES); +}); + +test("database dual reader migrates V1, accepts V2, and rejects mixed or future stores", () => { + const legacy = { version: 1, revision: 9, projects: [legacySnapshot()] }; + const migrated = readDesignProjectDatabaseV2(legacy, migrationPolicy); + assert.equal(migrated?.sourceVersion, 1); + assert.equal(migrated?.migrated, true); + assert.equal(migrated?.database.revision, 9); + assert.equal(migrated?.database.projects[0]?.version, 2); + assert.deepEqual(readDesignProjectDatabaseV2(migrated?.database, migrationPolicy), { + sourceVersion: 2, + migrated: false, + database: migrated?.database, + }); + assert.equal(readDesignProjectDatabaseV2({ ...legacy, version: 3 }, migrationPolicy), undefined); + assert.equal( + parseDesignProjectDatabaseV2({ + version: 2, + revision: 1, + projects: [ + migrated?.database.projects[0], + { ...migrated?.database.projects[0], id: "project:two" }, + ], + }), + undefined, + "chat and artifact ownership remain globally unique", + ); +}); + +test("V2 DataStore policy reads V1 safely and writes canonical V2 on the next mutation", async (t) => { + const root = await mkdtemp(join(tmpdir(), "aiden-design-v2-store-")); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, "design-projects.json"); + await writeFile( + path, + JSON.stringify({ version: 1, revision: 9, projects: [legacySnapshot()] }), + "utf8", + ); + const storePolicy = designProjectDatabaseV2StorePolicy(migrationPolicy); + const store = new DataStore("design-projects.json", emptyDesignProjectDatabaseV2(), () => root, { + ...storePolicy, + rejectUnsafeWrite: true, + reloadBeforeWrite: true, + }); + assert.equal((await store.load()).version, 2); + assert.equal(await store.loadedFromUnsafeFile(), false); + await store.update((database) => { + database.revision += 1; + }); + const persisted = JSON.parse(await readFile(path, "utf8")) as Record; + assert.equal(persisted.version, 2); + assert.equal(persisted.revision, 10); + assert.ok(parseDesignProjectDatabaseV2(persisted)); +}); diff --git a/main/services/design-project-contract-v2.ts b/main/services/design-project-contract-v2.ts new file mode 100644 index 00000000..4f80dc8d --- /dev/null +++ b/main/services/design-project-contract-v2.ts @@ -0,0 +1,351 @@ +import type { + DesignProjectCanvas, + DesignProjectCanvasNodeV1, + DesignProjectCanvasNodeV2, + DesignProjectDatabaseV2, + DesignProjectSnapshotV2, + DesignProjectTitlePolicyV2, + DesignProjectViewport, + DesignScreenPresentationV2, +} from "../../renderer/shared/design-projects.js"; +import { + MAX_DESIGN_PROJECT_NODES, + MAX_DESIGN_PROJECT_SNAPSHOT_BYTES, + MAX_DESIGN_PROJECT_STORE_BYTES, + MAX_DESIGN_PROJECTS, + parseDesignProjectCanvasV1, + parseDesignProjectDatabaseV1, + parseDesignProjectSnapshotV1, + type DesignProjectDatabaseV1, + type DesignProjectCanvasV1, + type DesignProjectSnapshotV1, +} from "./design-project-contract.js"; +import { + DEFAULT_BLANK_DESIGN_PROJECT_TITLE, + migrateDesignProjectTitleStateFromV1, + migrateDesignScreenPresentationFromViewport, + normalizeDesignProjectTitlePolicyV2, + normalizeDesignScreenPresentationV2, +} from "./design-project-v2-policy.js"; + +export const DESIGN_PROJECT_SNAPSHOT_VERSION_V2 = 2 as const; +export const DESIGN_PROJECT_DATABASE_VERSION_V2 = 2 as const; +const V2_METADATA_HEADROOM_PER_NODE = 128; +const V2_METADATA_HEADROOM_PER_PROJECT = + MAX_DESIGN_PROJECT_NODES * V2_METADATA_HEADROOM_PER_NODE + 1_024; +export const MAX_DESIGN_PROJECT_SNAPSHOT_BYTES_V2 = + MAX_DESIGN_PROJECT_SNAPSHOT_BYTES + V2_METADATA_HEADROOM_PER_PROJECT; +export const MAX_DESIGN_PROJECT_STORE_BYTES_V2 = + MAX_DESIGN_PROJECT_STORE_BYTES + MAX_DESIGN_PROJECTS * V2_METADATA_HEADROOM_PER_PROJECT; + +const SNAPSHOT_V2_KEYS = new Set([ + "version", + "id", + "revision", + "title", + "titlePolicy", + "chatId", + "workspaceId", + "connectionState", + "createdAt", + "updatedAt", + "canvas", + "referenceAssetIds", + "designSystemBinding", + "previewScriptId", +]); +const REQUIRED_SNAPSHOT_V2_KEYS = new Set([ + "version", + "id", + "revision", + "title", + "titlePolicy", + "chatId", + "connectionState", + "createdAt", + "updatedAt", + "canvas", + "referenceAssetIds", +]); +const CANVAS_KEYS = new Set(["viewport", "flowViewport", "nodes"]); +const NODE_V2_KEYS = new Set([ + "id", + "kind", + "canonicalOrigin", + "x", + "y", + "lineageId", + "artifactMediaIds", + "activeMediaId", + "assetId", + "presentation", +]); +const DATABASE_KEYS = new Set(["version", "revision", "projects"]); + +function exactKeys( + value: Record, + allowed: ReadonlySet, + required: ReadonlySet = allowed, +): boolean { + return ( + Object.keys(value).every((key) => allowed.has(key)) && + [...required].every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); +} + +function legacyNode(node: Record): Record { + const { presentation: _presentation, ...legacy } = node; + return legacy; +} + +function legacySnapshot(value: Record): Record | undefined { + if (!value.canvas || typeof value.canvas !== "object" || Array.isArray(value.canvas)) { + return undefined; + } + const canvas = value.canvas as Record; + if (!exactKeys(canvas, CANVAS_KEYS) || !Array.isArray(canvas.nodes)) return undefined; + const { titlePolicy: _titlePolicy, ...legacyValue } = value; + return { + ...legacyValue, + version: 1, + canvas: { + ...canvas, + nodes: canvas.nodes.map((node) => + node && typeof node === "object" && !Array.isArray(node) + ? legacyNode(node as Record) + : node, + ), + }, + }; +} + +export function parseDesignProjectCanvasV2( + value: unknown, +): DesignProjectSnapshotV2["canvas"] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const canvas = value as Record; + if (!exactKeys(canvas, CANVAS_KEYS) || !Array.isArray(canvas.nodes)) return undefined; + const common = parseDesignProjectCanvasV1({ + ...canvas, + nodes: canvas.nodes.map((node) => + node && typeof node === "object" && !Array.isArray(node) + ? legacyNode(node as Record) + : node, + ), + }); + if (!common) return undefined; + const nodes: DesignProjectCanvasNodeV2[] = []; + for (let index = 0; index < canvas.nodes.length; index += 1) { + const rawNode = canvas.nodes[index]; + const parsedNode = common.nodes[index]; + if (!rawNode || typeof rawNode !== "object" || Array.isArray(rawNode) || !parsedNode) { + return undefined; + } + const node = rawNode as Record; + if (!exactKeys(node, NODE_V2_KEYS, new Set(Object.keys(legacyNode(node))))) return undefined; + if (parsedNode.kind !== "artboard") { + if (node.presentation !== undefined) return undefined; + nodes.push({ ...parsedNode } as DesignProjectCanvasNodeV2); + continue; + } + const presentation = normalizeDesignScreenPresentationV2(node.presentation); + if (!presentation) return undefined; + nodes.push({ ...parsedNode, presentation } as DesignProjectCanvasNodeV2); + } + return { ...common, nodes }; +} + +/** Accepts legacy layout payloads during rollout; semantic V2 facts are merged by the store. */ +export function parseDesignProjectCanvasForV2Update( + value: unknown, +): DesignProjectCanvas | undefined { + return parseDesignProjectCanvasV2(value) ?? parseDesignProjectCanvasV1(value); +} + +export function parseDesignProjectSnapshotV2(value: unknown): DesignProjectSnapshotV2 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const snapshot = value as Record; + if ( + !exactKeys(snapshot, SNAPSHOT_V2_KEYS, REQUIRED_SNAPSHOT_V2_KEYS) || + snapshot.version !== DESIGN_PROJECT_SNAPSHOT_VERSION_V2 + ) { + return undefined; + } + const titlePolicy = normalizeDesignProjectTitlePolicyV2(snapshot.titlePolicy); + const downgraded = legacySnapshot(snapshot); + if (!titlePolicy || !downgraded) return undefined; + delete downgraded.titlePolicy; + const common = parseDesignProjectSnapshotV1(downgraded); + if (!common) return undefined; + const parsedCanvas = parseDesignProjectCanvasV2(snapshot.canvas); + if (!parsedCanvas) return undefined; + const parsed: DesignProjectSnapshotV2 = { + ...common, + version: DESIGN_PROJECT_SNAPSHOT_VERSION_V2, + titlePolicy, + canvas: parsedCanvas, + }; + if ( + titlePolicy.state === "auto-eligible" && + parsed.title !== DEFAULT_BLANK_DESIGN_PROJECT_TITLE + ) { + return undefined; + } + if (titlePolicy.state === "auto-applied") { + const source = parsedCanvas.nodes.find( + (node) => + node.kind === "artboard" && + node.lineageId === titlePolicy.sourceLineageId && + node.artifactMediaIds?.includes(titlePolicy.sourceMediaId) === true, + ); + if (!source) return undefined; + } + return Buffer.byteLength(JSON.stringify(parsed), "utf8") <= MAX_DESIGN_PROJECT_SNAPSHOT_BYTES_V2 + ? parsed + : undefined; +} + +export interface DesignProjectV2MigrationPolicy { + titlePolicy(title: string): DesignProjectTitlePolicyV2; + screenPresentation(viewport: DesignProjectViewport): DesignScreenPresentationV2; +} + +export const DEFAULT_DESIGN_PROJECT_V2_MIGRATION_POLICY: DesignProjectV2MigrationPolicy = { + titlePolicy: (title) => migrateDesignProjectTitleStateFromV1(title).titlePolicy, + screenPresentation: migrateDesignScreenPresentationFromViewport, +}; + +export function migrateDesignProjectSnapshotV1ToV2( + value: unknown, + policy: DesignProjectV2MigrationPolicy = DEFAULT_DESIGN_PROJECT_V2_MIGRATION_POLICY, +): DesignProjectSnapshotV2 | undefined { + const legacy = parseDesignProjectSnapshotV1(value); + if (!legacy || legacy.version !== 1) return undefined; + return parseDesignProjectSnapshotV2({ + ...legacy, + version: DESIGN_PROJECT_SNAPSHOT_VERSION_V2, + titlePolicy: policy.titlePolicy(legacy.title), + canvas: { + ...legacy.canvas, + nodes: legacy.canvas.nodes.map((node: DesignProjectCanvasNodeV1) => + node.kind === "artboard" + ? { ...node, presentation: policy.screenPresentation(legacy.canvas.viewport) } + : node, + ), + }, + }); +} + +function validateDatabaseOwnership(projects: readonly DesignProjectSnapshotV2[]): boolean { + const artifactMediaIds = projects.flatMap((project) => + project.canvas.nodes.flatMap((node) => node.artifactMediaIds ?? []), + ); + return ( + new Set(projects.map(({ id }) => id)).size === projects.length && + new Set(projects.map(({ chatId }) => chatId)).size === projects.length && + new Set(artifactMediaIds).size === artifactMediaIds.length + ); +} + +function serializedBytesAtMost(value: unknown, maxBytes: number): boolean { + try { + return Buffer.byteLength(JSON.stringify(value), "utf8") <= maxBytes; + } catch { + return false; + } +} + +export function parseDesignProjectDatabaseV2(value: unknown): DesignProjectDatabaseV2 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + if (!serializedBytesAtMost(value, MAX_DESIGN_PROJECT_STORE_BYTES_V2)) return undefined; + const database = value as Record; + if ( + !exactKeys(database, DATABASE_KEYS) || + database.version !== DESIGN_PROJECT_DATABASE_VERSION_V2 || + !Number.isSafeInteger(database.revision) || + (database.revision as number) < 0 || + !Array.isArray(database.projects) || + database.projects.length > MAX_DESIGN_PROJECTS + ) { + return undefined; + } + const projects = database.projects.map(parseDesignProjectSnapshotV2); + if (projects.some((project) => !project)) return undefined; + const parsed = projects as DesignProjectSnapshotV2[]; + if (!validateDatabaseOwnership(parsed)) return undefined; + return { + version: DESIGN_PROJECT_DATABASE_VERSION_V2, + revision: database.revision as number, + projects: parsed, + }; +} + +export function migrateDesignProjectDatabaseV1ToV2( + value: unknown, + policy: DesignProjectV2MigrationPolicy = DEFAULT_DESIGN_PROJECT_V2_MIGRATION_POLICY, +): DesignProjectDatabaseV2 | undefined { + if (!serializedBytesAtMost(value, MAX_DESIGN_PROJECT_STORE_BYTES)) return undefined; + const legacy = parseDesignProjectDatabaseV1(value); + if (!legacy || legacy.version !== 1) return undefined; + const projects = legacy.projects.map((project) => + migrateDesignProjectSnapshotV1ToV2(project, policy), + ); + if (projects.some((project) => !project)) return undefined; + return parseDesignProjectDatabaseV2({ + version: DESIGN_PROJECT_DATABASE_VERSION_V2, + revision: legacy.revision, + projects, + }); +} + +export type DesignProjectDatabaseV2ReadResult = + | { sourceVersion: 2; migrated: false; database: DesignProjectDatabaseV2 } + | { sourceVersion: 1; migrated: true; database: DesignProjectDatabaseV2 }; + +/** Strict dual reader used by store plumbing; unknown and future versions fail closed. */ +export function readDesignProjectDatabaseV2( + value: unknown, + policy: DesignProjectV2MigrationPolicy = DEFAULT_DESIGN_PROJECT_V2_MIGRATION_POLICY, +): DesignProjectDatabaseV2ReadResult | undefined { + const current = parseDesignProjectDatabaseV2(value); + if (current) return { sourceVersion: 2, migrated: false, database: current }; + const migrated = migrateDesignProjectDatabaseV1ToV2(value, policy); + return migrated ? { sourceVersion: 1, migrated: true, database: migrated } : undefined; +} + +/** Canonical normalization hook for a V2 DataStore. */ +export function normalizeDesignProjectDatabaseV2( + value: unknown, + policy: DesignProjectV2MigrationPolicy = DEFAULT_DESIGN_PROJECT_V2_MIGRATION_POLICY, +): DesignProjectDatabaseV2 | undefined { + return readDesignProjectDatabaseV2(value, policy)?.database; +} + +export function emptyDesignProjectDatabaseV2(): DesignProjectDatabaseV2 { + return { version: DESIGN_PROJECT_DATABASE_VERSION_V2, revision: 0, projects: [] }; +} + +/** Reusable DataStore hooks: V1 is safe to read and the next write is canonical V2. */ +export function designProjectDatabaseV2StorePolicy( + policy: DesignProjectV2MigrationPolicy = DEFAULT_DESIGN_PROJECT_V2_MIGRATION_POLICY, +): { + normalize: (value: unknown) => DesignProjectDatabaseV2; + isSafe: (value: unknown) => boolean; +} { + return { + normalize: (value) => + normalizeDesignProjectDatabaseV2(value, policy) ?? emptyDesignProjectDatabaseV2(), + isSafe: (value) => readDesignProjectDatabaseV2(value, policy) !== undefined, + }; +} + +export type { + DesignProjectCanvasV1, + DesignProjectCanvasNodeV2, + DesignProjectDatabaseV1, + DesignProjectDatabaseV2, + DesignProjectSnapshotV1, + DesignProjectSnapshotV2, + DesignProjectTitlePolicyV2, + DesignScreenPresentationV2, +}; diff --git a/main/services/design-project-contract.ts b/main/services/design-project-contract.ts index 7a804e54..ac0c71fe 100644 --- a/main/services/design-project-contract.ts +++ b/main/services/design-project-contract.ts @@ -1,3 +1,9 @@ +import type { + DesignProjectCanvasV2, + DesignProjectDatabaseV2, + DesignProjectSnapshotV2, +} from "../../renderer/shared/design-projects.js"; + export const DESIGN_PROJECT_SNAPSHOT_VERSION = 1 as const; export const DESIGN_PROJECT_DATABASE_VERSION = 1 as const; @@ -88,7 +94,7 @@ export interface DesignProjectCanvasV1 { } export interface DesignProjectSnapshotV1 { - version: typeof DESIGN_PROJECT_SNAPSHOT_VERSION; + version: 1; id: string; revision: number; title: string; @@ -108,11 +114,16 @@ export interface DesignProjectSnapshotV1 { } export interface DesignProjectDatabaseV1 { - version: typeof DESIGN_PROJECT_DATABASE_VERSION; + version: 1; revision: number; projects: DesignProjectSnapshotV1[]; } +/** Explicit persistence compatibility while V1 is dual-read into canonical V2. */ +export type DesignProjectSnapshot = DesignProjectSnapshotV1 | DesignProjectSnapshotV2; +export type DesignProjectDatabase = DesignProjectDatabaseV1 | DesignProjectDatabaseV2; +export type DesignProjectCanvas = DesignProjectCanvasV1 | DesignProjectCanvasV2; + function exactKeys( value: Record, allowed: ReadonlySet, @@ -120,7 +131,7 @@ function exactKeys( ): boolean { return ( Object.keys(value).every((key) => allowed.has(key)) && - [...required].every((key) => key in value) + [...required].every((key) => Object.prototype.hasOwnProperty.call(value, key)) ); } @@ -340,7 +351,8 @@ export function parseDesignProjectSnapshotV1(value: unknown): DesignProjectSnaps !safeRevision(snapshot.revision) || !isDesignProjectOpaqueId(snapshot.chatId) || (snapshot.workspaceId !== undefined && !isDesignProjectOpaqueId(snapshot.workspaceId)) || - (snapshot.previewScriptId !== undefined && !isDesignProjectOpaqueId(snapshot.previewScriptId)) || + (snapshot.previewScriptId !== undefined && + !isDesignProjectOpaqueId(snapshot.previewScriptId)) || (snapshot.connectionState !== "prototype-only" && snapshot.connectionState !== "connected") || !safeTimestamp(snapshot.createdAt) || !safeTimestamp(snapshot.updatedAt) || diff --git a/main/services/design-project-health.ts b/main/services/design-project-health.ts index d1b1ac49..2b4bdd52 100644 --- a/main/services/design-project-health.ts +++ b/main/services/design-project-health.ts @@ -1,4 +1,4 @@ -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { isValidDesignArtifactSource, projectOwnsPublishedDesignSource, diff --git a/main/services/design-project-lifecycle.test.ts b/main/services/design-project-lifecycle.test.ts index 12969b5e..e9dbee20 100644 --- a/main/services/design-project-lifecycle.test.ts +++ b/main/services/design-project-lifecycle.test.ts @@ -4,12 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import type { Chat } from "./types.js"; +import { type DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { - emptyDesignProjectDatabase, - parseDesignProjectDatabaseV1, - type DesignProjectDatabaseV1, - type DesignProjectSnapshotV1, -} from "./design-project-contract.js"; + designProjectDatabaseV2StorePolicy, + emptyDesignProjectDatabaseV2, + parseDesignProjectDatabaseV2, + type DesignProjectDatabaseV2, +} from "./design-project-contract-v2.js"; import { DesignProjectPublicationUncertainError, type DesignProjectDeletePlanV1, @@ -300,13 +301,14 @@ test("ambiguous duplicate publication retains its installed row, backing chat, a const journal = new DesignProjectLifecycleJournalStore(() => root); const chats = new Map(); let failPublication = false; - const data = new DataStore( + const policy = designProjectDatabaseV2StorePolicy(); + const data = new DataStore( "design-projects.json", - emptyDesignProjectDatabase(), + emptyDesignProjectDatabaseV2(), () => root, { - normalize: (value) => parseDesignProjectDatabaseV1(value) ?? emptyDesignProjectDatabase(), - isSafe: (value) => parseDesignProjectDatabaseV1(value) !== undefined, + normalize: policy.normalize, + isSafe: policy.isSafe, rejectCorruptWrite: true, rejectUnsafeWrite: true, rejectExternalChanges: true, @@ -406,13 +408,14 @@ test("ambiguous duplicate publication retains its installed row, backing chat, a test("ambiguous delete publication uses fresh disk authority and resumes its cascade after restart", async (t) => { const root = await temporaryRoot(t); let failPublication = false; - const data = new DataStore( + const policy = designProjectDatabaseV2StorePolicy(); + const data = new DataStore( "design-projects.json", - emptyDesignProjectDatabase(), + emptyDesignProjectDatabaseV2(), () => root, { - normalize: (value) => parseDesignProjectDatabaseV1(value) ?? emptyDesignProjectDatabase(), - isSafe: (value) => parseDesignProjectDatabaseV1(value) !== undefined, + normalize: policy.normalize, + isSafe: policy.isSafe, rejectCorruptWrite: true, rejectUnsafeWrite: true, rejectExternalChanges: true, @@ -491,7 +494,7 @@ test("ambiguous delete publication uses fresh disk authority and resumes its cas await assert.rejects(coordinator.deletePlan(plan), /injected chat failure/u); assert.equal(await projects.get(project.id), undefined); - const disk = parseDesignProjectDatabaseV1( + const disk = parseDesignProjectDatabaseV2( JSON.parse(await readFile(join(root, "design-projects.json"), "utf8")), ); assert.equal( diff --git a/main/services/design-project-lifecycle.ts b/main/services/design-project-lifecycle.ts index dedd1dbb..0beff05e 100644 --- a/main/services/design-project-lifecycle.ts +++ b/main/services/design-project-lifecycle.ts @@ -14,7 +14,7 @@ import type { DesignProjectStore, PreparedDesignProjectDuplicate, } from "./design-project-store.js"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; import { designProjectLifecycleOperationId, type DesignProjectDeleteLifecycleRecordV1, @@ -143,7 +143,9 @@ function duplicateRecord(input: { */ export class RecoverableDesignProjectDuplicatePort implements DesignProjectDuplicatePort { constructor( - private readonly projectStore: Pick, + private readonly projectStore: { + get(id: string): Promise; + }, private readonly chats: DesignProjectDuplicateChatPort, private readonly artifacts: DesignProjectDuplicateArtifactPort, private readonly journal: DesignProjectLifecycleJournalPort, diff --git a/main/services/design-project-store.test.ts b/main/services/design-project-store.test.ts index 7cf3dc90..ec104b50 100644 --- a/main/services/design-project-store.test.ts +++ b/main/services/design-project-store.test.ts @@ -4,12 +4,13 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; +import { type DesignProjectCanvasV1 } from "./design-project-contract.js"; import { - emptyDesignProjectDatabase, - parseDesignProjectDatabaseV1, - type DesignProjectCanvasV1, - type DesignProjectDatabaseV1, -} from "./design-project-contract.js"; + designProjectDatabaseV2StorePolicy, + emptyDesignProjectDatabaseV2, + parseDesignProjectDatabaseV2, + type DesignProjectDatabaseV2, +} from "./design-project-contract-v2.js"; import { DataStore } from "./data-store.js"; import type { ChatHtmlArtifactV1 } from "../../renderer/shared/chat-artifacts.js"; import { GenerativeUiArtifactStore } from "./generative-ui-artifact-store.js"; @@ -63,10 +64,11 @@ function canvas(): DesignProjectCanvasV1 { function failpointProjectDataStore( root: string, afterDestinationPublish: () => Promise, -): DataStore { - return new DataStore("design-projects.json", emptyDesignProjectDatabase(), () => root, { - normalize: (value) => parseDesignProjectDatabaseV1(value) ?? emptyDesignProjectDatabase(), - isSafe: (value) => parseDesignProjectDatabaseV1(value) !== undefined, +): DataStore { + const policy = designProjectDatabaseV2StorePolicy(); + return new DataStore("design-projects.json", emptyDesignProjectDatabaseV2(), () => root, { + normalize: policy.normalize, + isSafe: policy.isSafe, rejectCorruptWrite: true, rejectUnsafeWrite: true, rejectExternalChanges: true, @@ -134,9 +136,7 @@ test("owner-only store supports create, list, get, update, rename, and CAS", asy const updated = await store.update({ id: created.id, expectedRevision: created.revision, - connectionState: created.connectionState, canvas: moved, - referenceAssetIds: created.referenceAssetIds, }); assert.equal(updated.revision, 2); assert.equal(updated.updatedAt, 101, "timestamps remain monotonic when the wall clock stalls"); @@ -146,9 +146,7 @@ test("owner-only store supports create, list, get, update, rename, and CAS", asy store.update({ id: created.id, expectedRevision: created.revision, - connectionState: created.connectionState, canvas: moved, - referenceAssetIds: created.referenceAssetIds, }), (error: unknown) => error instanceof DesignProjectRevisionConflictError && error.currentRevision === 2, @@ -167,6 +165,249 @@ test("owner-only store supports create, list, get, update, rename, and CAS", asy ); }); +test("canonical V2 writes preserve Screen semantics and use dedicated CAS operations", async (t) => { + const root = await temporaryRoot(t); + let now = 100; + const store = new DesignProjectStore({ + root: () => root, + now: () => ++now, + mintProjectId: () => "project:v2-semantics", + }); + await store.initialize(); + const blank = await store.create({ + chatId: "chat:v2-semantics", + title: "Untitled Design", + titleOrigin: "blank", + connectionState: "prototype-only", + canvas: { + viewport: "phone", + flowViewport: { x: 0, y: 0, zoom: 1 }, + nodes: [], + }, + }); + assert.equal(blank.version, 2); + assert.deepEqual(blank.titlePolicy, { state: "auto-eligible" }); + + const first = await store.publishGeneratedRevisions({ + projectId: blank.id, + chatId: blank.chatId, + revisions: [ + { + mediaId: "design:first", + candidateTitle: "Checkout flow", + ownership: { + version: 1, + kind: "new-artboard", + projectId: blank.id, + lineageId: "lineage:first", + presentation: { + surface: "web", + frame: { preset: "desktop", width: 1_200, height: 760 }, + }, + }, + }, + ], + }); + assert.equal(first.title, "Checkout flow"); + assert.deepEqual(first.titlePolicy, { + state: "auto-applied", + sourceLineageId: "lineage:first", + sourceMediaId: "design:first", + }); + assert.deepEqual(first.canvas.nodes[0]?.presentation, { + surface: "web", + frame: { preset: "desktop", width: 1_200, height: 760 }, + }); + assert.equal(first.canvas.viewport, "phone", "preview preference remains independent"); + + const framed = await store.setScreenPresentation({ + id: first.id, + expectedRevision: first.revision, + lineageId: "lineage:first", + presentation: { + surface: "web", + frame: { preset: "custom", width: 1_440, height: 900 }, + }, + }); + const requested = structuredClone(framed.canvas); + const requestedScreen = requested.nodes[0]; + if (requestedScreen?.kind !== "artboard") throw new Error("Screen fixture missing."); + requestedScreen.presentation = { + surface: "app", + frame: { preset: "phone", width: 390, height: 844 }, + }; + await assert.rejects( + store.update({ + id: framed.id, + expectedRevision: framed.revision, + canvas: requested, + }), + /semantics.*immutable/iu, + ); + const movedCanvas = structuredClone(framed.canvas); + movedCanvas.nodes[0]!.x += 24; + const layoutOnly = await store.update({ + id: framed.id, + expectedRevision: framed.revision, + canvas: movedCanvas, + }); + assert.deepEqual(layoutOnly.canvas.nodes[0]?.presentation, framed.canvas.nodes[0]?.presentation); + + const secondRevision = await store.publishGeneratedRevisions({ + projectId: layoutOnly.id, + chatId: layoutOnly.chatId, + revisions: [ + { + mediaId: "design:second", + ownership: { + version: 1, + kind: "revision", + projectId: layoutOnly.id, + lineageId: "lineage:first", + baseMediaId: "design:first", + }, + }, + ], + }); + const forgedActiveCanvas = structuredClone(secondRevision.canvas); + const forgedActiveScreen = forgedActiveCanvas.nodes[0]; + if (forgedActiveScreen?.kind !== "artboard") throw new Error("Screen fixture missing."); + forgedActiveScreen.activeMediaId = "design:first"; + await assert.rejects( + store.update({ + id: secondRevision.id, + expectedRevision: secondRevision.revision, + canvas: forgedActiveCanvas, + }), + /semantics.*immutable/iu, + ); + const restoredFirst = await store.setActiveRevision({ + id: secondRevision.id, + expectedRevision: secondRevision.revision, + lineageId: "lineage:first", + mediaId: "design:first", + }); + assert.equal(restoredFirst.canvas.nodes[0]?.activeMediaId, "design:first"); + await assert.rejects( + store.setActiveRevision({ + id: restoredFirst.id, + expectedRevision: restoredFirst.revision, + lineageId: "lineage:first", + mediaId: "design:unowned", + }), + DesignProjectConflictError, + ); + + const disk = parseDesignProjectDatabaseV2( + JSON.parse(await readFile(join(root, "design-projects.json"), "utf8")), + ); + assert.equal(disk?.projects[0]?.version, 2); +}); + +test("an unusable first Screen title consumes automatic eligibility permanently", async (t) => { + const root = await temporaryRoot(t); + const store = new DesignProjectStore({ + root: () => root, + mintProjectId: () => "project:title-consumed", + }); + await store.initialize(); + const blank = await store.create({ + chatId: "chat:title-consumed", + title: "Untitled Design", + titleOrigin: "blank", + connectionState: "prototype-only", + }); + const first = await store.publishGeneratedRevisions({ + projectId: blank.id, + chatId: blank.chatId, + revisions: [ + { + mediaId: "design:invalid-title", + candidateTitle: "bad\nname", + ownership: { + version: 1, + kind: "new-artboard", + projectId: blank.id, + lineageId: "lineage:invalid-title", + }, + }, + ], + }); + assert.equal(first.title, "Untitled Design"); + assert.deepEqual(first.titlePolicy, { state: "manual" }); + + const removed = await store.removeMissingGeneratedArtboard({ + projectId: first.id, + expectedRevision: first.revision, + lineageId: "lineage:invalid-title", + activeMediaId: "design:invalid-title", + }); + const later = await store.publishGeneratedRevisions({ + projectId: removed.id, + chatId: removed.chatId, + revisions: [ + { + mediaId: "design:later-valid", + candidateTitle: "Later valid title", + ownership: { + version: 1, + kind: "new-artboard", + projectId: removed.id, + lineageId: "lineage:later-valid", + }, + }, + ], + }); + assert.equal(later.title, "Untitled Design"); + assert.deepEqual(later.titlePolicy, { state: "manual" }); +}); + +test("V1 stores dual-read losslessly and canonicalize on the next mutation", async (t) => { + const root = await temporaryRoot(t); + const legacy = { + version: 1 as const, + revision: 4, + projects: [ + { + version: 1 as const, + id: "project:legacy", + revision: 7, + title: "Legacy title", + chatId: "chat:legacy", + connectionState: "prototype-only" as const, + createdAt: 10, + updatedAt: 20, + canvas: canvas(), + referenceAssetIds: ["asset:reference-a"], + }, + ], + }; + await writeFile(join(root, "design-projects.json"), JSON.stringify(legacy), "utf8"); + const store = new DesignProjectStore({ root: () => root, now: () => 30 }); + await store.initialize(); + const migrated = await store.get("project:legacy"); + assert.ok(migrated); + assert.equal(migrated.version, 2); + assert.equal(migrated.revision, 7); + assert.equal(migrated.title, "Legacy title"); + assert.deepEqual(migrated.titlePolicy, { state: "manual" }); + assert.deepEqual(migrated.canvas.nodes[0]?.presentation, { + surface: "unknown", + frame: { preset: "desktop", width: 1_200, height: 760 }, + }); + const renamed = await store.rename({ + id: migrated.id, + expectedRevision: migrated.revision, + title: "Renamed legacy title", + }); + assert.equal(renamed.revision, 8); + const disk = parseDesignProjectDatabaseV2( + JSON.parse(await readFile(join(root, "design-projects.json"), "utf8")), + ); + assert.equal(disk?.revision, 5); + assert.equal(disk?.projects[0]?.title, "Renamed legacy title"); +}); + test("generic canvas updates cannot invent or rewrite generated lineage ownership", async (t) => { const root = await temporaryRoot(t); const store = new DesignProjectStore({ root: () => root }); @@ -182,7 +423,6 @@ test("generic canvas updates cannot invent or rewrite generated lineage ownershi store.update({ id: project.id, expectedRevision: project.revision, - connectionState: project.connectionState, canvas: { ...project.canvas, nodes: project.canvas.nodes.map((node) => @@ -195,19 +435,16 @@ test("generic canvas updates cannot invent or rewrite generated lineage ownershi : node, ), }, - referenceAssetIds: project.referenceAssetIds, }), - /lineage is immutable/iu, + /semantics.*lineage.*immutable/iu, ); const moved = await store.update({ id: project.id, expectedRevision: project.revision, - connectionState: project.connectionState, canvas: { ...project.canvas, nodes: project.canvas.nodes.map((node) => ({ ...node, x: node.x + 12 })), }, - referenceAssetIds: project.referenceAssetIds, }); assert.equal(moved.canvas.nodes[0]?.x, project.canvas.nodes[0]!.x + 12); }); @@ -360,7 +597,7 @@ test("create retains an exact row installed before a durability failure", async const installed = await store.get("project:ambiguous-create"); assert.equal(installed?.chatId, "chat:ambiguous-create"); - const persisted = parseDesignProjectDatabaseV1( + const persisted = parseDesignProjectDatabaseV2( JSON.parse(await readFile(join(root, "design-projects.json"), "utf8")), ); assert.deepEqual(persisted?.projects, [installed]); @@ -477,6 +714,52 @@ test("rebind preserves Prototype history but clears old Connected App authority" assert.deepEqual(rebound.referenceAssetIds, connected.referenceAssetIds); }); +test("preview setup publishes the exact Connected App node through a dedicated CAS mutation", async (t) => { + const root = await temporaryRoot(t); + const store = new DesignProjectStore({ + root: () => root, + mintProjectId: () => "project:preview-node", + }); + await store.initialize(); + const project = await store.create({ + chatId: "chat:preview-node", + title: "Preview node", + connectionState: "connected", + workspaceId: "workspace:preview-node", + }); + + const configured = await store.setPreviewScript({ + id: project.id, + expectedRevision: project.revision, + previewScriptId: "dev", + }); + assert.equal(configured.previewScriptId, "dev"); + assert.deepEqual(configured.canvas.nodes, [ + { + id: "source-preview:workspace:preview-node", + kind: "source-preview", + canonicalOrigin: "connected-app", + x: 0, + y: 0, + }, + ]); + await assert.rejects( + store.setPreviewScript({ + id: project.id, + expectedRevision: project.revision, + previewScriptId: "preview", + }), + DesignProjectRevisionConflictError, + ); + + const unchanged = await store.setPreviewScript({ + id: configured.id, + expectedRevision: configured.revision, + previewScriptId: "dev", + }); + assert.deepEqual(unchanged, configured); +}); + test("duplicate remaps complete artifact history, assets, nodes, and lineage", async (t) => { const root = await temporaryRoot(t); let id = 0; @@ -751,7 +1034,7 @@ test("delete reconciliation reloads a post-publication disk snapshot instead of await assert.rejects(store.delete(plan), /injected directory sync failure/u); assert.equal((await store.get(project.id))?.id, project.id); - const disk = parseDesignProjectDatabaseV1( + const disk = parseDesignProjectDatabaseV2( JSON.parse(await readFile(join(root, "design-projects.json"), "utf8")), ); assert.equal(disk?.revision, plan.expectedDatabaseRevision + 1); @@ -872,7 +1155,7 @@ test("a main-store restart restores 20 artboards, 10 hydrated references, previe await restartedReferences.initialize(); const restored = await restarted.get(created.id); assert.ok(restored); - assert.deepEqual(restored?.canvas, exactCanvas); + assert.deepEqual(restored?.canvas, created.canvas); assert.deepEqual(restored?.referenceAssetIds, created.referenceAssetIds); assert.equal(restored?.revision, created.revision); assert.equal(restored?.previewScriptId, "dev"); @@ -1068,25 +1351,9 @@ test("named migration fixtures have one explicit deterministic outcome", async ( await store.initialize(); const migrated = await store.getOrMigrateLegacyChat("chat:fixture-mixed"); assert.ok(migrated); - const mixed = await store.update({ + const mixed = await store.setPreviewScript({ id: migrated.id, expectedRevision: migrated.revision, - connectionState: migrated.connectionState, - workspaceId: migrated.workspaceId, - canvas: { - ...migrated.canvas, - nodes: [ - ...migrated.canvas.nodes, - { - id: "source-preview:fixture-mixed", - kind: "source-preview", - canonicalOrigin: "connected-app", - x: -1_320, - y: 0, - }, - ], - }, - referenceAssetIds: [], previewScriptId: "dev", }); assert.deepEqual(mixed.canvas.nodes.map(({ canonicalOrigin }) => canonicalOrigin).sort(), [ @@ -1094,6 +1361,10 @@ test("named migration fixtures have one explicit deterministic outcome", async ( "generated-artifact", ]); assert.equal(mixed.previewScriptId, "dev"); + assert.equal( + mixed.canvas.nodes.find(({ kind }) => kind === "source-preview")?.id, + "source-preview:workspace:fixture-mixed", + ); }); await t.test("copied-chat", async (t) => { @@ -1194,6 +1465,7 @@ test("corrupt and unsafe stores stay unavailable without overwriting original by for (const [name, contents] of [ ["corrupt", "{not-json"], ["unsafe", JSON.stringify({ version: 1, revision: 0, projects: [], extra: true })], + ["future", JSON.stringify({ version: 3, revision: 0, projects: [] })], ] as const) { await t.test(name, async (t) => { const root = await temporaryRoot(t); diff --git a/main/services/design-project-store.ts b/main/services/design-project-store.ts index 167c8489..3e948c1a 100644 --- a/main/services/design-project-store.ts +++ b/main/services/design-project-store.ts @@ -2,19 +2,34 @@ import { createHash, randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { DataStore } from "./data-store.js"; import { - DESIGN_PROJECT_SNAPSHOT_VERSION, MAX_DESIGN_PROJECTS, - MAX_DESIGN_PROJECT_STORE_BYTES, - emptyDesignProjectDatabase, isDesignProjectOpaqueId, normalizeDesignProjectTitle, - parseDesignProjectDatabaseV1, - parseDesignProjectSnapshotV1, - type DesignProjectCanvasV1, - type DesignProjectDatabaseV1, + parseDesignProjectCanvasV1, + type DesignProjectCanvas, type DesignProjectConnectionState, - type DesignProjectSnapshotV1, } from "./design-project-contract.js"; +import { + DESIGN_PROJECT_SNAPSHOT_VERSION_V2, + MAX_DESIGN_PROJECT_STORE_BYTES_V2, + designProjectDatabaseV2StorePolicy, + emptyDesignProjectDatabaseV2, + parseDesignProjectCanvasV2, + parseDesignProjectSnapshotV2, + type DesignProjectDatabaseV2, + type DesignProjectSnapshotV2, +} from "./design-project-contract-v2.js"; +import { + DEFAULT_BLANK_DESIGN_PROJECT_TITLE, + DEFAULT_NEW_DESIGN_SCREEN_PRESENTATION, + applyFirstPublishedScreenTitle, + applyManualDesignProjectTitle, + createDesignProjectTitleState, + migrateDesignProjectTitleStateFromV1, + migrateDesignScreenPresentationFromViewport, + normalizeDesignScreenPresentationV2, + type DesignScreenPresentationV2, +} from "./design-project-v2-policy.js"; import { generatedDesignNodeId, type OwnedDesignGeneratedRevisionV1, @@ -94,21 +109,18 @@ export interface CreateDesignProjectInput { title: string; connectionState: DesignProjectConnectionState; workspaceId?: string; - canvas?: DesignProjectCanvasV1; + canvas?: DesignProjectCanvas; referenceAssetIds?: readonly string[]; - designSystemBinding?: DesignProjectSnapshotV1["designSystemBinding"]; + designSystemBinding?: DesignProjectSnapshotV2["designSystemBinding"]; previewScriptId?: string; + /** Explicit blank projects remain eligible for one first-publication title. */ + titleOrigin?: "blank" | "manual"; } export interface UpdateDesignProjectInput { id: string; expectedRevision: number; - connectionState: DesignProjectConnectionState; - workspaceId?: string; - canvas: DesignProjectCanvasV1; - referenceAssetIds: readonly string[]; - designSystemBinding?: DesignProjectSnapshotV1["designSystemBinding"]; - previewScriptId?: string; + canvas: DesignProjectCanvas; } export interface ConnectDesignProjectInput { @@ -117,6 +129,41 @@ export interface ConnectDesignProjectInput { workspaceId: string; } +export interface SetDesignProjectActiveRevisionInput { + id: string; + expectedRevision: number; + lineageId: string; + mediaId: string; +} + +export interface SetDesignProjectScreenPresentationInput { + id: string; + expectedRevision: number; + lineageId: string; + presentation: DesignScreenPresentationV2; +} + +export interface AttachDesignProjectReferenceInput { + id: string; + expectedRevision: number; + nodeId: string; + assetId: string; + x: number; + y: number; +} + +export interface SetDesignProjectDesignSystemBindingInput { + id: string; + expectedRevision: number; + binding?: NonNullable; +} + +export interface SetDesignProjectPreviewScriptInput { + id: string; + expectedRevision: number; + previewScriptId: string; +} + export interface PublishDesignGeneratedRevisionsInput { projectId: string; chatId: string; @@ -185,7 +232,7 @@ export interface DesignProjectDuplicatePort { * becomes visible. The returned rollback must remove only this preparation. */ prepareDuplicate(input: { - source: DesignProjectSnapshotV1; + source: DesignProjectSnapshotV2; targetProjectId: string; targetTitle: string; }): Promise; @@ -197,7 +244,7 @@ export interface DesignProjectCascadeFacts { } export interface DesignProjectCascadePlanner { - inspect(snapshot: DesignProjectSnapshotV1): Promise; + inspect(snapshot: DesignProjectSnapshotV2): Promise; } export interface DesignProjectDeletePlanV1 { @@ -218,23 +265,29 @@ export interface DesignProjectStoreOptions { filename?: string; now?: () => number; mintProjectId?: () => string; - dataStore?: DataStore; + dataStore?: DataStore; legacySource?: LegacyDesignProjectSource; duplicatePort?: DesignProjectDuplicatePort; cascadePlanner?: DesignProjectCascadePlanner; } -function createDataStore(options: DesignProjectStoreOptions): DataStore { - return new DataStore(options.filename ?? STORE_FILE, emptyDesignProjectDatabase(), options.root, { - maxBytes: MAX_DESIGN_PROJECT_STORE_BYTES, - fileMode: 0o600, - normalize: (value) => parseDesignProjectDatabaseV1(value) ?? emptyDesignProjectDatabase(), - isSafe: (value) => parseDesignProjectDatabaseV1(value) !== undefined, - rejectCorruptWrite: true, - rejectUnsafeWrite: true, - rejectExternalChanges: true, - reloadBeforeWrite: true, - }); +function createDataStore(options: DesignProjectStoreOptions): DataStore { + const v2Policy = designProjectDatabaseV2StorePolicy(); + return new DataStore( + options.filename ?? STORE_FILE, + emptyDesignProjectDatabaseV2(), + options.root, + { + maxBytes: MAX_DESIGN_PROJECT_STORE_BYTES_V2, + fileMode: 0o600, + normalize: v2Policy.normalize, + isSafe: v2Policy.isSafe, + rejectCorruptWrite: true, + rejectUnsafeWrite: true, + rejectExternalChanges: true, + reloadBeforeWrite: true, + }, + ); } function clone(value: T): T { @@ -267,17 +320,100 @@ function requireTitle(value: unknown): string { return title; } -function requireSnapshot(value: unknown): DesignProjectSnapshotV1 { - const parsed = parseDesignProjectSnapshotV1(value); +function requireSnapshot(value: unknown): DesignProjectSnapshotV2 { + const parsed = parseDesignProjectSnapshotV2(value); if (!parsed) throw new Error("Invalid Design Project snapshot."); return parsed; } +function migrateCanvasToV2(canvas: DesignProjectCanvas): DesignProjectSnapshotV2["canvas"] { + const current = parseDesignProjectCanvasV2(canvas); + if (current) return current; + const legacy = parseDesignProjectCanvasV1(canvas); + if (!legacy) throw new Error("Invalid Design Project canvas."); + const migrated = parseDesignProjectCanvasV2({ + ...legacy, + nodes: legacy.nodes.map((node) => + node.kind === "artboard" + ? { + ...node, + presentation: migrateDesignScreenPresentationFromViewport(legacy.viewport), + } + : node, + ), + }); + if (!migrated) throw new Error("Legacy Design Project canvas cannot be migrated safely."); + return migrated; +} + +function mergeLayoutOnlyCanvas( + current: DesignProjectSnapshotV2["canvas"], + requested: DesignProjectCanvas, +): DesignProjectSnapshotV2["canvas"] { + if (requested.nodes.length !== current.nodes.length) { + throw new DesignProjectConflictError( + "Canvas membership can be changed only by a dedicated Design operation.", + ); + } + const requestedById = new Map(requested.nodes.map((node) => [node.id, node])); + if (requestedById.size !== requested.nodes.length) { + throw new DesignProjectConflictError("Canvas node identity is ambiguous."); + } + const merged = parseDesignProjectCanvasV2({ + ...requested, + nodes: current.nodes.map((existing) => { + const requestedNode = requestedById.get(existing.id); + if (!requestedNode || requestedNode.kind !== existing.kind) { + throw new DesignProjectConflictError( + "Canvas membership can be changed only by a dedicated Design operation.", + ); + } + const requestedSemantics = { ...requestedNode } as Record; + const existingSemantics = { ...existing } as Record; + delete requestedSemantics.x; + delete requestedSemantics.y; + delete existingSemantics.x; + delete existingSemantics.y; + // Legacy layout payloads predate per-Screen presentation. Their omission + // is tolerated during rollout, but an explicitly supplied value must be + // the exact main-owned value. + if (existing.kind === "artboard" && !("presentation" in requestedSemantics)) { + delete existingSemantics.presentation; + } + if (!isDeepStrictEqual(requestedSemantics, existingSemantics)) { + throw new DesignProjectConflictError( + "Canvas semantics and lineage are immutable through layout updates.", + ); + } + return { + ...existing, + x: requestedNode.x, + y: requestedNode.y, + }; + }), + }); + if (!merged) throw new Error("Invalid Design Project canvas."); + return merged; +} + +function titlePolicyAfterRemovedProvenance( + project: DesignProjectSnapshotV2, + lineageId: string, + mediaId?: string, +): DesignProjectSnapshotV2["titlePolicy"] { + const policy = project.titlePolicy; + return policy?.state === "auto-applied" && + policy.sourceLineageId === lineageId && + (mediaId === undefined || policy.sourceMediaId === mediaId) + ? { state: "manual" } + : policy; +} + function requireCurrent( - database: DesignProjectDatabaseV1, + database: DesignProjectDatabaseV2, id: string, expectedRevision: number, -): { index: number; project: DesignProjectSnapshotV1 } { +): { index: number; project: DesignProjectSnapshotV2 } { const index = database.projects.findIndex((project) => project.id === id); const project = database.projects[index]; if (!project) throw new DesignProjectNotFoundError(); @@ -288,8 +424,8 @@ function requireCurrent( } function assertImmutableArtboardOwnership( - current: DesignProjectCanvasV1, - requested: DesignProjectCanvasV1, + current: DesignProjectCanvas, + requested: DesignProjectCanvas, ): void { const currentArtboards = current.nodes.filter((node) => node.kind === "artboard"); const requestedArtboards = requested.nodes.filter((node) => node.kind === "artboard"); @@ -314,7 +450,7 @@ function assertImmutableArtboardOwnership( } } -function summary(project: DesignProjectSnapshotV1): DesignProjectSummaryV1 { +function summary(project: DesignProjectSnapshotV2): DesignProjectSummaryV1 { return { id: project.id, revision: project.revision, @@ -394,7 +530,7 @@ function migrationTitle(value: string): string { return "Untitled Design"; } -function migratedSnapshot(facts: LegacyDesignChatFacts): DesignProjectSnapshotV1 { +function migratedSnapshot(facts: LegacyDesignChatFacts): DesignProjectSnapshotV2 { if ( !isDesignProjectOpaqueId(facts.chatId) || typeof facts.title !== "string" || @@ -435,6 +571,7 @@ function migratedSnapshot(facts: LegacyDesignChatFacts): DesignProjectSnapshotV1 } const createdAt = facts.createdAt; const updatedAt = Math.max(facts.updatedAt, createdAt); + const titleState = migrateDesignProjectTitleStateFromV1(migrationTitle(facts.title)); const nodes = artifacts.map((artifact, index) => { return { id: deterministicMigratedNodeId(artifact.mediaId), @@ -445,13 +582,14 @@ function migratedSnapshot(facts: LegacyDesignChatFacts): DesignProjectSnapshotV1 lineageId: deterministicMigratedLineageId(artifact.mediaId), artifactMediaIds: [artifact.mediaId], activeMediaId: artifact.mediaId, + presentation: migrateDesignScreenPresentationFromViewport("desktop"), }; }); const candidate = { - version: DESIGN_PROJECT_SNAPSHOT_VERSION, + version: DESIGN_PROJECT_SNAPSHOT_VERSION_V2, id: deterministicMigratedProjectId(facts.chatId), revision: 1, - title: migrationTitle(facts.title), + ...titleState, chatId: facts.chatId, ...(facts.workspaceId === undefined ? {} : { workspaceId: facts.workspaceId }), connectionState: facts.connectionState, @@ -466,7 +604,7 @@ function migratedSnapshot(facts: LegacyDesignChatFacts): DesignProjectSnapshotV1 }; // Validate connection/workspace relationships, counts, coordinate bounds, and // serialized size through the same path used for every ordinary write. - const parsed = parseDesignProjectSnapshotV1(candidate); + const parsed = parseDesignProjectSnapshotV2(candidate); if (!parsed) { throw new DesignProjectMigrationBlockedError( "Legacy Design chat facts cannot produce a safe project snapshot.", @@ -486,7 +624,7 @@ function safeCascadeIds(values: readonly string[] | undefined, label: string): s } export class DesignProjectStore { - private readonly data: DataStore; + private readonly data: DataStore; private readonly now: () => number; private readonly mintProjectId: () => string; private readonly legacySource: LegacyDesignProjectSource | undefined; @@ -534,7 +672,7 @@ export class DesignProjectStore { } private async reconcilePublishedSnapshot( - expected: DesignProjectSnapshotV1, + expected: DesignProjectSnapshotV2, ): Promise<"installed" | "absent" | "uncertain"> { try { await this.data.reload(); @@ -558,14 +696,14 @@ export class DesignProjectStore { .sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id)); } - async get(id: string): Promise { + async get(id: string): Promise { this.requireAvailable(); if (!isDesignProjectOpaqueId(id)) return undefined; const project = (await this.data.load()).projects.find((candidate) => candidate.id === id); return project ? clone(project) : undefined; } - async getByChatId(chatId: string): Promise { + async getByChatId(chatId: string): Promise { this.requireAvailable(); if (!isDesignProjectOpaqueId(chatId)) return undefined; const project = (await this.data.load()).projects.find( @@ -574,44 +712,53 @@ export class DesignProjectStore { return project ? clone(project) : undefined; } - async create(input: CreateDesignProjectInput): Promise { + async create(input: CreateDesignProjectInput): Promise { this.requireAvailable(); const id = requireIdentity(this.mintProjectId(), "identity"); const timestamp = monotonicTimestamp(this.now); + const normalizedTitle = requireTitle(input.title); + const titleState = createDesignProjectTitleState({ + title: normalizedTitle, + origin: + input.titleOrigin ?? + (normalizedTitle === DEFAULT_BLANK_DESIGN_PROJECT_TITLE ? "blank" : "manual"), + }); const snapshot = requireSnapshot({ - version: DESIGN_PROJECT_SNAPSHOT_VERSION, + version: DESIGN_PROJECT_SNAPSHOT_VERSION_V2, id, revision: 1, - title: requireTitle(input.title), + ...titleState, chatId: requireIdentity(input.chatId, "chat identity"), ...(input.workspaceId === undefined ? {} : { workspaceId: input.workspaceId }), connectionState: input.connectionState, createdAt: timestamp, updatedAt: timestamp, - canvas: input.canvas ?? { - viewport: "desktop", - flowViewport: { x: 0, y: 0, zoom: 1 }, - nodes: [], - }, + canvas: migrateCanvasToV2( + input.canvas ?? { + viewport: "desktop", + flowViewport: { x: 0, y: 0, zoom: 1 }, + nodes: [], + }, + ), referenceAssetIds: [...(input.referenceAssetIds ?? [])], ...(input.designSystemBinding ? { designSystemBinding: input.designSystemBinding } : {}), ...(input.previewScriptId ? { previewScriptId: input.previewScriptId } : {}), }); try { return await this.data.update((database) => { - if (database.projects.length >= MAX_DESIGN_PROJECTS) { - throw new DesignProjectConflictError("Design Project storage is at capacity."); - } - if (database.projects.some((project) => project.id === snapshot.id)) { - throw new DesignProjectConflictError("Design Project identity was reused."); - } - if (database.projects.some((project) => project.chatId === snapshot.chatId)) { - throw new DesignProjectConflictError("This chat already owns a Design Project."); - } - database.projects.push(snapshot); - database.revision += 1; - return clone(snapshot); - }); + if (database.projects.length >= MAX_DESIGN_PROJECTS) { + throw new DesignProjectConflictError("Design Project storage is at capacity."); + } + if (database.projects.some((project) => project.id === snapshot.id)) { + throw new DesignProjectConflictError("Design Project identity was reused."); + } + if (database.projects.some((project) => project.chatId === snapshot.chatId)) { + throw new DesignProjectConflictError("This chat already owns a Design Project."); + } + database.projects.push(snapshot); + database.revision += 1; + return clone(snapshot); + }); } catch (error) { if (error instanceof DesignProjectConflictError) throw error; const publication = await this.reconcilePublishedSnapshot(snapshot); @@ -622,34 +769,210 @@ export class DesignProjectStore { } } - async update(input: UpdateDesignProjectInput): Promise { + async update(input: UpdateDesignProjectInput): Promise { + this.requireAvailable(); + const id = requireIdentity(input.id, "identity"); + const expectedRevision = requireRevision(input.expectedRevision); + return this.data.update((database) => { + const { index, project } = requireCurrent(database, id, expectedRevision); + const canvas = mergeLayoutOnlyCanvas(project.canvas, input.canvas); + assertImmutableArtboardOwnership(project.canvas, canvas); + const updated = requireSnapshot({ + ...project, + revision: project.revision + 1, + updatedAt: monotonicTimestamp(this.now, project.updatedAt), + canvas, + }); + database.projects[index] = updated; + database.revision += 1; + return clone(updated); + }); + } + + async attachReferenceAsset( + input: AttachDesignProjectReferenceInput, + ): Promise { + this.requireAvailable(); + const id = requireIdentity(input.id, "identity"); + const expectedRevision = requireRevision(input.expectedRevision); + const nodeId = requireIdentity(input.nodeId, "reference node identity"); + const assetId = requireIdentity(input.assetId, "reference asset identity"); + return this.data.update((database) => { + const { index, project } = requireCurrent(database, id, expectedRevision); + if ( + project.canvas.nodes.some((node) => node.id === nodeId) || + project.referenceAssetIds.includes(assetId) + ) { + throw new DesignProjectConflictError("This reference image is already attached."); + } + const updated = requireSnapshot({ + ...project, + revision: project.revision + 1, + updatedAt: monotonicTimestamp(this.now, project.updatedAt), + canvas: { + ...project.canvas, + nodes: [ + ...project.canvas.nodes, + { + id: nodeId, + kind: "reference-image", + canonicalOrigin: "reference-asset", + x: input.x, + y: input.y, + assetId, + }, + ], + }, + referenceAssetIds: [...project.referenceAssetIds, assetId], + }); + database.projects[index] = updated; + database.revision += 1; + return clone(updated); + }); + } + + async setDesignSystemBinding( + input: SetDesignProjectDesignSystemBindingInput, + ): Promise { this.requireAvailable(); const id = requireIdentity(input.id, "identity"); const expectedRevision = requireRevision(input.expectedRevision); return this.data.update((database) => { const { index, project } = requireCurrent(database, id, expectedRevision); - assertImmutableArtboardOwnership(project.canvas, input.canvas); const base: Record = { ...project }; - delete base.workspaceId; delete base.designSystemBinding; - delete base.previewScriptId; const updated = requireSnapshot({ ...base, revision: project.revision + 1, updatedAt: monotonicTimestamp(this.now, project.updatedAt), - connectionState: input.connectionState, - ...(input.workspaceId === undefined ? {} : { workspaceId: input.workspaceId }), - canvas: input.canvas, - referenceAssetIds: [...input.referenceAssetIds], - ...(input.designSystemBinding === undefined - ? {} - : { designSystemBinding: input.designSystemBinding }), - ...(input.connectionState === "connected" && - (input.previewScriptId ?? project.previewScriptId) - ? { - previewScriptId: input.previewScriptId ?? project.previewScriptId, - } - : {}), + ...(input.binding ? { designSystemBinding: input.binding } : {}), + }); + database.projects[index] = updated; + database.revision += 1; + return clone(updated); + }); + } + + async setPreviewScript( + input: SetDesignProjectPreviewScriptInput, + ): Promise { + this.requireAvailable(); + const id = requireIdentity(input.id, "identity"); + const expectedRevision = requireRevision(input.expectedRevision); + const previewScriptId = requireIdentity(input.previewScriptId, "preview script identity"); + return this.data.update((database) => { + const { index, project } = requireCurrent(database, id, expectedRevision); + if (project.connectionState !== "connected" || !project.workspaceId) { + throw new DesignProjectConflictError("Only a Connected App can save a preview script."); + } + const sourceNodeId = `source-preview:${project.workspaceId}`; + if ( + project.canvas.nodes.some( + (node) => node.kind === "source-preview" && node.id !== sourceNodeId, + ) + ) { + throw new DesignProjectConflictError( + "The Connected App preview identity does not match this workspace.", + ); + } + const hasSourceNode = project.canvas.nodes.some( + (node) => node.kind === "source-preview" && node.id === sourceNodeId, + ); + if (project.previewScriptId === previewScriptId && hasSourceNode) return clone(project); + const updated = requireSnapshot({ + ...project, + revision: project.revision + 1, + updatedAt: monotonicTimestamp(this.now, project.updatedAt), + previewScriptId, + canvas: hasSourceNode + ? project.canvas + : { + ...project.canvas, + nodes: [ + ...project.canvas.nodes, + { + id: sourceNodeId, + kind: "source-preview", + canonicalOrigin: "connected-app", + x: 0, + y: 0, + }, + ], + }, + }); + database.projects[index] = updated; + database.revision += 1; + return clone(updated); + }); + } + + async setActiveRevision( + input: SetDesignProjectActiveRevisionInput, + ): Promise { + this.requireAvailable(); + const id = requireIdentity(input.id, "identity"); + const expectedRevision = requireRevision(input.expectedRevision); + const lineageId = requireIdentity(input.lineageId, "lineage identity"); + const mediaId = requireIdentity(input.mediaId, "artifact identity"); + return this.data.update((database) => { + const { index, project } = requireCurrent(database, id, expectedRevision); + const matches = project.canvas.nodes.filter( + (node) => + node.kind === "artboard" && + node.lineageId === lineageId && + node.artifactMediaIds?.includes(mediaId) === true, + ); + if (matches.length !== 1) { + throw new DesignProjectConflictError( + "The requested Screen revision does not belong to this project lineage.", + ); + } + if (matches[0]!.activeMediaId === mediaId) return clone(project); + const updated = requireSnapshot({ + ...project, + revision: project.revision + 1, + updatedAt: monotonicTimestamp(this.now, project.updatedAt), + canvas: { + ...project.canvas, + nodes: project.canvas.nodes.map((node) => + node === matches[0] ? { ...node, activeMediaId: mediaId } : node, + ), + }, + }); + database.projects[index] = updated; + database.revision += 1; + return clone(updated); + }); + } + + async setScreenPresentation( + input: SetDesignProjectScreenPresentationInput, + ): Promise { + this.requireAvailable(); + const id = requireIdentity(input.id, "identity"); + const expectedRevision = requireRevision(input.expectedRevision); + const lineageId = requireIdentity(input.lineageId, "lineage identity"); + const presentation = normalizeDesignScreenPresentationV2(input.presentation); + if (!presentation) throw new Error("Invalid Design Screen presentation."); + return this.data.update((database) => { + const { index, project } = requireCurrent(database, id, expectedRevision); + const matches = project.canvas.nodes.filter( + (node) => node.kind === "artboard" && node.lineageId === lineageId, + ); + if (matches.length !== 1) { + throw new DesignProjectConflictError("The requested Screen does not exist."); + } + if (isDeepStrictEqual(matches[0]!.presentation, presentation)) return clone(project); + const updated = requireSnapshot({ + ...project, + revision: project.revision + 1, + updatedAt: monotonicTimestamp(this.now, project.updatedAt), + canvas: { + ...project.canvas, + nodes: project.canvas.nodes.map((node) => + node === matches[0] ? { ...node, presentation } : node, + ), + }, }); database.projects[index] = updated; database.revision += 1; @@ -664,7 +987,7 @@ export class DesignProjectStore { */ async removeMissingGeneratedArtboard( input: RemoveMissingGeneratedArtboardInput, - ): Promise { + ): Promise { this.requireAvailable(); const projectId = requireIdentity(input.projectId, "identity"); const expectedRevision = requireRevision(input.expectedRevision); @@ -687,6 +1010,7 @@ export class DesignProjectStore { } const updated = requireSnapshot({ ...project, + titlePolicy: titlePolicyAfterRemovedProvenance(project, lineageId), revision: project.revision + 1, updatedAt: monotonicTimestamp(this.now, project.updatedAt), canvas: { @@ -708,7 +1032,7 @@ export class DesignProjectStore { */ async removeMissingGeneratedRevision( input: RemoveMissingGeneratedRevisionInput, - ): Promise { + ): Promise { this.requireAvailable(); const projectId = requireIdentity(input.projectId, "identity"); const expectedRevision = requireRevision(input.expectedRevision); @@ -740,6 +1064,7 @@ export class DesignProjectStore { const target = matches[0]!; const updated = requireSnapshot({ ...project, + titlePolicy: titlePolicyAfterRemovedProvenance(project, lineageId, missingMediaId), revision: project.revision + 1, updatedAt: monotonicTimestamp(this.now, project.updatedAt), canvas: { @@ -768,7 +1093,7 @@ export class DesignProjectStore { */ async removeMissingReferenceAsset( input: RemoveMissingReferenceAssetInput, - ): Promise { + ): Promise { this.requireAvailable(); const projectId = requireIdentity(input.projectId, "identity"); const expectedRevision = requireRevision(input.expectedRevision); @@ -815,7 +1140,7 @@ export class DesignProjectStore { */ async publishGeneratedRevisions( input: PublishDesignGeneratedRevisionsInput, - ): Promise { + ): Promise { this.requireAvailable(); const projectId = requireIdentity(input.projectId, "identity"); const chatId = requireIdentity(input.chatId, "chat identity"); @@ -876,6 +1201,10 @@ export class DesignProjectStore { } let changed = false; const nodes = clone(project.canvas.nodes); + const successfulScreenCountBefore = nodes.filter((node) => node.kind === "artboard").length; + let firstCreatedScreen: + | { lineageId: string; mediaId: string; candidateTitle: string | undefined } + | undefined; for (const revision of input.revisions) { const alreadyPublished = nodes.find( (node) => @@ -913,12 +1242,29 @@ export class DesignProjectStore { lineageId: revision.ownership.lineageId, artifactMediaIds: [revision.mediaId], activeMediaId: revision.mediaId, + presentation: revision.ownership.presentation ?? DEFAULT_NEW_DESIGN_SCREEN_PRESENTATION, }); + firstCreatedScreen ??= { + lineageId: revision.ownership.lineageId, + mediaId: revision.mediaId, + candidateTitle: revision.candidateTitle, + }; changed = true; } if (!changed) return clone(project); + const titleState = firstCreatedScreen + ? applyFirstPublishedScreenTitle({ + current: { title: project.title, titlePolicy: project.titlePolicy }, + candidateTitle: firstCreatedScreen.candidateTitle, + successfulScreenCountBefore, + successfulScreenCountAfter: nodes.filter((node) => node.kind === "artboard").length, + sourceLineageId: firstCreatedScreen.lineageId, + sourceMediaId: firstCreatedScreen.mediaId, + }) + : { title: project.title, titlePolicy: project.titlePolicy }; const updated = requireSnapshot({ ...project, + ...titleState, revision: project.revision + 1, updatedAt: monotonicTimestamp(this.now, project.updatedAt), canvas: { ...project.canvas, nodes }, @@ -935,7 +1281,7 @@ export class DesignProjectStore { * Workspace eligibility is intentionally checked by the main-owned * connection service immediately before this CAS mutation. */ - async connect(input: ConnectDesignProjectInput): Promise { + async connect(input: ConnectDesignProjectInput): Promise { this.requireAvailable(); const id = requireIdentity(input.id, "identity"); const expectedRevision = requireRevision(input.expectedRevision); @@ -975,17 +1321,21 @@ export class DesignProjectStore { id: string; expectedRevision: number; title: string; - }): Promise { + }): Promise { this.requireAvailable(); const id = requireIdentity(input.id, "identity"); const expectedRevision = requireRevision(input.expectedRevision); const title = requireTitle(input.title); return this.data.update((database) => { const { index, project } = requireCurrent(database, id, expectedRevision); + const titleState = applyManualDesignProjectTitle( + { title: project.title, titlePolicy: project.titlePolicy ?? { state: "manual" } }, + title, + ); const updated = requireSnapshot({ ...project, revision: project.revision + 1, - title, + ...titleState, updatedAt: monotonicTimestamp(this.now, project.updatedAt), }); database.projects[index] = updated; @@ -998,7 +1348,7 @@ export class DesignProjectStore { id: string; expectedRevision: number; title?: string; - }): Promise { + }): Promise { this.requireAvailable(); if (!this.duplicatePort) { throw new DesignProjectUnavailableError("Design Project duplication is not configured."); @@ -1032,6 +1382,7 @@ export class DesignProjectStore { id: targetProjectId, revision: 1, title: targetTitle, + titlePolicy: { state: "manual" }, chatId: targetChatId, createdAt: timestamp, updatedAt: timestamp, @@ -1059,23 +1410,23 @@ export class DesignProjectStore { referenceAssetIds: source.referenceAssetIds.map((assetId) => assetMap.get(assetId)), }); try { - const installed = await this.data.update((database) => { - requireCurrent(database, id, expectedRevision); - if (database.projects.length >= MAX_DESIGN_PROJECTS) { - throw new DesignProjectConflictError("Design Project storage is at capacity."); - } - if (database.projects.some((project) => project.id === duplicate.id)) { - throw new DesignProjectConflictError("Design Project identity was reused."); - } - if (database.projects.some((project) => project.chatId === duplicate.chatId)) { - throw new DesignProjectConflictError("Duplicate chat already owns a Design Project."); - } - database.projects.push(duplicate); - database.revision += 1; - return clone(duplicate); - }); + const installed = await this.data.update((database) => { + requireCurrent(database, id, expectedRevision); + if (database.projects.length >= MAX_DESIGN_PROJECTS) { + throw new DesignProjectConflictError("Design Project storage is at capacity."); + } + if (database.projects.some((project) => project.id === duplicate.id)) { + throw new DesignProjectConflictError("Design Project identity was reused."); + } + if (database.projects.some((project) => project.chatId === duplicate.chatId)) { + throw new DesignProjectConflictError("Duplicate chat already owns a Design Project."); + } + database.projects.push(duplicate); + database.revision += 1; + return clone(duplicate); + }); rollback = false; - return installed; + return installed; } catch (error) { if ( error instanceof DesignProjectConflictError || @@ -1191,7 +1542,7 @@ export class DesignProjectStore { } /** Lazy, deterministic migration for the compatibility `/design/$chatId` route. */ - async getOrMigrateLegacyChat(chatId: string): Promise { + async getOrMigrateLegacyChat(chatId: string): Promise { this.requireAvailable(); const safeChatId = requireIdentity(chatId, "chat identity"); const existing = await this.getByChatId(safeChatId); diff --git a/main/services/design-project-v2-policy.test.ts b/main/services/design-project-v2-policy.test.ts new file mode 100644 index 00000000..8792d3a6 --- /dev/null +++ b/main/services/design-project-v2-policy.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + applyFirstPublishedScreenTitle, + applyManualDesignProjectTitle, + createDesignProjectTitleState, + migrateDesignProjectTitleStateFromV1, + migrateDesignScreenPresentationFromViewport, + normalizeDesignProjectTitlePolicyV2, + normalizeDesignProjectTitleStateV2, + normalizeDesignScreenPresentationV2, +} from "./design-project-v2-policy.js"; + +test("blank projects are auto-eligible while explicit and migrated titles are manual", () => { + assert.deepEqual(createDesignProjectTitleState({ origin: "blank" }), { + title: "Untitled Design", + titlePolicy: { state: "auto-eligible" }, + }); + assert.deepEqual(createDesignProjectTitleState({ origin: "manual", title: " Checkout " }), { + title: "Checkout", + titlePolicy: { state: "manual" }, + }); + assert.deepEqual(migrateDesignProjectTitleStateFromV1("Legacy concept"), { + title: "Legacy concept", + titlePolicy: { state: "manual" }, + }); + assert.throws( + () => createDesignProjectTitleState({ origin: "blank", title: "Not a placeholder" }), + /Only the blank/u, + ); +}); + +test("only the first successful Screen publication can apply an automatic title", () => { + const eligible = createDesignProjectTitleState({ origin: "blank" }); + const unchangedWithoutScreen = applyFirstPublishedScreenTitle({ + current: eligible, + candidateTitle: "Checkout", + successfulScreenCountBefore: 0, + successfulScreenCountAfter: 0, + sourceLineageId: "lineage:checkout", + sourceMediaId: "design:checkout", + }); + assert.equal(unchangedWithoutScreen, eligible); + + const applied = applyFirstPublishedScreenTitle({ + current: eligible, + candidateTitle: " Checkout flow ", + successfulScreenCountBefore: 0, + successfulScreenCountAfter: 1, + sourceLineageId: "lineage:checkout", + sourceMediaId: "design:checkout", + }); + assert.deepEqual(applied, { + title: "Checkout flow", + titlePolicy: { + state: "auto-applied", + sourceLineageId: "lineage:checkout", + sourceMediaId: "design:checkout", + }, + }); + + assert.deepEqual( + applyFirstPublishedScreenTitle({ + current: applied, + candidateTitle: "A later Screen", + successfulScreenCountBefore: 1, + successfulScreenCountAfter: 2, + sourceLineageId: "lineage:later", + sourceMediaId: "design:later", + }), + applied, + ); +}); + +test("manual titles always win and malformed first titles consume automatic eligibility", () => { + const manual = applyManualDesignProjectTitle( + createDesignProjectTitleState({ origin: "blank" }), + "My chosen name", + ); + assert.deepEqual( + applyFirstPublishedScreenTitle({ + current: manual, + candidateTitle: "Generated replacement", + successfulScreenCountBefore: 0, + successfulScreenCountAfter: 3, + sourceLineageId: "lineage:first", + sourceMediaId: "design:first", + }), + manual, + ); + + const eligible = createDesignProjectTitleState({ origin: "blank" }); + assert.deepEqual( + applyFirstPublishedScreenTitle({ + current: eligible, + candidateTitle: "bad\nname", + successfulScreenCountBefore: 0, + successfulScreenCountAfter: 1, + sourceLineageId: "lineage:first", + sourceMediaId: "design:first", + }), + { title: "Untitled Design", titlePolicy: { state: "manual" } }, + ); + assert.throws( + () => + applyFirstPublishedScreenTitle({ + current: eligible, + candidateTitle: "Checkout", + successfulScreenCountBefore: -1, + successfulScreenCountAfter: 1, + sourceLineageId: "lineage:first", + sourceMediaId: "design:first", + }), + /publication counts/u, + ); +}); + +test("title policy parsers reject extra fields and incomplete automatic provenance", () => { + assert.deepEqual(normalizeDesignProjectTitlePolicyV2({ state: "manual" }), { + state: "manual", + }); + assert.equal( + normalizeDesignProjectTitlePolicyV2({ state: "manual", sourceMediaId: "design:forged" }), + undefined, + ); + assert.equal( + normalizeDesignProjectTitlePolicyV2({ + state: "auto-applied", + sourceLineageId: "lineage:one", + }), + undefined, + ); + assert.equal( + normalizeDesignProjectTitleStateV2({ + title: "A manually chosen name", + titlePolicy: { state: "auto-eligible" }, + }), + undefined, + ); + assert.deepEqual( + normalizeDesignProjectTitleStateV2({ + title: "Checkout", + titlePolicy: { + state: "auto-applied", + sourceLineageId: "lineage:one", + sourceMediaId: "design:one", + }, + }), + { + title: "Checkout", + titlePolicy: { + state: "auto-applied", + sourceLineageId: "lineage:one", + sourceMediaId: "design:one", + }, + }, + ); +}); + +test("V1 viewport migration preserves frame geometry and separates surface intent", () => { + assert.deepEqual(migrateDesignScreenPresentationFromViewport("desktop"), { + surface: "unknown", + frame: { preset: "desktop", width: 1200, height: 760 }, + }); + assert.deepEqual(migrateDesignScreenPresentationFromViewport("tablet"), { + surface: "unknown", + frame: { preset: "tablet", width: 768, height: 900 }, + }); + assert.deepEqual(migrateDesignScreenPresentationFromViewport("phone"), { + surface: "unknown", + frame: { preset: "phone", width: 390, height: 844 }, + }); + + assert.deepEqual( + normalizeDesignScreenPresentationV2({ + surface: "web", + frame: { preset: "phone", width: 390, height: 844 }, + }), + { + surface: "web", + frame: { preset: "phone", width: 390, height: 844 }, + }, + "surface intent is independent from preview geometry", + ); + assert.deepEqual( + normalizeDesignScreenPresentationV2({ + surface: "app", + frame: { preset: "custom", width: 430, height: 932 }, + }), + { + surface: "app", + frame: { preset: "custom", width: 430, height: 932 }, + }, + ); +}); + +test("Screen presentation rejects contradictory presets, unsafe dimensions, and extra fields", () => { + for (const presentation of [ + { surface: "web", frame: { preset: "desktop", width: 1280, height: 760 } }, + { surface: "app", frame: { preset: "custom", width: 0, height: 844 } }, + { surface: "app", frame: { preset: "custom", width: 390.5, height: 844 } }, + { surface: "app", frame: { preset: "custom", width: 16_385, height: 844 } }, + { surface: "app", frame: { preset: "phone", width: 390, height: 844, scale: 2 } }, + { surface: "print", frame: { preset: "custom", width: 1200, height: 760 } }, + ]) { + assert.equal(normalizeDesignScreenPresentationV2(presentation), undefined); + } +}); diff --git a/main/services/design-project-v2-policy.ts b/main/services/design-project-v2-policy.ts new file mode 100644 index 00000000..5239f8a8 --- /dev/null +++ b/main/services/design-project-v2-policy.ts @@ -0,0 +1,257 @@ +/** Main-owned semantic policies used by the Design Project V2 contract. */ + +import type { + DesignProjectTitlePolicyV2, + DesignProjectViewport, + DesignScreenFramePresetV2, + DesignScreenPresentationV2, +} from "../../renderer/shared/design-projects.js"; + +export type { + DesignProjectTitlePolicyV2, + DesignScreenFramePresetV2, + DesignScreenFrameV2, + DesignScreenPresentationV2, + DesignScreenSurfaceV2, +} from "../../renderer/shared/design-projects.js"; + +export const DEFAULT_BLANK_DESIGN_PROJECT_TITLE = "Untitled Design"; +export const MAX_DESIGN_SCREEN_FRAME_DIMENSION = 16_384; + +const MAX_TITLE_CHARACTERS = 160; +const MAX_TITLE_BYTES = 512; +const SAFE_OPAQUE_ID = /^[A-Za-z0-9._:@+-]+$/u; + +export interface DesignProjectTitleStateV2 { + title: string; + titlePolicy: DesignProjectTitlePolicyV2; +} + +const PRESET_FRAMES: Readonly< + Record, Readonly<{ width: number; height: number }>> +> = { + desktop: { width: 1200, height: 760 }, + tablet: { width: 768, height: 900 }, + phone: { width: 390, height: 844 }, +}; + +export const DEFAULT_NEW_DESIGN_SCREEN_PRESENTATION: DesignScreenPresentationV2 = { + surface: "unknown", + frame: { preset: "desktop", width: 1_200, height: 760 }, +}; + +function exactKeys( + value: Record, + allowed: readonly string[], + required: readonly string[] = allowed, +): boolean { + const allowedSet = new Set(allowed); + return ( + Object.keys(value).every((key) => allowedSet.has(key)) && + required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); +} + +function normalizeTitle(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const title = value.normalize("NFKC").trim(); + if ( + title.length === 0 || + Array.from(title).length > MAX_TITLE_CHARACTERS || + Buffer.byteLength(title, "utf8") > MAX_TITLE_BYTES + ) { + return undefined; + } + for (let index = 0; index < title.length; index += 1) { + const code = title.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return undefined; + } + return title; +} + +function normalizeOpaqueId(value: unknown): string | undefined { + return typeof value === "string" && + value.length > 0 && + value.length <= 256 && + value.normalize("NFKC") === value && + SAFE_OPAQUE_ID.test(value) + ? value + : undefined; +} + +function requireTitle(value: unknown): string { + const title = normalizeTitle(value); + if (!title) throw new Error("Invalid Design Project title."); + return title; +} + +function requireOpaqueId(value: unknown, label: string): string { + const id = normalizeOpaqueId(value); + if (!id) throw new Error(`Invalid ${label}.`); + return id; +} + +function validScreenCount(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +export function normalizeDesignProjectTitlePolicyV2( + value: unknown, +): DesignProjectTitlePolicyV2 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const policy = value as Record; + if (policy.state === "auto-eligible" || policy.state === "manual") { + return exactKeys(policy, ["state"]) ? { state: policy.state } : undefined; + } + if ( + policy.state !== "auto-applied" || + !exactKeys(policy, ["state", "sourceLineageId", "sourceMediaId"]) + ) { + return undefined; + } + const sourceLineageId = normalizeOpaqueId(policy.sourceLineageId); + const sourceMediaId = normalizeOpaqueId(policy.sourceMediaId); + return sourceLineageId && sourceMediaId + ? { state: "auto-applied", sourceLineageId, sourceMediaId } + : undefined; +} + +export function normalizeDesignProjectTitleStateV2( + value: unknown, +): DesignProjectTitleStateV2 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const state = value as Record; + if (!exactKeys(state, ["title", "titlePolicy"])) return undefined; + const title = normalizeTitle(state.title); + const titlePolicy = normalizeDesignProjectTitlePolicyV2(state.titlePolicy); + if (!title || !titlePolicy) return undefined; + if (titlePolicy.state === "auto-eligible" && title !== DEFAULT_BLANK_DESIGN_PROJECT_TITLE) { + return undefined; + } + return { title, titlePolicy }; +} + +export function createDesignProjectTitleState(input: { + title?: unknown; + origin: "blank" | "manual"; +}): DesignProjectTitleStateV2 { + if (input.origin === "blank") { + const title = requireTitle(input.title ?? DEFAULT_BLANK_DESIGN_PROJECT_TITLE); + if (title !== DEFAULT_BLANK_DESIGN_PROJECT_TITLE) { + throw new Error("Only the blank Design Project title can be auto-eligible."); + } + return { + title, + titlePolicy: { state: "auto-eligible" }, + }; + } + return { title: requireTitle(input.title), titlePolicy: { state: "manual" } }; +} + +/** V1 title provenance is unknowable, so migration conservatively protects it as manual. */ +export function migrateDesignProjectTitleStateFromV1(title: unknown): DesignProjectTitleStateV2 { + return { title: requireTitle(title), titlePolicy: { state: "manual" } }; +} + +export function applyManualDesignProjectTitle( + _current: DesignProjectTitleStateV2, + title: unknown, +): DesignProjectTitleStateV2 { + return { title: requireTitle(title), titlePolicy: { state: "manual" } }; +} + +/** + * Apply one candidate only across the first successful Screen publication + * boundary. Retries and later publications cannot silently rename a project. + */ +export function applyFirstPublishedScreenTitle(input: { + current: DesignProjectTitleStateV2; + candidateTitle: unknown; + successfulScreenCountBefore: number; + successfulScreenCountAfter: number; + sourceLineageId: unknown; + sourceMediaId: unknown; +}): DesignProjectTitleStateV2 { + if (input.current.titlePolicy.state !== "auto-eligible") return input.current; + if ( + !validScreenCount(input.successfulScreenCountBefore) || + !validScreenCount(input.successfulScreenCountAfter) || + input.successfulScreenCountAfter < input.successfulScreenCountBefore + ) { + throw new Error("Invalid successful Screen publication counts."); + } + if (input.successfulScreenCountBefore !== 0 || input.successfulScreenCountAfter === 0) { + return input.current; + } + const title = normalizeTitle(input.candidateTitle); + if (!title) { + // Eligibility is a one-shot transition tied to the first successful + // Screen publication. An unusable generated label must not let a later + // Screen silently rename the project after recovery removes the first. + return { title: input.current.title, titlePolicy: { state: "manual" } }; + } + const sourceLineageId = requireOpaqueId(input.sourceLineageId, "title source lineage identity"); + const sourceMediaId = requireOpaqueId(input.sourceMediaId, "title source media identity"); + return { + title, + titlePolicy: { state: "auto-applied", sourceLineageId, sourceMediaId }, + }; +} + +function normalizeFrameDimension(value: unknown): number | undefined { + return Number.isSafeInteger(value) && + (value as number) >= 1 && + (value as number) <= MAX_DESIGN_SCREEN_FRAME_DIMENSION + ? (value as number) + : undefined; +} + +export function normalizeDesignScreenPresentationV2( + value: unknown, +): DesignScreenPresentationV2 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const presentation = value as Record; + if ( + !exactKeys(presentation, ["surface", "frame"]) || + (presentation.surface !== "app" && + presentation.surface !== "web" && + presentation.surface !== "unknown") || + !presentation.frame || + typeof presentation.frame !== "object" || + Array.isArray(presentation.frame) + ) { + return undefined; + } + const frame = presentation.frame as Record; + if ( + !exactKeys(frame, ["preset", "width", "height"]) || + (frame.preset !== "phone" && + frame.preset !== "tablet" && + frame.preset !== "desktop" && + frame.preset !== "custom") + ) { + return undefined; + } + const width = normalizeFrameDimension(frame.width); + const height = normalizeFrameDimension(frame.height); + if (!width || !height) return undefined; + if (frame.preset !== "custom") { + const expected = PRESET_FRAMES[frame.preset]; + if (width !== expected.width || height !== expected.height) return undefined; + } + return { + surface: presentation.surface, + frame: { preset: frame.preset, width, height }, + }; +} + +/** Preserve V1 preview geometry while assigning a conservative primary surface. */ +export function migrateDesignScreenPresentationFromViewport( + viewport: DesignProjectViewport, +): DesignScreenPresentationV2 { + const frame = PRESET_FRAMES[viewport]; + return { + surface: "unknown", + frame: { preset: viewport, width: frame.width, height: frame.height }, + }; +} diff --git a/main/services/design-system-attachment-service-main.ts b/main/services/design-system-attachment-service-main.ts index b03a6b30..1f4ea45b 100644 --- a/main/services/design-system-attachment-service-main.ts +++ b/main/services/design-system-attachment-service-main.ts @@ -1,7 +1,7 @@ import { DesignSystemAttachmentService } from "./design-system-attachment-service.js"; import { DesignSystemSnapshotStore } from "./design-system-snapshot-store.js"; import * as fs from "node:fs/promises"; -import type { DesignProjectSnapshotV1 } from "./design-project-contract.js"; +import type { DesignProjectSnapshot as DesignProjectSnapshotV1 } from "./design-project-contract.js"; export const designSystemSnapshotStore = new DesignSystemSnapshotStore(); export const designSystemAttachmentService = new DesignSystemAttachmentService( diff --git a/main/services/diagnostic-support.test.ts b/main/services/diagnostic-support.test.ts index 9291ee51..c6f43f4f 100644 --- a/main/services/diagnostic-support.test.ts +++ b/main/services/diagnostic-support.test.ts @@ -6,7 +6,12 @@ import test from "node:test"; import { gunzip } from "node:zlib"; import { promisify } from "node:util"; -import { initDiagnosticJournal, writeDiagnosticEvent, writeDiagnosticEventSync, flushDiagnosticJournal } from "./diagnostic-journal.js"; +import { + initDiagnosticJournal, + writeDiagnosticEvent, + writeDiagnosticEventSync, + flushDiagnosticJournal, +} from "./diagnostic-journal.js"; import { initDiagnosticHealth } from "./diagnostic-health.js"; import { flushSubagentRuntimeDiagnostics, @@ -24,7 +29,9 @@ import { const gunzipAsync = promisify(gunzip); -async function fixture(run: (root: string, logs: string, dumps: string) => Promise): Promise { +async function fixture( + run: (root: string, logs: string, dumps: string) => Promise, +): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-diagnostic-support-")); const logs = path.join(root, "logs"); const dumps = path.join(root, "dumps"); @@ -45,7 +52,11 @@ function app() { test("status reports bounded local evidence without exposing file paths", async () => { await fixture(async (_root, logs, dumps) => { - initDiagnosticJournal({ targetPath: path.join(logs, "aiden.log"), profile: "production", sessionId: "session-test" }); + initDiagnosticJournal({ + targetPath: path.join(logs, "aiden.log"), + profile: "production", + sessionId: "session-test", + }); writeDiagnosticEvent({ level: "warn", area: "renderer", event: "renderer-unresponsive" }); await flushDiagnosticJournal(); const status = await diagnosticSupportStatus({ logsPath: logs, crashDumpsPath: dumps }); @@ -70,8 +81,17 @@ test("status reports corrupt journal and health evidence as a sink failure", asy test("export is manifest-first, projects subagent identifiers, and validates its round trip", async () => { await fixture(async (root, logs, dumps) => { - initDiagnosticJournal({ targetPath: path.join(logs, "aiden.log"), profile: "production", sessionId: "session-test" }); - writeDiagnosticEvent({ level: "error", area: "providers", event: "provider-failed", code: "provider-failed" }); + initDiagnosticJournal({ + targetPath: path.join(logs, "aiden.log"), + profile: "production", + sessionId: "session-test", + }); + writeDiagnosticEvent({ + level: "error", + area: "providers", + event: "provider-failed", + code: "provider-failed", + }); await flushDiagnosticJournal(); initSubagentRuntimeDiagnostics(path.join(logs, "subagent-runtime.log")); await fs.writeFile( @@ -98,11 +118,21 @@ test("export is manifest-first, projects subagent identifiers, and validates its tempRoot: root, now: () => new Date("2026-08-27T13:00:00.000Z"), }); - assert.deepEqual(manifest.included, { generalRecords: 2, subagentRecords: 1, healthDays: 0, crashDumps: 0 }); + assert.deepEqual(manifest.included, { + generalRecords: 2, + subagentRecords: 1, + healthDays: 0, + crashDumps: 0, + }); assert.equal((await fs.stat(destination)).mode & 0o777, 0o600); - const bundle = JSON.parse((await gunzipAsync(await fs.readFile(destination))).toString("utf8")) as DiagnosticExportBundle; + const bundle = JSON.parse( + (await gunzipAsync(await fs.readFile(destination))).toString("utf8"), + ) as DiagnosticExportBundle; const text = JSON.stringify(bundle); - assert.doesNotMatch(text, /private-diagnostic-id|private-run-id|private-provider-id|private-model-id|private detail/u); + assert.doesNotMatch( + text, + /private-diagnostic-id|private-run-id|private-provider-id|private-model-id|private detail/u, + ); assert.match(text, /provider_failure/u); }); }); @@ -113,21 +143,35 @@ test("an export after a long idle period sweeps expired general fatal and subage const ninthDay = new Date("2026-08-09T00:00:00.001Z"); const journal = path.join(logs, "aiden.log"); initDiagnosticJournal({ targetPath: journal, profile: "production", now: () => firstDay }); - writeDiagnosticEvent({ level: "warn", area: "diagnostics", event: "retention-check", fields: { sequence: 1 } }); - writeDiagnosticEventSync({ level: "fatal", area: "app", event: "app-failed", fields: { sequence: 1 } }); + writeDiagnosticEvent({ + level: "warn", + area: "diagnostics", + event: "retention-check", + fields: { sequence: 1 }, + }); + writeDiagnosticEventSync({ + level: "fatal", + area: "app", + event: "app-failed", + fields: { sequence: 1 }, + }); await flushDiagnosticJournal(); const subagent = path.join(logs, "subagent-runtime.log"); initSubagentRuntimeDiagnostics(subagent); - await fs.writeFile(subagent, `${JSON.stringify({ - at: firstDay.toISOString(), - diagnosticId: "SA-expired", - providerId: "custom:test", - modelId: "test-model", - failure: "provider", - attempts: 1, - diagnostics: [{ stage: "provider", code: "provider_failure" }], - })}\n`, { mode: 0o600 }); + await fs.writeFile( + subagent, + `${JSON.stringify({ + at: firstDay.toISOString(), + diagnosticId: "SA-expired", + providerId: "custom:test", + modelId: "test-model", + failure: "provider", + attempts: 1, + diagnostics: [{ stage: "provider", code: "provider_failure" }], + })}\n`, + { mode: 0o600 }, + ); await fs.utimes(subagent, firstDay, firstDay); const destination = path.join(root, "idle-export.json.gz"); @@ -142,7 +186,9 @@ test("an export after a long idle period sweeps expired general fatal and subage }); assert.equal(manifest.included.generalRecords, 0); assert.equal(manifest.included.subagentRecords, 0); - const bundle = JSON.parse((await gunzipAsync(await fs.readFile(destination))).toString("utf8")) as DiagnosticExportBundle; + const bundle = JSON.parse( + (await gunzipAsync(await fs.readFile(destination))).toString("utf8"), + ) as DiagnosticExportBundle; assert.doesNotMatch(JSON.stringify(bundle), /SA-expired|"sequence":1/u); }); }); @@ -236,13 +282,16 @@ test("startup pruning enforces crash dump age and count without reading dump con const at = new Date(Date.now() - index * 1_000); await fs.utimes(file, at, at); } - await fs.writeFile(path.join(dumps, "oversized.dmp"), Buffer.alloc(16 * 1024 * 1024 + 1), { mode: 0o644 }); + await fs.writeFile(path.join(dumps, "oversized.dmp"), Buffer.alloc(16 * 1024 * 1024 + 1), { + mode: 0o644, + }); await pruneExpiredDiagnosticCrashDumps(dumps); const retained = (await fs.readdir(dumps)).filter((name) => name.endsWith(".dmp")); assert.equal(retained.length, 3); assert.equal(retained.includes("stale.dmp"), false); assert.equal(retained.includes("oversized.dmp"), false); - for (const name of retained) assert.equal((await fs.stat(path.join(dumps, name))).mode & 0o777, 0o600); + for (const name of retained) + assert.equal((await fs.stat(path.join(dumps, name))).mode & 0o777, 0o600); }); }); @@ -262,9 +311,16 @@ test("unknown records and symlinked sources fail closed or stay excluded", async app: app(), tempRoot: root, }); - assert.doesNotMatch((await gunzipAsync(await fs.readFile(destination))).toString("utf8"), /outside/u); + assert.doesNotMatch( + (await gunzipAsync(await fs.readFile(destination))).toString("utf8"), + /outside/u, + ); - await fs.writeFile(path.join(logs, "aiden.log"), `${JSON.stringify({ unknown: true })}\n`, "utf8"); + await fs.writeFile( + path.join(logs, "aiden.log"), + `${JSON.stringify({ unknown: true })}\n`, + "utf8", + ); await assert.rejects( createDiagnosticExport({ logsPath: logs, @@ -281,8 +337,8 @@ test("unknown records and symlinked sources fail closed or stay excluded", async path.join(logs, "aiden.log"), `${JSON.stringify({ version: 1, - at: "2026-08-27T12:00:00.000Z", sessionId: "session-forged", + at: new Date().toISOString(), level: "error", area: "app", event: "app-failed", @@ -309,23 +365,30 @@ test("subagent export rejects open-string categorical fields", async () => { initDiagnosticJournal({ targetPath: path.join(logs, "aiden.log"), profile: "production" }); const subagent = path.join(logs, "subagent-runtime.log"); initSubagentRuntimeDiagnostics(subagent); - await fs.writeFile(subagent, `${JSON.stringify({ - at: "2026-08-27T12:00:00.000Z", - diagnosticId: "SA-forged", - providerId: "private", - modelId: "private", - failure: "plaintext-private-value", - attempts: 1, - diagnostics: [], - })}\n`, { mode: 0o600 }); - await assert.rejects(createDiagnosticExport({ - logsPath: logs, - crashDumpsPath: dumps, - destination: path.join(root, "forged-subagent.json.gz"), - includeCrashDumps: false, - app: app(), - tempRoot: root, - }), /unknown record/u); + await fs.writeFile( + subagent, + `${JSON.stringify({ + at: new Date().toISOString(), + diagnosticId: "SA-forged", + providerId: "private", + modelId: "private", + failure: "plaintext-private-value", + attempts: 1, + diagnostics: [], + })}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + createDiagnosticExport({ + logsPath: logs, + crashDumpsPath: dumps, + destination: path.join(root, "forged-subagent.json.gz"), + includeCrashDumps: false, + app: app(), + tempRoot: root, + }), + /unknown record/u, + ); }); }); @@ -419,7 +482,9 @@ test("concurrent crash pruning and status tolerate disappearing dumps", async () await fs.utimes(dump, old, old); } await Promise.all([ - ...Array.from({ length: 20 }, () => diagnosticSupportStatus({ logsPath: logs, crashDumpsPath: dumps })), + ...Array.from({ length: 20 }, () => + diagnosticSupportStatus({ logsPath: logs, crashDumpsPath: dumps }), + ), ...Array.from({ length: 10 }, () => pruneExpiredDiagnosticCrashDumps(dumps)), ]); }); diff --git a/package.json b/package.json index d5d94d65..31ccf9ec 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "lint": "eslint .", "postinstall": "node scripts/patch-pi-oauth-branding.mjs && node scripts/vendor-generative-ui-libs.mjs", "generative-ui:vendor": "node scripts/vendor-generative-ui-libs.mjs", - "pretest:generative-ui": "npm run build:subagent-file-mutator && npm run test:design-recovery", + "pretest:generative-ui": "npm run build:subagent-file-mutator && npm run test:design-recovery && npm run test:design-v2-policy", + "test:design-v2-policy": "tsx --test main/services/design-project-v2-policy.test.ts main/services/design-project-contract-v2.test.ts", "test:design-recovery": "tsx --test main/services/design-project-health.test.ts main/services/design-artifact-recovery.test.ts main/services/design-generation-context.test.ts main/services/design-generation-publication.test.ts", "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", @@ -70,7 +71,7 @@ "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts renderer/lib/ipc-stream.test.ts", "test:btw": "tsx --test main/services/rpiv-btw/*.test.ts renderer/shared/btw.test.ts renderer/components/btw-card.test.tsx", "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", - "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/design-live-preview-authority.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts main/services/design-workspace-contract.test.ts main/services/design-project-contract.test.ts main/services/design-project-store.test.ts main/services/design-generated-revision-service.test.ts main/services/design-project-lifecycle.test.ts main/services/design-reference-asset-store.test.ts main/services/design-project-export-core.test.ts main/services/design-project-export.test.ts main/services/design-project-export-history.test.ts main/services/design-handoff-coordinator.test.ts main/services/design-handoff-journal-store.test.ts main/services/design-handoff-effect-store.test.ts main/services/design-handoff-application-service.test.ts main/services/design-system-snapshot-core.test.ts main/services/design-system-snapshot-store.test.ts main/services/design-system-workspace-extractor.test.ts main/services/design-system-attachment-service.test.ts main/services/design-comment-contract.test.ts main/services/design-comment-store.test.ts main/services/design-comment-store-main.test.ts main/services/design-direct-edit-core.test.ts main/services/design-direct-edit-transforms.test.ts main/services/design-direct-edit-service.test.ts main/services/design-direct-edit-main-adapters.test.ts main/services/design-source-graph-core.test.ts main/services/source-preview-transport-core.test.ts main/services/source-preview-transport-next-adapter.test.ts main/services/source-preview-next-runtime-adapter.test.ts main/services/source-preview-websocket-proxy.test.ts main/services/source-design-preview.test.ts main/services/source-designer-actions.test.ts main/services/source-designer-multifile.test.ts renderer/components/design-comments-panel.test.tsx renderer/components/design-project-library.test.tsx renderer/components/design-project-inspector.test.tsx renderer/components/design-project-persistence.test.tsx renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts renderer/shared/design-workspace.test.ts renderer/shared/design-comments.test.ts renderer/shared/design-projects.test.ts renderer/shared/source-designer.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", + "test:generative-ui": "tsx --test main/services/generative-ui-html.test.ts main/services/generative-ui-extension.test.ts main/services/generative-ui-artifact-store.test.ts main/services/design-live-preview-authority.test.ts main/services/generative-ui-host-libraries.test.ts main/services/generative-ui-protocol.test.ts main/services/design-workspace-contract.test.ts main/services/design-project-contract.test.ts main/services/design-project-store.test.ts main/services/design-generated-revision-service.test.ts main/services/design-project-lifecycle.test.ts main/services/design-reference-asset-store.test.ts main/services/design-project-export-core.test.ts main/services/design-project-export.test.ts main/services/design-project-export-history.test.ts main/services/design-handoff-coordinator.test.ts main/services/design-handoff-journal-store.test.ts main/services/design-handoff-effect-store.test.ts main/services/design-handoff-application-service.test.ts main/services/design-system-snapshot-core.test.ts main/services/design-system-snapshot-store.test.ts main/services/design-system-workspace-extractor.test.ts main/services/design-system-attachment-service.test.ts main/services/design-comment-contract.test.ts main/services/design-comment-store.test.ts main/services/design-comment-store-main.test.ts main/services/design-direct-edit-core.test.ts main/services/design-direct-edit-transforms.test.ts main/services/design-direct-edit-service.test.ts main/services/design-direct-edit-main-adapters.test.ts main/services/design-source-graph-core.test.ts main/services/source-preview-transport-core.test.ts main/services/source-preview-transport-next-adapter.test.ts main/services/source-preview-next-runtime-adapter.test.ts main/services/source-preview-websocket-proxy.test.ts main/services/source-design-preview.test.ts main/services/source-designer-actions.test.ts main/services/source-designer-multifile.test.ts renderer/components/design-comments-panel.test.tsx renderer/components/design-project-library.test.tsx renderer/components/design-project-inspector.test.tsx renderer/components/design-project-persistence.test.tsx renderer/components/design-screen-navigator.test.tsx renderer/shared/chat-artifacts.test.ts renderer/shared/generative-ui.test.ts renderer/shared/design-workspace.test.ts renderer/shared/design-comments.test.ts renderer/shared/design-projects.test.ts renderer/shared/design-selection.test.ts renderer/shared/source-designer.test.ts && node --test scripts/vendor-generative-ui-libs.test.mjs && playwright test --config=playwright.generative-ui.config.ts --fail-on-flaky-tests", "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", "test:config-recovery": "tsx --test main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", "pretest:subagents": "npm run build:worktree-remover && npm run build:subagent-run-store && node scripts/build-subagent-run-store.mjs --test && npm run build:subagent-file-mutator && node scripts/build-subagent-file-mutator.mjs --test && npm run build:subagent-shell-runner && node scripts/build-subagent-shell-runner.mjs --test && npm run test:subagents:inventory && npm run test:subagents:workspace-write && npm run test:subagents:phase5a && npm run test:subagents:phase5b && npm run test:subagents:phase5c && npm run test:subagents:phase5d && npm run test:subagents:phase5e && npm run test:subagents:phase6a && npm run test:subagents:phase6b && npm run test:subagents:phase7a && npm run test:subagents:soak:contracts", diff --git a/renderer/components/design-artifact-card.tsx b/renderer/components/design-artifact-card.tsx index 0462a5d6..7c014411 100644 --- a/renderer/components/design-artifact-card.tsx +++ b/renderer/components/design-artifact-card.tsx @@ -1,30 +1,41 @@ import { PanelsTopLeft } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; import type { ChatHtmlArtifactV1 } from "../shared/chat-artifacts"; +import { designArtifactNavigationTarget } from "../lib/design-artifact-navigation"; import { Text } from "./ui"; export function DesignArtifactCard({ chatId, + projectId, artifact, version, + revisionLabel, + onShowOnCanvas, }: { chatId: string; + /** Stable Design Project identity. Falls back to the legacy chat route while old callers migrate. */ + projectId?: string; artifact: ChatHtmlArtifactV1; version: number; + revisionLabel?: string; + onShowOnCanvas?: (artifact: ChatHtmlArtifactV1) => void; }) { const navigate = useNavigate(); + const target = designArtifactNavigationTarget({ legacyChatId: chatId, projectId, artifact }); return ( diff --git a/renderer/components/design-project-inspector.test.tsx b/renderer/components/design-project-inspector.test.tsx index 3b4f8ea6..50037354 100644 --- a/renderer/components/design-project-inspector.test.tsx +++ b/renderer/components/design-project-inspector.test.tsx @@ -33,13 +33,55 @@ test("code surface provides line numbers, find, provenance, copy, save, and bund assert.match(source, /source\.provenance/u); assert.match(source, /Read-only workspace source · Designer Action required/u); assert.match(source, /design-project-line-number/u); - assert.match(styles, /grid-template-columns: 48px minmax\(max-content, 1fr\)/u); + assert.match(styles, /grid-template-columns: 48px auto/u); assert.match(styles, /\.design-project-source mark[\s\S]*var\(--accent\)/u); for (const kind of ["comment", "keyword", "string", "number", "title", "variable"]) { assert.match(styles, new RegExp(`\\.design-project-syntax-${kind}`)); } }); +test("code rail contains long source and keeps status surfaces in normal flow", () => { + assert.match(source, /const sourceState = source/u); + assert.match(source, /data-source-state=\{sourceState\}/u); + assert.match(source, /aria-busy=\{sourceState === "loading" \? true : undefined\}/u); + assert.match(source, /className="design-project-inspector-state"/u); + assert.match(source, /role=\{sourceState === "stale" \? "alert" : undefined\}/u); + assert.match(source, /aria-atomic="true"/u); + assert.match(source, /placement="inline"/u); + + const drawerRule = + [...styles.matchAll(/\.design-project-inspector\[data-layout="drawer"\] \{([^}]*)\}/gu)] + .map((match) => match[1] ?? "") + .find((rule) => rule.includes("contain: inline-size")) ?? ""; + assert.match(drawerRule, /width: 100%;/u); + assert.match(drawerRule, /max-width: 100%;/u); + assert.match(drawerRule, /contain: inline-size;/u); + assert.match( + styles, + /\[data-design-workspace-canvas\][\s\S]*:has\(> \.design-project-inspector\[data-layout="drawer"\]\)[\s\S]*width: min\(520px, 100%\);/u, + ); + assert.match( + styles, + /\.design-project-inspector-panel \{[\s\S]*min-width: 0;[\s\S]*min-height: 0;/u, + ); + assert.match( + styles, + /\.design-project-source \{[\s\S]*max-width: 100%;[\s\S]*min-width: 0;[\s\S]*overflow: auto;/u, + ); + assert.match( + styles, + /\.design-project-source ol \{[\s\S]*width: max-content;[\s\S]*min-width: 100%;/u, + ); + const stateRule = styles.match(/\.design-project-inspector-state \{([^}]*)\}/u)?.[1] ?? ""; + assert.match(stateRule, /display: grid;/u); + assert.match(stateRule, /overflow: auto;/u); + assert.doesNotMatch(stateRule, /position:\s*(?:absolute|fixed)/u); + assert.match(styles, /\.design-project-source-footer \{[\s\S]*flex-wrap: wrap;/u); + assert.match(styles, /\.design-project-source-footer \{[\s\S]*box-sizing: border-box;/u); + assert.match(styles, /\.design-project-source-footer \{[\s\S]*max-height: min\(28%, 120px\);/u); + assert.match(styles, /\.design-project-source-footer > span[\s\S]*overflow-wrap: anywhere;/u); +}); + test("code shortcuts, drawer semantics, empty states, and revision actions remain explicit", () => { assert.match(source, /event\.metaKey \|\| event\.ctrlKey/u); assert.match(source, /event\.key\.toLocaleLowerCase\(\) === "f"/u); @@ -52,10 +94,22 @@ test("code shortcuts, drawer semantics, empty states, and revision actions remai assert.match(source, /Saved source needs reload/u); assert.match(source, /Reload source/u); assert.match(source, /sourceError && onRetrySource/u); - assert.match(source, /Loading workspace source…/u); - assert.match(source, /sourceLoading \? \(/u); - assert.match(source, /role="status" aria-live="polite"/u); + assert.match(source, /Loading source…/u); + assert.match(source, /exact saved revision or authorized connected element/u); + assert.match(source, /sourceState === "loading" \? \(/u); + assert.match(source, /role="status"/u); + assert.match(source, /aria-live="polite"/u); assert.match(source, /No saved revisions/u); + assert.match(source, /Previewing Revision/u); + assert.match(source, /this Screen currently uses Revision/u); + assert.match(source, /Return to current/u); + assert.match(source, /Refine from this/u); + assert.match(source, /Make current/u); + assert.match(source, /event\.stopPropagation\(\)/u); + assert.match(source, /!element\.closest\("\[hidden\], \[inert\], \[aria-hidden='true'\]"\)/u); + assert.match(source, /aria-modal=\{layout === "drawer" \? true/u); + assert.match(source, /aria-pressed=\{revision\.previewed === true\}/u); + assert.match(styles, /\.design-project-revision\[data-previewed="true"\]/u); assert.match(source, /key=\{`\$\{revision\.lineageId\}:\$\{revision\.id\}`\}/u); assert.match(source, /data-lineage-id=\{revision\.lineageId\}/u); assert.match(source, /Generated revisions/u); diff --git a/renderer/components/design-project-inspector.tsx b/renderer/components/design-project-inspector.tsx index dbb7e559..fc72bd7b 100644 --- a/renderer/components/design-project-inspector.tsx +++ b/renderer/components/design-project-inspector.tsx @@ -8,6 +8,7 @@ import { FileDown, FolderOpen, GitCompareArrows, + Loader2, Search, X, } from "lucide-react"; @@ -34,6 +35,8 @@ export interface DesignProjectInspectorProps { sourceLoading?: boolean; sourceError?: string; compareSource?: DesignProjectSourceDocument; + previewingHistoricalRevision?: boolean; + makeRevisionCurrentBusy?: boolean; revisions: readonly DesignProjectRevisionSummary[]; designerActions?: readonly DesignProjectDesignerActionSummary[]; preview?: React.ReactNode; @@ -49,6 +52,9 @@ export interface DesignProjectInspectorProps { latestExportName?: string; onRevealExport?: () => void; onSelectRevision: (lineageId: string, revisionId: string) => void; + onMakeRevisionCurrent?: () => void; + onReturnToCurrentRevision?: () => void; + onRefineFromRevision?: () => void; onSelectDesignerAction?: (actionId: string) => void; onCompareRevision?: (lineageId: string, revisionId: string) => void; onCloseComparison?: () => void; @@ -143,6 +149,8 @@ export function DesignProjectInspector({ sourceLoading = false, sourceError, compareSource, + previewingHistoricalRevision = false, + makeRevisionCurrentBusy = false, revisions, designerActions = [], preview, @@ -158,6 +166,9 @@ export function DesignProjectInspector({ latestExportName, onRevealExport, onSelectRevision, + onMakeRevisionCurrent, + onReturnToCurrentRevision, + onRefineFromRevision, onSelectDesignerAction, onCompareRevision, onCloseComparison, @@ -165,7 +176,9 @@ export function DesignProjectInspector({ formatTimestamp = defaultFormatTimestamp, }: DesignProjectInspectorProps) { const titleId = React.useId(); + const panelRef = React.useRef(null); const findRef = React.useRef(null); + const closeRef = React.useRef(null); const sourceLines = React.useMemo( () => (source ? designProjectSourceLines(source.content) : []), [source], @@ -174,6 +187,16 @@ export function DesignProjectInspector({ () => (source ? countDesignProjectSourceMatches(source.content, findQuery) : 0), [findQuery, source], ); + const sourceState = source + ? "ready" + : sourceLoading + ? "loading" + : sourceError + ? "stale" + : "unavailable"; + const currentRevisionNumber = revisions.findIndex((revision) => revision.active) + 1; + const previewRevisionNumber = + revisions.findIndex((revision) => revision.previewed && !revision.active) + 1; const moveTab = React.useCallback( (event: React.KeyboardEvent, tab: DesignProjectInspectorTab) => { @@ -197,9 +220,33 @@ export function DesignProjectInspector({ (event: React.KeyboardEvent) => { if (event.key === "Escape" && layout === "drawer" && onClose) { event.preventDefault(); + event.stopPropagation(); onClose(); return; } + if (event.key === "Tab" && layout === "drawer") { + const focusable = Array.from( + panelRef.current?.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + ) ?? [], + ).filter( + (element) => + !element.closest("[hidden], [inert], [aria-hidden='true']") && + getComputedStyle(element).visibility !== "hidden" && + getComputedStyle(element).display !== "none", + ); + if (focusable && focusable.length > 0) { + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } if (activeTab !== "code" || !source) return; if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "f") { event.preventDefault(); @@ -213,12 +260,19 @@ export function DesignProjectInspector({ [activeTab, layout, onClose, onSaveSource, source], ); + React.useEffect(() => { + if (layout !== "drawer") return; + const frame = requestAnimationFrame(() => closeRef.current?.focus({ preventScroll: true })); + return () => cancelAnimationFrame(frame); + }, [layout]); + return ( + ); +} diff --git a/renderer/components/design-workspace.tsx b/renderer/components/design-workspace.tsx index e0cb82f4..794b45e6 100644 --- a/renderer/components/design-workspace.tsx +++ b/renderer/components/design-workspace.tsx @@ -45,6 +45,7 @@ import { DesignProjectPersistenceBarrier, DesignPrototypeDirectEditRetryState, durableDesignWorkspaceArtifactGroups, + missingDurableDesignWorkspaceScreens, isDesignProjectMetadataOnlyUpdate, resolveDesignArtboardPosition, snapshotDesignTurnTargets, @@ -54,6 +55,23 @@ import { type DesignWorkspaceArtifactEntry, type DesignWorkspaceArtifactGroup, } from "../shared/design-workspace"; +import { + EMPTY_DESIGN_WORKBENCH_SELECTION, + beginDesignFlowSelectionSync, + clearDesignSelectionLayer, + consumeDesignFlowSelectionReport, + designDisplayedScreenRevision, + designPrimaryScreenSelection, + designSelectionContextOrder, + designSelectionTurnTargets, + reduceDesignWorkbenchSelection, + sameDesignSelectedNodeIds, + sameDesignWorkbenchSelectionState, + type DesignSelectionSource, + type DesignFlowSelectionSync, + type DesignWorkbenchSelection, + type DesignWorkbenchSelectionState, +} from "../shared/design-selection"; import { chatsApi, designerApi, workspacesApi } from "../lib/ipc"; import { cn } from "../lib/ui-utils"; import { @@ -86,13 +104,14 @@ import type { DesignProjectInspectorTab, DesignProjectRevisionSummary, DesignProjectSourceDocument, - DesignProjectSnapshotV1, + DesignProjectSnapshot as DesignProjectSnapshotV1, DesignSystemProjectionV1, DesignHandoffRecoveryViewV1, ManagedDesignHandoffPreviewV1, ExistingDesignHandoffPreviewV1, } from "../shared/design-projects"; import { DesignProjectInspector } from "./design-project-inspector"; +import { DesignScreenNavigator, type DesignScreenNavigatorItem } from "./design-screen-navigator"; import { DesignCommentsPanel } from "./design-comments-panel"; import { DesignHandoffRecoveryPanel } from "./design-handoff-recovery"; import type { @@ -149,10 +168,18 @@ interface SourceArtboardData extends Record { onExitInspect: () => void; } +interface MissingScreenNodeData extends Record { + kind: "missing-screen"; + title: string; + viewport: DesignViewport; + onRetry?: () => void; +} + type DesignArtboardNode = Node; type DesignImageNode = Node; type SourceArtboardNode = Node; -type StudioNode = DesignArtboardNode | DesignImageNode | SourceArtboardNode; +type MissingScreenNode = Node; +type StudioNode = DesignArtboardNode | DesignImageNode | SourceArtboardNode | MissingScreenNode; function DesignArtboardNodeView({ data, selected }: NodeProps) { const [preview, setPreview] = React.useState<{ @@ -215,8 +242,9 @@ function DesignArtboardNodeView({ data, selected }: NodeProps ) { } const DesignImageNodeMemo = React.memo(DesignImageNodeView); + +function MissingScreenNodeView({ data }: NodeProps) { + const size = VIEWPORT_SIZE[data.viewport]; + return ( +
+
+ {data.title} +
+
+
+ + Screen preview unavailable + + + This Screen is still saved in the project, but its artifact needs history recovery. + + {data.onRetry ? ( + + ) : null} +
+
+
+ ); +} + +const MissingScreenNodeMemo = React.memo(MissingScreenNodeView); const NODE_TYPES: NodeTypes = { designArtboard: DesignArtboardNodeMemo, designImage: DesignImageNodeMemo, sourceArtboard: SourceArtboardNodeMemo, + missingScreen: MissingScreenNodeMemo, }; +const EMPTY_STUDIO_EDGES: [] = []; +const STUDIO_FIT_VIEW_OPTIONS = { padding: 0.18, maxZoom: 0.9 } as const; function canvasImageAttachment(file: File): Promise { return new Promise((resolve, reject) => { @@ -480,6 +544,7 @@ function CanvasToolButton({ description, shortcut, active, + disabled = false, onClick, children, }: { @@ -487,6 +552,7 @@ function CanvasToolButton({ description: string; shortcut?: string; active?: boolean; + disabled?: boolean; onClick: () => void; children: React.ReactNode; }) { @@ -495,6 +561,7 @@ function CanvasToolButton({ +