diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0d7a1192..b07103b3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,3 +11,141 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## @xyflow/react and @xyflow/system + +Create Images canvas runtime closure covered by the notices below: + +- `@xyflow/react@12.9.3` +- `@xyflow/system@0.0.73` +- `classcat@5.0.5` +- `zustand@4.5.7` +- `use-sync-external-store@1.6.0` +- `d3-drag@3.0.0` +- `d3-dispatch@3.0.1` +- `d3-interpolate@3.0.1` +- `d3-selection@3.0.0` +- `d3-zoom@3.0.0` +- `d3-color@3.1.0` +- `d3-ease@3.0.1` +- `d3-timer@3.0.1` +- `d3-transition@3.0.1` + +Copyright (c) 2019-2025 webkid GmbH + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## classcat + +Copyright © Jorge Bucaran + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## zustand + +Copyright (c) 2019 Paul Henschel + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## use-sync-external-store + +Copyright (c) Meta Platforms, Inc. and affiliates. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## D3 ISC packages + +This notice covers `d3-drag`, `d3-dispatch`, `d3-interpolate`, `d3-selection`, `d3-timer`, `d3-transition`, and `d3-zoom`. + +Copyright 2010-2021 Mike Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## d3-color + +Copyright 2010-2022 Mike Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## d3-ease + +Copyright 2010-2021 Mike Bostock + +Copyright 2001 Robert Penner + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +- Neither the name of the author nor the names of contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## yauzl and yazl + +Create Images native archive runtime closure covered by the MIT notices below: + +- `yauzl@3.4.0` +- `yazl@3.3.1` +- `pend@1.2.0` +- `buffer-crc32@1.0.0` + +Copyright (c) 2014 Josh Wolfe + +Copyright (c) 2014 Andrew Kelley + +Copyright (c) 2013-2024 Brian J. Brennan + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## Konva and react-konva + +This notice covers `konva@10.3.1` and `react-konva@19.2.5`. + +Original Konva work Copyright (C) 2011 - 2013 by Eric Rowell (KineticJS) + +Modified Konva work Copyright (C) 2014 - present by Anton Lavrenov (Konva) + +react-konva Copyright (c) 2017 Anton Lavrenov + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/plans/README.md b/docs/plans/README.md index 375007c8..eec957a2 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -8,6 +8,8 @@ This directory is the source of truth for Aiden's implementation plans. The engi | -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | | [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. | +| [Create Images Mode](create-images-mode-plan.md) | Active | Phases 0–3 are GO; Phase 4 source/mock and Phase 5 MVP implementation are complete. Node Banana parity adds a required Finder image workspace, safe image import, light/dark presentation, and full-resolution inspection. Real-key Gemini acceptance and notarized release gates remain explicit. | +| [Create Images — Node Banana Learnings](create-images-node-banana-learnings-roadmap.md) | Active | Phases 1–6 and local Phase 7 hardening are implemented. Main Settings exposes device-local autosave/manual-save and canvas preferences; exact packaged acceptance is green. Billable live gates and release operations remain tracked. | | [Designer Mode](designer-mode-plan.md) | Planned | Phase 0 validation has not started in the runtime. | | [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Partial | Stored Pi catalogs, cache-only hydration, and explicit provider refresh ship; remote overlays for otherwise-static providers remain open. | | [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | @@ -36,4 +38,3 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Pi Thinking Disclosure](completed/pi-thinking-disclosure-plan.md) | Complete | Provider-neutral readable Pi thinking, a one-second inspectable preview, and a durable local presentation toggle now match the audited Pi contract. | Move a plan to `completed/` only when its original delivery scope is complete. Keep the original plan as historical documentation; follow-on work belongs in a new active plan. - diff --git a/docs/plans/create-images-mode-plan.md b/docs/plans/create-images-mode-plan.md new file mode 100644 index 00000000..787a32fa --- /dev/null +++ b/docs/plans/create-images-mode-plan.md @@ -0,0 +1,621 @@ +# Create Images Mode Plan + +Status: active; Phases 0–3 complete, Phase 4 implementation complete with manual opt-in real-provider acceptance pending +Date: 2026-08-10 +Aiden baseline: `c3d644485e543579bbf478bb1e7355ba6667ce65` +Node Banana baseline: `5c0e0ae6150f29a6de819f8d6f1dedba15151f7c` (`master`, version `1.9.0`) + +Source basis: current Aiden and Node Banana source, Aiden's project memory and required desktop UI references, three parallel architecture reviews, and current primary provider documentation. + +The inspected Node Banana checkout lives at `tmp/node-banana`. `tmp/*` is already ignored by Aiden's `.gitignore`, and `git check-ignore` verifies the checkout is excluded. The clone is research material only; it is not a vendored dependency or product source directory. + +## Verdict + +Build **Create Images** as a dedicated Aiden mode entered from the main sidebar and centered on a full visual node-workflow canvas. Do not reduce it to a prompt form with a gallery, and do not embed or port the Node Banana Next.js application. + +The product idea and several interaction patterns are strong. The implementation needs to be Aiden-native: + +1. Keep graph editing and viewport interaction in the renderer. +2. Keep credentials, provider requests, workflow persistence, binary assets, execution scheduling, and recovery in the Electron main process. +3. Store immutable asset references in graphs and history; never carry full-resolution base64 images through ordinary renderer state or IPC. +4. Ship a deliberately small image-only graph before annotation, loops, background removal, video, audio, 3D, or ComfyUI. +5. Cross each evidence gate in its owning phase: canvas packaging in Phases 1/5, durable assets in Phase 2, crash-safe runs in Phase 3, and explicit opt-in provider acceptance in Phase 4. + +This is a new durable product surface, not a chat accessory. A trustworthy MVP is approximately **8–12 engineering weeks for one experienced engineer**, subject to the phase-owned evidence gates. Annotation and a broad multi-provider catalog are follow-on work. + +## Product outcome + +A user selects **Create Images** in Aiden's sidebar and lands on a persistent node canvas. They can create or open a workflow, connect typed prompt and image nodes, choose an image model, preview expected provider/cost information, run all or part of the graph, stop it, inspect progress by node, and retain generated outputs locally. Closing and reopening Aiden restores the workflow and its durable output references. + +The canvas is the primary work surface. Templates, workflow history, model configuration, cost details, and Aiden-assisted graph creation are panels or canvas-adjacent actions; they are not a separate simplified image composer. + +### MVP user journey + +1. Open **Create Images** from the sidebar. +2. Choose a starter template or a blank workflow. +3. Add `Prompt`, `Image Input`, `Generate Image`, `Output`, or `Output Gallery` nodes from search or the add-node control. +4. Connect only compatible handles; get an immediate, accessible explanation for an invalid edge. +5. Configure the generation node with a connected image provider and supported model options. +6. Select **Run workflow** or **Run from here**. +7. Confirm provider, number of remote requests, and a best-effort cost estimate before paid work begins. +8. Observe queued/running/succeeded/failed/cancelled state on each node and stop outstanding work when needed. +9. Inspect, compare, download, or reuse durable results without re-running the graph. +10. Reopen Aiden and recover the saved graph, outputs, and terminal run record. + +### First-release non-goals + +- No video, audio, 3D, GIF, or ComfyUI nodes. +- No arbitrary user-authored JavaScript/Python nodes or shell execution. +- No hosted sharing/community workflow browser. +- No real-time collaboration. +- No unbounded provider/model marketplace. +- No loop edges, provider fallback chains, or automatic retries that can multiply paid requests. +- No silent model substitution. +- No automatic graph mutation by Aiden without a reviewable proposal and explicit approval. +- No reliance on a running local web server or Next.js API routes. + +## What to use from Node Banana + +Node Banana is MIT-licensed, but Aiden should use a clean, Aiden-native reimplementation by default. The following links are pinned to the inspected upstream revision so later upstream changes do not silently change this plan's evidence. + +| Upstream reference | What it demonstrates | Aiden decision | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`README.md`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/README.md) and [`prd-image-workflow.md`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/prd-image-workflow.md) | A useful middle ground between a one-shot prompt box and ComfyUI: visible data flow, reference images, annotations, and reusable workflows. | Adopt the product shape and full-canvas emphasis. Keep Aiden image-first and locally durable. | +| [`WorkflowCanvas.tsx`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/WorkflowCanvas.tsx), [`NodeSearchMenu.tsx`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/NodeSearchMenu.tsx), and [`FloatingActionBar.tsx`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/FloatingActionBar.tsx) | React Flow canvas composition, add-node discovery, selection actions, fit/zoom controls, and workflow-level run affordances. | Use as interaction references. Implement new Aiden components with semantic tokens and Aiden keyboard/focus behavior. | +| [`ConnectionDropMenu.tsx`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/ConnectionDropMenu.tsx) | Dropping a connection on empty canvas proposes nodes compatible with the source media type. | Reimplement from Aiden's central typed-port registry. This is a high-value graph interaction after ordinary add/search is stable. | +| [`types/nodes.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/types/nodes.ts) and [`components/nodes`](https://github.com/shrimbly/node-banana/tree/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/nodes) | A broad node catalog and reusable base-node conventions. | Use the catalog to sequence Aiden's scope. Define Aiden's own discriminated, versioned node contracts and typed ports. | +| [`workflowStore.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/workflowStore.ts) and [`executionUtils.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/utils/executionUtils.ts) | Dependency grouping, topological execution, run/stop state, save/load, migrations, grouping, undo, and autosave requirements. | Reuse the behavioral requirements, not the roughly 3,300-line renderer store. Split graph editing, persistence, scheduler, assets, providers, and ephemeral UI state. | +| [`nanoBananaExecutor.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/execution/nanoBananaExecutor.ts) and [`runWithFallback.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/execution/runWithFallback.ts) | Resolve upstream inputs, run one node, poll async work, record history/cost, propagate failure, and abort. | Reimplement in main. Defer fallback and loops until cost/cancellation semantics are proven. | +| [`providers/gemini.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/app/api/generate/providers/gemini.ts), [`providers/openai.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/app/api/generate/providers/openai.ts), [`providers/replicate.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/app/api/generate/providers/replicate.ts), and [`providers/fal.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/app/api/generate/providers/fal.ts) | Concrete request mapping, reference-image handling, async polling, output normalization, and provider-specific model parameters. | Use as test-oracle research beside current official docs. Build narrow main-owned adapters; never forward renderer-supplied credentials or arbitrary URLs. | +| [`AnnotationModal.tsx`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/AnnotationModal.tsx) and [`AnnotationNode.tsx`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/nodes/AnnotationNode.tsx) | A Konva-based reference-image annotation loop. | Use as a Phase 6 interaction reference after the core asset model ships. Keep annotation layers separate from flattened raster assets. | +| [`quickstart`](https://github.com/shrimbly/node-banana/tree/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/components/quickstart), [`lib/chat`](https://github.com/shrimbly/node-banana/tree/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/lib/chat), and [`api/quickstart`](https://github.com/shrimbly/node-banana/tree/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/app/api/quickstart) | Templates and natural-language graph proposal/edit operations. | Use templates early. Defer Aiden-assisted graph proposals until Aiden's graph schema and diff validator are stable. | +| [`undoHistory.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/undoHistory.ts) and [`CHANGELOG.md`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/CHANGELOG.md) | Snapshot history required special handling to preserve base64 string references and avoid memory growth. | Treat this as a warning. Use graph operation patches that reference immutable asset IDs and place a strict history bound. | +| [`mediaStorage.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/utils/mediaStorage.ts), [`lib/images/store.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/lib/images/store.ts), and [`thumbnailCache.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/thumbnailCache.ts) | Externalizing inline media, deduplication, thumbnails, TTL, and memory bounds are necessary. | Reimplement with SHA-256 content-addressed disk assets, lazy object URLs, and a bounded decoded-thumbnail cache. Never hydrate an entire workflow into base64. | +| [`localStorage.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/utils/localStorage.ts) and [`buildApiHeaders.ts`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/src/store/utils/buildApiHeaders.ts) | Browser persistence currently includes provider settings/API keys and renderer-constructed request headers. | Explicit anti-pattern for Aiden. Credentials remain main-only in `safeStorage`; the renderer sees only provider connection/capability status. | + +### Upstream patterns to defer or reject + +| Pattern | Decision | Reason | +| ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Next.js API routes | Reject | Aiden already has an Electron main/preload/renderer split; adding a localhost server creates lifecycle and attack-surface costs without product value. | +| One all-purpose Zustand store | Reject | Graph edits, durable storage, assets, remote runs, and ephemeral UI have different consistency and failure semantics. | +| Base64 media inside nodes and save payloads | Reject | It inflates IPC, undo snapshots, JSON writes, heap use, and crash-recovery time. | +| Renderer-held API keys/provider headers | Reject | It conflicts with Aiden's context-isolated trust boundary and credential design. | +| Browser download as workflow persistence | Reject | Aiden can provide atomic, main-owned local persistence and explicit export. | +| Dynamic arbitrary model schemas on day one | Defer | The UI, validation, cost, safety, and migration contract must first work for a curated capability set. | +| Loop edges and provider fallback | Defer | Both can unexpectedly multiply requests and cost; they need independent budgets and cancellation semantics. | +| Background removal/ONNX | Defer | Native/WASM packaging, model weight, CPU/GPU, and memory behavior require a separate spike. | +| ComfyUI, video, audio, 3D, GIF | Defer | They multiply media, preview, dependency, execution, and support contracts before the image graph is proven. | + +## Aiden integration contract + +### Route and shell + +- Add two routes under `chatLayoutRoute` in `renderer/main/router.tsx`: `/create-images` for the workflow library/empty state and `/create-images/$workflowId` for a durable canvas. The sidebar label remains **Create Images**. +- Add the sidebar row in `renderer/components/chat-sidebar.tsx` immediately after **New Agent** and before **Scheduled**. It uses the existing sidebar row semantics, keyboard focus, tooltip, and selected-state tokens. +- Keep the existing application sidebar and window controls. The canvas consumes the entire main-content region. +- Hide the terminal drawer and Environment review/files panel on `/create-images*`. Update route-capability decisions in `renderer/main/chat-layout.tsx` and `renderer/lib/command-system-core.ts`; a hidden panel must not retain focus or intercept shortcuts. +- Hide the global Aiden Assistant dock on `/create-images*` because its bottom-right position conflicts with canvas controls, the minimap, and inspector affordances. Phase 8 returns assistance as a route-owned proposal panel with graph-aware layout. +- Add route-aware command entries (`images.open`, then `images.newWorkflow` when implemented) through `renderer/shared/keybindings.ts` and the existing command catalog. Do not steal `Cmd+N` globally from chat in the first slice. +- Add a main-exposed `createImages` capability/feature flag. During development the sidebar entry may be gated; it must not lead to a half-initialized canvas in production. + +Use Aiden's existing fail-closed application-capability pattern. The initial environment gate should be `AIDEN_CREATE_IMAGES_ENABLED=1`, default-off. Main must check it in every Create Images handler as well as renderer navigation. When disabled, Aiden must not open, migrate, clean, or mutate the Create Images store and must not contact an image provider. + +The existing app navigation/footer should become shared sidebar structure with a route-specific body. Chat routes keep chat search, workspaces, and history. Create Images routes show **New workflow**, workflow search, and recent image workflows. Profile, Settings, Scheduled, update state, and collapse behavior remain shared. This avoids presenting “Search chats…” beneath a selected image-workflow mode. + +### Visual design and accessibility + +The required references are `docs/chatgpt-desktop-ui-inspiration.md` and `docs/chatgpt-ui-element-specimen.html`. Create Images should adapt their workbench hierarchy to Aiden rather than copy specimens: + +- stable shell and restrained chrome; +- canvas-first center with one compact top toolbar; +- quiet elevation, 1px semantic borders, and tokens from `renderer/styles.css` and `renderer/shared/appearance.ts`; +- 150–200 ms control transitions and 250–300 ms panel transitions with no more than 4–8 px movement; +- `prefers-reduced-motion` coverage and no decorative continuous canvas animation; +- visible keyboard focus, DOM-backed node labels/actions, screen-reader run-state announcements, and no status conveyed by color alone; +- an accessible node-list/inspector alternative for operations that are difficult on a spatial canvas; +- responsive acceptance at 390, 700, 1000, and 1280 px widths. At narrow widths the inspector becomes a sheet; the canvas is not replaced by a simplified composer. + +### Proposed renderer module boundary + +```text +renderer/create-images/ + create-images-view.tsx Route-level workbench and loading/recovery states + workflow-canvas.tsx React Flow boundary and viewport interaction + graph-controller.ts Typed edits, selection, undo/redo, validation hints + graph-store.ts Renderer graph + ephemeral view state only + node-registry.ts Node definitions, ports, inspectors, renderer views + nodes/ Aiden-owned node components + panels/ Workflow library, node search, inspector, run details + commands.ts Route-scoped command definitions + accessibility.ts Announcements and node-list projection + create-images-view.test.tsx +``` + +Do not allow node components to call providers or filesystem IPC directly. They emit typed edit/run intents through one controller. + +### Proposed shared contract boundary + +```text +renderer/shared/create-images/ + schema.ts Versioned workflow, node, edge, and asset DTOs + ports.ts text/image/mask/metadata compatibility rules + migrations.ts Pure forward migrations and validation reports + execution.ts Run/node state DTOs and event types + providers.ts Capability/model option DTOs; never secrets + ipc.ts Request/response schemas and payload limits +``` + +Every union is discriminated and exhaustive. Unknown node types are preserved as disabled placeholders during import/load, not silently discarded. Graph document revision, run ID, node ID, and event sequence are mandatory on state-changing IPC. + +### Proposed main-process boundary + +```text +main/services/create-images/ + workflow-store.ts Atomic manifests, index, autosave, recovery + asset-store.ts Content-addressed binaries, metadata, thumbnails, GC + graph-validator.ts Typed edges, required inputs, cycles, limits + execution-coordinator.ts Run snapshots, scheduling, cancellation, recovery + run-store.ts Durable run/node journal and terminal summaries + provider-registry.ts Image-specific provider capability registry + providers/gemini.ts First real adapter + providers/openai.ts Later adapter + providers/replicate.ts Later async adapter + providers/fal.ts Later async adapter + archive.ts Portable import/export with hostile-input defenses +main/handlers/create-images.ts Narrow, owner-checked IPC registration +``` + +Register handlers through `main/handlers/index.ts`. Add an exact `imageWorkflows:` invoke prefix and only necessary event names to `renderer/preload-channels.ts` and `renderer/preload.ts`; update `main/handlers/ipc-contract.test.ts` in the same change. The preload exposes narrow methods, not a generic invoke or filesystem bridge. + +Reuse `main/services/renderer-document-owner.ts` to bind every request/subscription to the active main frame and invalidate stale document epochs. Add typed renderer calls/subscriptions through `renderer/lib/ipc.ts` and centralize workflow/run query keys in `renderer/lib/queries.ts`; TanStack Query holds main-owned server state, while high-frequency graph drag/selection state stays out of the query cache. + +## Architecture decisions + +### 1. Canvas library: spike `@xyflow/react`, then adopt if the gate passes + +Node Banana demonstrates that React Flow supports the required custom nodes, handles, editable edges, groups, selection, minimap, viewport controls, and keyboard interactions. Aiden already uses React 19, but not React Flow. + +Phase 0 pins and audits `@xyflow/react` and proves dependency feasibility in a release-mode, sandboxed Electron fixture with 100 and 250 mixed nodes. Phase 1 repeats the measurements against Aiden's real production-built node components, light/dark tokens, keyboard edit path, and 1000×650 workbench; Phase 5 repeats them in the final distributable on minimum supported hardware. If either product gate fails, stop and evaluate a thinner SVG/HTML canvas boundary before enabling the feature. + +Do not add Konva/react-konva until annotation begins. Avoid Zustand unless the spike proves it materially simpler than Aiden's existing external-store patterns; if used, keep separate graph/view stores and prohibit provider, binary, and persistence logic in them. + +### 2. The graph is a versioned document, not live execution state + +`WorkflowDocumentV1` contains stable graph metadata only: + +```ts +type WorkflowDocumentV1 = { + schemaVersion: 1; + id: string; + title: string; + revision: number; + createdAt: string; + updatedAt: string; + viewport?: { x: number; y: number; zoom: number }; + nodes: WorkflowNodeV1[]; + edges: WorkflowEdgeV1[]; + assetRefs: string[]; + settings: { defaultProviderId?: string; concurrency: 1 | 2 | 3 | 4 }; +}; +``` + +Node configuration stores model-independent intent plus an explicit provider/model binding where needed. An image field stores an `assetId`, never bytes, a data URL, a remote URL, or an absolute path. Run state, errors, progress, provider job IDs, and transient previews live in run records, not in the graph document. + +Undo/redo stores bounded semantic graph operations or structural patches. Asset import creates an immutable asset once, then the edit references its ID. A 50-operation default is acceptable for the first spike; the final limit is decided by measured heap behavior. + +### 3. Use typed ports and immutable run snapshots + +MVP port kinds are `text`, `image`, `image[]`, and `metadata`. Each node definition declares input cardinality, required inputs, output kinds, whether it is pure/local/remote, and its versioned configuration schema. + +When a run begins, main validates the current graph revision and records an immutable run snapshot. Edits after that point produce a newer workflow revision but do not change the active run. Every completion event carries `{ workflowId, workflowRevision, runId, nodeId, sequence }`; the renderer ignores stale or duplicate events. + +Cycles are invalid in the MVP. Validation returns structured node/edge issues and never repairs the graph silently. **Run from here** includes the selected node's required ancestors and selected downstream path according to an explicit UI choice; it does not infer a paid fan-out invisibly. + +### 4. Main owns scheduling and remote work + +The coordinator performs a stable topological schedule: + +1. validate typed DAG and provider readiness; +2. materialize a run snapshot and journal it before network work; +3. mark nodes ready only when all required dependencies succeeded; +4. run ready local nodes and remote nodes under separate concurrency gates; +5. publish a node as succeeded only after every output is durably copied into the asset store; +6. mark required descendants blocked when an ancestor fails or is cancelled; +7. journal terminal run state and release asset/run leases. + +Default remote concurrency is 1 for the first paid release, with an advanced user range of 1–4 and provider-specific ceilings. Phase 0 may justify a higher default only with cost UX and provider-limit evidence. Rate limits use bounded exponential backoff with jitter and a retry budget. Paid retries require idempotency guarantees or renewed consent; no automatic provider fallback ships in the MVP. + +Cancellation is a durable intent, not merely an `AbortController`: + +- stop admitting new ready nodes; +- abort local HTTP/poll operations; +- call a provider's cancel endpoint when supported; +- persist `cancel_requested` and the remote job ID before the call; +- accept that some providers may finish after cancellation, but never attach a late result to a newer run; +- offer recovery of a valid late result only when it belongs to the same cancelled run and is safely persisted. + +On application restart, the coordinator reconciles nonterminal runs. Synchronous requests without a recoverable provider job ID become `interrupted`; durable async jobs are polled by their persisted ID. No run restarts from the beginning without explicit user approval. + +A route change inside the same renderer document does not cancel main-owned work; remounting the canvas queries and resubscribes to the run. Renderer reload/crash invalidates the document owner and triggers best-effort cancellation while preserving enough durable state to reconcile an already-submitted async job. Application quit joins Aiden's quit barrier and offers wait or cancel; forced/interrupted shutdown never causes silent resubmission on next launch. + +### 5. Main owns a content-addressed asset store + +Suggested layout under Electron `userData`: + +```text +create-images/ + index.json + workflows//workflow.json + workflows//autosave.journal + runs//run.json + assets/sha256//. + thumbnails//.webp + quarantine/ +``` + +Use the existing `DataStore` atomic-write/protected-file patterns for small indexes and manifests. Do not pass binary images through it. Phase 0 must compare the specialized JSON+journal design with SQLite using realistic workflow/run/asset counts; prefer Aiden's existing JSON foundation unless measured indexing or recovery needs justify a new native packaging dependency. Asset ingestion streams to a temporary file, computes SHA-256, validates magic bytes/MIME/dimensions/decoded pixel count, fsyncs as appropriate, and atomically publishes by digest. Deduplication is content-based. A manifest records byte length, media type, dimensions, timestamps, origin (`import`, provider/model/run, annotation), and optional safe generation metadata. + +The renderer receives a bounded thumbnail or selected full asset through an opaque, document-bound mechanism. Phase 0 must compare: + +- narrow IPC returning `ArrayBuffer`, followed by renderer-created/revoked object URLs; and +- an `aiden-asset://` protocol that accepts short-lived opaque tokens and validates the requesting webContents. + +Do not expose absolute paths, `file://` URLs, arbitrary custom-protocol paths, or a general read-file method. Revoke object URLs on node unmount/asset change. Keep decoded thumbnails in a byte-bounded LRU; graph swapping and zooming must release them. + +Define quotas before launch: configurable total asset budget, warning threshold, per-import byte/pixel limits, per-provider-response limit, and a safe garbage collector. GC only deletes an asset after an atomic reference scan proves it is unreferenced by every workflow, run, export lease, and open preview. Provide a repair/index rebuild path. + +### 6. Workflow persistence and portability are explicit products + +Autosave graph edits after a 1–2 second debounce and on route/app-close flush. Use optimistic revisions: a save with stale `expectedRevision` fails with a structured conflict instead of overwriting a newer renderer or recovery state. Keep a small recovery journal and last-known-good manifest. A corrupt workflow opens in a recovery view with exportable diagnostics, never as an empty canvas that overwrites the damaged file. + +Portable export should be a versioned `.aiden-images` ZIP containing a manifest, graph JSON, referenced assets, and optional terminal run metadata. Import treats the archive as hostile: + +- reject absolute paths, `..`, symlinks, duplicate normalized names, encrypted members, unsupported compression, excessive file count, zip bombs, and declared/actual size mismatches; +- parse and migrate in quarantine; +- validate hashes, MIME, dimensions, graph counts, port compatibility, and provider/model identifiers; +- never execute, connect a provider, or fetch a URL during import; +- publish atomically only after full validation. + +Add a **Node Banana JSON importer** after Aiden's native format is stable. It maps only the supported image subset (`imageInput`, `prompt`, `nanoBanana`/image generation, `output`, `outputGallery`, later annotation), externalizes inline images, strips provider credentials/settings, and reports every unsupported or rewritten node. Unknown nodes remain disabled placeholders where possible. This importer is compatibility code, not permission to copy upstream storage architecture. + +### 7. Image providers need a separate capability registry + +Aiden's current provider/model types are LLM-oriented. Do not force image capabilities into `ProviderModelType = "llm" | "embedding"` or assume every chat credential can authorize an image endpoint. Introduce an image-specific registry contract: + +```ts +interface ImageProviderAdapter { + getCapabilities(auth: MainOwnedAuth): Promise; + validate( + request: ImageGenerationIntent, + capabilities: ImageProviderCapabilities, + ): ValidatedImageRequest; + estimate?(request: ValidatedImageRequest): Promise; + submit(request: ValidatedImageRequest, context: RunContext): Promise; + poll?(job: ProviderJob, context: RunContext): Promise; + cancel?(job: ProviderJob, context: RunContext): Promise; + collect(job: ProviderJob, context: RunContext): Promise; +} +``` + +Create Images workflow/run authority remains global device-local Aiden data and is never tied to the active coding workspace. On first entry, however, the user must explicitly choose a separate image workspace through a main-owned native folder picker. Aiden keeps its protected content-addressed store authoritative while materializing non-overwriting, Finder-visible copies under `Imports/` and `Generated/`; it verifies the chosen directory identity and writability before imports or runs. Switching coding workspaces must not make a top-level creative workflow appear lost, and Aiden never writes generated assets into the active repository implicitly. + +Hostile image decoding stays outside privileged application logic. PNG/JPEG are validated byte-exactly; other bounded static rasters first use a disposable sandboxed Electron decoder and, on macOS only, may fall back to fixed-argument `/usr/bin/sips` conversion inside a private temporary directory. Every converted PNG is revalidated against the same byte, dimension, and pixel ceilings before canonical ingest. SVG/vector, animated, malformed, mismatched, oversized, or over-dimension inputs remain unsupported rather than weakening the boundary for format parity. + +Provider API keys remain in Aiden's main-owned `safeStorage` credential path. Reuse an existing provider credential only when its exact auth kind and scope are compatible; do not silently reuse OAuth/session credentials intended for chat. Renderer DTOs expose connected/disconnected, display name, capability options, and safe error codes only. + +Provider order: + +1. **Gemini** for the first vertical slice, subject to current model access and capability verification. The inspected Node Banana mapping contains preview-era IDs and is already susceptible to API drift. Its request construction is useful research, but Phase 0 must select the current stateless image API versus the newer provider interaction/state mechanism deliberately and pin only verified model IDs. Implementation follows the current [Gemini image-generation guide](https://ai.google.dev/gemini-api/docs/generate-content/image-generation) and [GenerateContent API](https://ai.google.dev/api/generate-content). +2. **OpenAI GPT Image 2** after the adapter contract is stable. Use the current [image generation guide](https://developers.openai.com/api/docs/guides/image-generation) and [GPT Image 2 model contract](https://developers.openai.com/api/docs/models/gpt-image-2), not upstream's older model assumptions. +3. **Replicate** as the first durable async adapter. The official API returns prediction IDs/get/cancel URLs, supports `Cancel-After`, and removes API prediction data after one hour by default; Aiden must persist IDs and copy outputs immediately. See [create a prediction](https://replicate.com/docs/topics/predictions/create-a-prediction) and [data retention](https://replicate.com/docs/topics/predictions/data-retention/). +4. **fal** after async recovery is proven. Its queue exposes durable request IDs/status/result/cancel operations, and cancellation may be advisory once processing starts. See [fal asynchronous inference](https://fal.ai/docs/documentation/model-apis/inference/queue). + +Kie and WaveSpeed remain out of scope until there is demand, a documented auth/lifecycle contract, and a security review. + +Preserve every valid provider output as an asset variant. Several inspected upstream adapters select only the first returned image; Aiden's normalized result must not silently discard paid outputs. + +Every adapter must use fixed HTTPS origins and a reviewed redirect/output-host policy; validate DNS/IP targets where URLs are provider-controlled; bound headers, bodies, decoded pixels, redirects, polling time, and total job lifetime. Logs redact credentials, prompts, inline media, signed URLs, and raw provider bodies. Provider safety refusals are normalized without weakening or bypassing the provider's policy. + +### 8. Cost, privacy, and consent are workflow states + +Before paid remote work, show provider/model, number of scheduled remote node invocations, image count/quality/size, whether images/prompts leave the device, and a best-effort estimate with its timestamp/source. If a precise estimate is unavailable, say so; never show false precision. + +User input and assets are sent only after **Run** confirmation. First cloud use states that prompts/reference images leave the Mac, provider terms and retention apply, cost may be incurred, the user must have rights/consent for uploaded material, and cancellation may not prevent completion or billing. Outputs are copied to Aiden's local store. Do not put prompt/image contents in telemetry. Provide provider-policy links and a clear per-workflow default provider. A provider/model change invalidates the previous estimate. Any retry or fan-out beyond the confirmed plan requires a bounded policy and visible accounting. + +## Node catalog and sequencing + +### MVP nodes + +The first new workflow opens with an editable `Prompt → Generate Image → Output` starter graph and a visible **Blank workflow** alternative. This lowers the canvas learning cost without introducing a separate prompt composer. + +| Node | Inputs | Outputs | Execution | Acceptance | +| ---------------- | ------------------------------------------- | ------------------- | ----------------- | ----------------------------------------------------------------------------------------------- | +| `Image Input` | local file/drop/paste | `image` | main asset ingest | Shows bounded thumbnail, metadata, replace/remove, and an explicit invalid-file error. | +| `Prompt` | optional upstream text | `text` | local | Multiline editor, variable insertion deferred, no execution side effects. | +| `Generate Image` | required text; optional image or image list | `image[]`, metadata | remote provider | Capability-driven options, estimate, progress, cancellation, durable result, normalized errors. | +| `Output` | image | metadata/reference | local publication | Marks a selected result, allows inspect/download/reuse, never duplicates bytes. | +| `Output Gallery` | image or image list | metadata/reference | local publication | Displays many bounded thumbnails with keyboard navigation and lazy full preview. | + +### Phase 6 image-workflow nodes + +- `Annotation`: non-destructive vector layer plus explicit flatten action. +- `Image Compare`: accessible before/after slider plus side-by-side fallback. +- `Resize/Crop`: deterministic local transform with a new immutable output asset. +- `Split Grid`: validated rows/columns and bounded output fan-out. +- `Array`: explicit bounded values/fan-out with cost preview. +- `Prompt Constructor`: structured text composition. +- `Router/Switch`: deterministic data routing; no paid retries. +- `LLM Prompt`: only after a clear boundary between chat model credentials/cost and image generation. + +### Later candidates + +Loops, provider fallback, background removal, video/audio/3D/GIF, ComfyUI, community sharing, and custom nodes each require a separate plan or explicit addendum with new threat, lifecycle, cost, and packaging gates. + +## Delivery plan + +### Phase 0 — architecture contracts and dependency feasibility + +Review correction (2026-08-11): the original Phase 0 checklist incorrectly pulled live-provider, durable-asset, crash-journal, and final distribution acceptance ahead of the phases that build those boundaries. A live paid call also cannot be an unattended development gate without a user-supplied credential and explicit data/cost consent. Those requirements are not removed: they remain blocking exits for Phases 2–5 below. Phase 0 is limited to non-network architecture contracts and deciding whether it is safe to begin the hidden canvas shell. + +Deliverables: + +1. Pin/audit `@xyflow/react`; build a release-mode, sandboxed Electron dependency fixture with custom nodes and 100/250-node datasets. The real Aiden canvas must pass the packaged gate at the end of Phase 1 and again in Phase 5. +2. Threat-model bounded IPC versus an opaque custom asset protocol and implement a document-owned, expiring, authorization-checked grant core. Phase 2 performs real image delivery, byte/pixel/heap measurements, CSP integration, and lifecycle tests. +3. Implement a pure typed DAG validator/scheduler contract with deterministic ordering, bounded concurrency, explicit paid paths, failure propagation, non-cooperative cancellation, immutable snapshots, run/revision/sequence identity, and stale completion rejection. +4. Prototype atomic workflow metadata manifests with Aiden's existing `DataStore` patterns, compare-and-swap revisions, structured corrupt/future-schema health, and renderer-document publication liveness. Run journals and crash recovery are Phase 3. +5. Verify the current official Gemini API/model/request contract without sending a paid request. The real text-to-image and reference-image acceptance is Phase 4 after the durable output boundary and explicit user consent exist. +6. Write an ADR selecting the canvas dependency, graph schema v1, scheduler contract, metadata store, proposed binary-delivery boundary, and first provider. +7. Record dependency licenses/provenance, add notices for the installed runtime subtree, and decide whether any Node Banana code—rather than ideas—will be adapted. + +GO gates: + +- the CSP-clean Electron dependency fixture initializes the exact graph counts and dimensions, stays within recorded 100/250-node render/viewport/selection/heap thresholds, and reports renderer errors; +- exact dependency versions, peer compatibility, licenses, lockfile provenance, and packaged-notice configuration are recorded; +- no credential, absolute path, full-size base64, or arbitrary URL crosses the preload contract; +- graph/schema bounds, port compatibility, cycles, manifest asset reconciliation, immutable plans, explicit downstream paths, deterministic execution, failure, cancellation, and late-result behavior pass focused tests; +- corrupt/future workflow metadata is distinguishable from an empty first run and cannot be overwritten; +- opaque grants bind the actual renderer-document owner format, check live authorization, expire, and revoke on invalidation; +- the current fixed Gemini request serializer accepts only curated models/options and contains no credential, renderer-selected endpoint, path, remote URL, or provider conversation state; +- focused tests, type-check, lint, build, scoped React diagnostics, and two fresh-context reviews pass after all validated findings are fixed. + +Deferred but still blocking gates: + +| Gate | Blocking phase | +| ----------------------------------------------------------------------------------------------- | -------------- | +| Real asset IPC/protocol comparison with large/high-pixel-count media, cleanup, and CSP | Phase 2 | +| Workflow/asset corruption, autosave, reopen, and crash recovery | Phase 2 | +| Run journal crash boundaries and duplicate-submission prevention with mock provider | Phase 3 | +| Opt-in real Gemini generation/edit, response validation, and durable-before-success publication | Phase 4 | +| Release-representative packaged performance, `npm run dist`, notices/SBOM, and clone exclusion | Phase 5 | + +Stop and revise this plan if any current-phase gate fails. Do not enable a provider or durable side effect earlier than its owning phase. + +### Phase 1 — route, sidebar, canvas, and graph editor (1–2 weeks) + +- Add the gated `/create-images` and `/create-images/$workflowId` routes and sidebar **Create Images** entry. +- Add route-aware terminal/environment behavior and command catalog entries. +- Implement blank/loading/recovery/error workbench states. +- Add React Flow boundary, Aiden-themed grid, pan/zoom/fit, minimap toggle, selection, multiselect, delete, duplicate, connect/disconnect, and bounded undo/redo. +- Implement registry and renderer nodes for the five MVP types using fixtures only. +- Add typed connection rules, cycle prevention, structured validation, node search, inspector, and accessible node-list projection. +- Persist only an in-memory fixture graph in this phase; no real provider button is enabled. + +Exit: a keyboard-accessible, theme-correct packaged canvas edits 100-node fixtures without product network or filesystem access. + +### Phase 2 — durable workflows and assets (1–2 weeks) + +- Implement workflow index/store, version/revision contract, atomic autosave, last-known-good recovery, and route/close flush. +- Implement main-owned asset ingest, SHA-256 dedupe, validation, thumbnails, byte-bounded cache, preview delivery, reference accounting, and repair/GC dry run. +- Wire Image Input, workflow create/rename/duplicate/delete, recent workflow reopening, and conflict/recovery UI. +- Define native `.aiden-images` archive export/import and hostile-archive fixtures. + +Exit: workflows with large imported images survive app restart and corruption simulations without graph base64, lost assets, silent overwrite, or unbounded heap growth. + +### Phase 3 — durable scheduler with mock provider (1–2 weeks) + +- Implement main graph validation, immutable run snapshots, coordinator, run journal, event sequencing, concurrency gates, run all/from-here/stop, and restart reconciliation. +- Add a deterministic local mock image provider with controllable delay, failure, rate limit, crash, duplicate/out-of-order events, and late completion. +- Build node/run progress UI, blocked descendants, actionable errors, retry rules, and terminal run history. +- Add cost/consent UI using mock estimates. + +Exit: all execution/recovery gates pass under deterministic tests before any billable adapter is enabled. + +### Phase 4 — Gemini vertical slice (1–2 weeks) + +- Add image-provider connection/capability status without exposing credentials. +- Implement curated Gemini models/options, current capability validation, request bounds, reference-image handling, response validation, output persistence, usage metadata, rate-limit handling, and refusal/error normalization. +- Require the explicit run consent summary and prevent duplicate submissions on renderer refresh/reconnect. +- Add mocked contract tests plus manual opt-in acceptance with a real user-supplied key. + +Exit: text-to-image and reference-image workflows run, cancel as far as the provider permits, recover safely, persist outputs, and account for requests without leaking secrets. + +### Phase 5 — MVP completion and release hardening (1–2 weeks) + +- Add starter templates, native import/export, download/reveal, workflow deletion/GC experience, a first-open image-workspace chooser with Finder reveal/sync/reconnect, first-run empty state, and documentation. +- Complete accessibility, responsive, reduced-motion, light/dark/high-contrast, keyboard, screen-reader announcement, and canvas performance passes. +- Add feature telemetry limited to non-content operational counters only if Aiden has an approved telemetry path; otherwise ship without it. +- Add the durable feature to onboarding's final bento gallery with its own optimized 1024×1024 transparent PNG in `renderer/assets/onboarding/` and update the hardcoded onboarding asset contract test. +- Complete CSP, packaging, asar, dependency/license, signed/notarized build, update-from-prior-release, and storage-migration acceptance. +- Roll out behind `createImages` capability: internal → opt-in preview → default when quality gates pass. + +Exit: every MVP acceptance criterion below passes in a packaged production-like build. + +### Phase 6 — annotation and local image utilities + +- Spike/pin Konva and react-konva only now. +- Add non-destructive annotation layers with versioned shapes and a flattened immutable output. +- Add compare, resize/crop, split-grid, array, prompt-constructor, and deterministic router/switch nodes. +- Re-run heap, decoded-pixel, undo, import/export, and packaging gates. + +### Phase 7 — provider expansion + +- Add OpenAI GPT Image 2 through the established synchronous adapter contract. +- Add Replicate and fal only after durable remote-job recovery and cancellation tests pass. +- Add curated model catalogs with capability timestamps, safe refresh, deprecation handling, and reproducible provider/model snapshots in runs. +- Add per-provider concurrency, rate-limit, output-retention, cancellation, estimate, and content-policy UX. + +### Phase 8 — Aiden-assisted workflows + +- Define a strict graph proposal schema and graph-diff validator. +- Let Aiden propose a workflow or bounded edit from plain language using the existing connected LLM system. +- Show nodes/edges/options/cost implications before apply. +- Apply as one undoable graph transaction only after confirmation. +- Never let generated output contain raw executable code, provider credentials, absolute paths, or unsupported node types. + +## Concrete implementation map + +| Area | Existing Aiden files to change | New files/modules | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| Capability | `main/handlers/app.ts`, `renderer/lib/app-capabilities.tsx` | fail-closed `createImages` capability and handler guard | +| Route/shell | `renderer/main/router.tsx`, `renderer/main/chat-layout.tsx`, `renderer/main/root-view.tsx` to suppress the global dock | `renderer/create-images/create-images-view.tsx` | +| Sidebar | `renderer/components/chat-sidebar.tsx`, `renderer/components/chat-sidebar.test.tsx` | none | +| Commands | `renderer/shared/keybindings.ts`, command registry/menu/palette tests, `renderer/lib/command-system-core.ts` | `renderer/create-images/commands.ts` | +| Canvas | `renderer/styles.css`, `renderer/shared/appearance.ts` only for reusable missing semantic tokens | renderer module tree described above | +| IPC/preload | `renderer/preload-channels.ts`, `renderer/preload.ts`, `renderer/lib/ipc.ts`, `renderer/lib/queries.ts`, `main/handlers/index.ts`, `main/handlers/ipc-contract.test.ts` | `main/handlers/create-images.ts`, shared IPC schemas | +| Persistence | reuse patterns from `main/services/data-store.ts`; do not change chat attachment storage | workflow/run/asset services described above | +| Credentials | reuse `main/services/secrets.ts` and exact compatible provider-registry credential retrieval | image provider registry/adapters | +| Onboarding | `renderer/components/onboarding-flow.tsx`, `renderer/components/onboarding-flow.test.tsx` | `renderer/assets/onboarding/features/create-images.png` | +| Packaging | `package.json`, lockfile, `THIRD_PARTY_NOTICES.md`, build/license checks | dependency provenance entry/ADR | + +## Test and verification matrix + +Add a focused `test:create-images` script and register every new test file in the correct aggregate scripts so CI runs it. + +### Pure graph tests + +- discriminated schema parsing and every forward migration; +- typed port compatibility/cardinality; +- duplicate/orphan edges and unknown nodes; +- stable topological ordering; +- cycle/self-loop rejection; +- run-all and every run-from-here boundary; +- failure/cancel/block propagation; +- bounded fan-out, node/edge counts, and graph depth; +- undo/redo transactions without asset byte duplication. + +### Persistence and asset tests + +- atomic save, stale expected revision, concurrent autosave, interrupted rename/write, corrupt current file, last-known-good recovery; +- content dedupe, reference count/rebuild, leases, delete/GC race, quota exhaustion, low disk, read-only directory, and repair dry run; +- MIME/magic mismatch, truncated image, EXIF edge cases, decompression/image bombs, extreme dimensions/pixel count, malicious SVG rejection, duplicate digest, and thumbnail failure; +- object URL/token ownership, expiry, renderer destruction, graph swap, and cache byte bounds; +- native and Node Banana import fixtures, unsupported-node reports, no credential import, zip traversal/symlink/bomb/file-count/size rejection. + +### Scheduler tests + +- concurrency limits and deterministic readiness; +- renderer reload/disconnect during a run; +- main crash at every submission/journal/output-publication boundary; +- duplicate and out-of-order provider events; +- rate limit/backoff budget; +- cancellation before submit, queued, polling, downloading, and after remote completion; +- late completion against cancelled/older revisions; +- restart reconciliation with and without durable remote IDs; +- no duplicate paid request after ambiguous submit response. + +### Provider/security tests + +- renderer payload cannot supply credentials, auth headers, absolute paths, or arbitrary request/output URLs; +- fixed origins, redirect limits, private/local IP rejection where applicable, DNS rebinding-safe fetch policy, response byte/time/pixel limits, and MIME validation; +- 401/403/429/5xx/timeout/malformed body/refusal/cancel normalization; +- signed URLs and prompt/image/API-key data are redacted from logs and errors; +- per-model capability drift disables incompatible saved options visibly; +- renderer/document ownership on every IPC and asset request; +- CSP is not widened to permit arbitrary remote content. + +### Renderer/accessibility tests + +- route and selected sidebar state; +- terminal/environment commands hidden and focus released on `/create-images*`; +- add/connect/move/select/delete/duplicate/undo/redo and node inspector behavior; +- keyboard-only node creation/connection alternative and screen-reader announcements; +- focus trap/return for panels and dialogs; +- non-color run states, reduced motion, zoom controls, and high contrast; +- empty/loading/recovery/offline/unconfigured/running/cancelled/partial-failure/quota-full states; +- 390/700/1000/1280 px layouts. + +### Performance and release tests + +- 100/250-node fixtures with representative thumbnails in packaged Electron; +- pan/zoom/connect latency and long-task count; +- heap/decoded-image cache after repeated open/close, run, graph swap, and preview cycles; +- 20 MB image and high-pixel-count rejection/handling; +- app quit during autosave/run/output publication; +- `npm run test`, typecheck, lint, production build, `npm run dist`, packaged smoke, prior-version migration, signed/notarized launch, and update path; +- packaged app contains required third-party notices and excludes `tmp/node-banana`, provider credentials, prompts, imported assets, run journals, and research-only fixtures. + +## Security and privacy threat boundaries + +| Boundary | Principal risk | Required control | +| ----------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| renderer → main IPC | forged IDs, giant payloads, path/URL injection, stale writes | schema and size validation, webContents/document ownership, opaque IDs, expected revisions, narrow methods | +| main → provider | secret leakage, SSRF/redirect abuse, unbounded responses, duplicate billing | main-only auth, fixed origins, redirect/host policy, byte/time limits, idempotency/reconciliation journal | +| provider output → asset store | malicious/mislabeled or huge media | stream to quarantine, magic/MIME/pixel validation, hash, atomic publish, safe thumbnail decoder | +| archive → local store | traversal, bombs, credential/executable smuggling | hostile ZIP policy, quarantine, full validation, no URL fetch or execution, atomic publish | +| graph → scheduler | cycles, fan-out/cost explosion, unsupported configs | typed validation, hard graph/fan-out/concurrency limits, immutable run snapshot, consent summary | +| event stream → renderer | stale/duplicate/out-of-order completion | run/revision/node/sequence identity and idempotent reducer | +| storage GC | deleting live or previewed assets | atomic reference rebuild, leases, quarantine/grace period, repair tooling | + +## Licensing and provenance + +Node Banana's [`LICENSE`](https://github.com/shrimbly/node-banana/blob/5c0e0ae6150f29a6de819f8d6f1dedba15151f7c/LICENSE) is MIT and identifies William Falloon as copyright holder. Product concepts and independently implemented behavior do not require code copying, so the default is a clean reimplementation. + +The upstream dependency tree is not uniformly MIT. In particular, the inspected background-removal path pins `@imgly/background-removal`, whose bundled package declares AGPLv3 rather than MIT. Do not add, adapt, or distribute that dependency in Aiden without explicit legal approval or a separately reviewed permissive/commercial replacement. + +If any source, tests, text, templates, or assets are copied or adapted: + +1. record the exact upstream commit, path, symbol, and adaptation in the Create Images ADR/provenance ledger; +2. preserve the MIT copyright and license in `THIRD_PARTY_NOTICES.md` and any required distribution location; +3. mark Aiden modifications clearly; +4. do not copy Node Banana branding, logo, community assets, example media, or prompts without separate rights review; +5. run a license audit for React Flow, optional Zustand/Konva, provider SDKs, image decoders, archive libraries, and all transitives; +6. verify the packaged artifact rather than assuming `package.json` notices are sufficient. + +## MVP acceptance criteria + +The MVP is complete only when all are true: + +- **Create Images** is a sidebar entry and opens a full persistent node canvas, not a simple prompt page. +- Five MVP node types can create a valid image workflow with typed edges and actionable validation. +- At least one real provider supports text-to-image and reference-image input through main-owned credentials and requests. +- The confirmation surface identifies destination provider/model, request count, data leaving device, and best-effort cost. +- Run all, run from here, stop, partial failure, rate limit, provider refusal, offline, and restart states are understandable and tested. +- Generated outputs are locally durable before success, and reopening Aiden restores graph/output references. +- Graph JSON, undo history, IPC, and logs contain no full-size base64 images, API keys, signed URLs, or absolute asset paths. +- Corrupt saves, low disk, quota exhaustion, app kill, stale events, and ambiguous provider submission do not silently lose work or duplicate paid execution. +- Native import/export rejects hostile archives; no import triggers network or execution. +- Packaged canvas performance passes the 100-node gate and remains functionally usable at 250 nodes. +- Keyboard, screen reader, reduced-motion, light/dark/high-contrast, and responsive gates pass. +- Onboarding's final feature gallery includes the shipped capability and its required 1024×1024 transparent PNG. +- Dependency/provenance notices are correct, and the ignored Node Banana clone is absent from source control and release artifacts. + +## Decisions locked by this plan + +- The sidebar label is **Create Images**; `/create-images` is the workflow library and `/create-images/$workflowId` is the durable canvas route. +- The primary UI is a full node-workflow canvas. +- Workflow/run authority and canonical assets are global device-local Aiden data. First entry requires an explicitly chosen, identity-bound image workspace containing non-overwriting Finder-visible import/generated mirrors; it is never inferred from the active coding repository. +- The MVP is image-only and ships five node types. +- Renderer owns interaction; main owns trust, durability, providers, and execution. +- Assets are content-addressed files referenced by IDs, not graph base64. +- Graph documents and run state are separate and versioned. +- Cycles, loops, fallback, arbitrary custom code, and broad dynamic model catalogs are not MVP features. +- Gemini is the planned first provider, subject to the Phase 0 capability spike. +- React Flow is the selected canvas library after the Phase 0 dependency-feasibility gate, subject to the real product-canvas gate in Phase 1 and final distributable gate in Phase 5. +- Node Banana is a pinned MIT research reference, not vendored application code. + +## Phase-owned evidence questions + +1. **Phase 1/5:** does the real Aiden canvas meet packaged 100/250-node performance and accessibility thresholds on supported hardware? +2. **Phase 2:** is bounded IPC or `aiden-asset://` safer and more performant for selected full images and many thumbnails? +3. **Phase 4:** is Aiden's current Google credential record an exact API-key match for Gemini image generation, or does Create Images need a separate connection flow? +4. **Phase 4:** which current Gemini image models/options are available to target accounts, and how is capability drift represented? +5. **Phase 2:** what total storage quota and per-image decoded-pixel limit fit supported hardware? +6. **Phase 1:** does React Flow's controlled store meet Aiden's interaction/undo needs without another direct state dependency? +7. **Phase 3/4:** what ambiguity strategy prevents a duplicate paid request when a provider accepts work but the connection fails before Aiden receives a durable result? +8. **Phase 2:** which local image decoder/metadata pipeline is safe, sandbox-compatible, packaged reliably, and bounded against decompression bombs? +9. **Phase 2/3:** do bounded JSON metadata plus separate journals meet measured scale, or does evidence justify SQLite's native dependency and migration surface? + +Each owning phase must answer its questions with measurements before crossing that boundary. The Phase 0 ADR records only the non-network foundation decision needed to start Phase 1. + +Phase 0 evidence, both fresh-context reviews, the resulting fixes, and the GO decision are recorded in [`create-images-phase-0-adr.md`](create-images-phase-0-adr.md). diff --git a/docs/plans/create-images-node-banana-learnings-roadmap.md b/docs/plans/create-images-node-banana-learnings-roadmap.md new file mode 100644 index 00000000..647e9371 --- /dev/null +++ b/docs/plans/create-images-node-banana-learnings-roadmap.md @@ -0,0 +1,168 @@ +# Create Images — Node Banana Learnings Roadmap + +Status: Active — implementation complete through Phase 6; release operations remain +Started: 2026-08-21 +Aiden baseline: `107712a29c44902659aecab430d1fd17f421e008` plus the preserved working-tree Gemini/lightbox fixes +Node Banana research revision: `5c0e0ae6150f29a6de819f8d6f1dedba15151f7c` (`master`, clean checkout) + +## Purpose + +This is the active follow-on plan for Create Images workflow polish and advanced creative workflows. It does not expand or rewrite the historical MVP plan. Node Banana remains a behavioral reference; Aiden implements the selected behavior with Aiden-owned schemas, main-process trust boundaries, durable journals, explicit paid consent, no automatic paid retry or fallback, semantic theme tokens, and accessible progressive disclosure. + +No phase may weaken these invariants: + +- Credentials, provider calls, asset bytes, native paths, rasterization, exports, and durable run state remain main-owned. +- Renderer contracts use validated, bounded values and opaque IDs. Graphs, clipboard fragments, diagnostics, and proposals contain no paths, credentials, provider payloads, image bytes, or raw responses. +- Paid submissions require the existing explicit consent path. Browsing, extraction, layout, pause inspection, and proposal application never submit provider work. +- Run snapshots remain immutable and journals remain monotonic, hash-chained, owner-scoped, and the authority for result history. +- Light, dark, high-contrast, reduced-motion, keyboard, focus-return, and screen-reader behavior are release gates. + +## Delivery checkpoints + +1. Close the existing clean Gemini live-acceptance gate only through an explicitly authorized in-app request. +2. Ship Phases 1–3 as an opt-in workflow-polish preview after source, package, update, migration, accessibility, and performance gates pass. +3. Ship Phases 4–6 behind per-device gates, then promote only after Phase 7 release evidence is complete. + +## Phase status + +| Phase | Scope | Status | Exit evidence | +| --- | --- | --- | --- | +| 0 | Stabilize and freeze the baseline | Source/package baseline frozen; live gate pending | Current source/package gates are green. The clean real-key Gemini acceptance remains an explicitly authorized, potentially billable release gate. | +| 1 | Canvas velocity and discoverability | Implemented and locally verified | Bounded clipboard/placement/layout/edge/scoped-run/export paths are keyboard-accessible and covered by source and packaged canvas acceptance. | +| 2 | Image iteration and output reuse | Implemented and locally verified | Recent outputs, galleries, lineage browsing, compare, extraction, presentation hiding, ZIP, restart, pruning, and zero-provider-traffic browsing contracts pass. | +| 3 | Presentation, performance, templates, and teaching | Implementation complete; preview release pending | Presentation, rendition, title/comment, preference, template, tutorial, diagnostics, accounting, accessibility, onboarding, and performance gates pass. The notarized opt-in preview, prior-release update, and populated-storage migration are release operations still pending. | +| 4 | Advanced creative structure | Implemented behind Power features | Packaged Konva integration, schema migrations, annotations/rasterization, prompt variables, groups, archive/import, undo, and bounds tests pass. | +| 5 | Durable pause and bounded batching | Implemented; live batch gate pending | Durable pause/resume, drift checks, Prompt List, eight-request admission, per-item journaling, cancellation, partial failure, and accounting tests pass. The explicitly authorized minimal live batch remains pending. | +| 6 | Prompt-to-workflow proposals | Implemented behind Power features | Selected-chat-model proposal generation, strict hostile-output validation, complete diff, provider-switch/drift handling, one-transaction apply, and no-auto-run contracts pass. | +| 7 | Broad release hardening | Local hardening complete; broad release pending | Current source, full-suite, build, signed development package, package verification, artifact fingerprint, and packaged acceptance gates are green. Notarization, prior-release update, populated-storage migration, minimum-hardware acceptance, and billable live gates remain. | + +## Phase 0 — Stabilize and freeze the baseline + +- Complete explicitly authorized live Gemini text-to-image and reference-edit acceptance without automatic submissions. +- Update Phase 4/5 evidence, project memory, the plan index, and this follow-on plan. +- Capture source/package fingerprints and the exact Node Banana research revision. +- Freeze existing schema, run, asset, accessibility, and packaged-performance results as regression baselines. +- Exit only after Gemini produces a validated durable output in a clean run and all current source gates pass. + +## Phase 1 — Canvas velocity and discoverability + +- Create and connect a compatible node as one undoable transaction when a connection is dropped on empty canvas. +- Add double-click and right-click contextual node search plus drag-from-palette exact placement. +- Add bounded cross-workflow graph-fragment copy/paste using a versioned Aiden MIME payload with graph data and opaque asset IDs only. +- Enforce paste precedence: valid Aiden fragment, image clipboard, then non-empty text creating a Prompt node. +- Add horizontal, vertical, and grid arrangement for multi-selection. +- Add a canvas-shortcuts help surface and direct shortcuts without conflicting with Aiden's command system. +- Add a selected-edge inspect/delete toolbar. Pause controls arrive in Phase 5; loop edges remain excluded. +- Add contextual Run this node through the existing scoped-run and consent path. +- Add native Save to output, image input, and lightbox surfaces through authorized main-owned export. + +## Phase 2 — Image iteration and output reuse + +- Add a device-wide Recent Images shelf derived from the latest 50 retained generated outputs. Show fan preview, count, prompt/model/time-safe metadata, an overflow drawer, presentation-only clear, and secure drag-back-to-canvas. +- The shelf never pins assets. Pruned outputs disappear; drag-back creates a durable Image Input reference. +- Complete Output Gallery count/grid, keyboard/lightbox navigation, download, presentation-only hide/restore, and selected/all extraction into collision-aware Image Input nodes. +- Add per-generator navigation across the latest 50 retained outputs in that node's lineage. Browsing submits no provider work. +- Add an Image Compare node with exactly two authorized image inputs and a draggable accessible A/B divider. +- Add bounded collision-safe ZIP export for selected images through native IPC. + +## Phase 3 — Presentation, performance, templates, and teaching + +- Add bounded node resizing, media-aspect fit on double-click, persisted dimensions, and accessible resize alternatives. +- Add adaptive rendition buckets with single-flight requests and byte-bounded `{assetId, rendition}` caching. +- Add custom node titles, bounded comments, unread navigation, and focus-safe editing. +- Add device-level canvas navigation preferences that preserve existing defaults. +- Add a main Settings → Create Images surface for the device-level autosave, Power features, and canvas-navigation preferences. Autosave remains the default; turning it off reveals an explicit workflow Save control, while Run deliberately saves the exact graph before review. +- Treat drag and resize updates as transient editor state. Publish one committed document after the gesture ends, and reject resize drafts that did not begin from an explicit node edit gesture so renderer layout observers cannot exhaust the main-owned mutation budget. +- Replace the text template list with an offline visual explorer with search, categories, tags, previews, and keyboard navigation. +- Add a disposable local-mock Create Images tutorial for add/connect/run/inspect/extract/save. It never bills or modifies the library unless the user explicitly keeps the result. +- Add safe diagnostic summary copy actions containing only codes, IDs, model, timestamps, and states. +- Distinguish estimate, reported actual cost, and unknown cost. Estimates require a main-owned source-stamped pricing snapshot; stale or unsupported values show Unknown. +- Produce the first notarized opt-in preview after light/dark/high-contrast/reduced-motion, 100/250-node, thumbnail-memory, onboarding, prior-update, and populated-storage migration gates pass. + +## Phase 4 — Advanced creative structure + +- Gate Annotation on a packaged Konva/react-konva dependency, license, memory, and interaction spike. A failed spike blocks Annotation until this plan is amended. +- Support rectangle, ellipse, arrow, freehand, and text shapes; selection/move/resize; semantic stroke/fill controls; undo/redo; and immutable flattened PNG output. +- Persist bounded shape specifications only. Validated shape data and opaque source assets cross into main-owned rasterization; renderer paths never do. +- Add Prompt `${name}` tokens with autocomplete, escaped literals, stable variable IDs, unique bounded names, at most 32 variables, one typed input per variable, and missing-value validation. +- Add resizable colored groups with rename, group/ungroup, semantic presets, and layout-only locking. Deleting a group preserves its members; locks never skip execution. +- Keep controls behind the per-device Power features setting, while revealing required controls for workflows that already contain them. + +## Phase 5 — Durable pause and bounded batching + +- Add edge breakpoints that pause after required upstream publication and before downstream provider submission. +- Persist a recoverable `paused` run state. Resume continues the same immutable snapshot and run ID without rerunning completed nodes. +- Revalidate credentials, capabilities, pricing/consent drift, assets, and graph/run identity before resume; Stop remains available while paused. +- Add a Prompt List node accepting newline items or a validated JSON string array. +- Limit a confirmed batch to eight provider invocations including output-count multipliers; preserve order and stable item IDs. +- Journal each item's queued/submitted/succeeded/failed/blocked/cancelled/output/usage/cost state. +- Consent shows the exact maximum request count and available estimate. Paid batch retries and fallbacks are never automatic. +- Cancellation blocks queued submissions. Already submitted work may finish or incur cost and remains attached only to the originating run. + +## Phase 6 — Prompt-to-workflow proposals + +- Use the currently selected Aiden chat model, never the image credential, to propose workflows. +- Send only a bounded user-authored request and require a strict graph proposal with no tools, credentials, paths, asset IDs, executable code, or provider requests. +- Limit proposals to 50 nodes, 200 edges, and shipped Create Images node types. Image inputs are empty placeholders. +- Validate with production schema, ports, graph limits, model capabilities, and request/cost analysis. +- Show the complete graph diff and request/cost implications before Apply. +- Apply as one undoable transaction. Apply never runs or contacts an image provider. +- Fail closed on malformed, unsupported, oversized, cyclic, or ambiguous proposals and leave the workflow unchanged. + +## Phase 7 — Broad release hardening + +- Run the full source, migration, accessibility, security, performance, packaging, update, notarization, and artifact-inspection matrix. +- Prove old workflows migrate without execution-meaning changes; future/corrupt schemas remain read-only and recoverable. +- Map supported Node Banana additions or report unsupported behavior without importing secrets or unsafe paths. +- Inspect ASAR, SBOM, and notices and prove research files, prompts, assets, credentials, journals, and fixtures are excluded. +- Promote advanced gates only after their phase evidence is complete. +- Broaden availability only after minimum-hardware 250-node, 50-image history, annotation, eight-item batch, crash recovery, and prior-release update gates pass. + +## Contract evolution + +- Version the workflow schema with migrations for node dimensions/titles/comments, groups, Image Compare, Annotation, Prompt variables, and Prompt List. A phase never writes a future field before its implementation gate is active. +- Add narrow main-owned APIs for recent outputs, rendition grants, asset extraction, native save/ZIP, presentation hiding, run pause/resume, and proposals. Inputs remain size-bounded and document-owner scoped. +- Extend run contracts with paused checkpoints and batch-item events while retaining immutable snapshots, monotonic sequences, hash chains, and no-duplicate-paid-request recovery. +- Store navigation and Power features as device preferences, not execution settings. +- Keep run journals authoritative; shelves, carousels, and galleries remain derived presentations. + +## Test and release gates + +- Register each new test in `test:create-images` or its owning aggregate. +- Cover migrations, hostile clipboard fragments, limits, undo, compatible creation, layout, dynamic ports, asset grants, pruning, gallery extraction, hiding, rendition pressure, exports, and hostile ZIP input. +- Cover keyboard/focus/screen-reader/shortcut/reduced-motion/theme/contrast/tutorial/responsive behavior. +- Cover annotation bounds, malformed shapes, raster failure, variable validation, groups, import/export, and reference accounting. +- Cover pause/restart, drift, stop, partial batches, eight-item admission, cancellation, ambiguity, and cost reconciliation. +- Cover hostile proposals, unsupported graphs, diff accuracy, zero automatic execution, and atomic apply. +- After React phases run React Doctor, focused tests, `test:create-images`, onboarding, type-check, lint, build, package/verify, and packaged acceptance. +- At release checkpoints also run the full suite, `dist`, notarized launch, prior-release update, populated-storage migration, and artifact inspection. + +## Provenance ledger + +| Reference | Audited revision | Use | Product-source adaptation | +| --- | --- | --- | --- | +| Node Banana | `5c0e0ae6150f29a6de819f8d6f1dedba15151f7c` | Behavioral research for canvas, image reuse, gallery, templates, compare, annotation, grouping, and batching interactions | None. Implementations are clean Aiden-native code unless a later row names an exact upstream path, symbol, license, and adaptation. | + +Excluded from adaptation: raw data-URL persistence, renderer-held credentials, direct filesystem paths, dark-only styling, automatic provider fallback, automatic billable retry, auto-run-on-connect, and execution-skipping group locks. Also out of product scope: video, audio, 3D, arbitrary providers/models, loop edges, and community templates. + +## Implementation and verification record — 2026-08-21 + +Phases 1–6 are implemented as Aiden-native code. Advanced creative and proposal controls remain progressively disclosed behind the device-level Power features setting. Phase 7 local hardening is complete, but this plan remains active because release operations and explicitly billable acceptance cannot be inferred from implementation approval. + +Current exact-tree evidence: + +- Source fingerprint: `54d38be108eb245d275ba27c4e0b5869fbd719acd34bfb5991a798e58dbc0a13`; the signed development package was reverified against it after the final autosave and gesture-publication fixes. +- Create Images aggregate: 11/11 pretests, 490/490 functional tests, 2/2 performance tests, and 15/15 native/source-integrity checks. +- Performance journal gate: 100/250/500-node append measurements of 20,209/51,953/109,068 ms; 1,502-event replay in 184 ms; 635,757-byte current journal. +- Output-rich restart gate: 1,000 terminal journals with 250 output IDs per run; 4,563 ms restart, 4,441 ms admission, 20,554 ms product path, and 69 ms retention. +- Repository gates completed during hardening: full `npm test`, onboarding tests, type-check, lint, build, package, package verification, source-fingerprint verification, and `git diff --check`. +- React Doctor reported no critical errors and an advisory score of 63/100. Its warnings are recorded rather than suppressed: the changed surface includes deliberately serialized durability/security work, the existing large canvas component, stable duplicate-output rendering, and packaged-evidence loops. +- Signed development package acceptance used `/create-images/stress-100`: 0 renderer errors, 0 network requests, 1/1 renderer-egress probe blocked, 39 keyboard actions, 38 live-region mutations, reduced-motion/focus/responsive checks, durable reload, opaque asset delivery, no graph base64, and exact configured-workspace storage mutations all passed. +- Exact packaged workflow acceptance also passed durable prompt autosave and renderer-restart persistence. Device-local manual save remains opt-in through Settings → Create Images; only high-frequency gestures defer publication until commit. +- Packaged Phase 2 storage acceptance imported and previewed a 21,033,819-byte 4000 × 4000 image through an authorized main-owned asset protocol and validated the exact 12-file configured-workspace contract. +- Acceptance attestation: `build/create-images-packaged-acceptance/attestation.json`. + +Release gates intentionally not executed: + +- No real-key Gemini text-to-image/reference acceptance or minimal live batch was submitted. Each remains potentially billable and requires explicit, per-run user authorization in the app. +- The development package is signed, but notarization was intentionally skipped. The first notarized opt-in preview, update from the prior release, populated-storage migration, and minimum-hardware release matrix remain Phase 3/7 release operations. diff --git a/docs/plans/create-images-phase-0-adr.md b/docs/plans/create-images-phase-0-adr.md new file mode 100644 index 00000000..0ded16d3 --- /dev/null +++ b/docs/plans/create-images-phase-0-adr.md @@ -0,0 +1,142 @@ +# Create Images Phase 0 Architecture Decision + +Status: GO; two fresh-context reviews completed and all validated findings fixed +Date: 2026-08-11 +Aiden baseline: `c3d644485e543579bbf478bb1e7355ba6667ce65` plus the uncommitted Create Images planning artifacts +Node Banana reference: `5c0e0ae6150f29a6de819f8d6f1dedba15151f7c` + +## Decision + +Proceed with the non-network Create Images shell and canvas foundation using: + +- exact `@xyflow/react@12.9.3` for the renderer canvas; +- Aiden-owned, dependency-light workflow schema, typed-port registry, graph validator, and deterministic scheduler; +- bounded JSON manifests built on Aiden's existing `DataStore` safety properties for workflow metadata; +- separate run journals and content-addressed binary assets in later phases; +- opaque, renderer-document-bound grants for an `aiden-asset` protocol rather than `file://`, absolute paths, remote URLs, or repeated full-image IPC; +- a fail-closed `AIDEN_CREATE_IMAGES_ENABLED=1` capability until release gates pass; +- Google's current Interactions API and a curated release-pinned image-model list for the future Gemini adapter; +- stateless image requests (`store: false`) for the MVP rather than provider-hosted `previous_interaction_id` chains. + +This GO does not enable a user-facing route or issue a billable provider request. A real Gemini smoke test requires a user-supplied compatible API key, explicit cost/data consent, and the Phase 4 output-ingestion boundary. It is deliberately not performed during this architecture phase. + +## Evidence + +### Canvas dependency and Electron feasibility harness + +Registry evidence for `@xyflow/react@12.9.3`: + +- license: MIT; +- React peer range: `>=17`, compatible with Aiden's React 19; +- unpacked package size: 1,173,257 bytes; +- direct runtime dependencies: `@xyflow/system@0.0.73`, `classcat@5.0.5`, and `zustand@4.5.7`; +- the complete code-bearing installed runtime closure, including `use-sync-external-store` and the D3 drag/selection/zoom/transition descendants, uses MIT, ISC, or BSD-3-Clause licenses and is inventoried in `THIRD_PARTY_NOTICES.md`. + +The Phase 0 harness bundles a custom-node React Flow surface in production mode, launches it in a context-isolated, sandboxed, Node-disabled Electron window at 1000×650, and measures 100/250-node initial render, 40 viewport updates, and 20 selection frames. Its stylesheet is external and CSP-compliant; it fails on CSP/renderer errors, wrong host dimensions, wrong graph population, no visible nodes, excessive long tasks, or threshold overruns. Command: `npm run test:create-images:canvas-spike`. + +Measured on the development Mac: + +| Fixture | Edges | Initial render | Viewport avg. | Selection-frame avg. | Visible / graph nodes | Long tasks | JS heap | +| --------- | ----: | -------------: | ------------: | -------------------: | --------------------: | ---------: | -----------: | +| 100 nodes | 99 | 80.5 ms | 1.25 ms | 16.77 ms | 28 / 100 | 0 | 5,721,133 B | +| 250 nodes | 249 | 65.9 ms | 2.51 ms | 16.67 ms | 49 / 250 | 1 / 92 ms | 20,172,885 B | + +Observed heap growth between cases was 14,451,752 bytes, below the 64 MB feasibility ceiling. These measurements prove library feasibility, not finished or packaged UI performance. The plan was corrected after review so the real Aiden canvas owns the packaged interaction/accessibility gate in Phase 1 and the minimum-hardware production-package gate remains in Phase 5. + +### Graph and execution kernel + +Implemented contracts: + +- `renderer/shared/create-images/schema.ts`: exact schema version, five-node MVP union, finite coordinates, bounded dense arrays/prompts/graphs/assets, asset-manifest reconciliation, no unknown fields, no inline media/paths/credentials, starter workflow. +- `renderer/shared/create-images/ports.ts`: stable semantic ports, media compatibility, cardinality, orphan/direction/duplicate/cycle checks, and run-readiness diagnostics. +- `renderer/shared/create-images/execution.ts`: schema-validated deep-frozen workflow snapshot, stable topological order, explicitly enumerated downstream paths, required-ancestor closure, run/workflow/revision/sequence identity, stale/out-of-order reduction, concurrency 1–4, discriminated settlement, failure blocking, immediate non-cooperative cancellation, stale-plan rejection, and late-completion suppression. + +The focused suite covers schema failure, sparse/oversized arrays, credential fields, future versions, asset-manifest mismatch, port errors/cycles, incomplete drafts, stable scheduling, concurrency, independent branches, downstream blocking, empty rejection reasons, immutable snapshots, explicit paid paths, non-cooperative cancellation, transition identity/order, scoped runs, and late completion after cancellation. + +### Workflow persistence + +The Phase 0 `WorkflowManifestStore` uses `DataStore` with: + +- an 8 MB metadata ceiling; +- strict database/document parsing; +- atomic staged publication and directory sync inherited from `DataStore`; +- corrupt-file and unsafe-future-schema write refusal; +- structured `healthy` / `corrupt` / `unsafe` load health so recovery never looks like an empty first run; +- exact revision compare-and-swap semantics; +- renderer-document liveness checked before publication; +- own-property lookup so valid IDs such as `constructor` cannot collide with `Object.prototype`; +- metadata-only summaries that do not expose prompts. + +Decision: retain bounded JSON for workflow manifests rather than add SQLite now. Aiden already has unusually strong JSON publication/recovery behavior, the expected MVP workflow index fits comfortably inside the bound, and a native database would add packaging, migration, backup, and corruption surfaces before evidence requires it. Run history and asset metadata remain separate so this decision can be revisited without changing the graph schema. Phase 2 must load-test realistic workflow/run counts and reopen the decision if index latency, write amplification, or recovery evidence fails. + +### Asset delivery + +Decision: use a narrowly registered `aiden-asset` protocol backed by opaque, short-lived grants bound to the current renderer document and an internal asset ID. The Phase 0 registry proves: + +- tokens reveal neither document nor asset ID; +- another document cannot resolve or revoke the token; +- the actual `processId:routingId:frameToken` owner contract is accepted and checked live; +- asset authorization is checked at mint and resolve; +- expiry, explicit revocation, and automatic invalidation revocation; +- bounded registry capacity with oldest-grant eviction. + +Phase 2 must add protocol registration before readiness, exact requesting-frame validation, range/cache semantics if necessary, CSP scoping, MIME/size enforcement, and tests against navigation/reload/destruction. Bounded `Uint8Array` IPC remains acceptable only for picker ingestion or a selected small preview; it is not the gallery transport. + +### Provider API and credential boundary + +Google's current [image-generation guide](https://ai.google.dev/gemini-api/docs/image-generation) documents the Interactions endpoint, current model IDs, reference images, response format, and rights requirements. The [Interactions API reference](https://ai.google.dev/api/interactions-api) documents durable/terminal states and its request/response contract. The earlier GenerateContent image API is now explicitly labeled legacy. + +The Phase 0 contract therefore: + +- fixes the origin to `https://generativelanguage.googleapis.com/v1beta/interactions`; +- allows only `gemini-3.1-flash-lite-image`, `gemini-3.1-flash-image`, and `gemini-3-pro-image` for this release snapshot; +- sends text and bounded Aiden-owned reference bytes only; +- sends no key, auth header, remote URL, absolute path, safety override, search tool, or provider conversation ID in the renderer-owned intent; +- requests one output because the current normalized adapter has not yet proven multi-output behavior; +- sets `store: false` and `background: false` for the stateless first slice; +- preserves the provider's default safety behavior. + +Phase 4 must resolve credentials with main-owned provider authority, verify API-key auth separately from OAuth, pin the directly used SDK or implement a fully bounded REST response parser, stage/validate the response before publishing success, and repeat the current-model contract check at implementation time. + +## Dependency and audit disposition + +`npm audit` reports three existing toolchain findings after install: + +- `esbuild` low severity through Aiden's direct dev dependency; +- `js-yaml` high severity through ESLint/electron-builder/electron-updater; +- `nanoid` high severity through Vite/PostCSS. + +`npm explain` shows none is introduced by the React Flow subtree. Do not apply `npm audit fix --force`; its suggested esbuild resolution is semver-major and unrelated to the canvas decision. Track these through the repository's ordinary dependency update work. `THIRD_PARTY_NOTICES.md` now includes the complete code-bearing React Flow runtime closure, and a focused contract test resolves the installed closure, pins every expected identity/license, and verifies notice/copyright coverage plus packaged-file configuration. Phase 5 still inspects the built artifact/SBOM rather than treating source configuration as final distribution proof. + +Node Banana remains a behavioral reference only. No upstream source, assets, templates, branding, or AGPL background-removal dependency were copied. + +## Phase 0 gates + +| Gate | Result | +| ------------------------------------------------------- | --------------------------------------- | +| Exact canvas dependency/license/peer review | Pass | +| 100/250-node sandboxed Electron feasibility measurement | Pass | +| Strict graph schema and typed-port validation | Pass | +| Deterministic bounded scheduler | Pass | +| Failure, cancellation, and late-result behavior | Pass | +| Atomic revision-checked metadata persistence | Pass | +| Corrupt/future-schema health and write refusal | Pass | +| Opaque live-owner/authorization-bound asset-grant core | Pass | +| Current Gemini request/model contract researched | Pass | +| Real paid provider generation | Assigned to explicit Phase 4 acceptance | +| Two fresh-context reviews and fixes | Pass | + +## Fresh-context review outcome + +Two agents received no conversation history and independently reviewed the frozen Phase 0 target. + +| Review | Initial result | Validated findings fixed | +| --------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Graph, scheduler, and persistence correctness | NO-GO | Non-cooperative cancel hang; rejection-without-reason success; missing run identity/reducer; mutable execution snapshot; implicit downstream fan-out; renderer-owner ID mismatch; corrupt/future-as-empty reads; sparse arrays; prototype-key IDs; node/manifest asset mismatch. | +| Security, provider, dependency, and benchmark | NO-GO | Phase gate mismatch; CSP-invalid/under-asserted harness; immutable snapshot and rejection defects; grant authorization/liveness; missing runtime notices. No provider endpoint/model/request-shape defect was found. | + +The plan now assigns live-provider, real-asset, journal-crash, packaged-performance, and final-distribution evidence to the phases that implement those boundaries. This is a sequencing correction, not a waiver: every deferred item remains a blocking exit in the plan. After repairs, the focused suite contains 30 passing tests (29 TypeScript contract cases plus the notice/package contract), and type-check, lint, production build, canvas harness, scoped React Doctor, and whitespace checks pass. + +## Conditions on Phase 1 + +Phase 1 may implement only the default-off, fixture-backed canvas shell. The feature must not register storage, protocol, provider, or IPC side effects during that phase. Its exit requires the real Aiden node components and shell to pass the packaged canvas/accessibility gate before Phase 2 starts. diff --git a/docs/plans/create-images-phase-1-evidence.md b/docs/plans/create-images-phase-1-evidence.md new file mode 100644 index 00000000..bf14a55b --- /dev/null +++ b/docs/plans/create-images-phase-1-evidence.md @@ -0,0 +1,80 @@ +# Create Images Phase 1 Evidence + +Status: **GO** — implementation, frozen-artifact verification, and both fresh-context reviews complete +Date: 2026-08-11 +Feature gate: `AIDEN_CREATE_IMAGES_ENABLED=1` + +## Implemented surface + +- Lazy, fail-closed `/create-images` library and `/create-images/$workflowId` canvas routes under Aiden's shared split-view shell. +- Capability-gated **Create Images** sidebar entry between **New Agent** and **Scheduled**. +- Route-specific workflow search/list in place of chat search, workspace switching, and chat history. +- Route-aware shell that removes Environment and Terminal while keeping the Assistant dock mounted but hidden, inert, and interaction-blocked so route changes cannot cancel an active response or discard its draft. +- Capability-aware `images.open` command and shortcut Settings entry with chat-only navigation shortcuts disabled on the canvas route. +- Full React Flow workbench with pan, zoom, fit, minimap toggle, selection, multiselect, delete, duplicate, connect/disconnect, bounded undo/redo, searchable modal add-node palette, and container-responsive inspector. +- Five Aiden-owned fixture nodes: Image Input, Prompt, Generate Image, Output, and Output Gallery. +- Central typed-port validation, connection cardinality, duplicate/cycle rejection, run-readiness details, accessible invalid-drop reasons, and a non-spatial keyboard connection/disconnection editor. +- Unique accessible node/control names, deterministic focus restoration, repeatable polite announcements, modal shortcut isolation, compact visible port rails, bounded prompt editing, responsive validation details, reduced-motion behavior, and light/dark semantic-token styling. + +The implementation remains fixture-only. The run control is disabled and this phase registers no Create Images IPC, protocol, persistence, asset, credential, or provider side effects. + +## Signed packaged-app acceptance + +`npm run test:create-images:packaged` launches the hardened, signed development `.app` from its ASAR with the real preload, CSP, shared shell, capability gate, and production route. Its one-shot private acceptance profile establishes a stable pre-route product-file baseline, instruments product network requests and durable files, drives native keyboard/pointer input, writes a nonce-bound private receipt, and exits cleanly. + +Final frozen-artifact receipt: + +- route `/create-images/stress-100`; node history `100 → 101 → 102 → 101 → 102`; +- real spatial disconnect/reconnect, keyboard-only **Connect nodes**, and two identical invalid drops with repeat live-region mutation; +- native edge Delete/Undo/Redo and connected-node cascade Delete/Undo/Redo restore exact node and edge counts in one history transaction; +- duplicate → Arrow move → Undo, toolbar Undo/Redo, modal focus trap/return, deletion focus restoration, and unique repeated-node names; +- responsive/overflow checks at 1280, 1000 with maximum sidebar, 700, and 390 px; narrow validation navigation and fully visible new-node bounds at 390 px; +- reduced-motion fit behavior, 38 observed live-region mutations, and 38 observed native keyboard actions; +- 0 renderer errors, 0 HTTP/HTTPS/WS/WSS requests, and 0 Aiden product-file mutations after route entry; +- `sandbox: true`, `contextIsolation: true`, `nodeIntegration: false`; +- 6,035.0927919999995 ms measured acceptance duration and clean bounded process exit. + +The durable attestation is `build/create-images-packaged-acceptance/attestation.json` and binds that receipt to: + +- source HEAD `c3d644485e543579bbf478bb1e7355ba6667ce65`; +- embedded/current package-input fingerprint `4eb47d56d26ef98ea608f5ac8980419384cd11f975946737904a590d9773cc3b`; +- ASAR SHA-256 `68c73146985f0a3f4c9abf624aaa733fc8d01861f374b5308ca214b158c91da3`; +- code-signature CDHash `043360e3b1be15fe5e8a4a1175b3fce0c457daa7`; +- bundle identifier/version `com.sambitcreate.aiden-agent` / `0.28.0`. + +The fingerprint is captured before source-consuming builds, verified unchanged after compilation, embedded in the ASAR, and compared with the working tree before and after acceptance. Mutable evidence/status paths (`docs/`, `.memory/`, and `.papercuts/`) are excluded because they are not package inputs. + +## Product-canvas performance gate + +`npm run test:create-images:canvas-product` bundles the production `WorkflowCanvas`, five real node renderers, fixture factory, and compiled Aiden renderer CSS. It launches a 1000×650 sandboxed, context-isolated, Node-disabled Electron window under `connect-src 'none'`. Measurements use a contemporaneous empty-frame baseline and separate long-task budget, assert visible-node culling, inspect node AABBs for overlap, bound a near-maximum prompt editor by internal scrolling, and run the edit and repeated-announcement paths. + +Final measurements: + +| Fixture | Edges | Initial render | Viewport avg. | Adjusted selection median | Visible / graph | Long tasks | JS heap | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 100 nodes | 75 | 99.30 ms | 1.25 ms | 0.00 ms | 2 / 100 | 0 | 9,465,508 B | +| 250 nodes | 186 | 116.40 ms | 1.25 ms | 0.00 ms | 2 / 250 | 1 / 53 ms | 16,363,660 B | + +Heap growth was 6,898,152 bytes. Both fixtures measured eight visible node bounds with zero overlapping pairs, kept the long prompt internally scrollable, and passed edits, repeated announcements, and semantic theme-surface checks. The launchers fail on both Electron 43 structured console errors and legacy numeric error severity, independent of message wording. Their shared bounded child lifecycle settles spawn errors and waits for stdio `close` after process exit, escalates timeout from `SIGTERM` to `SIGKILL`, and guarantees a final rejection instead of hanging or parsing truncated output. + +## Frozen-tree verification + +- `npm run test:create-images`: 54/54 (43 TypeScript cases plus the notice, source-fingerprint, Electron console-severity, and bounded child-lifecycle contracts). +- `npm run test:preflight`: 243/243 across its Artificial Analysis, Model Pad, shell/sidebar, appearance, composer, transition, accessibility, and shared renderer contracts. +- `npm run test:command-system`: 62/62, including capability-hidden Create Images shortcuts and modal/scope guards. +- `npm run type-check`: pass. +- `npm run lint`: pass. +- `npm run package` and `npm run package:verify`: pass for the hardened, fused, signed development `.app`. +- `npm run test:create-images:packaged`: pass against that exact signed artifact and clean process exit. +- `npm run test:create-images:canvas-product`: pass for the 100/250 production component cases. +- Production lazy-boundary gate: fixture builders/schema and Create Images CSS are absent from the eager renderer; the packaged acceptance driver is absent from eager main-process code and emitted as a separate dynamic chunk. +- ASAR inspection: one JS and one CSS lazy Create Images chunk plus `THIRD_PARTY_NOTICES.md`; zero packaged `@xyflow`, D3, Zustand, classcat, or Node Banana research files. +- Scoped React Doctor reported no unresolved Create Images correctness, accessibility, or security error; its remaining large-component warning describes the deliberate canvas orchestration boundary. + +## Node Banana provenance + +No Node Banana source, assets, templates, branding, prompts, or dependencies were copied. The clean implementation uses the pinned upstream `WorkflowCanvas.tsx`, `NodeSearchMenu.tsx`, and `FloatingActionBar.tsx` only as behavioral references for the canvas/search/action-bar shape; Aiden's typed ports, bounded history, accessible connection editor, route shell, nodes, fixtures, and styles are independently implemented. + +## Review gate + +Both Phase 1 fresh-context reviewers returned unconditional GO with no actionable findings on fingerprint `4eb47d56…773cc3b`. They independently confirmed the source, signed artifact, close-safe receipt, package contents, and this evidence document. diff --git a/docs/plans/create-images-phase-2-evidence.md b/docs/plans/create-images-phase-2-evidence.md new file mode 100644 index 00000000..8053ef0d --- /dev/null +++ b/docs/plans/create-images-phase-2-evidence.md @@ -0,0 +1,93 @@ +# Create Images Phase 2 Evidence + +Status: **GO** — both fresh-context reviewers independently approve the source, frozen signed artifact, durable attestation, and exact evidence +Date: 2026-08-11 +Feature gate: `AIDEN_CREATE_IMAGES_ENABLED=1` + +## Implemented durability surface + +- Main-owned workflow CRUD with strict opaque IDs, compare-and-swap revisions, bounded autosave, crash-survived journals, explicit conflict/recovery states, last-known-good manifests, deterministic recovery diagnostics, renderer-document liveness checks, and idempotent restart reconciliation after current-manifest publication. +- Opening a workflow restores its persisted viewport without publishing a write. Graph edits, viewport changes, rename, duplicate, delete, recovery, repair, and autosave use the same revision contract. +- Workflow inventory fails closed on unknown files, invalid IDs, symlinks, corrupt/future schemas, empty failed-publication ghosts, and bounded-scan overflow. Preflight quotas cap active workflows at 1,000 and aggregate manifest bytes at 512 MiB; deleted-workflow quarantine is isolated and capped at 32 entries and 128 MiB. +- Content-addressed PNG/JPEG storage with SHA-256 IDs, deduplication, descriptor-bounded reads, structural validation, mandatory deep decode, 16 MP decoded-pixel limit, 64 MiB compressed-image limit, 100,000-asset limit, 10 GiB total budget, and an 8 GiB warning threshold. +- Image decode and thumbnail generation run in a disposable hidden renderer with `sandbox: true`, `contextIsolation: true`, `nodeIntegration: false`, and `default-src 'none'`. No untrusted codec runs through `nativeImage` in the privileged main process. +- Opaque `aiden-asset://` grants are exact-document, main-frame, GET/image-only, expiring, one-use-authorized, path-free, and coupled to asset-store preview leases so GC cannot delete an open preview. +- Visible-node preview lifecycle uses per-asset single-flight loading, four-request concurrency, mount reference counts, pre-expiry renewal, delivery-error exponential backoff reset only by an exact-token image load, atomic token swap/revoke, virtualization remount recovery, same-digest adoption handoff, and immediate pruning on asset replacement/removal. +- Reference accounting covers workflow, run, export, and preview authorities under one snapshot lock. Asset GC supports race-checked dry runs/apply, grace periods, preview leases, and repair/index rebuild without exposing filesystem paths to the renderer. +- Renderer-owned mutations are limited to 120 operations per main document per 60 seconds. The production main renderer is deny-by-default for remote HTTP(S)/WS(S) egress; development permits only loopback transport. Other Aiden windows keep their own policy. +- Missing referenced assets remain editable and receive per-node, library, and storage diagnostics. Re-importing the exact original safely republishes only an absent content-addressed source; an existing corrupt source remains repair-only. +- Image Input controls have node-qualified accessible names, deterministic focus restoration, preview error recovery, and immediate grant cleanup. +- Native `.aiden-images` archive contracts independently preflight the sole bounded manifest before member reads, cap assets at the same 10 GiB aggregate as local storage plus workflow/manifest envelope, and reject traversal, links, duplicates, encryption, unsupported compression, credential/executable smuggling, size/CRC/digest mismatches, declared/actual byte differences, or disagreement between workflow references, manifest descriptors, and deeply validated assets. + +The Phase 2 archive work is the versioned format and hostile-input contract. The product importer/exporter, download/reveal UI, and workflow deletion/GC experience remain in Phase 5 exactly as scheduled by the main plan. Phase 3 owns run journals; no provider execution is enabled in this phase. + +## Device-local layout and limits + +The main process owns `/create-images`: + +```text +create-images/ + index.json + asset-index.json + workflows// + workflow.json + workflow.last-known-good.json + autosave.journal # present only while pending/recoverable + assets/sha256//. + thumbnails//.png + asset-quarantine/ + quarantine/ # workflow recovery evidence + deleted-workflows/ # separately bounded recoverable deletes +``` + +Workflow documents contain metadata and opaque asset IDs only—never base64 image data or absolute paths. Schema, renderer IPC, manifest persistence, and native archive workflow entries share an 8 MiB document ceiling. Preview grants default to 60 seconds, the registry is capped at 4,096 live grants, and visible preview acquisition is capped at four concurrent requests. + +## Large-image decoder gate + +`scripts/create-images-native-image.test.mjs` exercises the packaged decoder boundary with a deterministic static 4,000 × 4,000 PNG larger than 20 MiB. The final frozen-source run decoded and thumbnailed it in 2,103.30 ms while main-process private-memory growth was 81,312 KiB, below the 96 MiB gate. The test also verifies the sandboxed renderer boundary and fails on structured/legacy Electron console errors or CSP violations. + +## Signed packaged-app acceptance + +`npm run test:create-images:packaged` launches the signed development `.app` from ASAR with the real preload, CSP, installed production request policy, feature gate, workflow store, asset store, decoder utility, and custom protocol. The harness observes the installed request policy; it never replaces or clears the production `webRequest` listener. + +Final receipt: + +- 100-node route; graph history `100 → 101 → 102 → 101 → 102`, native edge/node Delete/Undo/Redo, keyboard connection, pointer reconnection, invalid-drop explanations, keyboard move/undo, modal focus, and responsive 1280/1000/700/390 px checks all passed; +- 39 native keyboard actions and 38 live-region mutations; +- 0 renderer errors, 0 unrelated remote requests, and 0 unrelated Aiden product-file mutations; +- hostile main-renderer egress probe: 1 request observed, 1 blocked by the installed production policy; +- asset protocol: 1 live grant, 2 image requests, 2 real authorizations, exact live main-frame evidence; +- durable prompt edit published as workflow revision 2, survived renderer reload, and kept graph JSON path-free and base64-free; +- exact 9-file Phase 2 mutation set: current workflow, identical last-known-good workflow, workflow index, asset index, three content-addressed protected asset-index predecessors, one content-addressed source image, and one 512 px thumbnail; +- source asset: 21,033,819 bytes, 4,000 × 4,000, SHA-256/asset ID `6ebf7cf212ab0f1c7a6c48f8599796527a3d3c09d45e39c18c17f51b62db39d3`; +- `sandbox: true`, `contextIsolation: true`, `nodeIntegration: false`; +- 14,006.821291 ms duration and clean bounded exit. + +The durable attestation is `build/create-images-packaged-acceptance/attestation.json` and binds the receipt to: + +- source HEAD `c3d644485e543579bbf478bb1e7355ba6667ce65`; +- embedded/current package-input fingerprint `808461b34da3f44839d8151b4578c61b23469f6d5c23fe8285f5713e50aabe25`; +- ASAR SHA-256 `6381631c33556e6f82e7283105bfd8c0235bd0f08e6acbacc54edf43c5c9a798`; +- code-signature CDHash `6dda105ac132c810a3da1e526c4815e3e64bc675`; +- bundle identifier/version `com.sambitcreate.aiden-agent` / `0.28.0`. + +The package fingerprint is captured before compilation, verified after compilation, embedded in ASAR, and compared before and after acceptance. It truthfully binds repository package inputs to this development artifact. A clean isolated install, installed dependency/toolchain closure, SBOM, notarized `npm run dist`, and update-from-prior-release evidence remain Phase 5 release gates; this Phase 2 document does not claim them. + +## Canvas and storage verification + +- `npm run test:create-images`: **150/150** (7 pretests, 131 TypeScript cases, 12 Node/script cases). +- `npm run type-check`: pass. +- `npm run lint`: pass. +- `git diff --check`: pass. +- `npm run build`: pass with schema/fixtures/feature CSS behind the lazy route and acceptance automation behind a separate main-process dynamic chunk. +- `npm run package`, `npm run package:verify`, and post-package fingerprint verification: pass. +- `npm run test:create-images:packaged`: pass against the exact signed artifact above. +- `npm run test:create-images:canvas-product`: pass. The 100-node fixture rendered in 99.20 ms with 1.245 ms average viewport operations, 2/100 DOM nodes, 0 long tasks, and 10,496,724 B JS heap. The 250-node fixture rendered in 116.20 ms with 1.2475 ms average viewport operations, 2/250 DOM nodes, one 55 ms long task, and 36,482,499 B JS heap. Heap growth was 25,985,775 B; both measured eight visible nodes with zero overlaps and a bounded scrollable 32K prompt editor. +- `npm run test:create-images:canvas-spike`: pass for 100/250 nodes; heap growth was 14,432,364 B and selection cadence stayed frame-bounded. +- Scoped React Doctor found no new Create Images-specific correctness diagnostic; its changed-scope detection fell back to the repository scan because the feature files are untracked in the current worktree. + +## Review repairs already incorporated + +The first two Phase 2 fresh-context reviews found and the implementation repaired: hidden crash journals, an impossible Delete parser, expiring previews without renewal, protocol grants disconnected from GC leases, unsafe inventory completeness, misleading recovery actions, unqualified Image Input labels/focus loss, unbounded workflow/quarantine growth, unbounded descriptor reads, privileged codec execution, permissive renderer egress, and weak packaged file/protocol evidence. + +The two final fresh-context reviews additionally repaired aggregate storage bypasses, durable reference races, archive bootstrap/cross-contract gaps, workflow/IPC/archive ceiling drift, read-only-open canonicalization, refetch-driven CAS overwrite, close-guard ownership, directory fsync and quarantine isolation, missing-source diagnostics/re-import, preview adoption and delivery retry leaks, empty failed-create ghosts, and idempotent exact-journal restart cleanup. Both reviewers independently give unconditional Phase 2 GO after no-launch verification of the exact signed artifact, attestation, and this evidence. diff --git a/docs/plans/create-images-phase-3-evidence.md b/docs/plans/create-images-phase-3-evidence.md new file mode 100644 index 00000000..f75a948a --- /dev/null +++ b/docs/plans/create-images-phase-3-evidence.md @@ -0,0 +1,66 @@ +# Create Images Phase 3 Evidence + +Status: **GO** — durable local-mock execution and recovery gates are complete; Phase 4 may add the first explicitly connected remote provider +Date: 2026-08-11 +Feature gate: `AIDEN_CREATE_IMAGES_ENABLED=1` + +## Implemented execution surface + +- Main-owned immutable workflow snapshots and exact deterministic plans for **Run all** and **Run from here**. Scoped execution requires either the selected node only or one explicitly chosen, connected source-to-sink path; hidden fan-out and forged rejoining paths fail closed. +- A deterministic device-local mock provider is the only production executor in Phase 3. It produces bounded valid PNGs, costs `$0`, makes no network request, and supports controlled success, delay, failure, rate limit, ambiguity, crash boundaries, duplicate/out-of-order events, and late completion. +- Global run concurrency is capped at four. The local mock retry contract is one initial attempt plus at most two explicitly safe automatic retries; ambiguous or accepted remote-style submissions never auto-resubmit. +- Run confirmation names scope, exact request/output counts, retry ceiling, destination, device/network boundary, and cost. Paid/provider retry remains manual and is not authorized by the Phase 3 consent surface. +- Run/node events are identity-bound, monotonic, and transition-checked. Output asset IDs are durably published before success; duplicate asset IDs remain valid ordered output positions. +- Ambiguous submissions terminalize as `needs_attention`. A separate compare-and-swap acknowledgement records the audit decision, clears admission only after durability, and never retries, reconciles, or resubmits provider work. +- Cancellation intent is durable before abort/provider cancellation. Renderer disconnect and app quit use distinct reasons; late valid outputs stay attached to the same cancelled run without converting it to success. +- App quit is fail-closed: active runs are inspected with a bounded deadline, the user explicitly chooses **Keep Aiden Open** or **Stop Runs and Quit**, cancellation durability failures keep the app open, and abandoned quit paths reopen run admission. + +## Run journal and recovery + +The main process owns `/create-images/runs`: + +```text +runs/ + run-index.json # derived, rebuildable, never execution authority + / + run.json # immutable start checkpoint + run.last-known-good.json + run.events.jsonl # fsynced hash-chained events + run.last-known-good.events.jsonl + run.pending.json # present only across a durable mutation boundary +``` + +- The journal uses bounded JSON checkpoints plus separate hash-chained JSONL event logs, avoiding a new native database dependency while meeting the measured 1,000-run gate. +- Every append persists a compact intent before the current and last-known-good logs. The intent binds run identity, base/target revisions, all four file identities, the exact event bytes, and the target journal SHA-256. +- File authority is bound to device, inode, size, nanosecond modification time, and change time. Reads are identity-bracketed; appends use no-follow descriptors and revalidate path/descriptor identity before and after writes. +- A torn current or last-known-good append is repaired only when the checkpoint and peer log remain the exact trusted base/target, the damaged file is same-inode append growth (or an originally absent newly created log), its original prefix replays to the exact base, its suffix is a strict prefix of the pending event record, and the target digest matches. Repair uses a staged mode-0600 file, fsync, atomic rename, and parent-directory fsync. Arbitrary replacement or digest drift remains recovery-only, and provider execution stays zero. +- Startup and live admission force a bounded on-disk inventory and authoritative checkpoint/log inspection. The derived index is identity-bound, rebuilt/quarantined when safe, and cannot authorize execution, deletion, ambiguity resolution, or reference release. +- Restart reconciliation never resubmits an unknown accepted request. Prepared/ambiguous work becomes `needs_attention`; accepted local-mock jobs reconcile deterministically; lost local/queued work becomes `interrupted` without fabricated cancellation or start provenance. +- History, recovery, retention, ambiguity acknowledgement, and irrecoverable discard are path-free and compare-and-swap guarded. Retention/discard are explicit two-step operations; unresolved ambiguity and recoverable records cannot be silently retired. +- Workflow deletion shares the run-admission fence and forces a fresh bounded run audit. Any active, terminal, recovery, unsafe, newly added unassociated, or otherwise unprovable run authority blocks deletion and preserves workflow/run asset references. + +## Renderer authority and accessibility + +- Main publishes complete subscription snapshots with monotonic per-subscription sequence numbers. Full authoritative lists enter renderer state only through that sequenced subscription path. +- Start/stop/ambiguity acknowledgements are partial mutation results and use a separate reconciler that cannot add, remove, or reinterpret history/recovery state. +- A bounded causal tombstone set prevents delayed mutations from resurrecting recovered, pruned, or discarded runs. Same-run sequences never regress; terminal-to-active, active-to-terminal, retention fallback, and empty-state handoffs require authoritative evidence and update output-preview ownership atomically. +- Selected history loading, recovery, ambiguity acknowledgement, discard, and prune use mount/request generations plus exact selection, candidate, membership, and tombstone checks. Delayed or post-unmount responses cannot change cache, detail, previews, toast, or focus. Unrelated run notifications do not strand selected detail requests. +- Run controls, path chooser, confirmations, progress, node/run badges, actionable errors, terminal history, recovery, retention, ambiguity, and discard surfaces use semantic text/glyphs rather than color alone; dialogs preserve controlled focus, keyboard behavior, reduced motion, forced colors, and responsive layouts. + +## Verification + +- Last complete registered aggregate before the final renderer-only causal fixes: `npm run test:create-images` **353/353** (8 pretests, 331 functional tests, 2 performance tests, 12 Node/script checks). +- Final changed-scope renderer gate after all causal/lifecycle fixes: **47/47**; the exact sequence-advance acknowledgement regression is included. +- `npm run type-check`: pass after the final fix. +- Full and scoped ESLint, `oxfmt --check`, and `git diff --check`: pass. +- `npm run build`: pass; Create Images remains behind its lazy route at 335.87 kB JS / 100.11 kB gzip and 44.60 kB CSS / 6.93 kB gzip, with acceptance code in a separate main-process chunk. +- 500-node successful journal: 1,502 events, 635,757-byte current log, 119.34 s append, 183 ms cold replay (all under enforced gates). +- 1,000 output-rich terminal journals × 250 asset IDs: 4.58 s restart, 4.94 s authoritative admission audit, 22.24 s full modeled product path, 81 ms retention lookup, 355,073-byte derived index; caches remained bounded at 32 journals / 1,123,456 bytes and 128 tails / 57,472 bytes. +- Product Electron canvas: 100/250 nodes passed with 2 mounted DOM nodes, zero visible overlaps, bounded scrollable long prompts, edit/announcement checks, and 15,750,875-byte heap growth. The independent spike also passed with 14,316,472-byte heap growth. +- React Doctor completed after each final React wiring change. Because the Create Images tree is untracked in this worktree, changed-scope detection fell back to the repository-wide baseline; no new high-confidence diagnostic was tied to the Phase 3 wiring. + +No package, signing, notarization, packaged acceptance, network request, real provider call, or paid work was performed for Phase 3. Phase 2's signed artifact remains its own frozen evidence. Phase 4 owns the explicit user-supplied Gemini connection and real-provider opt-in acceptance; Phase 5 owns the final signed/notarized distribution and migration gates. + +## Review outcome + +Fresh read-only correctness and reliability rounds repeatedly exercised crash boundaries, same-process authority changes, cancellation, ambiguity, retention/discard/delete, subscriptions, and renderer async ordering. Every actionable finding was repaired and regression-covered. The final user-directed wrap accepted the frozen Phase 3 source after the reliability lane returned unconditional GO and the last correctness finding (same-run acknowledgement notification arriving before its reply) was fixed with a 47/47 focused gate and full static verification. diff --git a/docs/plans/create-images-phase-4-evidence.md b/docs/plans/create-images-phase-4-evidence.md new file mode 100644 index 00000000..1c4f6e31 --- /dev/null +++ b/docs/plans/create-images-phase-4-evidence.md @@ -0,0 +1,48 @@ +# Create Images Phase 4 Evidence + +Status: **IMPLEMENTATION COMPLETE; LIVE ACCEPTANCE IN PROGRESS** — the Gemini vertical slice is source- and mock-verified; authorized live attempts exposed and repaired request- and response-compatibility defects, and one clean live rerun remains +Date: 2026-08-11; updated 2026-08-20 +Feature gate: `AIDEN_CREATE_IMAGES_ENABLED=1` + +## Shipped source surface + +- Create Images reads a main-owned Gemini image-provider status derived only from the exact stored Google API-key credential kind and a release-curated capability catalog. The renderer never receives the credential, credential record, provider endpoint, or arbitrary headers. +- Generate Image nodes expose curated Gemini model, aspect-ratio, image-size, MIME, reference-image, and output-count choices. Capability drift and unsupported combinations fail closed before review or transport. +- Cloud execution is a distinct, explicit mode. Main rebuilds the immutable scoped plan, resolves exact prompt/reference/request/output accounting, and returns a one-shot renderer review plan. The renderer echoes only an HMAC-bound authorization ID, consent fingerprint, opaque token, and `reviewed: true`. +- Start revalidates workflow revision, scope, capability fingerprint, credential record/revision, accounting, expiry, and the one-shot consent. Tokens are consumed before durable publication and cannot be replayed. +- The durable run authorization records only safe provider/model/capability/credential revision/accounting fingerprints. It never stores the consent token, API key, prompt text, endpoint, remote URL, raw response, or filesystem path. +- Every Gemini attempt durably publishes `submission-prepared` before re-resolving main-owned credentials or entering the adapter. Credential drift is confirmed-not-sent. Any post-send transport loss, timeout, or abort becomes explicit ambiguity and is never automatically retried. +- The adapter uses one fixed Google Interactions endpoint and `x-goog-api-key`, rejects redirects and unexpected/private response URLs, bounds request/reference/response/output bytes, validates static image bytes and declared media, normalizes safe failures, and cancels stalled body readers. +- Paid-provider accounting is one reviewed request per initial planned request with no automatic Gemini retry. Provider concurrency/rate leases are bounded. Cost remains truthfully unavailable when the provider does not report it. +- Valid outputs enter the existing content-addressed asset store before journal success, retain safe Gemini/model/dimension/usage metadata, remain run-authorized across restart, and use the existing opaque preview-grant path. +- Renderer confirmation states the exact scope, request/output/reference counts, transfer and rights consequences, unknown provider cost, advisory cancellation, and duplicate-submission risk. Local mock remains a separate `$0`, no-network option. +- Provider transport diagnostics stay in the main-process development log as bounded structured metadata. The renderer presents concise human status, next steps, and actions rather than request codes, attempt counters, provider bodies, prompts, paths, or credentials. + +## Mocked and static verification + +- The pinned endpoint, stateless `store: false`/`background: false` request, inline image delivery, response-format fields, curated Nano Banana model IDs, aspect ratios, sizes, and 14-reference ceiling were checked against Google's current [Interactions API reference](https://ai.google.dev/api/interactions-api) and [Gemini image-generation guide](https://ai.google.dev/gemini-api/docs/image-generation) on 2026-08-11. +- `npm run test:create-images`: pass — 8 pretests, 440 functional assertions, 2 durability/performance tests, and 15 Node/script checks. +- Gemini-focused coverage includes one-shot consent/replay rejection, durable provider authorization, reference-byte accounting, credential drift with zero transport calls, malformed/oversized response rejection, redirect/origin enforcement, timeout and abort ambiguity, provider error normalization, capability drift, rate/concurrency bounds, and secret/path-free persistence. +- 500-node successful journal: 1,502 events, 635,757-byte current log, 106.39 s append, 186 ms replay. +- 1,000 output-rich terminal journals × 250 asset IDs: 4.60 s restart, 4.41 s authoritative admission audit, 20.61 s modeled product path, 70 ms retention lookup, 355,073-byte derived index, bounded caches. +- `npm run type-check`: pass. +- `npm run lint`: pass. +- `git diff --check`: pass. +- `npm run build`: pass. Create Images remains lazy at 361.11 kB JS / 106.23 kB gzip and 48.82 kB CSS / 7.47 kB gzip; the lazy-boundary verifier passed. +- Provider UI React Doctor checks were run during implementation. Untracked Create Images files forced a repository-wide baseline scan; no new provider-UI-specific high-confidence diagnostic remained. +- The 2026-08-20 compatibility repair passed the 440-assertion Create Images suite, both performance tests, 15 script checks, full type-check, full lint, diff-check, React Doctor review, and `npm run build`. PNG requests omit `response_format.mime_type`, matching Google's default-format examples. Response parsing now mirrors Google's `interaction.output_image` contract by selecting the last generated image block, because Gemini 3 may expose interim thought images in the response timeline. A missing final MIME is inferred only from fully validated PNG/JPEG bytes, and a declared MIME must still match those bytes. + +## Live acceptance progress and remaining exit gate + +The Phase 4 plan explicitly requires manual opt-in acceptance with a real user-supplied Gemini key. On 2026-08-20, the user authorized and launched device-local reference-image runs. The first attempts were rejected before output with a safely normalized `request_rejected` result; Aiden had explicitly sent `image/png` in a response-format field that did not work on the live REST path. After that repair, Google accepted and completed a `gemini-3.1-flash-image` request in about eight seconds, but Aiden's adapter rejected the successful response as `output-invalid` before asset publication. The old parser incorrectly required exactly one image across the complete model-output timeline and an exact requested MIME. Google's current guide specifies that Gemini 3 can expose interim thought images and that `interaction.output_image` returns the last generated image block. The repaired adapter follows that final-image rule and validates the actual bounded PNG/JPEG bytes. Safe structural diagnostics now go to the main-process development log; no raw Google body, credential, prompt, image bytes, or native path is logged or shown in the renderer. + +This evidence still does **not** claim a full Phase 4 GO. The repaired request has not yet completed a clean live acceptance. + +The remaining acceptance is exactly: + +1. the user explicitly connects their Google API key and chooses Gemini mode; +2. review and approve one text-to-image run and one device-local reference-image run; +3. verify the real response contract, durable outputs/metadata, history/restart behavior, provider-visible request accounting, cancellation copy, and absence of secrets in renderer IPC and durable records; +4. record the account-visible model/catalog result and any reported usage without storing the key or prompt content in evidence. + +User-authorized live requests reached both request-validation and completed-response boundaries, but no output has yet completed Aiden's durable asset-publication path. No additional paid request was triggered by the repair work. No successful paid output, packaging, signing, notarization, or packaged acceptance was performed for this Phase 4 source gate. Final release evidence must keep the repaired real-provider acceptance open until a clean user-authorized rerun completes. diff --git a/docs/plans/create-images-phase-5-evidence.md b/docs/plans/create-images-phase-5-evidence.md new file mode 100644 index 00000000..f6eb841f --- /dev/null +++ b/docs/plans/create-images-phase-5-evidence.md @@ -0,0 +1,76 @@ +# Create Images Phase 5 Evidence + +Status: **IMPLEMENTATION COMPLETE; POST-ACCEPTANCE SOURCE POLISH VERIFIED; FRESH PACKAGE AND DISTRIBUTION GATES PENDING** — the MVP completion surface passed source, product-canvas, signed development-package, and isolated packaged acceptance. The later image-import/preview polish below changes package inputs, so that signed development artifact is retained as historical evidence and is no longer an attestation of the current source. A fresh signed package, notarized distribution, update-from-prior-release, and migration acceptance have not been run. +Date: 2026-08-19 +Feature gate: `AIDEN_CREATE_IMAGES_ENABLED=1` + +## Shipped MVP completion surface + +- Four device-local starter choices ship: blank, starter, reference-led edit, and variant set. Every template creates fresh workflow/node/edge IDs and passes the strict graph schema. +- Native `.aiden-images` export is main-owned and revision-bound. It stages verified referenced assets under a private directory, writes a versioned stored ZIP to a native-dialog destination, fsyncs before atomic publication, and never returns a path to the renderer. +- Native import treats the archive as hostile. It performs a manifest-independent central-directory bootstrap, rejects unsafe paths/kinds, duplicate names, encryption, unsupported compression, entry/count/size/compression bombs, CRC/SHA/declared-size mismatches, and validates the exact workflow/asset/media/dimension relationship before publishing. Import never connects a provider, executes a workflow, or fetches a URL. +- The Node Banana v1 JSON compatibility importer is a clean Aiden-native conversion, not vendored Node Banana code. It maps only the supported image subset, regenerates all identities, validates a bounded acyclic graph, externalizes safe inline static images through Aiden's sandboxed image pipeline, strips credentials/settings/paths/runtime output, and presents an explicit per-node rewrite/skip report before navigation. +- Retained run outputs can be saved through a native dialog only after both run-journal and reference-authority checks. Asset bytes remain path-free over IPC and are reverified immediately before export. +- Asset cleanup is a two-step, main-owned plan/apply flow with a seven-day grace period. The confirmation exposes only verified counts/bytes; apply rechecks workflow/run/export/preview references and rejects stale plans. +- Workflow deletion remains admission-fenced and refuses deletion while any active, retained, ambiguous, recovery, unsafe, or unassociated run authority exists. +- The workflow library includes accessible first-run template/import actions. Narrow canvas layouts retain the actionable validation-issues trigger; reduced-motion, forced-colors, keyboard, focus-restoration, and non-spatial controls remain covered. +- Create Images is advertised in the final onboarding bento gallery with its own optimized 1024 × 1024 transparent PNG. +- Aiden has no approved product telemetry pipeline for this surface, so Phase 5 ships without feature telemetry rather than introducing a new content or operational reporting path. + +## Post-acceptance image import and canvas polish + +- First entry now stops at an accessible, path-free setup surface until the user chooses an image workspace through Electron's main-owned native directory picker. Aiden keeps workflow/run manifests and canonical content-addressed assets protected internally, then publishes non-overwriting Finder-visible mirrors under `Imports/` and `Generated/`. The root is marker- and filesystem-identity-bound, symlinks and replacement roots fail closed, ordinary status IPC never exposes an absolute path, and imports/provider runs are disabled until the configured root is writable. +- The workflow library exposes explicit **Open in Finder**, **Sync images**, and **Change folder** actions with retry/unavailable states. Asset publication notifies the mirror only after the canonical CAS commit and outside its mutation lock; a mirror failure never weakens canonical durability, but subsequent preflight blocks new runs until the folder is reconnected. +- Clipboard image paste now matches Node Banana's useful canvas interaction without copying its renderer/base64 storage model. Pasting outside editable controls replaces a selected Image Input or creates a centered, collision-aware Image Input node. Electron main reads and bounds the clipboard image, emits a canonical PNG into the same hostile-image ingest path, and returns only opaque asset metadata. +- The populated Image Input is now an image-first compact canvas node: the imported image is full-bleed but `object-fit: contain`, its natural aspect is bounded, and Replace/Remove controls appear on hover or keyboard focus. It keeps Aiden's typed React Flow port and opaque main-owned asset identity without retaining Node Banana's surrounding generic card or renderer/base64 storage model. +- Create Images canvas surfaces, nodes, edges, selection rings, overlays, empty states, and control panels use Aiden semantic appearance tokens. Live isolated Electron QA covered both Aiden Light and Aiden Dark with a 1800 × 1800 WebP and a 2548 × 3300 TIFF; both rendered as compact image-only references and remained completely visible in either theme. +- Native canvas drop and chooser import accept bounded static raster inputs even when macOS supplies an empty or generic MIME type. Canonical PNG/JPEG remain byte-exact. Static WebP, AVIF, BMP, ICO, TIFF/HEIF-family inputs, and single-frame GIF normalize to a canonical PNG when they are supported by either Electron's disposable sandboxed decoder or the bounded macOS ImageIO fallback. The fallback copies bounded bytes into a private temporary directory, invokes fixed `/usr/bin/sips` arguments without a shell, enforces a 20-second timeout and output limit, then fully revalidates PNG dimensions and pixels before ingest. Animated GIF/WebP/HEIF, SVG/vector, malformed, oversized, over-dimension, and magic/extension-mismatched inputs still fail closed. +- Preview delivery remains opaque and main-authorized. A missing/unsupported thumbnail may fall back to a freshly validated canonical source under a short internal lease; grant requests have a bounded timeout/retry path, late tokens are revoked, and image delivery only resets backoff after the exact `` reports success. +- Preview-manager disposal is deferred by one task and cancelled when React development Strict Mode replays effect cleanup/setup. This fixes the live-only state where import succeeded durably but the reused manager stayed permanently disposed and the node remained on “Loading preview…”. Actual unmount still disposes and revokes. +- Generated images, retained Output images, and imported references now open in one full-screen inspector modeled on Node Banana's direct click-to-expand behavior but completed to Aiden's interaction and security standards. The Radix surface starts at a useful fitted size and adds bounded 5%–800% zoom, pointer-anchored wheel zoom, drag/arrow-key pan, Fit/1:1 controls, Escape dismissal, reduced motion, forced colors, and exact trigger-focus restoration. Canvas cards continue loading bounded 512px thumbnails; the inspector asks for the fully validated source through an exact `/original` rendition of the same opaque, expiring, document-bound asset grant. Live isolated Electron QA confirmed the retained Gemini result had a 1024 × 1024 natural source, not the 512px thumbnail. + +## Dependency and provenance evidence + +- `yauzl@3.4.0` and `yazl@3.3.1` are exact runtime pins; `@types/yauzl@3.4.0` and `@types/yazl@3.3.1` are exact development pins. +- `THIRD_PARTY_NOTICES.md` contains the complete direct ZIP runtime closure (`yauzl`, `yazl`, `pend`, and `buffer-crc32`), and the registered notice test verifies exact versions, licenses, and notice text. +- The selected yauzl release is newer than the `3.2.1` fix for [CVE-2026-31988](https://github.com/advisories/GHSA-2c72-c9vx-76g4). Both libraries use streaming ZIP APIs and ZIP64 support documented by their upstream projects: [yauzl](https://github.com/thejoshwolfe/yauzl) and [yazl](https://github.com/thejoshwolfe/yazl). +- The Node Banana implementation remains a clean compatibility layer based on the plan's pinned MIT research reference. No upstream branding, assets, prompts, application source, or renderer/base64 storage architecture is included. + +## Source and product verification + +- `npm run test:create-images`: pass after the workspace/clipboard/import/lightbox polish — 9 pretests, 443 functional assertions, 2 durability/performance tests, and 15 Node/script checks. +- Phase 5 coverage includes native round-trip, duplicate-manifest and invalid-ZIP refusal before publication, exact private asset export, four template graphs, supported/unsupported Node Banana conversion and real inline-image externalization, path/credential/base64 stripping, two-step cleanup IPC, retained-output authorization, and exact notice closure. +- 500-node successful journal: 1,502 durable events, 635,757-byte current log, 111.87 s append, 196 ms cold replay. +- 1,000 output-rich terminal journals × 250 asset IDs: 4.83 s restart, 4.68 s authoritative admission audit, 21.63 s modeled product path, 78 ms retention lookup, 355,073-byte derived index, bounded caches. +- `npm run test:onboarding`: pass — 14/14, including the 23-tile gallery and one-megapixel alpha-PNG contract. The Create Images tile was regenerated against the existing `aiden-assistant.png` and `attachments-vision.png` illustrations, then verified as a 1024 × 1024 RGBA PNG with genuine transparency. +- `npm run test:create-images:canvas-product`: pass after updating the stress-row spacing for the full capability-driven Generate Image card. Both 100- and 250-node cases reported zero visible overlaps, exact 1000 × 650 hosts, visible-node culling, bounded prompt editors, edit/announcement correctness, and 8,674,258-byte heap growth. Average viewport operations were 1.245 ms and 1.248 ms. +- `npm run type-check`: pass. +- `npm run lint`: pass. +- `git diff --check`: pass. +- `npm run build`: pass after the workspace/clipboard/import/lightbox polish. The lazy Create Images route is 399.38 kB JS / 116.48 kB gzip and 62.77 kB CSS / 9.29 kB gzip; the lazy-boundary verifier passed. Electron's main build keeps the macOS converter in a separate on-demand chunk, so ordinary PNG/JPEG and browser-decodable imports do not load it. +- React Doctor was run after the React work. Its current changed-branch scan covered 154 files (63/100, 127 broad existing diagnostics); it reported no lightbox-specific component, accessibility, or security diagnostic. + +## Development-signed packaged acceptance + +The acceptance was refreshed on 2026-08-21 after the Node Banana follow-on implementation and is bound to the exact current package inputs. Project evidence files are excluded from the source fingerprint, so recording these results does not stale the artifact. + +- `npm run package`, `npm run package:verify`, and `npm run package:fingerprint:verify`: pass. +- Exact source fingerprint: `eee98a151e1836eb7ca6158bccdf54aff5e9ed36c89ddc410d5c44cbb6252d44`. +- App identity: bundle `com.sambitcreate.aiden-agent`, version `0.28.0`, Developer ID signature CDHash `a9fc3b6a6259137ee2200ce274769b5b26d9dbf5`. +- ASAR SHA-256: `76ef235d071c6a63e848d7da16ede469ddc616d4e0c6896d2f1e300b0594479f`. +- `npm run test:create-images:packaged`: pass in an isolated private profile in 14,440.26425 ms. +- The packaged receipt records 39 keyboard actions, 38 live-region mutations, narrow validation/add placement, reduced motion, focus restoration, spatial/keyboard connection editing, durable reload, one blocked egress probe, 0 remote requests, 0 renderer errors, sandboxing, context isolation, and `nodeIntegration: false`. +- Asset delivery recorded one opaque grant, two image requests, and two exact authorizations from a live main frame. +- Acceptance configures a private disposable external image workspace through the real main-owned picker result path before service initialization. Durable evidence is an exact 12-file set: workspace record and validated predecessor, current/LKG workflow, workflow index, unchanged empty run index, asset index, three protected asset-index predecessors, one 21,033,819-byte 4000 × 4000 content-addressed PNG, and its 512px thumbnail. No autosave journal, run journal, quarantine file, unrelated asset, or arbitrary product mutation was present. +- Durable attestation: `build/create-images-packaged-acceptance/attestation.json` (mode and identity are revalidated by the acceptance script). + +## Deliberately open release gates + +This evidence is sufficient to continue implementation work, but it is not a Phase 5 distribution GO. The following plan gates remain explicit: + +1. run `npm run dist` for the final release candidate and verify its notarized distribution identity/artifacts; +2. install over the prior supported Aiden release and verify the updater path, preserved device-local workflows/runs/assets, and no automatic provider execution; +3. run clean-install plus populated-storage migration/recovery acceptance on the final distribution; +4. complete the separately tracked Phase 4 opt-in real-key Gemini text-to-image and reference-image acceptance only after the user explicitly authorizes potentially billable provider requests. + +No live Gemini request, provider charge, notarization, updater publication, or production installation was triggered by this evidence run. diff --git a/eslint.config.js b/eslint.config.js index 9747d86d..f50d0bd3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import globals from "globals"; export default [ { - ignores: ["build/**", "release/**", "node_modules/**", ".memory/**", ".papercuts/**"], + ignores: ["build/**", "release/**", "node_modules/**", "tmp/**", ".memory/**", ".papercuts/**"], }, js.configs.recommended, { @@ -18,8 +18,11 @@ export default [ plugins: { "@typescript-eslint": tsPlugin }, rules: { "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], - "no-undef": "off" + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "no-undef": "off", }, }, ]; diff --git a/main-window.html b/main-window.html index ae27712c..e1a98247 100644 --- a/main-window.html +++ b/main-window.html @@ -5,7 +5,7 @@ Aiden Agent diff --git a/main/handlers/app.ts b/main/handlers/app.ts index 73d0d61f..a0b2fda6 100644 --- a/main/handlers/app.ts +++ b/main/handlers/app.ts @@ -19,6 +19,7 @@ import { app, logger } from "../platform.js"; import { currentRuntimeProfile } from "../runtime-profile.js"; import { subagentsEnabled } from "../services/subagents/feature-flag.js"; +import { createImagesEnabled } from "../services/create-images/feature-flag.js"; // App handlers - these are the methods your app provides to the frontend export const appHandlers = { @@ -31,6 +32,7 @@ export const appHandlers = { environment: currentRuntimeProfile().id, capabilities: { subagents: subagentsEnabled(), + createImages: createImagesEnabled(), }, }; }, diff --git a/main/handlers/create-images.ts b/main/handlers/create-images.ts new file mode 100644 index 00000000..c5792e55 --- /dev/null +++ b/main/handlers/create-images.ts @@ -0,0 +1,2172 @@ +import { randomUUID } from "node:crypto"; +import * as path from "node:path"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "../platform.js"; +import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; +import { createImagesEnabled } from "../services/create-images/feature-flag.js"; +import { createImagesService } from "../services/create-images/create-images-service.js"; +import { + CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID, + createImagesGeminiProviderStatus, +} from "../services/create-images/gemini-provider-status-core.js"; +import { CreateImagesMutationRateLimiter } from "../services/create-images/mutation-rate-limit-core.js"; +import { shouldReleaseCreateImagesRunOwner } from "../services/create-images/run-publication-binding-core.js"; +import { providerRegistry } from "../services/provider-registry.js"; +import { + WorkflowManifestLoadError, + WorkflowRevisionConflictError, + type WorkflowRecoveryHealth, +} from "../services/create-images/workflow-manifest-store.js"; +import { + DEFAULT_ASSET_STORE_LIMITS, + AssetStoreError, + type AssetMetadataDto, +} from "../services/create-images/asset-store-core.js"; +import { AssetImageValidationError } from "../services/create-images/asset-image-validation-core.js"; +import { + CreateImagesImageImportError, + ingestCreateImagesImageFile, +} from "../services/create-images/electron-asset-import.js"; +import { CreateImagesNativeArchiveError } from "../services/create-images/native-archive-service.js"; +import { CreateImagesNodeBananaServiceError } from "../services/create-images/node-banana-import-service.js"; +import { writeCreateImagesOutputZip } from "../services/create-images/native-output-zip.js"; +import type { CreateImagesWorkspaceStatus as CreateImagesWorkspaceStoreStatus } from "../services/create-images/workspace-store.js"; +import { + createImagesAssetGrantUrl, + parseCreateImagesCreateWorkflowRequest, + parseCreateImagesApplyAssetCleanupRequest, + parseCreateImagesDeleteWorkflowRequest, + parseCreateImagesDiscardDegradedRunRequest, + parseCreateImagesDiscardAutosaveRequest, + parseCreateImagesDownloadWorkflowAssetRequest, + parseCreateImagesDownloadRunAssetRequest, + parseCreateImagesDownloadRunAssetsZipRequest, + parseCreateImagesDroppedAssetImportRequest, + parseCreateImagesDuplicateWorkflowRequest, + parseCreateImagesExportArchiveRequest, + parseCreateImagesGetWorkflowRequest, + parseCreateImagesImportArchiveRequest, + parseCreateImagesImportNodeBananaRequest, + parseCreateImagesGrantAssetRequest, + parseCreateImagesGrantRunAssetRequest, + parseCreateImagesGetRunRequest, + parseCreateImagesListRunsRequest, + parseCreateImagesListRecentOutputsRequest, + parseCreateImagesGetPresentationRequest, + parseCreateImagesSetAssetHiddenRequest, + parseCreateImagesPlanRunHistoryPruneRequest, + parseCreateImagesPlanAssetCleanupRequest, + parseCreateImagesPasteImageRequest, + parseCreateImagesPrepareRunRequest, + parseCreateImagesProposeWorkflowRequest, + parseCreateImagesPickAssetRequest, + parseCreateImagesPlanDegradedRunDiscardRequest, + parseCreateImagesPruneRunHistoryRequest, + parseCreateImagesRecoverWorkflowRequest, + parseCreateImagesRecoverRunRequest, + parseCreateImagesResolveRunAmbiguityRequest, + parseCreateImagesRenameWorkflowRequest, + parseCreateImagesRepairWorkflowRequest, + parseCreateImagesRevokeAssetGrantRequest, + parseCreateImagesSaveWorkflowRequest, + parseCreateImagesStartRunRequest, + parseCreateImagesStopRunRequest, + parseCreateImagesResumeRunRequest, + parseCreateImagesSubscribeRunsRequest, + parseCreateImagesUnsubscribeRunsRequest, + parseCreateImagesWorkspaceRequest, + type CreateImagesAssetGrantView, + type CreateImagesAssetCleanupPlanResult, + type CreateImagesAssetCleanupResult, + type CreateImagesAssetView, + type CreateImagesDroppedAssetImportItem, + type CreateImagesDroppedAssetImportResult, + type CreateImagesDownloadRunAssetResult, + type CreateImagesExportArchiveResult, + type CreateImagesImportArchiveResult, + type CreateImagesImportNodeBananaResult, + type CreateImagesRecentOutputListResult, + type CreateImagesPresentationResult, + type CreateImagesRunChangedNotification, + type CreateImagesRunMutationResult, + type CreateImagesPrepareRunResult, + type CreateImagesProposeWorkflowResult, + type CreateImagesPasteImageResult, + type CreateImagesWorkflowMutationResult, + type CreateImagesWorkflowRecoveryView, + type CreateImagesWorkspaceStatus, + type CreateImagesChooseWorkspaceResult, + type CreateImagesOpenWorkspaceResult, + type CreateImagesSyncWorkspaceResult, +} from "../../renderer/shared/create-images/ipc.js"; +import { createImagesWorkflowFromTemplate } from "../../renderer/shared/create-images/templates.js"; +import { + CREATE_IMAGES_GEMINI_PROVIDER_ID, + CREATE_IMAGES_PROVIDER_STATUS_VERSION, + type CreateImagesProviderStatus, +} from "../../renderer/shared/create-images/providers.js"; + +const CREATE_IMAGES_ASSET_CLEANUP_GRACE_MS = 7 * 24 * 60 * 60_000; + +function recoveryView(health: WorkflowRecoveryHealth): CreateImagesWorkflowRecoveryView { + if (health.status === "missing") return { status: "missing", workflowId: health.workflowId }; + const { currentPath: _path, ...safe } = health; + return safe; +} + +function assetView(asset: AssetMetadataDto): CreateImagesAssetView { + return { + assetId: asset.assetId, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + width: asset.width, + height: asset.height, + importedAt: asset.createdAt, + ...(asset.displayName ? { originalName: asset.displayName } : {}), + }; +} + +function archiveBaseName(title: string): string { + const safe = [...title.normalize("NFKC")] + .map((character) => + character.charCodeAt(0) <= 0x1f || '\\/:*?"<>|'.includes(character) ? "-" : character, + ) + .join("") + .replace(/\s+/gu, " ") + .trim() + .slice(0, 80); + return safe || "Image workflow"; +} + +function archiveFailureMessage(error: unknown): string { + if (!(error instanceof CreateImagesNativeArchiveError)) { + return "The native workflow archive operation failed safely."; + } + if (error.code === "archive_invalid") { + return "This .aiden-images file is invalid, unsafe, or unsupported."; + } + if (error.code === "archive_revision_conflict") { + return "The workflow changed before the archive was written. Try exporting again."; + } + if (error.code === "archive_workflow_missing") return "The workflow no longer exists."; + return "Aiden could not read or write the native workflow archive."; +} + +function nodeBananaImportFailureMessage(error: unknown): string { + if (error instanceof CreateImagesNodeBananaServiceError && error.code === "invalid") { + return "This Node Banana JSON file is invalid, too large, or unsupported."; + } + return "Aiden could not import the Node Banana workflow safely."; +} + +async function mutationFailure(error: unknown): Promise { + const service = createImagesService(); + if (error instanceof WorkflowRevisionConflictError) { + if (error.actualRevision === null) return { status: "not-found" }; + const current = await service.workflows.get(error.workflowId).catch(() => undefined); + if (!current) return { status: "not-found" }; + return { + status: "conflict", + expectedRevision: error.expectedRevision ?? 1, + currentRevision: error.actualRevision, + current, + }; + } + if (error instanceof WorkflowManifestLoadError) { + return { + status: "unavailable", + message: + error.status === "unsafe" + ? "This workflow was created by a newer version of Aiden and is read-only." + : "This workflow needs recovery before it can be changed.", + }; + } + return { + status: "unavailable", + message: "The device-local workflow store failed safely. Try again.", + }; +} + +function assetImportFailureMessage(error: unknown): string { + if (error instanceof CreateImagesImageImportError) { + if (error.code === "animated_image") { + return "Animated images are not supported yet. Export a still frame and try again."; + } + if (error.code === "vector_image") { + return "Vector images such as SVG are not supported. Export a static raster image and try again."; + } + return "This image format could not be converted safely."; + } + if (error instanceof AssetImageValidationError) { + if (error.code === "image_dimensions_exceeded") { + return "The selected image exceeds the 16 megapixel import limit."; + } + return "The selected file is not a supported, valid static image."; + } + if (!(error instanceof AssetStoreError)) return "The selected image could not be imported."; + if (error.code === "asset_ingest_too_large") { + return "The selected image is larger than the 64 MB import limit."; + } + if (error.code === "asset_store_quota_exceeded") { + return "Create Images storage is full. Remove unused assets before importing another image."; + } + if (error.code === "asset_store_repair_required") { + return "Image storage needs repair before another image can be imported."; + } + return "The selected file is not a supported, valid static image."; +} + +function reportAssetImportFailure(error: unknown): void { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code ?? "unknown") + : "unknown"; + console.warn("[create-images] Image import failed safely.", { + name: error instanceof Error ? error.name : "UnknownError", + code, + message: error instanceof Error ? error.message : "Unknown image import failure.", + }); +} + +function workspaceStatusView( + status: CreateImagesWorkspaceStoreStatus, +): CreateImagesWorkspaceStatus { + if (!status.configured || status.state === "unconfigured") return { status: "unconfigured" }; + if (status.state === "ready") { + return { + status: "ready", + displayName: status.displayName ?? "Image workspace", + importedAssetCount: status.importedCount, + generatedAssetCount: status.generatedCount, + conflictCount: status.conflictCount + status.driftedCount, + ...(status.lastSyncedAt ? { lastSyncedAt: status.lastSyncedAt } : {}), + }; + } + const reason = + status.state === "unwritable" + ? "permission-denied" + : status.state === "drifted" + ? "changed" + : "unsafe"; + const message = + status.state === "unwritable" + ? "Aiden cannot write to the selected image workspace. Check its permissions or choose another folder." + : status.state === "drifted" + ? "The selected image workspace moved, was replaced, or is no longer available." + : status.state === "repair_required" + ? "The image workspace configuration needs repair before it can be used." + : "The selected image workspace contains an unsafe or conflicting entry."; + return { + status: "unavailable", + reason, + ...(status.displayName ? { displayName: status.displayName } : {}), + message, + }; +} + +function grantView( + grant: { token: string; expiresAt: number }, + asset: AssetMetadataDto, +): CreateImagesAssetGrantView { + return { + token: grant.token, + url: createImagesAssetGrantUrl(grant.token), + expiresAt: grant.expiresAt, + asset: assetView(asset), + }; +} + +async function ingestSelectedImage( + service: ReturnType, + filePath: string, +) { + return ingestCreateImagesImageFile(service.assets, filePath); +} + +async function* clipboardImageBytes(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +async function ingestClipboardImage(service: ReturnType) { + const image = clipboard.readImage(); + if (image.isEmpty()) return undefined; + const size = image.getSize(); + const pixels = size.width * size.height; + if ( + !Number.isSafeInteger(size.width) || + !Number.isSafeInteger(size.height) || + size.width < 1 || + size.height < 1 || + size.width > DEFAULT_ASSET_STORE_LIMITS.maxWidth || + size.height > DEFAULT_ASSET_STORE_LIMITS.maxHeight || + !Number.isSafeInteger(pixels) || + pixels > DEFAULT_ASSET_STORE_LIMITS.maxPixels + ) { + throw new AssetImageValidationError( + "image_dimensions_exceeded", + "The clipboard image dimensions exceed Aiden's configured safety limit.", + ); + } + const bytes = image.toPNG(); + if (bytes.byteLength < 1) { + throw new AssetStoreError("invalid_asset_request", "The clipboard image is empty."); + } + if (bytes.byteLength > DEFAULT_ASSET_STORE_LIMITS.maxImportBytes) { + throw new AssetStoreError( + "asset_ingest_too_large", + `The clipboard image exceeds the ${DEFAULT_ASSET_STORE_LIMITS.maxImportBytes}-byte ingest limit.`, + ); + } + return service.assets.ingest(clipboardImageBytes(bytes), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "Clipboard image.png", + validationDisplayName: "clipboard.png", + }); +} + +export function registerCreateImagesHandlers(): void { + if (!createImagesEnabled()) return; + let pickerActive = false; + let clipboardPasteActive = false; + let archiveDialogActive = false; + let workspacePickerActive = false; + const mutationRateLimiter = new CreateImagesMutationRateLimiter(); + const mutationAllowed = (owner: { id: number }, cost = 1): boolean => + mutationRateLimiter.consume(`webcontents:${owner.id}`, cost); + const mutationRateFailure = (): CreateImagesWorkflowMutationResult => ({ + status: "unavailable", + message: "Too many workflow changes were requested. Wait a moment and try again.", + }); + const runRateFailure = (): CreateImagesRunMutationResult => ({ + status: "unavailable", + message: "Too many run requests were made. Wait a moment and try again.", + }); + const prepareRunRateFailure = (): CreateImagesPrepareRunResult => ({ + status: "unavailable", + message: "Too many run requests were made. Wait a moment and try again.", + }); + const runSubscriptions = new Map< + string, + { + workflowId: string; + ownerId: number; + documentId: string; + streamSequence: number; + send(payload: CreateImagesRunChangedNotification): void; + release(): void; + } + >(); + const runOwners = new Map(); + let removeRunListener: (() => void) | undefined; + const readRateLimiter = new CreateImagesMutationRateLimiter(Date.now, 120, 60_000, 64); + const readOwnerKey = (owner: { id: number }): string => `webcontents:${owner.id}:run-read`; + const readAllowed = (owner: { id: number }, cost: number): boolean => + readRateLimiter.consume(readOwnerKey(owner), cost); + const runReadRateFailure = (owner: { id: number }) => ({ + status: "unavailable" as const, + message: "Too many run history requests were made. Wait a moment and try again.", + retryAfterMs: Math.max(500, readRateLimiter.retryAfterMs(readOwnerKey(owner))), + }); + const providerStatusRateFailure = (owner: { id: number }): CreateImagesProviderStatus => ({ + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: CREATE_IMAGES_GEMINI_PROVIDER_ID, + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "rate-limited", + retryAfterMs: Math.max(500, readRateLimiter.retryAfterMs(readOwnerKey(owner))), + }); + const runPublicationStates = new Map(); + const runOperationsByOwner = new Map(); + let activeRunOperations = 0; + const acquireRunOperation = (ownerId: number): (() => void) | undefined => { + const ownerOperations = runOperationsByOwner.get(ownerId) ?? 0; + if (activeRunOperations >= 8 || ownerOperations >= 2) return undefined; + activeRunOperations += 1; + runOperationsByOwner.set(ownerId, ownerOperations + 1); + let released = false; + return () => { + if (released) return; + released = true; + activeRunOperations -= 1; + const remaining = (runOperationsByOwner.get(ownerId) ?? 1) - 1; + if (remaining > 0) runOperationsByOwner.set(ownerId, remaining); + else runOperationsByOwner.delete(ownerId); + }; + }; + const runBounded = async ( + ownerId: number, + operation: () => Promise, + ): Promise<{ status: "completed"; value: Result } | { status: "busy" }> => { + const release = acquireRunOperation(ownerId); + if (!release) return { status: "busy" }; + try { + return { status: "completed", value: await operation() }; + } finally { + release(); + } + }; + + const releaseRunOwner = (runId: string): void => { + const binding = runOwners.get(runId); + if (!binding) return; + runOwners.delete(runId); + binding.releaseInvalidation(); + }; + + const scheduleRunPublication = (workflowId: string): void => { + const service = createImagesService(); + const hasConsumer = + [...runOwners.values()].some((binding) => binding.workflowId === workflowId) || + [...runSubscriptions.values()].some((subscription) => subscription.workflowId === workflowId); + if (!hasConsumer) return; + if (!runPublicationStates.has(workflowId) && runPublicationStates.size >= 256) return; + const state = runPublicationStates.get(workflowId) ?? { + dirty: false, + running: false, + }; + state.dirty = true; + runPublicationStates.set(workflowId, state); + if (state.running) return; + state.running = true; + void (async () => { + try { + while (state.dirty) { + state.dirty = false; + let snapshot: Awaited> | undefined; + for (let attempt = 0; attempt < 3 && !snapshot; attempt += 1) { + try { + const bounded = await runBounded(-1, () => service.runs.list(workflowId)); + if (bounded.status === "completed") { + snapshot = bounded.value; + break; + } + } catch { + // A failed local snapshot follows the same bounded retry path + // as queue pressure and never creates an unbounded task. + } + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 25 * 2 ** attempt); + timeout.unref?.(); + }); + } + if (!snapshot) { + snapshot = { + status: "unavailable", + message: "Run updates are temporarily busy.", + retryAfterMs: 500, + }; + } + for (const [runId, binding] of runOwners) { + if (binding.workflowId !== workflowId) continue; + if (shouldReleaseCreateImagesRunOwner(runId, snapshot)) { + releaseRunOwner(runId); + } + } + for (const [subscriptionId, subscription] of runSubscriptions) { + if (subscription.workflowId !== workflowId) continue; + try { + subscription.streamSequence += 1; + subscription.send({ + subscriptionId, + streamSequence: subscription.streamSequence, + snapshot, + }); + } catch { + subscription.release(); + } + } + } + } catch { + // A coalesced notification retries once; any later durable run change + // also schedules a fresh complete snapshot. + } finally { + state.running = false; + if (state.dirty) queueMicrotask(() => scheduleRunPublication(workflowId)); + else if (runPublicationStates.get(workflowId) === state) { + runPublicationStates.delete(workflowId); + } + } + })(); + }; + + const ensureRunListener = (): void => { + if (removeRunListener) return; + removeRunListener = createImagesService().runs.subscribe(scheduleRunPublication); + }; + + const bindRunToOwner = ( + owner: ReturnType, + workflowId: string, + runId: string, + ): void => { + releaseRunOwner(runId); + let live = true; + let releaseInvalidation: () => void = () => undefined; + const invalidate = (): void => { + if (!live) return; + live = false; + const current = runOwners.get(runId); + if (current?.releaseInvalidation === releaseInvalidation) runOwners.delete(runId); + void createImagesService().runs.stop(workflowId, runId, "renderer-disconnected"); + }; + releaseInvalidation = owner.onInvalidated(invalidate); + if (!live || owner.isDestroyed()) { + releaseInvalidation(); + invalidate(); + return; + } + runOwners.set(runId, { workflowId, releaseInvalidation }); + ensureRunListener(); + }; + + ipcMain.handle( + "imageWorkflows:workspaceStatus", + async (event, value: unknown): Promise => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workspace request.")); + parseCreateImagesWorkspaceRequest(value); + if (!readAllowed(owner, 2)) { + return { + status: "unavailable", + reason: "sync-failed", + message: "Too many workspace requests were made. Wait a moment and try again.", + }; + } + const bounded = await runBounded(owner.id, () => createImagesService().workspace.status()); + if (bounded.status === "busy" || owner.isDestroyed()) { + return { + status: "unavailable", + reason: "sync-failed", + message: "The image workspace is busy. Wait a moment and try again.", + }; + } + return workspaceStatusView(bounded.value); + }, + ); + + ipcMain.handle( + "imageWorkflows:chooseWorkspace", + async (event, value: unknown): Promise => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workspace request.")); + parseCreateImagesWorkspaceRequest(value); + if (!mutationAllowed(owner, 2)) { + return { + status: "unavailable", + message: "Too many workspace changes were requested. Wait a moment and try again.", + }; + } + if (workspacePickerActive) { + return { status: "unavailable", message: "Another workspace picker is already open." }; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent) return { status: "unavailable", message: "The Aiden window is unavailable." }; + workspacePickerActive = true; + try { + const picked = await dialog.showOpenDialog(parent, { + title: "Choose an image workspace", + defaultPath: app.getPath("pictures"), + buttonLabel: "Use Folder", + properties: ["openDirectory", "createDirectory"], + }); + if (picked.canceled || !picked.filePaths[0]) return { status: "canceled" }; + if (owner.isDestroyed()) { + return { status: "unavailable", message: "The Aiden window was closed." }; + } + const status = await createImagesService().workspace.configureChosenDirectory( + picked.filePaths[0], + ); + const workspace = workspaceStatusView(status); + return workspace.status === "ready" + ? { status: "ready", workspace } + : { + status: "unavailable", + message: + workspace.status === "unavailable" + ? workspace.message + : "The image workspace was not configured.", + }; + } catch { + return { + status: "unavailable", + message: "Aiden could not safely configure that image workspace.", + }; + } finally { + workspacePickerActive = false; + } + }, + ); + + ipcMain.handle( + "imageWorkflows:openWorkspace", + async (event, value: unknown): Promise => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workspace request.")); + parseCreateImagesWorkspaceRequest(value); + if (!readAllowed(owner, 1)) { + return { status: "unavailable", message: "The image workspace is temporarily busy." }; + } + try { + const status = await createImagesService().workspace.status(); + if (!status.configured) return { status: "unconfigured" }; + const target = await createImagesService().workspace.openRoot(); + if (owner.isDestroyed()) { + return { status: "unavailable", message: "The Aiden window was closed." }; + } + const error = await shell.openPath(target.filePath); + return error + ? { status: "unavailable", message: "Finder could not open the image workspace." } + : { status: "opened" }; + } catch { + return { + status: "unavailable", + message: "Reconnect the image workspace before opening it in Finder.", + }; + } + }, + ); + + ipcMain.handle( + "imageWorkflows:syncWorkspace", + async (event, value: unknown): Promise => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workspace request.")); + parseCreateImagesWorkspaceRequest(value); + if (!mutationAllowed(owner, 2)) { + return { status: "unavailable", message: "The image workspace is temporarily busy." }; + } + const current = await createImagesService().workspace.status(); + if (!current.configured) return { status: "unconfigured" }; + const bounded = await runBounded(owner.id, async () => { + await createImagesService().workspace.syncAll(); + return createImagesService().workspace.status(); + }); + if (bounded.status === "busy" || owner.isDestroyed()) { + return { status: "unavailable", message: "The image workspace is temporarily busy." }; + } + const workspace = workspaceStatusView(bounded.value); + return workspace.status === "ready" + ? { status: "synced", workspace } + : { + status: "unavailable", + message: + workspace.status === "unavailable" + ? workspace.message + : "The image workspace is not configured.", + }; + }, + ); + + ipcMain.handle("imageWorkflows:providerStatus", async (event) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted provider request.")); + if (!readAllowed(owner, 2)) return providerStatusRateFailure(owner); + const bounded = await runBounded(owner.id, () => + createImagesGeminiProviderStatus({ + credentialKind: () => + providerRegistry.getBuiltinCredentialKind(CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID), + requestAuth: () => + providerRegistry.getBuiltinRequestAuth(CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID), + }), + ); + if (bounded.status === "busy" || owner.isDestroyed()) { + return providerStatusRateFailure(owner); + } + return bounded.value; + }); + + ipcMain.handle("imageWorkflows:list", async (event) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const service = createImagesService(); + try { + await service.initializeReadOnlyLibrary(); + const summaries = await service.workflows.list(); + const recoveries = [] as CreateImagesWorkflowRecoveryView[]; + for (const summary of summaries) { + if (summary.health === "healthy") continue; + recoveries.push(recoveryView(await service.workflows.inspect(summary.id))); + } + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + return { + status: "ready" as const, + workflows: summaries.map((summary) => ({ + id: summary.id, + title: summary.title, + revision: summary.revision, + createdAt: summary.createdAt, + updatedAt: summary.updatedAt, + nodeCount: summary.nodeCount, + edgeCount: summary.edgeCount, + assetCount: summary.assetCount, + missingAssetCount: service.missingAssetIdsForWorkflow(summary.id).length, + health: summary.health, + })), + recoveries, + }; + } catch { + return { + status: "unavailable" as const, + message: "Workflow storage is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:get", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const { workflowId } = parseCreateImagesGetWorkflowRequest(value); + const service = createImagesService(); + try { + await service.initialize(); + const workflow = await service.workflows.get(workflowId); + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + return workflow + ? { + status: "ready" as const, + workflow, + missingAssetIds: service.missingAssetIdsForWorkflow(workflow.id), + } + : { status: "not-found" as const }; + } catch (error) { + if (error instanceof WorkflowManifestLoadError) { + const recovery = recoveryView(await service.workflows.inspect(workflowId)); + return recovery.status === "unsafe" + ? { + status: "unsafe" as const, + recovery, + message: "This workflow belongs to an unsupported future schema.", + } + : { status: "recovery-required" as const, recovery }; + } + return { + status: "unavailable" as const, + message: "Workflow storage is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:create", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesCreateWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + const now = new Date().toISOString(); + const workflowId = randomUUID(); + const workflow = createImagesWorkflowFromTemplate({ + template: input.template, + workflowId, + now, + nextId: randomUUID, + ...(input.title ? { title: input.title } : {}), + }); + try { + const saved = await createImagesService().mutateWorkflow(workflowId, [], () => + createImagesService().workflows.create(workflow, () => !owner.isDestroyed()), + ); + return { status: "saved" as const, workflow: saved }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:importArchive", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted archive request.")); + parseCreateImagesImportArchiveRequest(value); + if (!mutationAllowed(owner, 10)) { + return { + status: "unavailable" as const, + message: "Too many workflow changes were requested. Wait a moment and try again.", + } satisfies CreateImagesImportArchiveResult; + } + if (archiveDialogActive) { + return { + status: "unavailable" as const, + message: "Another workflow archive dialog is open.", + } satisfies CreateImagesImportArchiveResult; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The workflow archive picker is unavailable.", + } satisfies CreateImagesImportArchiveResult; + } + archiveDialogActive = true; + const service = createImagesService(); + try { + await service.initialize(); + const picked = await dialog.showOpenDialog(parent, { + properties: ["openFile"], + filters: [{ name: "Aiden Image Workflow", extensions: ["aiden-images"] }], + }); + if (picked.canceled || !picked.filePaths[0]) return { status: "canceled" as const }; + if (owner.isDestroyed()) { + return { status: "unavailable" as const, message: "The workflow library was closed." }; + } + const imported = await service.archives.importFromFile( + picked.filePaths[0], + () => !owner.isDestroyed(), + ); + return { status: "imported" as const, ...imported }; + } catch (error) { + return { status: "unavailable" as const, message: archiveFailureMessage(error) }; + } finally { + archiveDialogActive = false; + } + }); + + ipcMain.handle("imageWorkflows:importNodeBanana", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted compatibility import.")); + parseCreateImagesImportNodeBananaRequest(value); + if (!mutationAllowed(owner, 10)) { + return { + status: "unavailable" as const, + message: "Too many workflow changes were requested. Wait a moment and try again.", + } satisfies CreateImagesImportNodeBananaResult; + } + if (archiveDialogActive) { + return { + status: "unavailable" as const, + message: "Another workflow import or export dialog is open.", + } satisfies CreateImagesImportNodeBananaResult; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The workflow compatibility picker is unavailable.", + } satisfies CreateImagesImportNodeBananaResult; + } + archiveDialogActive = true; + const service = createImagesService(); + try { + await service.initialize(); + const picked = await dialog.showOpenDialog(parent, { + properties: ["openFile"], + filters: [{ name: "Node Banana Workflow", extensions: ["json"] }], + }); + if (picked.canceled || !picked.filePaths[0]) return { status: "canceled" as const }; + if (owner.isDestroyed()) { + return { status: "unavailable" as const, message: "The workflow library was closed." }; + } + const imported = await service.nodeBananaImports.importFromFile( + picked.filePaths[0], + () => !owner.isDestroyed(), + ); + return { status: "imported" as const, ...imported }; + } catch (error) { + return { + status: "unavailable" as const, + message: nodeBananaImportFailureMessage(error), + }; + } finally { + archiveDialogActive = false; + } + }); + + ipcMain.handle("imageWorkflows:exportArchive", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted archive request.")); + const input = parseCreateImagesExportArchiveRequest(value); + if (!readAllowed(owner, 12)) { + return { + status: "unavailable" as const, + message: "Too many workflow reads were requested. Wait a moment and try again.", + } satisfies CreateImagesExportArchiveResult; + } + if (archiveDialogActive) { + return { + status: "unavailable" as const, + message: "Another workflow archive dialog is open.", + } satisfies CreateImagesExportArchiveResult; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The workflow archive dialog is unavailable.", + } satisfies CreateImagesExportArchiveResult; + } + archiveDialogActive = true; + const service = createImagesService(); + try { + await service.initialize(); + const workflow = await service.workflows.get(input.workflowId); + if (!workflow) return { status: "not-found" as const }; + if (workflow.revision !== input.expectedRevision) { + return { status: "conflict" as const, currentRevision: workflow.revision }; + } + const picked = await dialog.showSaveDialog(parent, { + defaultPath: `${archiveBaseName(workflow.title)}.aiden-images`, + filters: [{ name: "Aiden Image Workflow", extensions: ["aiden-images"] }], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + if (picked.canceled || !picked.filePath) return { status: "canceled" as const }; + if (owner.isDestroyed()) { + return { status: "unavailable" as const, message: "The workflow library was closed." }; + } + const exported = await service.archives.exportToFile({ + ...input, + destination: picked.filePath, + }); + shell.showItemInFolder( + picked.filePath.endsWith(".aiden-images") + ? picked.filePath + : `${picked.filePath}.aiden-images`, + ); + return { status: "exported" as const, ...exported }; + } catch (error) { + if ( + error instanceof CreateImagesNativeArchiveError && + error.code === "archive_revision_conflict" + ) { + const current = await service.workflows.get(input.workflowId).catch(() => undefined); + return { + status: "conflict" as const, + ...(current ? { currentRevision: current.revision } : {}), + }; + } + if ( + error instanceof CreateImagesNativeArchiveError && + error.code === "archive_workflow_missing" + ) { + return { status: "not-found" as const }; + } + return { status: "unavailable" as const, message: archiveFailureMessage(error) }; + } finally { + archiveDialogActive = false; + } + }); + + ipcMain.handle("imageWorkflows:save", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesSaveWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + try { + const saved = await createImagesService().mutateWorkflow( + input.workflow.id, + input.workflow.assetRefs, + () => + createImagesService().workflows.save( + input.workflow, + input.expectedRevision, + () => !owner.isDestroyed(), + ), + ); + return { status: "saved" as const, workflow: saved }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:rename", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesRenameWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + try { + const current = await createImagesService().workflows.get(input.workflowId); + if (!current) return { status: "not-found" as const }; + const saved = await createImagesService().mutateWorkflow( + input.workflowId, + current.assetRefs, + () => + createImagesService().workflows.rename( + input.workflowId, + input.title, + input.expectedRevision, + new Date().toISOString(), + () => !owner.isDestroyed(), + ), + ); + return { status: "saved" as const, workflow: saved }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:duplicate", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesDuplicateWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + const service = createImagesService(); + try { + const source = await service.workflows.get(input.workflowId); + if (!source) return { status: "not-found" as const }; + const workflowId = randomUUID(); + const saved = await service.mutateWorkflow( + workflowId, + source.assetRefs, + () => + service.workflows.duplicate( + input.workflowId, + { + workflowId, + expectedRevision: input.expectedRevision, + ...(input.title ? { title: input.title } : {}), + now: new Date().toISOString(), + }, + () => !owner.isDestroyed(), + ), + { allowMissingAssetIds: source.assetRefs }, + ); + return { status: "saved" as const, workflow: saved }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:delete", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesDeleteWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + const service = createImagesService(); + try { + return await service.deleteWorkflow( + input.workflowId, + input.expectedRevision, + () => !owner.isDestroyed(), + ); + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:recover", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesRecoverWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + const service = createImagesService(); + try { + await service.initialize(); + const workflow = await service.workflows.recover( + input.workflowId, + input.source, + input.expectedCandidateRevision, + new Date().toISOString(), + () => !owner.isDestroyed(), + ); + await service.refreshReferenceAuthority(); + return { status: "saved" as const, workflow }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:repairRecoveryMetadata", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesRepairWorkflowRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + const service = createImagesService(); + try { + await service.initialize(); + const workflow = await service.workflows.repairRecoveryMetadata( + input.workflowId, + input.expectedRevision, + () => !owner.isDestroyed(), + ); + await service.refreshReferenceAuthority(); + return { status: "saved" as const, workflow }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:discardAutosave", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow request.")); + const input = parseCreateImagesDiscardAutosaveRequest(value); + if (!mutationAllowed(owner)) return mutationRateFailure(); + const service = createImagesService(); + try { + await service.initialize(); + await service.workflows.discardAutosave( + input.workflowId, + input.expectedTargetRevision, + () => !owner.isDestroyed(), + ); + const workflow = await service.workflows.get(input.workflowId); + if (!workflow) return { status: "not-found" as const }; + await service.refreshReferenceAuthority(); + return { status: "saved" as const, workflow }; + } catch (error) { + return mutationFailure(error); + } + }); + + ipcMain.handle("imageWorkflows:pickAsset", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted asset request.")); + const { workflowId } = parseCreateImagesPickAssetRequest(value); + if (pickerActive) + return { + status: "unavailable" as const, + message: "Another image picker is open.", + }; + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The image picker is unavailable.", + }; + } + pickerActive = true; + const service = createImagesService(); + try { + await service.initialize(); + const workflow = await service.workflows.get(workflowId); + if (!workflow) + return { + status: "unavailable" as const, + message: "The workflow no longer exists.", + }; + const picked = await dialog.showOpenDialog(parent, { + properties: ["openFile"], + }); + if (picked.canceled || !picked.filePaths[0]) return { status: "canceled" as const }; + if (owner.isDestroyed()) + return { + status: "unavailable" as const, + message: "The workflow was closed.", + }; + const filePath = picked.filePaths[0]; + const result = await ingestSelectedImage(service, filePath); + service.noteAssetAvailable(result.asset.assetId); + if (owner.isDestroyed()) + return { + status: "unavailable" as const, + message: "The workflow was closed.", + }; + const grant = await service.grantAsset(owner, result.asset.assetId, () => true); + return { + status: "imported" as const, + grant: grantView(grant, result.asset), + }; + } catch (error) { + reportAssetImportFailure(error); + return { + status: "unavailable" as const, + message: assetImportFailureMessage(error), + }; + } finally { + pickerActive = false; + } + }); + + ipcMain.handle( + "imageWorkflows:pasteImage", + async (event, value: unknown): Promise => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted asset request.")); + let input; + try { + input = parseCreateImagesPasteImageRequest(value); + } catch { + return { + status: "unavailable", + message: "The clipboard image request was invalid.", + }; + } + if (clipboardPasteActive) { + return { + status: "unavailable", + message: "Another clipboard image import is already in progress.", + }; + } + if (!mutationAllowed(owner)) { + return { + status: "unavailable", + message: "Too many image imports were requested. Wait a moment and try again.", + }; + } + clipboardPasteActive = true; + const service = createImagesService(); + try { + await service.initialize(); + const workflow = await service.workflows.get(input.workflowId); + if (!workflow) { + return { status: "unavailable", message: "The workflow no longer exists." }; + } + const result = await ingestClipboardImage(service); + if (!result) return { status: "no-image" }; + service.noteAssetAvailable(result.asset.assetId); + if (owner.isDestroyed()) { + return { status: "unavailable", message: "The workflow was closed." }; + } + const grant = await service.grantAsset(owner, result.asset.assetId, () => true); + return { + status: "imported", + grant: grantView(grant, result.asset), + }; + } catch (error) { + return { + status: "unavailable", + message: assetImportFailureMessage(error), + }; + } finally { + clipboardPasteActive = false; + } + }, + ); + + ipcMain.handle("aiden:create-images:import-dropped-files", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted asset request.")); + let input; + try { + input = parseCreateImagesDroppedAssetImportRequest(value); + } catch { + return { + status: "unavailable", + message: "The dropped image request was invalid.", + } satisfies CreateImagesDroppedAssetImportResult; + } + if (!input.filePaths.every((filePath) => path.isAbsolute(filePath))) { + return { + status: "unavailable", + message: "Aiden could not access the dropped files.", + } satisfies CreateImagesDroppedAssetImportResult; + } + if (!mutationAllowed(owner, input.filePaths.length)) { + return { + status: "unavailable", + message: "Too many images were imported at once. Wait a moment and try again.", + } satisfies CreateImagesDroppedAssetImportResult; + } + const bounded = await runBounded( + owner.id, + async (): Promise => { + const service = createImagesService(); + await service.initialize(); + const workflow = await service.workflows.get(input.workflowId); + if (!workflow) { + return { status: "unavailable", message: "The workflow no longer exists." }; + } + const items: CreateImagesDroppedAssetImportItem[] = []; + for (const filePath of input.filePaths) { + if (owner.isDestroyed()) { + return { status: "unavailable", message: "The workflow was closed." }; + } + try { + const result = await ingestSelectedImage(service, filePath); + service.noteAssetAvailable(result.asset.assetId); + const grant = await service.grantAsset(owner, result.asset.assetId, () => true); + items.push({ status: "imported", grant: grantView(grant, result.asset) }); + } catch (error) { + items.push({ + status: "unavailable", + fileName: path.basename(filePath).slice(0, 255) || "Image", + message: assetImportFailureMessage(error), + }); + } + } + return { status: "completed", items }; + }, + ); + return bounded.status === "completed" + ? bounded.value + : ({ + status: "unavailable", + message: "Image import is busy. Wait a moment and try again.", + } satisfies CreateImagesDroppedAssetImportResult); + }); + + ipcMain.handle("imageWorkflows:grantAsset", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted asset request.")); + const input = parseCreateImagesGrantAssetRequest(value); + const service = createImagesService(); + try { + await service.initialize(); + if (!service.references.isWorkflowAssetReferenced(input.workflowId, input.assetId)) { + return { status: "forbidden" as const }; + } + const asset = await service.assets.getAvailable(input.assetId); + if (!asset) { + service.noteAssetMissing(input.assetId); + return { status: "not-found" as const }; + } + const grant = await service.grantAsset(owner, input.assetId, (assetId) => + service.references.isWorkflowAssetReferenced(input.workflowId, assetId), + ); + return { status: "ready" as const, grant: grantView(grant, asset) }; + } catch (error) { + if ( + error instanceof AssetStoreError && + (error.code === "asset_not_found" || error.code === "asset_source_missing") + ) { + service.noteAssetMissing(input.assetId); + return { status: "not-found" as const }; + } + return { + status: "unavailable" as const, + message: "The asset preview is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:revokeAssetGrant", (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted asset request.")); + const { token } = parseCreateImagesRevokeAssetGrantRequest(value); + return createImagesService().grants.revoke(token, owner); + }); + + ipcMain.handle("imageWorkflows:prepareRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesPrepareRunRequest(value); + if (!mutationAllowed(owner, 2)) return prepareRunRateFailure(); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.prepareGeminiRun({ + workflowId: input.workflowId, + expectedRevision: input.expectedRevision, + scope: input.scope, + }), + ); + if (bounded.status === "busy" || owner.isDestroyed()) return prepareRunRateFailure(); + return bounded.value; + } catch { + return { + status: "unavailable" as const, + message: "The Gemini run plan could not be prepared safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:startRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesStartRunRequest(value); + if (!mutationAllowed(owner)) return runRateFailure(); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.start( + { + workflowId: input.workflowId, + expectedRevision: input.expectedRevision, + scope: input.scope, + executionMode: input.consent.executionMode, + ...(input.consent.executionMode === "gemini" + ? { + providerConsent: { + version: input.consent.version, + authorizationId: input.consent.authorizationId, + consentFingerprint: input.consent.consentFingerprint, + token: input.consent.token, + reviewed: true as const, + }, + } + : {}), + }, + () => !owner.isDestroyed(), + ), + ); + if (bounded.status === "busy") return runRateFailure(); + const result = bounded.value; + if (result.status === "started") { + bindRunToOwner(owner, input.workflowId, result.run.runId); + } + return result; + } catch { + return { + status: "unavailable" as const, + message: "The reviewed image run could not be started safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:stopRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesStopRunRequest(value); + if (!mutationAllowed(owner, 2)) return runRateFailure(); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.stop(input.workflowId, input.runId, "user"), + ); + return bounded.status === "completed" ? bounded.value : runRateFailure(); + } catch { + return { + status: "unavailable" as const, + message: "The stop request could not be recorded safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:resumeRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesResumeRunRequest(value); + if (!mutationAllowed(owner, 2)) return runRateFailure(); + try { + const bounded = await runBounded(owner.id, () => createImagesService().runs.resume(input)); + return bounded.status === "completed" ? bounded.value : runRateFailure(); + } catch { + return { + status: "unavailable" as const, + message: "The paused run could not be resumed safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:proposeWorkflow", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow proposal request.")); + const input = parseCreateImagesProposeWorkflowRequest(value); + if (!mutationAllowed(owner, 8)) { + return { + status: "unavailable", + message: "Too many workflow proposals were requested. Wait a moment and try again.", + } satisfies CreateImagesProposeWorkflowResult; + } + const controller = new AbortController(); + const releaseInvalidation = owner.onInvalidated(() => controller.abort()); + try { + const bounded = await runBounded(owner.id, async (): Promise => { + const service = createImagesService(); + await service.initialize(); + const current = await service.workflows.get(input.workflowId); + if (!current) { + return { status: "unavailable", message: "The workflow no longer exists." }; + } + if (current.revision !== input.expectedRevision) { + return { status: "conflict", currentRevision: current.revision }; + } + const result = await service.proposals.propose({ + request: input.request, + current: input.workflow, + providerId: input.providerId, + model: input.model, + signal: controller.signal, + }); + if (owner.isDestroyed()) { + return { status: "unavailable", message: "The workflow was closed." }; + } + const latest = await service.workflows.get(input.workflowId); + if (!latest) return { status: "unavailable", message: "The workflow no longer exists." }; + if (latest.revision !== input.expectedRevision) { + return { status: "conflict", currentRevision: latest.revision }; + } + return result; + }); + return bounded.status === "completed" + ? bounded.value + : ({ + status: "unavailable", + message: "Another workflow operation is still active. Try again shortly.", + } satisfies CreateImagesProposeWorkflowResult); + } catch { + return { + status: "unavailable", + message: "Aiden could not prepare a workflow proposal safely.", + } satisfies CreateImagesProposeWorkflowResult; + } finally { + releaseInvalidation(); + } + }); + + ipcMain.handle("imageWorkflows:resolveRunAmbiguity", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesResolveRunAmbiguityRequest(value); + if (!mutationAllowed(owner, 2)) return runRateFailure(); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.resolveRunAmbiguity(input), + ); + return bounded.status === "completed" ? bounded.value : runRateFailure(); + } catch { + return { + status: "unavailable" as const, + message: "The unresolved submission acknowledgement could not be recorded safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:planDegradedRunDiscard", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesPlanDegradedRunDiscardRequest(value); + if (!readAllowed(owner, 12)) return runReadRateFailure(owner); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.planDegradedRunDiscard(input.runId), + ); + return bounded.status === "completed" ? bounded.value : runReadRateFailure(owner); + } catch { + return { + status: "unavailable" as const, + message: "The damaged run discard plan is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:discardDegradedRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesDiscardDegradedRunRequest(value); + if (!mutationAllowed(owner, 20)) return runRateFailure(); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.discardDegradedRun(input), + ); + return bounded.status === "completed" ? bounded.value : runRateFailure(); + } catch { + return { + status: "unavailable" as const, + message: "The damaged run record could not be discarded safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:listRuns", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const { workflowId } = parseCreateImagesListRunsRequest(value); + if (!readAllowed(owner, 12)) return runReadRateFailure(owner); + try { + const bounded = await runBounded(owner.id, async () => { + const service = createImagesService(); + await service.initialize(); + if (!(await service.workflows.get(workflowId))) return { status: "not-found" as const }; + return service.runs.list(workflowId); + }); + if (bounded.status === "busy") return runReadRateFailure(owner); + const result = bounded.value; + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + return result; + } catch { + return { + status: "unavailable" as const, + message: "Local run history is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:subscribeRuns", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const { workflowId } = parseCreateImagesSubscribeRunsRequest(value); + if (!readAllowed(owner, 12)) return runReadRateFailure(owner); + const service = createImagesService(); + try { + await service.initialize(); + if (!(await service.workflows.get(workflowId))) return { status: "not-found" as const }; + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + if (runSubscriptions.size >= 128) { + return { + status: "unavailable" as const, + message: "Too many run subscriptions are open.", + }; + } + const subscriptionId = randomUUID(); + let live = true; + let releaseInvalidation: () => void = () => undefined; + const release = (): void => { + if (!live) return; + live = false; + releaseInvalidation(); + runSubscriptions.delete(subscriptionId); + }; + releaseInvalidation = owner.onInvalidated(release); + if (!live || owner.isDestroyed()) { + release(); + throw new Error("The renderer document is no longer active."); + } + runSubscriptions.set(subscriptionId, { + workflowId, + ownerId: owner.id, + documentId: owner.documentId, + streamSequence: 0, + send: (payload) => owner.send("imageWorkflows:run-changed", payload), + release, + }); + ensureRunListener(); + // The subscription is live before this initial read begins. Any durable + // change in the gap is delivered with a higher stream sequence and the + // renderer applies it after this baseline snapshot. + try { + let snapshot: Awaited> | undefined; + for (let attempt = 0; attempt < 4 && !snapshot; attempt += 1) { + const bounded = await runBounded(owner.id, () => service.runs.list(workflowId)); + if (bounded.status === "completed") { + snapshot = bounded.value; + break; + } + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 25 * 2 ** attempt); + timeout.unref?.(); + }); + } + if (!snapshot) { + runSubscriptions.get(subscriptionId)?.release(); + return runReadRateFailure(owner); + } + return { + status: "ready" as const, + subscriptionId, + streamSequence: 0, + snapshot, + }; + } catch (error) { + runSubscriptions.get(subscriptionId)?.release(); + throw error; + } + } catch { + return { + status: "unavailable" as const, + message: "Run updates are unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:getRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesGetRunRequest(value); + if (!readAllowed(owner, 3)) return runReadRateFailure(owner); + const service = createImagesService(); + try { + await service.initialize(); + if (!(await service.workflows.get(input.workflowId))) return { status: "not-found" as const }; + const bounded = await runBounded(owner.id, () => + service.runs.get(input.workflowId, input.runId), + ); + if (bounded.status === "busy") return runReadRateFailure(owner); + const result = bounded.value; + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + return result; + } catch { + return { + status: "unavailable" as const, + message: "The durable run record is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:recoverRun", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesRecoverRunRequest(value); + if (!mutationAllowed(owner)) return runRateFailure(); + const service = createImagesService(); + try { + const bounded = await runBounded(owner.id, async () => { + await service.initialize(); + if (!(await service.workflows.get(input.workflowId))) { + return { status: "not-found" as const }; + } + return service.runs.recover( + input.workflowId, + input.runId, + input.source, + input.expectedCandidateJournalRevision, + ); + }); + return bounded.status === "completed" ? bounded.value : runRateFailure(); + } catch { + return { + status: "unavailable" as const, + message: "The run record could not be recovered safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:planRunHistoryPrune", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesPlanRunHistoryPruneRequest(value); + if (!readAllowed(owner, 12)) return runReadRateFailure(owner); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.planHistoryPrune(input.keepLatest), + ); + return bounded.status === "completed" ? bounded.value : runReadRateFailure(owner); + } catch { + return { + status: "unavailable" as const, + message: "The run history cleanup plan is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:pruneRunHistory", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const input = parseCreateImagesPruneRunHistoryRequest(value); + if (!mutationAllowed(owner, 20)) return runRateFailure(); + try { + const bounded = await runBounded(owner.id, () => + createImagesService().runs.pruneHistory(input.keepLatest, input.authorizationToken), + ); + return bounded.status === "completed" ? bounded.value : runRateFailure(); + } catch { + return { + status: "unavailable" as const, + message: "Run history could not be pruned safely.", + }; + } + }); + + ipcMain.handle("imageWorkflows:unsubscribeRuns", (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run request.")); + const { subscriptionId } = parseCreateImagesUnsubscribeRunsRequest(value); + const subscription = runSubscriptions.get(subscriptionId); + if ( + !subscription || + subscription.ownerId !== owner.id || + subscription.documentId !== owner.documentId + ) { + return false; + } + subscription.release(); + return true; + }); + + ipcMain.handle("imageWorkflows:grantRunAsset", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run asset request.")); + const input = parseCreateImagesGrantRunAssetRequest(value); + if (!readAllowed(owner, 3)) return runReadRateFailure(owner); + const service = createImagesService(); + try { + const bounded = await runBounded(owner.id, async () => { + await service.initialize(); + if ( + !(await service.runs.isRunAssetReferenced( + input.workflowId, + input.runId, + input.assetId, + )) || + !service.references.isRunAssetReferenced(input.runId, input.assetId) + ) { + return { status: "forbidden" as const }; + } + const asset = await service.assets.getAvailable(input.assetId); + if (!asset) return { status: "not-found" as const }; + const grant = await service.grantAsset(owner, input.assetId, (assetId) => + service.references.isRunAssetReferenced(input.runId, assetId), + ); + return { status: "ready" as const, grant: grantView(grant, asset) }; + }); + return bounded.status === "completed" ? bounded.value : runReadRateFailure(owner); + } catch (error) { + if ( + error instanceof AssetStoreError && + (error.code === "asset_not_found" || error.code === "asset_source_missing") + ) { + return { status: "not-found" as const }; + } + return { + status: "unavailable" as const, + message: "The run output is unavailable.", + }; + } + }); + + ipcMain.handle("imageWorkflows:downloadRunAsset", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run asset request.")); + const input = parseCreateImagesDownloadRunAssetRequest(value); + if (!readAllowed(owner, 8)) { + return { + status: "unavailable" as const, + message: "Too many image export requests were made. Wait a moment and try again.", + } satisfies CreateImagesDownloadRunAssetResult; + } + if (archiveDialogActive) { + return { + status: "unavailable" as const, + message: "Another image or workflow save dialog is open.", + } satisfies CreateImagesDownloadRunAssetResult; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The image save dialog is unavailable.", + } satisfies CreateImagesDownloadRunAssetResult; + } + archiveDialogActive = true; + const service = createImagesService(); + try { + await service.initialize(); + const authorized = + (await service.runs.isRunAssetReferenced(input.workflowId, input.runId, input.assetId)) && + service.references.isRunAssetReferenced(input.runId, input.assetId); + if (!authorized) return { status: "forbidden" as const }; + const asset = await service.assets.getAvailable(input.assetId); + if (!asset) return { status: "not-found" as const }; + const extension = asset.mediaType === "image/png" ? "png" : "jpg"; + const picked = await dialog.showSaveDialog(parent, { + defaultPath: `Aiden image ${input.assetId.slice(0, 8)}.${extension}`, + filters: [ + { + name: asset.mediaType === "image/png" ? "PNG Image" : "JPEG Image", + extensions: [extension], + }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + if (picked.canceled || !picked.filePath) return { status: "canceled" as const }; + if (owner.isDestroyed()) { + return { status: "unavailable" as const, message: "The workflow was closed." }; + } + if ( + !(await service.runs.isRunAssetReferenced(input.workflowId, input.runId, input.assetId)) || + !service.references.isRunAssetReferenced(input.runId, input.assetId) + ) { + return { status: "forbidden" as const }; + } + await service.assets.exportAssetToFile(input.assetId, picked.filePath); + shell.showItemInFolder(picked.filePath); + return { status: "saved" as const, fileName: path.basename(picked.filePath) }; + } catch (error) { + if ( + error instanceof AssetStoreError && + (error.code === "asset_not_found" || error.code === "asset_source_missing") + ) { + return { status: "not-found" as const }; + } + return { + status: "unavailable" as const, + message: "Aiden could not save this retained image.", + }; + } finally { + archiveDialogActive = false; + } + }); + + ipcMain.handle("imageWorkflows:downloadRunAssetsZip", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted run ZIP request.")); + const input = parseCreateImagesDownloadRunAssetsZipRequest(value); + if (!readAllowed(owner, 4)) { + return { + status: "unavailable" as const, + message: "Too many image export requests were made. Wait a moment and try again.", + } satisfies CreateImagesDownloadRunAssetResult; + } + if (archiveDialogActive) { + return { + status: "unavailable" as const, + message: "Another image or workflow save dialog is open.", + } satisfies CreateImagesDownloadRunAssetResult; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The image ZIP save dialog is unavailable.", + } satisfies CreateImagesDownloadRunAssetResult; + } + archiveDialogActive = true; + const service = createImagesService(); + const authorized = async () => { + const retained = await Promise.all( + input.assetIds.map((assetId) => + service.runs.isRunAssetReferenced(input.workflowId, input.runId, assetId), + ), + ); + return retained.every(Boolean) && + input.assetIds.every((assetId) => + service.references.isRunAssetReferenced(input.runId, assetId), + ); + }; + try { + await service.initialize(); + if (!(await authorized())) return { status: "forbidden" as const }; + const picked = await dialog.showSaveDialog(parent, { + defaultPath: `Aiden images ${input.runId.slice(0, 8)}.zip`, + filters: [{ name: "ZIP Archive", extensions: ["zip"] }], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + if (picked.canceled || !picked.filePath) return { status: "canceled" as const }; + if (owner.isDestroyed()) { + return { status: "unavailable" as const, message: "The workflow was closed." }; + } + if (!(await authorized())) return { status: "forbidden" as const }; + await writeCreateImagesOutputZip(service.assets, input.assetIds, picked.filePath); + shell.showItemInFolder(picked.filePath); + return { status: "saved" as const, fileName: path.basename(picked.filePath) }; + } catch { + return { + status: "unavailable" as const, + message: "Aiden could not save the selected images as a ZIP archive.", + }; + } finally { + archiveDialogActive = false; + } + }); + + ipcMain.handle("imageWorkflows:downloadWorkflowAsset", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted workflow asset request.")); + const input = parseCreateImagesDownloadWorkflowAssetRequest(value); + if (!readAllowed(owner, 8)) { + return { + status: "unavailable" as const, + message: "Too many image export requests were made. Wait a moment and try again.", + } satisfies CreateImagesDownloadRunAssetResult; + } + if (archiveDialogActive) { + return { + status: "unavailable" as const, + message: "Another image or workflow save dialog is open.", + } satisfies CreateImagesDownloadRunAssetResult; + } + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent || parent.isDestroyed()) { + return { + status: "unavailable" as const, + message: "The image save dialog is unavailable.", + } satisfies CreateImagesDownloadRunAssetResult; + } + archiveDialogActive = true; + const service = createImagesService(); + try { + await service.initialize(); + if (!service.references.isWorkflowAssetReferenced(input.workflowId, input.assetId)) { + return { status: "forbidden" as const }; + } + const asset = await service.assets.getAvailable(input.assetId); + if (!asset) return { status: "not-found" as const }; + const extension = asset.mediaType === "image/png" ? "png" : "jpg"; + const picked = await dialog.showSaveDialog(parent, { + defaultPath: `Aiden image ${input.assetId.slice(0, 8)}.${extension}`, + filters: [ + { + name: asset.mediaType === "image/png" ? "PNG Image" : "JPEG Image", + extensions: [extension], + }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }); + if (picked.canceled || !picked.filePath) return { status: "canceled" as const }; + if (owner.isDestroyed()) { + return { status: "unavailable" as const, message: "The workflow was closed." }; + } + if (!service.references.isWorkflowAssetReferenced(input.workflowId, input.assetId)) { + return { status: "forbidden" as const }; + } + await service.assets.exportAssetToFile(input.assetId, picked.filePath); + shell.showItemInFolder(picked.filePath); + return { status: "saved" as const, fileName: path.basename(picked.filePath) }; + } catch (error) { + if ( + error instanceof AssetStoreError && + (error.code === "asset_not_found" || error.code === "asset_source_missing") + ) { + return { status: "not-found" as const }; + } + return { + status: "unavailable" as const, + message: "Aiden could not save this workflow image.", + }; + } finally { + archiveDialogActive = false; + } + }); + + ipcMain.handle("imageWorkflows:listRecentOutputs", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted recent image request.")); + const input = parseCreateImagesListRecentOutputsRequest(value); + if (!readAllowed(owner, 2)) { + return { + status: "unavailable" as const, + message: "Recent images are busy. Wait a moment and try again.", + } satisfies CreateImagesRecentOutputListResult; + } + try { + const service = createImagesService(); + await service.initialize(); + return { + status: "ready" as const, + items: await service.runs.listRecentOutputs(input.limit), + } satisfies CreateImagesRecentOutputListResult; + } catch { + return { + status: "unavailable" as const, + message: "Aiden could not load recent generated images.", + } satisfies CreateImagesRecentOutputListResult; + } + }); + + ipcMain.handle("imageWorkflows:getPresentation", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted presentation request.")); + const input = parseCreateImagesGetPresentationRequest(value); + if (!readAllowed(owner, 1)) { + return { + status: "unavailable" as const, + message: "Gallery presentation is busy. Wait a moment and try again.", + } satisfies CreateImagesPresentationResult; + } + try { + const service = createImagesService(); + await service.initialize(); + if (!(await service.workflows.get(input.workflowId))) return { status: "not-found" as const }; + return { + status: "ready" as const, + hiddenAssetIds: await service.presentation.hiddenAssetIds(input.workflowId), + } satisfies CreateImagesPresentationResult; + } catch { + return { + status: "unavailable" as const, + message: "Gallery presentation could not be loaded.", + } satisfies CreateImagesPresentationResult; + } + }); + + ipcMain.handle("imageWorkflows:setAssetHidden", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted presentation request.")); + const input = parseCreateImagesSetAssetHiddenRequest(value); + if (!mutationAllowed(owner, 1)) { + return { + status: "unavailable" as const, + message: "Too many gallery changes were requested. Wait a moment and try again.", + } satisfies CreateImagesPresentationResult; + } + try { + const service = createImagesService(); + await service.initialize(); + if (!(await service.workflows.get(input.workflowId))) return { status: "not-found" as const }; + if (!(await service.runs.isRunAssetReferenced(input.workflowId, input.runId, input.assetId))) { + return { status: "forbidden" as const }; + } + return { + status: "ready" as const, + hiddenAssetIds: await service.presentation.setAssetHidden( + input.workflowId, + input.assetId, + input.hidden, + ), + } satisfies CreateImagesPresentationResult; + } catch { + return { + status: "unavailable" as const, + message: "Gallery presentation could not be updated.", + } satisfies CreateImagesPresentationResult; + } + }); + + ipcMain.handle("imageWorkflows:planAssetCleanup", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted storage request.")); + parseCreateImagesPlanAssetCleanupRequest(value); + if (!readAllowed(owner, 12)) { + return { + status: "unavailable" as const, + message: "Too many storage requests were made. Wait a moment and try again.", + } satisfies CreateImagesAssetCleanupPlanResult; + } + try { + const bounded = await runBounded(owner.id, async () => { + const service = createImagesService(); + await service.initialize(); + const plan = await service.assets.planGarbageCollection( + CREATE_IMAGES_ASSET_CLEANUP_GRACE_MS, + ); + if (plan.candidateAssetIds.length === 0) return { status: "empty" as const }; + return { + status: "ready" as const, + planId: plan.planId, + candidateCount: plan.candidateAssetIds.length, + reclaimableBytes: plan.reclaimableBytes, + expiresAt: plan.expiresAt, + }; + }); + return bounded.status === "completed" + ? bounded.value + : { + status: "unavailable" as const, + message: "Storage cleanup is busy. Try again in a moment.", + }; + } catch { + return { + status: "unavailable" as const, + message: "Aiden could not safely plan unused image cleanup.", + }; + } + }); + + ipcMain.handle("imageWorkflows:applyAssetCleanup", async (event, value: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted storage request.")); + const input = parseCreateImagesApplyAssetCleanupRequest(value); + if (!mutationAllowed(owner, 10)) { + return { + status: "unavailable" as const, + message: "Too many storage changes were requested. Wait a moment and try again.", + } satisfies CreateImagesAssetCleanupResult; + } + try { + const bounded = await runBounded(owner.id, async () => { + const service = createImagesService(); + await service.initialize(); + const result = await service.assets.applyGarbageCollection(input.planId); + if (result.stale || !result.applied) return { status: "stale" as const }; + return { + status: "cleaned" as const, + deletedCount: result.deletedAssetIds.length, + reclaimedBytes: result.reclaimedBytes, + skippedCount: result.skipped.length, + }; + }); + return bounded.status === "completed" + ? bounded.value + : { + status: "unavailable" as const, + message: "Storage cleanup is busy. Try again in a moment.", + }; + } catch { + return { + status: "unavailable" as const, + message: "Aiden did not delete any images because storage cleanup could not be verified.", + }; + } + }); + + ipcMain.handle("imageWorkflows:storageHealth", async (event) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted storage request.")); + if (!readAllowed(owner, 12)) { + throw new Error("Too many storage health requests were made. Wait a moment and try again."); + } + const bounded = await runBounded(owner.id, async () => { + const service = createImagesService(); + await service.initializeReadOnlyLibrary(); + const [workflows, status, assets, runIndex] = await Promise.all([ + service.workflows.list(), + service.assets.status(), + service.assets.list(), + service.runs.journals.indexHealth(), + ]); + const degradedInventory = await Promise.all([ + service.runs.journals.degradedRuns(100), + service.runs.journals.degradedRunCount(), + ]).then( + ([records, count]) => ({ records, count, unavailable: false }), + () => ({ records: [], count: 0, unavailable: true }), + ); + const degradedRecords = degradedInventory.records.map((record) => { + const workflowId = + "workflowId" in record && typeof record.workflowId === "string" + ? record.workflowId + : undefined; + return { + runId: record.runId, + association: workflowId ? ("workflow" as const) : ("unassociated" as const), + ...(workflowId ? { workflowId } : {}), + status: record.status, + reason: record.reason, + discardEligible: + record.status === "unsafe" || + (record.status === "recovery-required" && record.canRecover === false), + }; + }); + const degradedDetails = { + degradedRecordCount: degradedInventory.count, + degradedRecordsTruncated: + degradedInventory.unavailable || degradedInventory.count > degradedRecords.length, + degradedRecords, + }; + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + return { + workflowCount: workflows.length, + assetCount: status.assetCount, + assetBytes: status.totalAssetBytes, + recoverableWorkflowCount: workflows.filter((workflow) => workflow.recoveryAvailable).length, + orphanAssetCount: assets.filter((asset) => asset.referenceCount === 0).length, + missingAssetCount: service.missingAssetCount(), + runIndex: + runIndex.status === "healthy" + ? { + status: + runIndex.diagnostic === "rebuilt-corrupt-index" + ? ("recovered" as const) + : ("healthy" as const), + entryCount: runIndex.entryCount, + ...(runIndex.quarantinedIndexCount === undefined + ? {} + : { quarantinedIndexCount: runIndex.quarantinedIndexCount }), + ...degradedDetails, + } + : { + status: + runIndex.status === "unsafe" ? ("unsafe" as const) : ("needs-attention" as const), + ...degradedDetails, + }, + }; + }); + if (bounded.status === "busy") { + throw new Error("Device-local storage health is busy. Wait a moment and try again."); + } + return bounded.value; + }); +} diff --git a/main/handlers/index.ts b/main/handlers/index.ts index f02f01da..8c5306d9 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -24,6 +24,7 @@ import { registerAssistantHandlers } from "./assistant.js"; import { registerShortcutHandlers } from "./shortcuts.js"; import { registerTelegramHandlers } from "./telegram.js"; import { registerSubagentHandlers } from "./subagents.js"; +import { registerCreateImagesHandlers } from "./create-images.js"; import { ipcMain, logger } from "../platform.js"; import { writeDevLog } from "../services/dev-log.js"; @@ -65,6 +66,7 @@ export function registerHandlers(): void { registerShortcutHandlers(); registerTelegramHandlers(); registerSubagentHandlers(); + registerCreateImagesHandlers(); logger.info("handlers", "✓ IPC handlers registered"); diff --git a/main/handlers/ipc-contract.test.ts b/main/handlers/ipc-contract.test.ts index df6d4862..7b732c95 100644 --- a/main/handlers/ipc-contract.test.ts +++ b/main/handlers/ipc-contract.test.ts @@ -44,6 +44,14 @@ function calleeName(expression: ts.LeftHandSideExpression): string | undefined { return undefined; } +function isIpcHandle(expression: ts.LeftHandSideExpression): boolean { + if (!ts.isPropertyAccessExpression(expression) || expression.name.text !== "handle") return false; + return ( + ts.isIdentifier(expression.expression) && + (expression.expression.text === "ipcMain" || expression.expression.text === "electronIpcMain") + ); +} + function literalValue( expression: ts.Expression | undefined, channelConstants: ReadonlyMap, @@ -85,7 +93,7 @@ function collectInventory(sourceFiles: readonly ts.SourceFile[]): IpcInventory { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const name = calleeName(node.expression); - if (name === "handle") { + if (name === "handle" && isIpcHandle(node.expression)) { const channel = literalValue(node.arguments[0], channelConstants); if (channel) inventory.handlers.add(channel); } else if (name === "send" || name === "broadcast") { @@ -171,6 +179,14 @@ test("dedicated native bridge channels exactly match the live native handlers", ); }); +test("native dropped-file import cannot be invoked through the generic renderer bridge", () => { + const channel = NATIVE_INVOKE_CHANNELS.createImagesImportDroppedFiles; + assert.equal( + INVOKE_PREFIXES.some((prefix) => channel.startsWith(prefix)), + false, + ); +}); + test("fixed attachment bridge preserves OS drop and bounded clipboard flows", async () => { const calls: Array<{ channel: string; args: unknown[] }> = []; const trustedFile = {} as File; diff --git a/main/index.ts b/main/index.ts index 4b74c8fc..a5d3772a 100644 --- a/main/index.ts +++ b/main/index.ts @@ -8,7 +8,7 @@ import { registerNativeHandlers, shell, } from "./platform.js"; -import { Menu, nativeImage, nativeTheme } from "electron"; +import { Menu, nativeImage, nativeTheme, type MessageBoxSyncOptions } from "electron"; import path from "node:path"; import { registerHandlers } from "./handlers/index.js"; @@ -57,10 +57,7 @@ import { devLogPath } from "./services/dev-log.js"; import { scheduleService } from "./services/schedule-service.js"; import { telegramService } from "./services/telegram/telegram-service.js"; import { registerAppPathOpener } from "./services/app-navigation.js"; -import { - effectiveBindings, - migrateLegacyKeybindings, -} from "../renderer/shared/keybindings.js"; +import { effectiveBindings, migrateLegacyKeybindings } from "../renderer/shared/keybindings.js"; import type { NotificationChannel } from "../renderer/preload-channels.js"; import type { AppSettings } from "./services/types.js"; import { ONBOARDING_COMPLETE_STORAGE_KEY } from "../renderer/shared/onboarding.js"; @@ -101,6 +98,16 @@ import { reconcilePendingMcpCredentialCleanup, } from "./services/mcp-credential-cleanup.js"; import { resetOnboardingData } from "./services/onboarding-reset.js"; +import { loadCreateImagesPackagedAcceptanceSession } from "./services/create-images/packaged-canvas-acceptance-core.js"; +import { createImagesEnabled } from "./services/create-images/feature-flag.js"; +import { + installCreateImagesAssetProtocol, + registerCreateImagesAssetScheme, +} from "./services/create-images/asset-protocol.js"; +import { createImagesService } from "./services/create-images/create-images-service.js"; +import type { CreateImagesRunView } from "../renderer/shared/create-images/ipc.js"; + +if (createImagesEnabled()) registerCreateImagesAssetScheme(); const ownsSingleInstanceLock = app.requestSingleInstanceLock(); @@ -117,22 +124,26 @@ let closeGuard = { path: undefined as string | undefined, saving: false, }; -let protectedAction: "close" | "quit" | "reload" | "onboarding-reset" | null = - null; +let protectedAction: "close" | "quit" | "reload" | "onboarding-reset" | null = null; let forceAppQuit = false; let cleanupStarted = false; let lifecycleCheckInFlight = false; let shutdownStarted = false; let installUpdateOnQuit = false; let pendingPackagedSubagentSoakReceipt: SubagentPackagedSoakSession | undefined; -const disposeAppUpdateStateSubscription = appUpdateService.subscribe( - (snapshot) => { - ipcMain.broadcast("app:update-state", snapshot); - }, -); +const disposeAppUpdateStateSubscription = appUpdateService.subscribe((snapshot) => { + ipcMain.broadcast("app:update-state", snapshot); +}); const SUBAGENT_PACKAGED_SOAK_WAIT_MS = 30_000; const SUBAGENT_PACKAGED_SOAK_POLL_MS = 25; +const CREATE_IMAGES_QUIT_SAFETY_TIMEOUT_MS = 6_000; + +const MAIN_WINDOW_SECURITY_PREFERENCES = Object.freeze({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true, +}); // These scripts are fixed at build time. The strict one-shot control record // selects only among their named actions; it never supplies a selector, route, @@ -186,10 +197,7 @@ function hasCloseGuard(): boolean { return closeGuard.dirty || closeGuard.gitBusy || closeGuard.saving; } -function confirmProtectedAction( - window: BrowserWindow, - action: "close" | "reload", -): boolean { +function confirmProtectedAction(window: BrowserWindow, action: "close" | "reload"): boolean { if (closeGuard.gitBusy) { dialog.showMessageBoxSync(window, { type: "info", @@ -227,9 +235,7 @@ function confirmProtectedAction( : "Reloading Aiden will permanently discard those edits.", buttons: [ "Keep Editing", - action === "close" - ? "Discard Edits and Close" - : "Discard Edits and Reload", + action === "close" ? "Discard Edits and Close" : "Discard Edits and Reload", ], defaultId: 0, cancelId: 0, @@ -238,6 +244,98 @@ function confirmProtectedAction( return response === 1; } +function showQuitMessageBox( + window: BrowserWindow | undefined, + options: MessageBoxSyncOptions, +): number { + return window && !window.isDestroyed() + ? dialog.showMessageBoxSync(window, options) + : dialog.showMessageBoxSync(options); +} + +function showImageRunsCouldNotStop(window?: BrowserWindow): void { + showQuitMessageBox(window, { + type: "error", + title: "Image workflows could not stop safely", + message: "Aiden could not durably save cancellation for every active image workflow.", + detail: + "Aiden will stay open. Review Create Images, then try stopping the runs or quitting again.", + buttons: ["Keep Aiden Open"], + defaultId: 0, + cancelId: 0, + noLink: true, + }); +} + +async function activeImageRunsWithinQuitDeadline(): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + createImagesService().runs.activeRuns(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Create Images quit inspection timed out.")), + CREATE_IMAGES_QUIT_SAFETY_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function confirmActiveImageRunsBeforeQuit(window?: BrowserWindow): Promise { + if (!createImagesEnabled()) return true; + let activeRuns: CreateImagesRunView[]; + try { + activeRuns = await activeImageRunsWithinQuitDeadline(); + } catch (error) { + logger.error("main", "Could not inspect active Create Images runs before quit.", error); + if (!window || !window.isDestroyed()) { + showQuitMessageBox(window, { + type: "info", + title: "Image run status is unavailable", + message: "Aiden could not safely confirm whether a local image workflow is still running.", + detail: "Keep Aiden open, review Create Images, then try quitting again.", + buttons: ["Keep Aiden Open"], + defaultId: 0, + cancelId: 0, + noLink: true, + }); + } + return false; + } + if (activeRuns.length === 0) return true; + const plural = activeRuns.length === 1 ? "workflow is" : "workflows are"; + const stopLabel = activeRuns.length === 1 ? "Stop Run and Quit" : "Stop Runs and Quit"; + const response = showQuitMessageBox(window, { + type: "warning", + title: + activeRuns.length === 1 ? "Image workflow still running" : "Image workflows still running", + message: `${activeRuns.length} local mock image ${plural} still running.`, + detail: + "Keep Aiden open to wait for completion, or stop the runs durably before quitting. Completed outputs stay on this device. These Phase 3 mock runs make no network requests and cost $0.", + buttons: ["Keep Aiden Open", stopLabel], + defaultId: 0, + cancelId: 0, + noLink: true, + }); + if (response !== 1) return false; + try { + const stopped = await createImagesService().runs.stopAll("app-quit"); + if (stopped.status === "safe-to-quit") return true; + logger.error( + "main", + `Create Images cancellation was not durable for ${stopped.failedRunIds.length} run(s); quit was cancelled.`, + ); + } catch (error) { + logger.error("main", "Could not durably stop active Create Images runs before quit.", error); + } + createImagesService().runs.resumeRunAdmissionsAfterCancelledShutdown(); + showImageRunsCouldNotStop(window); + return false; +} + function cleanupApplication(): void { if (cleanupStarted) return; cleanupStarted = true; @@ -254,6 +352,11 @@ function cleanupApplication(): void { void mcpManager.closeAll(); } +function resumeCreateImagesAfterCancelledShutdown(): void { + if (!createImagesEnabled()) return; + createImagesService().runs.resumeRunAdmissionsAfterCancelledShutdown(); +} + async function shutdownAndQuit(settingsPrepared = false): Promise { if (shutdownStarted) return; shutdownStarted = true; @@ -263,11 +366,37 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { } catch (error) { shutdownStarted = false; computerUseSettings.resumeAfterCancelledShutdown(); + resumeCreateImagesAfterCancelledShutdown(); + logger.error("main", "Computer Use state was not durable; Aiden will stay open.", error); + return; + } + } + if (createImagesEnabled()) { + try { + const stopped = await createImagesService().runs.stopAll("app-quit"); + if (stopped.status === "blocked") { + shutdownStarted = false; + protectedAction = null; + computerUseSettings.resumeAfterCancelledShutdown(); + createImagesService().runs.resumeRunAdmissionsAfterCancelledShutdown(); + logger.error( + "main", + `Create Images cancellation was not durable for ${stopped.failedRunIds.length} run(s); Aiden will stay open.`, + ); + showImageRunsCouldNotStop(mainWindow ?? undefined); + return; + } + } catch (error) { + shutdownStarted = false; + protectedAction = null; + computerUseSettings.resumeAfterCancelledShutdown(); + createImagesService().runs.resumeRunAdmissionsAfterCancelledShutdown(); logger.error( "main", - "Computer Use state was not durable; Aiden will stay open.", + "Create Images shutdown did not reach a durable cancellation boundary; Aiden will stay open.", error, ); + showImageRunsCouldNotStop(mainWindow ?? undefined); return; } } @@ -285,11 +414,7 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { ); } } catch (error) { - logger.error( - "main", - "Parent generation shutdown did not complete cleanly.", - error, - ); + logger.error("main", "Parent generation shutdown did not complete cleanly.", error); } const subagentsSettled = await subagentRuntimeRegistry.shutdown(); if (!subagentsSettled) { @@ -300,17 +425,16 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { } const session = pendingPackagedSubagentSoakReceipt; pendingPackagedSubagentSoakReceipt = undefined; - const quitReceiptFinalization = - await tryFinalizeSubagentPackagedSoakQuitReceipt( - session, - parentSettled, - subagentsSettled, - { - flushMetrics: () => subagentHealthMetrics.flush(), - snapshotMetrics: () => subagentHealthMetrics.snapshotForPackagedSoak(), - writeReceipt: writeSubagentPackagedSoakReceipt, - }, - ); + const quitReceiptFinalization = await tryFinalizeSubagentPackagedSoakQuitReceipt( + session, + parentSettled, + subagentsSettled, + { + flushMetrics: () => subagentHealthMetrics.flush(), + snapshotMetrics: () => subagentHealthMetrics.snapshotForPackagedSoak(), + writeReceipt: writeSubagentPackagedSoakReceipt, + }, + ); if (quitReceiptFinalization.status === "lifecycle_unsettled") { logger.warn( "main", @@ -328,9 +452,7 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { quitReceiptFinalization.error, ); } - if ( - requiresSubagentPackagedSoakFailureExit(session, quitReceiptFinalization) - ) { + if (requiresSubagentPackagedSoakFailureExit(session, quitReceiptFinalization)) { logger.error( "main", "Packaged subagent soak finalization did not create a valid receipt; exiting with failure.", @@ -354,11 +476,7 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { terminalService.flushHistory(), ]); } catch (error) { - logger.error( - "main", - "Application service shutdown did not complete cleanly.", - error, - ); + logger.error("main", "Application service shutdown did not complete cleanly.", error); } forceAppQuit = true; if (installUpdateOnQuit) { @@ -372,10 +490,15 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { app.quit(); } -async function refreshCloseGuardFromRenderer( - window: BrowserWindow, -): Promise { +async function refreshCloseGuardFromRenderer(window: BrowserWindow): Promise { try { + const createImagesFlushAllowed = await window.webContents.executeJavaScript( + "window.__aidenFlushCreateImagesForLifecycle?.() ?? true", + true, + ); + if (createImagesFlushAllowed !== true) { + throw new Error("Create Images autosave did not authorize the protected action."); + } const latest = (await window.webContents.executeJavaScript( `({ dirty: document.documentElement.dataset.aidenDirty === "1", @@ -396,8 +519,7 @@ async function refreshCloseGuardFromRenderer( path: closeGuard.path, saving: latest?.saving === true, }; - return Number.isSafeInteger(latest?.revision) && - Number(latest.revision) >= 0 + return Number.isSafeInteger(latest?.revision) && Number(latest.revision) >= 0 ? Number(latest.revision) : 0; } catch (error) { @@ -418,10 +540,7 @@ async function refreshCloseGuardFromRenderer( } } -async function armRendererUnload( - window: BrowserWindow, - revision: number, -): Promise { +async function armRendererUnload(window: BrowserWindow, revision: number): Promise { try { return ( (await window.webContents.executeJavaScript( @@ -447,8 +566,7 @@ async function authorizeProtectedAction( for (let attempt = 0; attempt < 3; attempt += 1) { const revision = await refreshCloseGuardFromRenderer(window); if (revision === null) return false; - if (hasCloseGuard() && !confirmProtectedAction(window, action)) - return false; + if (hasCloseGuard() && !confirmProtectedAction(window, action)) return false; if (await armRendererUnload(window, revision)) return true; } if (!window.isDestroyed()) { @@ -505,23 +623,18 @@ async function requestApplicationQuit(window: BrowserWindow): Promise { lifecycleCheckInFlight = true; try { if (!(await authorizeProtectedAction(window, "close"))) return false; + if (!(await confirmActiveImageRunsBeforeQuit(window))) return false; try { await computerUseSettings.shutdown(); } catch (error) { computerUseSettings.resumeAfterCancelledShutdown(); - logger.error( - "main", - "Computer Use state was not durable; quit was cancelled.", - error, - ); + logger.error("main", "Computer Use state was not durable; quit was cancelled.", error); if (!window.isDestroyed()) { dialog.showMessageBoxSync(window, { type: "error", title: "Aiden couldn't save Computer Use", - message: - "Aiden will stay open because Computer Use could not be safely turned off.", - detail: - "Check that the app can write its settings, then try quitting again.", + message: "Aiden will stay open because Computer Use could not be safely turned off.", + detail: "Check that the app can write its settings, then try quitting again.", buttons: ["Keep Aiden Open"], defaultId: 0, noLink: true, @@ -539,6 +652,7 @@ async function requestApplicationQuit(window: BrowserWindow): Promise { return shutdownStarted; } finally { lifecycleCheckInFlight = false; + if (!shutdownStarted) resumeCreateImagesAfterCancelledShutdown(); if (!shutdownStarted && installUpdateOnQuit) { installUpdateOnQuit = false; appUpdateService.announceSnapshot(); @@ -546,9 +660,7 @@ async function requestApplicationQuit(window: BrowserWindow): Promise { } } -async function clearRendererOnboardingCompletion( - window: BrowserWindow, -): Promise { +async function clearRendererOnboardingCompletion(window: BrowserWindow): Promise { try { return ( (await window.webContents.executeJavaScript( @@ -562,14 +674,8 @@ async function clearRendererOnboardingCompletion( )) === true ); } catch (error) { - logger.error( - "main", - "Could not clear the onboarding completion marker.", - error, - ); - throw new Error( - "Aiden couldn’t prepare onboarding for restart. Try again.", - ); + logger.error("main", "Could not clear the onboarding completion marker.", error); + throw new Error("Aiden couldn’t prepare onboarding for restart. Try again."); } } @@ -584,21 +690,12 @@ async function restoreRendererOnboardingCompletion( true, ); } catch (error) { - logger.error( - "main", - "Could not restore the onboarding completion marker.", - error, - ); + logger.error("main", "Could not restore the onboarding completion marker.", error); } } async function requestOnboardingReset(window: BrowserWindow): Promise { - if ( - lifecycleCheckInFlight || - shutdownStarted || - installUpdateOnQuit || - window.isDestroyed() - ) { + if (lifecycleCheckInFlight || shutdownStarted || installUpdateOnQuit || window.isDestroyed()) { return false; } lifecycleCheckInFlight = true; @@ -619,8 +716,7 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { dialog.showMessageBoxSync(window, { type: "error", title: "Aiden couldn't save Computer Use", - message: - "Onboarding was not reset because Computer Use could not be safely turned off.", + message: "Onboarding was not reset because Computer Use could not be safely turned off.", detail: "Check that the app can write its settings, then try again.", buttons: ["Keep Aiden Open"], defaultId: 0, @@ -630,8 +726,7 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { return false; } - const onboardingWasComplete = - await clearRendererOnboardingCompletion(window); + const onboardingWasComplete = await clearRendererOnboardingCompletion(window); protectedAction = "onboarding-reset"; if (!(await closeRendererBeforeShutdown(window))) { protectedAction = null; @@ -646,11 +741,7 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { computerUseSettings.resumeAfterCancelledShutdown(); settingsPrepared = false; protectedAction = null; - logger.error( - "main", - "Onboarding reset was incomplete after the renderer closed.", - error, - ); + logger.error("main", "Onboarding reset was incomplete after the renderer closed.", error); try { await createMainWindow(); } catch (recoveryError) { @@ -664,10 +755,8 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { dialog.showMessageBoxSync(mainWindow, { type: "error", title: "Aiden couldn't finish the reset", - message: - "Some setup data could not be cleared. Retry Reset onboarding.", - detail: - "Aiden reopened without deleting your chats, projects, schedules, or skills.", + message: "Some setup data could not be cleared. Retry Reset onboarding.", + detail: "Aiden reopened without deleting your chats, projects, schedules, or skills.", buttons: ["Keep Aiden Open"], defaultId: 0, noLink: true, @@ -693,22 +782,16 @@ async function requestOnboardingReset(window: BrowserWindow): Promise { } ipcMain.handle("app:setCloseGuard", (event, value: unknown) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - event.sender.id !== mainWindow.webContents.id - ) + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) return false; - const input = ( - typeof value === "object" && value !== null ? value : {} - ) as Record; + const input = (typeof value === "object" && value !== null ? value : {}) as Record< + string, + unknown + >; closeGuard = { dirty: input.dirty === true, gitBusy: input.gitBusy === true, - path: - typeof input.path === "string" && input.path.length <= 4_096 - ? input.path - : undefined, + path: typeof input.path === "string" && input.path.length <= 4_096 ? input.path : undefined, saving: input.saving === true, }; return true; @@ -716,21 +799,12 @@ ipcMain.handle("app:setCloseGuard", (event, value: unknown) => { ipcMain.handle("app:resetOnboarding", async (event) => { const window = mainWindow; - if ( - !window || - window.isDestroyed() || - event.sender.id !== window.webContents.id - ) - return false; + if (!window || window.isDestroyed() || event.sender.id !== window.webContents.id) return false; return requestOnboardingReset(window); }); ipcMain.handle("app:getUpdateState", (event) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - event.sender.id !== mainWindow.webContents.id - ) { + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { return { status: "idle", version: null, @@ -742,11 +816,7 @@ ipcMain.handle("app:getUpdateState", (event) => { ipcMain.handle( "app:checkForUpdates", async (event): Promise => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - event.sender.id !== mainWindow.webContents.id - ) { + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { return { outcome: "unavailable" }; } return appUpdateService.checkNow(false); @@ -754,11 +824,7 @@ ipcMain.handle( ); ipcMain.handle("app:restartToUpdate", (event): AppUpdateRestartResult => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - event.sender.id !== mainWindow.webContents.id - ) { + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { return { accepted: false, reason: "unavailable", @@ -786,19 +852,13 @@ ipcMain.handle("app:restartToUpdate", (event): AppUpdateRestartResult => { }); ipcMain.handle("app:renderer-ready", (event) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - event.sender.id !== mainWindow.webContents.id - ) + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) return false; rendererReadiness.markReady(); return true; }); -async function applyDockIconPreference( - preference: DockIconPreference, -): Promise { +async function applyDockIconPreference(preference: DockIconPreference): Promise { if (process.platform !== "darwin" || !app.dock) return false; const iconPath = preference === "monochrome" @@ -809,16 +869,13 @@ async function applyDockIconPreference( ? path.join(process.resourcesPath, "app-icon.png") : path.join(app.getAppPath(), "resources", "app-icon.png"); const icon = nativeImage.createFromPath(iconPath); - if (icon.isEmpty()) - throw new Error(`Dock icon is unavailable: ${path.basename(iconPath)}`); + if (icon.isEmpty()) throw new Error(`Dock icon is unavailable: ${path.basename(iconPath)}`); app.dock.setIcon(icon); await app.dock.show(); return true; } -async function restoreDockIconPreference( - preference: DockIconPreference, -): Promise { +async function restoreDockIconPreference(preference: DockIconPreference): Promise { try { await applyDockIconPreference(preference); } catch (error) { @@ -827,35 +884,22 @@ async function restoreDockIconPreference( try { await applyDockIconPreference("aiden"); } catch (fallbackError) { - logger.warn( - "main", - "Could not restore the default Dock icon", - fallbackError, - ); + logger.warn("main", "Could not restore the default Dock icon", fallbackError); } } } ipcMain.handle("app:setDockIcon", async (event, value: unknown) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - event.sender.id !== mainWindow.webContents.id - ) + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) return false; - if (value !== "aiden" && value !== "monochrome") - throw new Error("Invalid Dock icon preference."); + if (value !== "aiden" && value !== "monochrome") throw new Error("Invalid Dock icon preference."); return applyDockIconPreference(value); }); function openExternalUrl(value: string): void { try { const url = new URL(value); - if ( - url.protocol === "http:" || - url.protocol === "https:" || - url.protocol === "mailto:" - ) { + if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:") { void shell.openExternal(url.toString()); } } catch { @@ -893,9 +937,7 @@ async function createMainWindow(): Promise { show: false, webPreferences: { preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, + ...MAIN_WINDOW_SECURITY_PREFERENCES, }, }); resetRendererReadiness(); @@ -924,16 +966,10 @@ async function createMainWindow(): Promise { mainWindow !== createdWindow ) return; - const recovery = mainWindowLoads.replace( - createdWindow.loadURL(mainWindowUrl), - ); + const recovery = mainWindowLoads.replace(createdWindow.loadURL(mainWindowUrl)); void recovery.promise.catch((error: unknown) => { if (!mainWindowLoads.isCurrent(recovery)) return; - logger.error( - "main", - "Could not recover the main renderer after it exited.", - error, - ); + logger.error("main", "Could not recover the main renderer after it exited.", error); if (!createdWindow.isDestroyed()) createdWindow.destroy(); }); }); @@ -1069,15 +1105,9 @@ function deliverMainWindowNotificationSafely( channel: NotificationChannel, payload: Record, ): void { - void deliverMainWindowNotification(channel, payload).catch( - (error: unknown) => { - logger.warn( - "main", - `Could not deliver renderer command "${channel}".`, - error, - ); - }, - ); + void deliverMainWindowNotification(channel, payload).catch((error: unknown) => { + logger.warn("main", `Could not deliver renderer command "${channel}".`, error); + }); } function showMainWindow(): void { @@ -1087,9 +1117,7 @@ function showMainWindow(): void { } function pauseForPackagedSubagentSoak(): Promise { - return new Promise((resolve) => - setTimeout(resolve, SUBAGENT_PACKAGED_SOAK_POLL_MS), - ); + return new Promise((resolve) => setTimeout(resolve, SUBAGENT_PACKAGED_SOAK_POLL_MS)); } async function waitForPackagedSubagentSoak( @@ -1104,9 +1132,7 @@ async function waitForPackagedSubagentSoak( throw new Error(`Packaged subagent soak did not reach ${step}.`); } -async function runPackagedSubagentSoakRendererScript( - script: string, -): Promise { +async function runPackagedSubagentSoakRendererScript(script: string): Promise { const window = mainWindow; if (!window || window.isDestroyed()) { throw new Error("Packaged subagent soak lost its main window."); @@ -1126,18 +1152,13 @@ async function packagedSubagentSoakGenerationError(): Promise { return typeof result === "string" && result ? result : null; } -async function settlePackagedSubagentSoak( - session: SubagentPackagedSoakSession, -): Promise { +async function settlePackagedSubagentSoak(session: SubagentPackagedSoakSession): Promise { if (!(await llmClient.waitForChatIdle(SUBAGENT_PACKAGED_SOAK_CHAT_ID))) { - throw new Error( - "Packaged subagent soak did not settle its parent generation.", - ); + throw new Error("Packaged subagent soak did not settle its parent generation."); } await waitForPackagedSubagentSoak( "child settlement", - () => - !subagentRuntimeRegistry.hasChatChildren(SUBAGENT_PACKAGED_SOAK_CHAT_ID), + () => !subagentRuntimeRegistry.hasChatChildren(SUBAGENT_PACKAGED_SOAK_CHAT_ID), ); await subagentHealthMetrics.flush(); await writeSubagentPackagedSoakReceipt( @@ -1152,9 +1173,7 @@ async function settlePackagedSubagentSoak( * main-only and fixed-function: normal users have no new IPC, renderer API, or * automation endpoint. */ -async function runPackagedSubagentSoak( - session: SubagentPackagedSoakSession, -): Promise { +async function runPackagedSubagentSoak(session: SubagentPackagedSoakSession): Promise { await deliverMainWindowNotification("app:navigate", { path: SUBAGENT_PACKAGED_SOAK_CHAT_PATH, }); @@ -1164,35 +1183,26 @@ async function runPackagedSubagentSoak( await waitForPackagedSubagentSoak("child start", async () => { const generationError = await packagedSubagentSoakGenerationError(); if (generationError) { - throw new Error( - `Packaged subagent soak parent generation failed: ${generationError}`, - ); + throw new Error(`Packaged subagent soak parent generation failed: ${generationError}`); } - return subagentRuntimeRegistry.hasChatChildren( - SUBAGENT_PACKAGED_SOAK_CHAT_ID, - ); + return subagentRuntimeRegistry.hasChatChildren(SUBAGENT_PACKAGED_SOAK_CHAT_ID); }); // Ownership alone is intentionally insufficient: a child is registered // before it acquires a slot and dispatches provider work. Wait for Pi's // response callback so the loopback child request is actually in flight. await waitForPackagedSubagentSoak("child provider response", () => - subagentRuntimeRegistry.hasChatProviderResponse( - SUBAGENT_PACKAGED_SOAK_CHAT_ID, - ), + subagentRuntimeRegistry.hasChatProviderResponse(SUBAGENT_PACKAGED_SOAK_CHAT_ID), ); await waitForPackagedSubagentSoak( "aggregate child start", - async () => - (await subagentHealthMetrics.snapshotForPackagedSoak()).starts === 1, + async () => (await subagentHealthMetrics.snapshotForPackagedSoak()).starts === 1, ); const action = subagentPackagedSoakAction(session.control.mode); switch (action.kind) { case "renderer_stop": await waitForPackagedSubagentSoak("user stop", () => - runPackagedSubagentSoakRendererScript( - SUBAGENT_PACKAGED_SOAK_STOP_SCRIPT, - ), + runPackagedSubagentSoakRendererScript(SUBAGENT_PACKAGED_SOAK_STOP_SCRIPT), ); await settlePackagedSubagentSoak(session); return; @@ -1201,9 +1211,7 @@ async function runPackagedSubagentSoak( path: action.path, }); await waitForPackagedSubagentSoak("Settings navigation", () => - runPackagedSubagentSoakRendererScript( - SUBAGENT_PACKAGED_SOAK_SETTINGS_VISIBLE_SCRIPT, - ), + runPackagedSubagentSoakRendererScript(SUBAGENT_PACKAGED_SOAK_SETTINGS_VISIBLE_SCRIPT), ); await settlePackagedSubagentSoak(session); return; @@ -1218,19 +1226,13 @@ registerAppPathOpener(async (path) => { await deliverMainWindowNotification("app:navigate", { path }); }); -function setupApplicationMenu( - settings: AppSettings, - acceleratorsEnabled = true, -): void { +function setupApplicationMenu(settings: AppSettings, acceleratorsEnabled = true): void { if (!acceleratorsEnabled) { Menu.setApplicationMenu(null); return; } - const bindings = effectiveBindings( - migrateLegacyKeybindings(settings.keybindings, settings), - ); - const command = (commandId: keyof typeof bindings) => - bindings[commandId] ?? undefined; + const bindings = effectiveBindings(migrateLegacyKeybindings(settings.keybindings, settings)); + const command = (commandId: keyof typeof bindings) => bindings[commandId] ?? undefined; const menu = Menu.buildFromTemplate([ { label: app.getName(), @@ -1298,8 +1300,7 @@ function setupApplicationMenu( label: "Reload", accelerator: "Command+R", click: () => { - if (mainWindow && !mainWindow.isDestroyed()) - void requestWindowReload(mainWindow); + if (mainWindow && !mainWindow.isDestroyed()) void requestWindowReload(mainWindow); }, }, { @@ -1361,7 +1362,16 @@ if (!ownsSingleInstanceLock) { if (mainWindow && !mainWindow.isDestroyed()) { void requestApplicationQuit(mainWindow); } else { - void shutdownAndQuit(); + lifecycleCheckInFlight = true; + void (async () => { + try { + if (await confirmActiveImageRunsBeforeQuit()) { + await shutdownAndQuit(); + } + } finally { + if (!shutdownStarted) lifecycleCheckInFlight = false; + } + })(); } }); @@ -1388,21 +1398,14 @@ if (!ownsSingleInstanceLock) { reloadPortableConfig, async (previous, next) => { await Promise.all([ - reconcileExternalProviderCredentialChanges( - previous.providers, - next.providers, - ), - reconcileExternalMcpCredentialChanges( - previous.mcpServers, - next.mcpServers, - (serverId) => mcpManager.disconnect(serverId), + reconcileExternalProviderCredentialChanges(previous.providers, next.providers), + reconcileExternalMcpCredentialChanges(previous.mcpServers, next.mcpServers, (serverId) => + mcpManager.disconnect(serverId), ), ]); }, ); - setPortableCredentialSnapshotListener(() => - reloadAndReconcilePortableConfig.syncCurrent(), - ); + setPortableCredentialSnapshotListener(() => reloadAndReconcilePortableConfig.syncCurrent()); const portableConfigWatcher = createPortableConfigWatcher( reloadAndReconcilePortableConfig, () => { @@ -1410,30 +1413,33 @@ if (!ownsSingleInstanceLock) { ipcMain.broadcast("app:config-externally-changed", {}); }, (error: unknown) => - logger.warn( - "portable-config", - "Failed to re-read the portable config", - error, - ), + logger.warn("portable-config", "Failed to re-read the portable config", error), ); app .whenReady() .then(async () => { const runtimeProfile = currentRuntimeProfile(); - if ( - runtimeProfile.id === "development" && - process.platform === "darwin" - ) { + if (createImagesEnabled()) { + const images = createImagesService(); + await installCreateImagesAssetProtocol(images.grants, { + response: (assetId, rendition) => images.assetResponse(assetId, rendition), + }); + } + if (runtimeProfile.id === "development" && process.platform === "darwin") { app.dock?.setBadge("DEV"); } const packagedSubagentSoak = await loadSubagentPackagedSoakSession({ isPackaged: isPackagedRuntime(), }); + const packagedCreateImagesAcceptance = await loadCreateImagesPackagedAcceptanceSession({ + isPackaged: isPackagedRuntime(), + }); + if (packagedSubagentSoak && packagedCreateImagesAcceptance) { + throw new Error("Only one packaged acceptance session may run at a time."); + } if (packagedSubagentSoak && !subagentsEnabled()) { - throw new Error( - "Packaged subagent soak requires the internal subagent opt-in.", - ); + throw new Error("Packaged subagent soak requires the internal subagent opt-in."); } if (!isPackagedRuntime()) { logger.info("dev-log", `Writing dev log to ${devLogPath() ?? "unknown"}`); @@ -1448,9 +1454,7 @@ if (!ownsSingleInstanceLock) { }); } try { - terminalService.installHistoryStore( - await TerminalHistoryStore.create(), - ); + terminalService.installHistoryStore(await TerminalHistoryStore.create()); } catch (error) { logger.warn( "terminal", @@ -1467,9 +1471,7 @@ if (!ownsSingleInstanceLock) { await piCompactionSessionStore.deleteChat(chatId); await chatStore.remove(chatId); }); - const visibleChatIds = new Set( - (await chatStore.list()).map((chat) => chat.id), - ); + const visibleChatIds = new Set((await chatStore.list()).map((chat) => chat.id)); await Promise.all([ piRuntimeEffectStore.reconcileChats(visibleChatIds), piCompactionSessionStore.reconcileChats(visibleChatIds), @@ -1478,16 +1480,14 @@ if (!ownsSingleInstanceLock) { listWorkspaces: () => configStore.listWorkspaces(), deletionPending: (workspace) => { const managed = workspace.managedWorktree; - if (!managed?.worktreeGitDir || !managed.ownershipToken) - return Promise.resolve(false); + if (!managed?.worktreeGitDir || !managed.ownershipToken) return Promise.resolve(false); return gitManagedWorktreeDeletionPending( managed.worktreePath, managed.worktreeGitDir, managed.ownershipToken, ); }, - blockWorkspace: (workspaceId) => - scheduleService.cancelWorkspace(workspaceId), + blockWorkspace: (workspaceId) => scheduleService.cancelWorkspace(workspaceId), deleteWorktree: async (workspace) => { const managed = workspace.managedWorktree!; await gitDeleteManagedWorktree( @@ -1502,8 +1502,7 @@ if (!ownsSingleInstanceLock) { managed.worktreeInode, ); }, - removeWorkspaceRecord: (workspaceId) => - configStore.removeWorkspace(workspaceId), + removeWorkspaceRecord: (workspaceId) => configStore.removeWorkspace(workspaceId), finalizeDeletion: async (workspace) => { const managed = workspace.managedWorktree!; await gitFinalizeManagedWorktreeDeletion( @@ -1546,11 +1545,7 @@ if (!ownsSingleInstanceLock) { try { await reconcilePendingMcpCredentialCleanup(); } catch (error) { - logger.error( - "mcp", - "Could not reconcile an interrupted MCP credential cleanup.", - error, - ); + logger.error("mcp", "Could not reconcile an interrupted MCP credential cleanup.", error); } const appearance = normalizeAppearanceConfig(settings.appearance); nativeTheme.themeSource = appearance.mode; @@ -1562,11 +1557,7 @@ if (!ownsSingleInstanceLock) { void deliverMainWindowNotification("app:command", { commandId: "composer.focus", }).catch((error: unknown) => { - logger.warn( - "shortcut", - "Could not focus the composer from the global shortcut", - error, - ); + logger.warn("shortcut", "Could not focus the composer from the global shortcut", error); }); }); initDictationShortcut(() => { @@ -1576,11 +1567,7 @@ if (!ownsSingleInstanceLock) { void deliverMainWindowNotification("app:command", { commandId: "assistant.open", }).catch((error: unknown) => { - logger.warn( - "assistant", - "Could not open Aiden from the global shortcut", - error, - ); + logger.warn("assistant", "Could not open Aiden from the global shortcut", error); }); }); try { @@ -1599,13 +1586,32 @@ if (!ownsSingleInstanceLock) { // The active profile's portable config is user-editable, so pick // hand-edits up without a restart. Registered after whenReady because // powerMonitor is only usable once the app is ready. - app.on( - "browser-window-focus", - () => void portableConfigWatcher.refresh(), - ); + app.on("browser-window-focus", () => void portableConfigWatcher.refresh()); powerMonitor.on("resume", () => void portableConfigWatcher.refresh()); await createMainWindow(); + if (packagedCreateImagesAcceptance) { + const { runPackagedCreateImagesAcceptance } = + await import("./services/create-images/packaged-canvas-acceptance-runner.js"); + const acceptanceWindow = mainWindow; + if (!acceptanceWindow || acceptanceWindow.isDestroyed()) { + throw new Error("Packaged Create Images acceptance requires a live main window."); + } + await runPackagedCreateImagesAcceptance(packagedCreateImagesAcceptance, { + window: acceptanceWindow, + runtimeProfile: currentRuntimeProfile(), + reloadRenderer: async () => { + resetRendererReadiness(); + acceptanceWindow.webContents.reload(); + await rendererReadiness.wait(); + }, + navigate: (path) => deliverMainWindowNotification("app:navigate", { path }), + }); + forceAppQuit = true; + protectedAction = "quit"; + app.quit(); + return; + } if (packagedSubagentSoak) { await runPackagedSubagentSoak(packagedSubagentSoak); return; diff --git a/main/runtime-profile-bootstrap.test.ts b/main/runtime-profile-bootstrap.test.ts index b91e7281..1264263c 100644 --- a/main/runtime-profile-bootstrap.test.ts +++ b/main/runtime-profile-bootstrap.test.ts @@ -18,7 +18,9 @@ test("the Electron build enters through the profile bootstrap", () => { new URL("../scripts/build-electron.mjs", import.meta.url), "utf8", ); - assert.match(buildScript, /entryPoints: \["main\/bootstrap\.ts"\]/u); + assert.match(buildScript, /entryPoints: \{ index: "main\/bootstrap\.ts" \}/u); + assert.match(buildScript, /outdir: "build\/main"/u); + assert.match(buildScript, /splitting: true/u); }); test("development shortcut registration is gated without removing in-app menu accelerators", () => { diff --git a/main/services/create-images/asset-delivery-core.ts b/main/services/create-images/asset-delivery-core.ts new file mode 100644 index 00000000..663d9469 --- /dev/null +++ b/main/services/create-images/asset-delivery-core.ts @@ -0,0 +1,224 @@ +import { randomBytes } from "node:crypto"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; + +export const ASSET_DELIVERY_GRANT_TTL_MS = 60_000; + +export interface AssetDeliveryGrantLease { + expiresAt: number; + release(): void; +} + +interface AssetDeliveryGrant { + token: string; + documentId: string; + assetId: string; + expiresAt: number; + createdAt: number; + owner: RendererDocumentOwner; + isAuthorized: (assetId: string) => boolean; + disposeInvalidation: () => void; + lease: AssetDeliveryGrantLease; +} + +interface AuthorizedProtocolRequest { + assetId: string; + expiresAt: number; + remaining: number; +} + +export interface AssetDeliveryGrantView { + token: string; + expiresAt: number; +} + +/** + * Opaque, document-bound grants for a future aiden-asset protocol. No local + * path or asset identifier is encoded in the renderer-visible token. + */ +export class AssetDeliveryGrantRegistry { + private readonly grants = new Map(); + private readonly protocolRequests = new Map(); + + constructor( + private readonly now: () => number = Date.now, + private readonly ttlMs = ASSET_DELIVERY_GRANT_TTL_MS, + // A v1 workflow may reference 2,000 distinct assets. Leave room for the + // renderer's bounded atomic-renewal overlap without evicting live previews. + private readonly maxGrants = 4_096, + ) { + if (!Number.isFinite(ttlMs) || ttlMs < 1_000 || ttlMs > 5 * 60_000) { + throw new Error("Asset delivery grants require a 1–300 second lifetime."); + } + if (!Number.isInteger(maxGrants) || maxGrants < 1 || maxGrants > 10_000) { + throw new Error("Invalid asset delivery grant capacity."); + } + } + + private pruneExpired(): void { + const now = this.now(); + for (const [token, grant] of this.grants) { + if (grant.expiresAt <= now || grant.owner.isDestroyed()) this.deleteGrant(token); + } + for (const [token, request] of this.protocolRequests) { + if (request.expiresAt <= now || !this.grants.has(token)) this.protocolRequests.delete(token); + } + } + + private deleteGrant(token: string): boolean { + const grant = this.grants.get(token); + if (!grant) return false; + this.grants.delete(token); + this.protocolRequests.delete(token); + grant.disposeInvalidation(); + this.releaseLease(grant.lease); + return true; + } + + private releaseLease(lease: AssetDeliveryGrantLease): void { + try { + lease.release(); + } catch { + // A grant must still disappear if best-effort lease cleanup reports an + // error. The lease expiry remains the GC-safe backstop. + } + } + + mint( + owner: RendererDocumentOwner, + assetId: string, + isAuthorized: (assetId: string) => boolean, + lease: AssetDeliveryGrantLease, + ): AssetDeliveryGrantView { + if (!OPAQUE_ID_PATTERN.test(assetId)) { + this.releaseLease(lease); + throw new Error("Asset delivery grants require opaque asset IDs."); + } + if (!owner.documentId || owner.documentId.length > 512 || owner.isDestroyed()) { + this.releaseLease(lease); + throw new Error("Asset delivery grants require a live renderer document owner."); + } + if (!isAuthorized(assetId)) { + this.releaseLease(lease); + throw new Error("The renderer document is not authorized to access this asset."); + } + if (!Number.isFinite(lease.expiresAt) || lease.expiresAt <= this.now()) { + this.releaseLease(lease); + throw new Error("Asset delivery grants require a live preview lease."); + } + this.pruneExpired(); + while (this.grants.size >= this.maxGrants) { + const oldest = [...this.grants.values()].sort( + (left, right) => left.createdAt - right.createdAt, + )[0]; + if (!oldest) break; + this.deleteGrant(oldest.token); + } + const createdAt = this.now(); + const token = randomBytes(32).toString("base64url"); + const grant: AssetDeliveryGrant = { + token, + documentId: owner.documentId, + assetId, + createdAt, + expiresAt: Math.min(createdAt + this.ttlMs, lease.expiresAt), + owner, + isAuthorized, + disposeInvalidation: () => undefined, + lease, + }; + this.grants.set(token, grant); + grant.disposeInvalidation = owner.onInvalidated(() => this.deleteGrant(token)); + return { token, expiresAt: grant.expiresAt }; + } + + resolve(token: string, owner: RendererDocumentOwner): string | undefined { + this.pruneExpired(); + const grant = this.grants.get(token); + if ( + !grant || + owner.isDestroyed() || + grant.owner.isDestroyed() || + grant.documentId !== owner.documentId || + grant.owner.id !== owner.id + ) { + return undefined; + } + try { + if (!grant.isAuthorized(grant.assetId)) { + this.deleteGrant(token); + return undefined; + } + } catch { + this.deleteGrant(token); + return undefined; + } + return grant.assetId; + } + + /** + * Authorize a single protocol request from the exact frame document that + * received this grant. The protocol handler must subsequently consume the + * ticket; calling the handler directly cannot resolve a renderer grant. + */ + authorizeProtocolRequest(token: string, webContentsId: number, documentId: string): boolean { + this.pruneExpired(); + const grant = this.grants.get(token); + if ( + !grant || + grant.owner.isDestroyed() || + grant.owner.id !== webContentsId || + grant.documentId !== documentId + ) { + return false; + } + try { + if (!grant.isAuthorized(grant.assetId)) { + this.deleteGrant(token); + return false; + } + } catch { + this.deleteGrant(token); + return false; + } + const current = this.protocolRequests.get(token); + this.protocolRequests.set(token, { + assetId: grant.assetId, + expiresAt: Math.min(grant.expiresAt, this.now() + 10_000), + remaining: Math.min(8, (current?.remaining ?? 0) + 1), + }); + return true; + } + + consumeProtocolRequest(token: string): string | undefined { + this.pruneExpired(); + const request = this.protocolRequests.get(token); + if (!request || request.remaining < 1) return undefined; + if (request.remaining === 1) this.protocolRequests.delete(token); + else this.protocolRequests.set(token, { ...request, remaining: request.remaining - 1 }); + return request.assetId; + } + + revoke(token: string, owner: RendererDocumentOwner): boolean { + const grant = this.grants.get(token); + if (!grant || grant.documentId !== owner.documentId || grant.owner.id !== owner.id) + return false; + return this.deleteGrant(token); + } + + revokeDocument(owner: RendererDocumentOwner): number { + let revoked = 0; + for (const [token, grant] of this.grants) { + if (grant.documentId !== owner.documentId || grant.owner.id !== owner.id) continue; + this.deleteGrant(token); + revoked += 1; + } + return revoked; + } + + size(): number { + this.pruneExpired(); + return this.grants.size; + } +} diff --git a/main/services/create-images/asset-image-validation-core.ts b/main/services/create-images/asset-image-validation-core.ts new file mode 100644 index 00000000..c311aea5 --- /dev/null +++ b/main/services/create-images/asset-image-validation-core.ts @@ -0,0 +1,343 @@ +import path from "node:path"; + +export type SafeAssetMediaType = "image/jpeg" | "image/png"; +export type SafeAssetExtension = "jpg" | "png"; + +export interface AssetImageLimits { + maxWidth: number; + maxHeight: number; + maxPixels: number; +} + +export interface ValidatedImageDescriptor { + mediaType: SafeAssetMediaType; + extension: SafeAssetExtension; + width: number; + height: number; + pixels: number; +} + +export class AssetImageValidationError extends Error { + constructor( + public readonly code: + | "unsupported_format" + | "mime_mismatch" + | "extension_mismatch" + | "truncated_image" + | "malformed_image" + | "image_dimensions_exceeded", + message: string, + ) { + super(message); + this.name = "AssetImageValidationError"; + } +} + +const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return crc >>> 0; +}); +const PNG_BIT_DEPTHS: Readonly> = { + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], +}; + +function readU32(bytes: Uint8Array, offset: number): number { + return ( + bytes[offset]! * 0x1_000_000 + + bytes[offset + 1]! * 0x1_0000 + + bytes[offset + 2]! * 0x100 + + bytes[offset + 3]! + ); +} + +function crc32(bytes: Uint8Array, start: number, end: number): number { + let crc = 0xffff_ffff; + for (let index = start; index < end; index += 1) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ bytes[index]!) & 0xff]!; + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function assertDimensions(width: number, height: number, limits: AssetImageLimits): void { + const pixels = width * height; + if ( + !Number.isSafeInteger(width) || + !Number.isSafeInteger(height) || + width < 1 || + height < 1 || + width > limits.maxWidth || + height > limits.maxHeight || + !Number.isSafeInteger(pixels) || + pixels > limits.maxPixels + ) { + throw new AssetImageValidationError( + "image_dimensions_exceeded", + "The image dimensions exceed Aiden's configured safety limit.", + ); + } +} + +function isPng(bytes: Uint8Array): boolean { + return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte); +} + +function validatePng(bytes: Uint8Array, limits: AssetImageLimits): ValidatedImageDescriptor { + if (bytes.byteLength < 33) { + throw new AssetImageValidationError("truncated_image", "The PNG file is truncated."); + } + let offset = PNG_SIGNATURE.byteLength; + let width = 0; + let height = 0; + let colorType = -1; + let sawHeader = false; + let sawPalette = false; + let sawImageData = false; + let sawEnd = false; + let chunkCount = 0; + while (offset < bytes.byteLength) { + if (offset + 12 > bytes.byteLength) { + throw new AssetImageValidationError("truncated_image", "The PNG chunk header is truncated."); + } + const length = readU32(bytes, offset); + const typeStart = offset + 4; + const dataStart = offset + 8; + const dataEnd = dataStart + length; + const chunkEnd = dataEnd + 4; + if (!Number.isSafeInteger(chunkEnd) || chunkEnd > bytes.byteLength) { + throw new AssetImageValidationError("truncated_image", "The PNG chunk body is truncated."); + } + const type = String.fromCharCode(...bytes.subarray(typeStart, typeStart + 4)); + if (!/^[A-Za-z]{4}$/u.test(type)) { + throw new AssetImageValidationError( + "malformed_image", + "The PNG contains an invalid chunk type.", + ); + } + if (crc32(bytes, typeStart, dataEnd) !== readU32(bytes, dataEnd)) { + throw new AssetImageValidationError("malformed_image", "The PNG contains a corrupt chunk."); + } + chunkCount += 1; + if (chunkCount > 10_000) { + throw new AssetImageValidationError("malformed_image", "The PNG contains too many chunks."); + } + if (!sawHeader && type !== "IHDR") { + throw new AssetImageValidationError( + "malformed_image", + "The PNG header is not the first chunk.", + ); + } + if (type === "IHDR") { + if (sawHeader || length !== 13) { + throw new AssetImageValidationError("malformed_image", "The PNG header is malformed."); + } + width = readU32(bytes, dataStart); + height = readU32(bytes, dataStart + 4); + const bitDepth = bytes[dataStart + 8]!; + colorType = bytes[dataStart + 9]!; + if ( + !PNG_BIT_DEPTHS[colorType]?.includes(bitDepth) || + bytes[dataStart + 10] !== 0 || + bytes[dataStart + 11] !== 0 || + (bytes[dataStart + 12] !== 0 && bytes[dataStart + 12] !== 1) + ) { + throw new AssetImageValidationError( + "malformed_image", + "The PNG header uses unsupported values.", + ); + } + assertDimensions(width, height, limits); + sawHeader = true; + } else if (type === "PLTE") { + if (sawImageData || length < 3 || length > 768 || length % 3 !== 0) { + throw new AssetImageValidationError("malformed_image", "The PNG palette is malformed."); + } + sawPalette = true; + } else if (type === "IDAT") { + if (colorType === 3 && !sawPalette) { + throw new AssetImageValidationError("malformed_image", "The indexed PNG has no palette."); + } + sawImageData = true; + } else if (type === "IEND") { + if (length !== 0 || !sawImageData || chunkEnd !== bytes.byteLength) { + throw new AssetImageValidationError("malformed_image", "The PNG end marker is malformed."); + } + sawEnd = true; + } else if (/^[A-Z]/u.test(type) || type === "acTL" || type === "fcTL" || type === "fdAT") { + throw new AssetImageValidationError( + "unsupported_format", + "Only static PNG images with known critical chunks are supported.", + ); + } + offset = chunkEnd; + if (sawEnd) break; + } + if (!sawHeader || !sawImageData || !sawEnd) { + throw new AssetImageValidationError("truncated_image", "The PNG file is incomplete."); + } + return { mediaType: "image/png", extension: "png", width, height, pixels: width * height }; +} + +const UNSUPPORTED_SOF = new Set([0xc1, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]); + +function validateJpeg(bytes: Uint8Array, limits: AssetImageLimits): ValidatedImageDescriptor { + if (bytes.byteLength < 8 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + throw new AssetImageValidationError("truncated_image", "The JPEG file is truncated."); + } + let offset = 2; + let width = 0; + let height = 0; + let sawFrame = false; + let sawScan = false; + let inEntropy = false; + while (offset < bytes.byteLength) { + if (!inEntropy) { + if (bytes[offset] !== 0xff) { + throw new AssetImageValidationError( + "malformed_image", + "The JPEG marker stream is malformed.", + ); + } + while (bytes[offset] === 0xff) offset += 1; + } else { + while (offset < bytes.byteLength && bytes[offset] !== 0xff) offset += 1; + if (offset >= bytes.byteLength) break; + while (bytes[offset] === 0xff) offset += 1; + if (bytes[offset] === 0x00) { + offset += 1; + continue; + } + if (bytes[offset]! >= 0xd0 && bytes[offset]! <= 0xd7) { + offset += 1; + continue; + } + inEntropy = false; + } + if (offset >= bytes.byteLength) break; + const marker = bytes[offset]!; + offset += 1; + if (marker === 0xd9) { + if (!sawFrame || !sawScan || offset !== bytes.byteLength) { + throw new AssetImageValidationError("malformed_image", "The JPEG end marker is malformed."); + } + return { + mediaType: "image/jpeg", + extension: "jpg", + width, + height, + pixels: width * height, + }; + } + if (marker === 0xd8 || marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) { + throw new AssetImageValidationError( + "malformed_image", + "The JPEG contains an invalid marker.", + ); + } + if (offset + 2 > bytes.byteLength) { + throw new AssetImageValidationError( + "truncated_image", + "The JPEG segment header is truncated.", + ); + } + const length = bytes[offset]! * 256 + bytes[offset + 1]!; + if (length < 2 || offset + length > bytes.byteLength) { + throw new AssetImageValidationError("truncated_image", "The JPEG segment is truncated."); + } + const dataStart = offset + 2; + if (marker === 0xc0 || marker === 0xc2) { + if (sawFrame || length < 11 || bytes[dataStart] !== 8) { + throw new AssetImageValidationError("malformed_image", "The JPEG frame is malformed."); + } + height = bytes[dataStart + 1]! * 256 + bytes[dataStart + 2]!; + width = bytes[dataStart + 3]! * 256 + bytes[dataStart + 4]!; + const components = bytes[dataStart + 5]!; + if (![1, 3, 4].includes(components) || length !== 8 + components * 3) { + throw new AssetImageValidationError( + "malformed_image", + "The JPEG frame components are malformed.", + ); + } + assertDimensions(width, height, limits); + sawFrame = true; + } else if (UNSUPPORTED_SOF.has(marker)) { + throw new AssetImageValidationError( + "unsupported_format", + "Only baseline and progressive 8-bit JPEG images are supported.", + ); + } + if (marker === 0xda) { + if (!sawFrame) { + throw new AssetImageValidationError("malformed_image", "The JPEG scan precedes its frame."); + } + sawScan = true; + inEntropy = true; + } + offset += length; + } + throw new AssetImageValidationError( + "truncated_image", + "The JPEG file has no complete end marker.", + ); +} + +function normalizedDeclaredMime(value: string | undefined): string | undefined { + return value?.split(";", 1)[0]?.trim().toLowerCase(); +} + +export function sanitizeAssetDisplayName(value: string | undefined): string | undefined { + if (!value) return undefined; + const base = [...path.basename(value.replace(/\\/gu, "/"))] + .filter((character) => character.codePointAt(0)! >= 32 && character.codePointAt(0) !== 127) + .join("") + .trim(); + if (!base) return undefined; + return base.slice(0, 255); +} + +export function validateImageBytes( + bytes: Uint8Array, + declaredMimeType: string | undefined, + displayName: string | undefined, + limits: AssetImageLimits, +): ValidatedImageDescriptor { + const descriptor = isPng(bytes) + ? validatePng(bytes, limits) + : bytes[0] === 0xff && bytes[1] === 0xd8 + ? validateJpeg(bytes, limits) + : (() => { + throw new AssetImageValidationError( + "unsupported_format", + "Only validated static PNG and JPEG images are supported.", + ); + })(); + const mime = normalizedDeclaredMime(declaredMimeType); + if (mime !== undefined && mime !== descriptor.mediaType) { + throw new AssetImageValidationError( + "mime_mismatch", + "The declared media type does not match the image contents.", + ); + } + const safeName = sanitizeAssetDisplayName(displayName); + const extension = safeName ? path.extname(safeName).slice(1).toLowerCase() : undefined; + if ( + extension && + !( + (descriptor.mediaType === "image/png" && extension === "png") || + (descriptor.mediaType === "image/jpeg" && (extension === "jpg" || extension === "jpeg")) + ) + ) { + throw new AssetImageValidationError( + "extension_mismatch", + "The filename extension does not match the image contents.", + ); + } + return descriptor; +} diff --git a/main/services/create-images/asset-import-normalization-core.test.ts b/main/services/create-images/asset-import-normalization-core.test.ts new file mode 100644 index 00000000..a024b518 --- /dev/null +++ b/main/services/create-images/asset-import-normalization-core.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createImagesCanonicalValidationName, + createImagesImportSourcePolicy, +} from "./asset-import-normalization-core.js"; + +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function gif(frames: number): Uint8Array { + const header = [...bytes("GIF89a"), 1, 0, 1, 0, 0, 0, 0]; + const image = [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0]; + return Uint8Array.from([...header, ...Array.from({ length: frames }, () => image).flat(), 0x3b]); +} + +test("classifies canonical and sandbox-normalized static raster formats", () => { + assert.deepEqual( + createImagesImportSourcePolicy( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + "photo.unknown", + ), + { kind: "canonical", format: "png" }, + ); + assert.deepEqual(createImagesImportSourcePolicy(Uint8Array.from([0xff, 0xd8]), "photo.bin"), { + kind: "canonical", + format: "jpeg", + }); + assert.deepEqual( + createImagesImportSourcePolicy(bytes("RIFF\u0004\u0000\u0000\u0000WEBPVP8 "), "photo.webp"), + { kind: "normalize", format: "webp" }, + ); + assert.deepEqual(createImagesImportSourcePolicy(bytes("BMstatic"), "photo.bmp"), { + kind: "normalize", + format: "bmp", + }); + assert.deepEqual(createImagesImportSourcePolicy(bytes("unknown"), "photo.heic"), { + kind: "normalize", + format: "heic", + }); + assert.deepEqual(createImagesImportSourcePolicy(gif(1), "photo.gif"), { + kind: "normalize", + format: "gif", + }); +}); + +test("rejects vector and animation-bearing sources before sandbox conversion", () => { + assert.deepEqual(createImagesImportSourcePolicy(bytes(""), "image.txt"), { + kind: "reject", + reason: "vector", + }); + assert.deepEqual(createImagesImportSourcePolicy(gif(2), "image.bin"), { + kind: "reject", + reason: "animated", + }); + assert.deepEqual( + createImagesImportSourcePolicy( + bytes("RIFF\u0004\u0000\u0000\u0000WEBPANIM\u0000\u0000\u0000\u0000"), + "image.webp", + ), + { kind: "reject", reason: "animated" }, + ); + assert.deepEqual(createImagesImportSourcePolicy(bytes("anything"), "image.svgz"), { + kind: "reject", + reason: "vector", + }); +}); + +test("creates a bounded canonical validation name without exposing a path", () => { + assert.equal(createImagesCanonicalValidationName("/private/example.WEBP", "png"), "example.png"); + assert.equal(createImagesCanonicalValidationName(undefined, "jpg"), "image.jpg"); + assert.ok(createImagesCanonicalValidationName(`${"a".repeat(500)}.tiff`, "png").length <= 240); +}); diff --git a/main/services/create-images/asset-import-normalization-core.ts b/main/services/create-images/asset-import-normalization-core.ts new file mode 100644 index 00000000..54c6228f --- /dev/null +++ b/main/services/create-images/asset-import-normalization-core.ts @@ -0,0 +1,166 @@ +import path from "node:path"; + +export type CreateImagesImportSourcePolicy = + | { kind: "canonical"; format: "jpeg" | "png" } + | { kind: "normalize"; format: string } + | { kind: "reject"; reason: "animated" | "vector" }; + +function ascii(bytes: Uint8Array, start: number, end: number): string { + return String.fromCharCode(...bytes.subarray(start, Math.min(end, bytes.byteLength))); +} + +function littleEndianU32(bytes: Uint8Array, offset: number): number { + return ( + bytes[offset]! + + bytes[offset + 1]! * 0x100 + + bytes[offset + 2]! * 0x1_0000 + + bytes[offset + 3]! * 0x1_000_000 + ); +} + +function animatedWebp(bytes: Uint8Array): boolean { + let offset = 12; + let chunks = 0; + while (offset + 8 <= bytes.byteLength && chunks < 10_000) { + const type = ascii(bytes, offset, offset + 4); + const length = littleEndianU32(bytes, offset + 4); + if (!Number.isSafeInteger(length)) return false; + if (type === "ANIM" || type === "ANMF") return true; + const next = offset + 8 + length + (length % 2); + if (!Number.isSafeInteger(next) || next <= offset || next > bytes.byteLength) return false; + offset = next; + chunks += 1; + } + return false; +} + +function skipGifSubBlocks(bytes: Uint8Array, start: number): number | undefined { + let offset = start; + let blocks = 0; + while (offset < bytes.byteLength && blocks < 100_000) { + const length = bytes[offset]!; + offset += 1; + if (length === 0) return offset; + if (offset + length > bytes.byteLength) return undefined; + offset += length; + blocks += 1; + } + return undefined; +} + +/** Return true only when a structurally-walkable GIF contains multiple frames. */ +function animatedGif(bytes: Uint8Array): boolean { + if (bytes.byteLength < 13) return false; + let offset = 13; + const globalColorTable = (bytes[10]! & 0x80) !== 0; + if (globalColorTable) offset += 3 * 2 ** ((bytes[10]! & 0x07) + 1); + let frames = 0; + let blocks = 0; + while (offset < bytes.byteLength && blocks < 100_000) { + const marker = bytes[offset]!; + offset += 1; + if (marker === 0x3b) return false; + if (marker === 0x21) { + if (offset >= bytes.byteLength) return false; + offset += 1; + const next = skipGifSubBlocks(bytes, offset); + if (next === undefined) return false; + offset = next; + } else if (marker === 0x2c) { + if (offset + 9 > bytes.byteLength) return false; + const localColorTable = (bytes[offset + 8]! & 0x80) !== 0; + const localColorTableBytes = localColorTable ? 3 * 2 ** ((bytes[offset + 8]! & 0x07) + 1) : 0; + offset += 9 + localColorTableBytes; + if (offset >= bytes.byteLength) return false; + offset += 1; + const next = skipGifSubBlocks(bytes, offset); + if (next === undefined) return false; + offset = next; + frames += 1; + if (frames > 1) return true; + } else { + return false; + } + blocks += 1; + } + return false; +} + +function looksLikeSvg(bytes: Uint8Array): boolean { + const prefix = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }) + .decode(bytes.subarray(0, Math.min(bytes.byteLength, 16 * 1024))) + .toLowerCase(); + return /)/u.test(prefix); +} + +function extension(value: string | undefined): string { + return value ? path.extname(value).slice(1).toLowerCase() : ""; +} + +/** + * Classify only policy-sensitive formats. The disposable Chromium decoder is + * the authority for whether every other static raster can actually be decoded. + */ +export function createImagesImportSourcePolicy( + bytes: Uint8Array, + displayName?: string, +): CreateImagesImportSourcePolicy { + const fileExtension = extension(displayName); + if (fileExtension === "svg" || fileExtension === "svgz" || looksLikeSvg(bytes)) { + return { kind: "reject", reason: "vector" }; + } + if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a") { + return animatedGif(bytes) + ? { kind: "reject", reason: "animated" } + : { kind: "normalize", format: "gif" }; + } + if (fileExtension === "gif") { + return { kind: "normalize", format: "gif" }; + } + if ( + bytes.byteLength >= 8 && + bytes[0] === 0x89 && + ascii(bytes, 1, 4) === "PNG" && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return { kind: "canonical", format: "png" }; + } + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + return { kind: "canonical", format: "jpeg" }; + } + if (bytes.byteLength >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") { + return animatedWebp(bytes) + ? { kind: "reject", reason: "animated" } + : { kind: "normalize", format: "webp" }; + } + if (bytes.byteLength >= 12 && ascii(bytes, 4, 8) === "ftyp") { + const brand = ascii(bytes, 8, 12).toLowerCase(); + if (["avis", "hevc", "hevx", "msf1"].includes(brand)) { + return { kind: "reject", reason: "animated" }; + } + return { kind: "normalize", format: brand || "isobmff" }; + } + if (bytes[0] === 0x42 && bytes[1] === 0x4d) { + return { kind: "normalize", format: "bmp" }; + } + if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) { + return { kind: "normalize", format: "ico" }; + } + if (ascii(bytes, 0, 4) === "II*\0" || ascii(bytes, 0, 4) === "MM\0*") { + return { kind: "normalize", format: "tiff" }; + } + return { kind: "normalize", format: fileExtension || "unknown" }; +} + +export function createImagesCanonicalValidationName( + displayName: string | undefined, + extension: "jpg" | "png", +): string { + const base = path.basename((displayName || "image").replace(/\\/gu, "/")); + const parsed = path.parse(base); + const stem = (parsed.name || "image").slice(0, 239 - extension.length); + return `${stem}.${extension}`; +} diff --git a/main/services/create-images/asset-protocol-core.test.ts b/main/services/create-images/asset-protocol-core.test.ts new file mode 100644 index 00000000..af3ef813 --- /dev/null +++ b/main/services/create-images/asset-protocol-core.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + authorizeCreateImagesAssetRequest, + createImagesProtocolDocumentId, + parseCreateImagesAssetProtocolRequest, + parseCreateImagesAssetProtocolToken, +} from "./asset-protocol-core.js"; + +test("asset protocol accepts only the canonical opaque grant URL", () => { + const token = "A".repeat(43); + assert.equal(parseCreateImagesAssetProtocolToken(`aiden-asset://asset/${token}`), token); + assert.deepEqual(parseCreateImagesAssetProtocolRequest(`aiden-asset://asset/${token}/original`), { + token, + rendition: "original", + }); + assert.deepEqual(parseCreateImagesAssetProtocolRequest(`aiden-asset://asset/${token}`), { + token, + rendition: "preview", + }); + assert.deepEqual(parseCreateImagesAssetProtocolRequest(`aiden-asset://asset/${token}/preview-128`), { + token, + rendition: "preview-128", + }); + assert.deepEqual(parseCreateImagesAssetProtocolRequest(`aiden-asset://asset/${token}/preview-256`), { + token, + rendition: "preview-256", + }); + for (const url of [ + `aiden-asset://other/${token}`, + `aiden-asset://asset/${token}?path=/tmp/private`, + `aiden-asset://asset/${token}/original/extra`, + "aiden-asset://asset/../../etc/passwd", + "file:///tmp/private", + ]) { + assert.equal(parseCreateImagesAssetProtocolToken(url), undefined, url); + } +}); + +test("asset protocol document identity accepts only a live main frame", () => { + const frame = { + processId: 12, + routingId: 34, + frameToken: "frame", + parent: null, + detached: false, + }; + assert.equal(createImagesProtocolDocumentId(frame), "12:34:frame"); + assert.equal(createImagesProtocolDocumentId({ ...frame, parent: {} }), undefined); + assert.equal(createImagesProtocolDocumentId({ ...frame, detached: true }), undefined); +}); + +test("asset protocol authorization requires a GET image request from the exact main document", () => { + const frame = { + processId: 4, + routingId: 8, + frameToken: "frame-token", + parent: null, + detached: false, + }; + let observed: readonly unknown[] = []; + const allowed = authorizeCreateImagesAssetRequest( + { + url: `aiden-asset://asset/${"a".repeat(43)}`, + method: "GET", + resourceType: "image", + webContentsId: 12, + frame, + }, + (...values) => { + observed = values; + return true; + }, + ); + assert.equal(allowed, true); + assert.deepEqual(observed, ["a".repeat(43), 12, "4:8:frame-token"]); + assert.equal( + authorizeCreateImagesAssetRequest( + { + url: `aiden-asset://asset/${"a".repeat(43)}`, + method: "POST", + resourceType: "image", + webContentsId: 12, + frame, + }, + () => true, + ), + false, + ); +}); diff --git a/main/services/create-images/asset-protocol-core.ts b/main/services/create-images/asset-protocol-core.ts new file mode 100644 index 00000000..1f14b7d5 --- /dev/null +++ b/main/services/create-images/asset-protocol-core.ts @@ -0,0 +1,94 @@ +const GRANT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/u; + +export interface CreateImagesAssetProtocolRequest { + token: string; + rendition: "preview" | "preview-128" | "preview-256" | "preview-512" | "original"; +} + +export function parseCreateImagesAssetProtocolRequest( + value: string, +): CreateImagesAssetProtocolRequest | undefined { + try { + const url = new URL(value); + if ( + url.protocol !== "aiden-asset:" || + url.hostname !== "asset" || + url.port || + url.username || + url.password || + url.search || + url.hash + ) { + return undefined; + } + const match = /^\/([A-Za-z0-9_-]{32,128})(?:\/(original|preview-(?:128|256|512)))?$/u.exec( + url.pathname, + ); + const token = match?.[1]; + if (!token || !GRANT_TOKEN_PATTERN.test(token)) return undefined; + return { + token, + rendition: + match[2] === "original" || + match[2] === "preview-128" || + match[2] === "preview-256" || + match[2] === "preview-512" + ? match[2] + : "preview", + }; + } catch { + return undefined; + } +} + +export function parseCreateImagesAssetProtocolToken(value: string): string | undefined { + return parseCreateImagesAssetProtocolRequest(value)?.token; +} + +export function createImagesProtocolDocumentId(frame: { + processId: number; + routingId: number; + frameToken: string; + parent: unknown; + detached: boolean; +}): string | undefined { + if ( + frame.detached || + frame.parent !== null || + !Number.isInteger(frame.processId) || + !Number.isInteger(frame.routingId) || + typeof frame.frameToken !== "string" || + frame.frameToken.length === 0 + ) { + return undefined; + } + return `${frame.processId}:${frame.routingId}:${frame.frameToken}`; +} + +export function authorizeCreateImagesAssetRequest( + details: { + url: string; + method: string; + resourceType: string; + webContentsId?: number; + frame?: { + processId: number; + routingId: number; + frameToken: string; + parent: unknown; + detached: boolean; + } | null; + }, + authorize: (token: string, webContentsId: number, documentId: string) => boolean, +): boolean { + const token = parseCreateImagesAssetProtocolToken(details.url); + const documentId = details.frame ? createImagesProtocolDocumentId(details.frame) : undefined; + return ( + details.method === "GET" && + details.resourceType === "image" && + token !== undefined && + documentId !== undefined && + details.webContentsId !== undefined && + authorize(token, details.webContentsId, documentId) + ); +} diff --git a/main/services/create-images/asset-protocol.ts b/main/services/create-images/asset-protocol.ts new file mode 100644 index 00000000..6edf92e3 --- /dev/null +++ b/main/services/create-images/asset-protocol.ts @@ -0,0 +1,144 @@ +import { protocol, session, webContents } from "electron"; +import { isPackagedRuntime } from "../../runtime-mode.js"; +import { AssetDeliveryGrantRegistry } from "./asset-delivery-core.js"; +import { + authorizeCreateImagesAssetRequest, + parseCreateImagesAssetProtocolRequest, +} from "./asset-protocol-core.js"; +import { shouldBlockAidenRendererEgress } from "./renderer-egress-core.js"; + +export interface CreateImagesAssetProtocolSource { + response( + assetId: string, + rendition: "preview" | "preview-128" | "preview-256" | "preview-512" | "original", + ): Promise; +} + +let schemeRegistered = false; +let protocolInstalled = false; + +export interface CreateImagesRequestPolicyObservation { + kind: "asset" | "renderer-egress"; + url: string; + allowed: boolean; + method: string; + resourceType: string; + webContentsIdPresent: boolean; + framePresent: boolean; + frameIsMain: boolean; + frameDetached: boolean; +} + +const requestObservers = new Set<(value: CreateImagesRequestPolicyObservation) => void>(); + +export function observeCreateImagesRequestPolicy( + observer: (value: CreateImagesRequestPolicyObservation) => void, +): () => void { + requestObservers.add(observer); + return () => requestObservers.delete(observer); +} + +function publishRequestObservation( + details: Electron.OnBeforeRequestListenerDetails, + kind: CreateImagesRequestPolicyObservation["kind"], + allowed: boolean, +): void { + const observation: CreateImagesRequestPolicyObservation = { + kind, + url: details.url, + allowed, + method: details.method, + resourceType: details.resourceType, + webContentsIdPresent: details.webContentsId !== undefined, + framePresent: details.frame !== null, + frameIsMain: details.frame?.parent === null, + frameDetached: details.frame?.detached ?? true, + }; + for (const observer of requestObservers) { + try { + observer(observation); + } catch { + // Observability can never alter the production authorization decision. + } + } +} + +/** Must run before `app.whenReady()`. It registers no handler or service. */ +export function registerCreateImagesAssetScheme(): void { + if (schemeRegistered) return; + schemeRegistered = true; + protocol.registerSchemesAsPrivileged([ + { + scheme: "aiden-asset", + privileges: { + standard: true, + secure: true, + bypassCSP: false, + allowServiceWorkers: false, + supportFetchAPI: false, + corsEnabled: false, + stream: true, + }, + }, + ]); +} + +/** Install exact-document authorization and the streaming protocol handler. */ +export async function installCreateImagesAssetProtocol( + grants: AssetDeliveryGrantRegistry, + source: CreateImagesAssetProtocolSource, +): Promise { + if (protocolInstalled) return; + protocolInstalled = true; + const targetSession = session.defaultSession; + + targetSession.webRequest.onBeforeRequest( + { + urls: ["aiden-asset://*/*", "http://*/*", "https://*/*", "ws://*/*", "wss://*/*"], + }, + (details, callback) => { + if (details.url.startsWith("aiden-asset:")) { + const allowed = authorizeCreateImagesAssetRequest( + details, + (token, webContentsId, documentId) => + grants.authorizeProtocolRequest(token, webContentsId, documentId), + ); + publishRequestObservation(details, "asset", allowed); + callback({ cancel: !allowed }); + return; + } + const rendererUrl = + details.webContentsId === undefined + ? undefined + : webContents.fromId(details.webContentsId)?.getURL(); + const blocked = shouldBlockAidenRendererEgress({ + requestUrl: details.url, + rendererUrl, + packaged: isPackagedRuntime(), + }); + publishRequestObservation(details, "renderer-egress", !blocked); + callback({ cancel: blocked }); + }, + ); + + await targetSession.protocol.handle("aiden-asset", async (request) => { + const parsed = parseCreateImagesAssetProtocolRequest(request.url); + if (!parsed) return new Response("Not found", { status: 404 }); + const assetId = grants.consumeProtocolRequest(parsed.token); + if (!assetId) return new Response("Not found", { status: 404 }); + try { + const response = await source.response(assetId, parsed.rendition); + if (!response || !response.ok || !response.body) { + return new Response("Not found", { status: 404 }); + } + const headers = new Headers(response.headers); + headers.set("Cache-Control", "no-store, max-age=0"); + headers.set("Content-Disposition", "inline"); + headers.set("X-Content-Type-Options", "nosniff"); + headers.delete("Set-Cookie"); + return new Response(response.body, { status: 200, headers }); + } catch { + return new Response("Unavailable", { status: 503 }); + } + }); +} diff --git a/main/services/create-images/asset-store-core.test.ts b/main/services/create-images/asset-store-core.test.ts new file mode 100644 index 00000000..3dbfcedf --- /dev/null +++ b/main/services/create-images/asset-store-core.test.ts @@ -0,0 +1,884 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + AssetStoreError, + ContentAddressedAssetStore, + DEFAULT_ASSET_STORE_LIMITS, + type AssetMetadataDto, + type AssetDeepValidator, + type AssetReferenceAuthority, + type AssetReferenceSnapshot, + type AssetStoreLimits, + type AssetThumbnailGenerator, +} from "./asset-store-core.js"; +import { AssetDeliveryGrantRegistry } from "./asset-delivery-core.js"; +import { AssetImageValidationError, validateImageBytes } from "./asset-image-validation-core.js"; +import { ByteBoundedLru } from "./asset-thumbnail-cache-core.js"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const checksumInput = new Uint8Array(typeBytes.byteLength + data.byteLength); + checksumInput.set(typeBytes); + checksumInput.set(data, typeBytes.byteLength); + const result = new Uint8Array(12 + data.byteLength); + result.set(u32(data.byteLength)); + result.set(checksumInput, 4); + result.set(u32(crc32(checksumInput)), result.byteLength - 4); + return result; +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function makePng(width = 1, height = 1, variant = 0): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(width)); + header.set(u32(height), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, variant & 0xff, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +function makeJpeg(width = 1, height = 1): Uint8Array { + const frame = Uint8Array.from([ + 0xff, + 0xc0, + 0, + 11, + 8, + (height >>> 8) & 0xff, + height & 0xff, + (width >>> 8) & 0xff, + width & 0xff, + 1, + 1, + 0x11, + 0, + ]); + const scan = Uint8Array.from([0xff, 0xda, 0, 8, 1, 1, 0, 0, 63, 0, 1, 2, 3, 0xff, 0xd9]); + return concat(Uint8Array.from([0xff, 0xd8]), frame, scan); +} + +async function* chunks( + bytes: Uint8Array, + chunkSize = bytes.byteLength, +): AsyncGenerator { + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + yield bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)); + } +} + +class FakeReferenceAuthority implements AssetReferenceAuthority { + snapshot: AssetReferenceSnapshot = { + epoch: "epoch-0", + completeKinds: ["workflow", "run", "export"], + records: [], + }; + + async withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise { + return callback(structuredClone(this.snapshot)); + } +} + +const acceptingDecoder: AssetDeepValidator = { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, +}; + +function limits(overrides: Partial = {}): AssetStoreLimits { + return { + ...structuredClone(DEFAULT_ASSET_STORE_LIMITS), + ...overrides, + thumbnailSizes: overrides.thumbnailSizes ?? [...DEFAULT_ASSET_STORE_LIMITS.thumbnailSizes], + }; +} + +async function withRoot(run: (root: string) => Promise): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-assets-test-")); + try { + await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +function createStore( + root: string, + authority = new FakeReferenceAuthority(), + options: { + limits?: AssetStoreLimits; + now?: () => number; + deepValidator?: AssetDeepValidator; + thumbnailGenerator?: AssetThumbnailGenerator; + onAssetPublished?: (asset: AssetMetadataDto) => Promise | void; + } = {}, +): ContentAddressedAssetStore { + return new ContentAddressedAssetStore(root, authority, { + deepValidator: options.deepValidator ?? acceptingDecoder, + ...(options.limits ? { limits: options.limits } : {}), + ...(options.now ? { now: options.now } : {}), + ...(options.thumbnailGenerator ? { thumbnailGenerator: options.thumbnailGenerator } : {}), + ...(options.onAssetPublished ? { onAssetPublished: options.onAssetPublished } : {}), + }); +} + +test("validates static PNG/JPEG structure, declarations, truncation, and dimension bombs", () => { + const imageLimits = { maxWidth: 10_000, maxHeight: 10_000, maxPixels: 1_000_000 }; + assert.deepEqual(validateImageBytes(makePng(4, 5), "image/png", "safe.png", imageLimits), { + mediaType: "image/png", + extension: "png", + width: 4, + height: 5, + pixels: 20, + }); + assert.deepEqual(validateImageBytes(makeJpeg(7, 9), "image/jpeg", "safe.jpeg", imageLimits), { + mediaType: "image/jpeg", + extension: "jpg", + width: 7, + height: 9, + pixels: 63, + }); + const jpeg = makeJpeg(7, 9); + const exif = Uint8Array.from([0xff, 0xe1, 0, 8, 69, 120, 105, 102, 0, 0]); + assert.equal( + validateImageBytes( + concat(jpeg.subarray(0, 2), exif, jpeg.subarray(2)), + "image/jpeg", + "exif.jpg", + imageLimits, + ).width, + 7, + ); + assert.throws( + () => validateImageBytes(makePng().subarray(0, 40), "image/png", "x.png", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "truncated_image", + ); + assert.throws( + () => validateImageBytes(makeJpeg().subarray(0, -1), "image/jpeg", "x.jpg", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "truncated_image", + ); + assert.throws( + () => validateImageBytes(makePng(), "image/jpeg", "x.png", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "mime_mismatch", + ); + assert.throws( + () => validateImageBytes(makePng(), "image/png", "x.jpg", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "extension_mismatch", + ); + assert.throws( + () => + validateImageBytes( + new TextEncoder().encode(""), + "image/svg+xml", + "x.svg", + imageLimits, + ), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "unsupported_format", + ); + assert.throws( + () => validateImageBytes(makePng(2_000, 2_000), "image/png", "x.png", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "image_dimensions_exceeded", + ); +}); + +test("byte-bounded LRU evicts exactly and never retains an oversized value", () => { + const cache = new ByteBoundedLru<{ byteLength: number; value: string }>(10); + cache.set("a", { byteLength: 4, value: "a" }); + cache.set("b", { byteLength: 6, value: "b" }); + assert.equal(cache.byteLength, 10); + assert.equal(cache.get("a")?.value, "a"); + cache.set("c", { byteLength: 5, value: "c" }); + assert.equal(cache.get("b"), undefined); + assert.equal(cache.byteLength, 9); + cache.set("huge", { byteLength: 11, value: "huge" }); + assert.equal(cache.get("huge"), undefined); + assert.equal(cache.byteLength, 9); +}); + +test("streams into quarantine, publishes by digest, deduplicates, and never returns a path", async () => { + await withRoot(async (root) => { + let decoderCalls = 0; + const deepValidator: AssetDeepValidator = { + async validate({ descriptor, filePath }) { + decoderCalls += 1; + assert.equal(path.isAbsolute(filePath), true); + return { width: descriptor.width, height: descriptor.height }; + }, + }; + const store = createStore(root, new FakeReferenceAuthority(), { deepValidator }); + const bytes = makePng(11, 13); + const first = await store.ingest(chunks(bytes, 7), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "/private/user/portrait.png", + }); + assert.equal(first.asset.assetId, createHash("sha256").update(bytes).digest("hex")); + assert.equal(first.asset.displayName, "portrait.png"); + assert.equal(first.deduplicated, false); + assert.equal(JSON.stringify(first).includes(root), false); + const second = await store.ingest(chunks(bytes, 3), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "other.png", + }); + assert.equal(second.deduplicated, true); + assert.equal(second.asset.assetId, first.asset.assetId); + assert.equal((await store.status()).assetCount, 1); + assert.equal(decoderCalls, 2); + const published = path.join( + root, + "assets", + "sha256", + first.asset.assetId.slice(0, 2), + `${first.asset.assetId}.png`, + ); + assert.deepEqual(new Uint8Array(await fs.readFile(published)), bytes); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + }); +}); + +test("notifies an optional observer only after CAS publication and outside its mutation fence", async () => { + await withRoot(async (root) => { + let store!: ContentAddressedAssetStore; + let observed: AssetMetadataDto | undefined; + let observedStatus: Awaited> | undefined; + store = createStore(root, new FakeReferenceAuthority(), { + onAssetPublished: async (asset) => { + observed = asset; + observedStatus = await store.status(); + }, + }); + const result = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "observed.png", + }); + assert.equal(observed?.assetId, result.asset.assetId); + assert.equal(observedStatus?.assetCount, 1); + }); +}); + +test("uses a main-owned canonical validation name while preserving the imported label", async () => { + await withRoot(async (root) => { + const store = createStore(root); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "reference.webp", + validationDisplayName: "reference.png", + declaredMimeType: "image/png", + }); + assert.equal(imported.asset.displayName, "reference.webp"); + assert.equal(imported.asset.mediaType, "image/png"); + }); +}); + +test("a stale second store never deletes a digest already published by another store", async () => { + await withRoot(async (root) => { + const firstStore = createStore(root); + const staleStore = createStore(root); + await Promise.all([firstStore.status(), staleStore.status()]); + const bytes = makePng(); + const first = await firstStore.ingest(chunks(bytes), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await assert.rejects( + staleStore.ingest(chunks(bytes), { + origin: { kind: "import" }, + displayName: "asset.png", + }), + /changed outside the app/u, + ); + const published = path.join( + root, + "assets", + "sha256", + first.asset.assetId.slice(0, 2), + `${first.asset.assetId}.png`, + ); + assert.deepEqual(new Uint8Array(await fs.readFile(published)), bytes); + assert.ok(await createStore(root).get(first.asset.assetId)); + }); +}); + +test("refuses a symlinked digest directory instead of publishing outside the store", async () => { + await withRoot(async (root) => { + const store = createStore(root); + await store.status(); + const bytes = makePng(); + const assetId = createHash("sha256").update(bytes).digest("hex"); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-assets-outside-")); + try { + await fs.symlink(outside, path.join(root, "assets", "sha256", assetId.slice(0, 2)), "dir"); + await assert.rejects( + store.ingest(chunks(bytes), { + origin: { kind: "import" }, + displayName: "asset.png", + }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_repair_required", + ); + assert.deepEqual(await fs.readdir(outside), []); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); +}); + +test("deep decoder rejection leaves no published asset or quarantine temp", async () => { + await withRoot(async (root) => { + const store = createStore(root, new FakeReferenceAuthority(), { + deepValidator: { + async validate() { + throw new Error("decoder rejected compressed payload"); + }, + }, + }); + await assert.rejects( + store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "x.png", + }), + /decoder rejected/u, + ); + assert.equal((await store.status()).assetCount, 0); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + }); +}); + +test("enforces per-ingest and aggregate quotas without charging a duplicate", async () => { + await withRoot(async (root) => { + const firstBytes = makePng(1, 1, 1); + const secondBytes = makePng(1, 1, 2); + const store = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ + maxImportBytes: firstBytes.byteLength, + maxProviderResponseBytes: firstBytes.byteLength, + totalAssetBytes: firstBytes.byteLength + 1, + warningAssetBytes: firstBytes.byteLength, + }), + }); + const first = await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }); + assert.equal(first.quotaWarning, true); + assert.equal( + ( + await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }) + ).deduplicated, + true, + ); + await assert.rejects( + store.ingest(chunks(secondBytes), { + origin: { kind: "import" }, + displayName: "second.png", + }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_quota_exceeded", + ); + await assert.rejects( + store.ingest(chunks(concat(firstBytes, Uint8Array.from([1]))), { + origin: { kind: "import" }, + displayName: "large.png", + }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_ingest_too_large", + ); + assert.equal((await store.status()).assetCount, 1); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + }); +}); + +test("treats an otherwise valid over-budget asset index as unsafe", async () => { + await withRoot(async (root) => { + const firstBytes = makePng(1, 1, 1); + const secondBytes = makePng(1, 1, 2); + const store = createStore(root); + await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }); + await store.ingest(chunks(secondBytes), { + origin: { kind: "import" }, + displayName: "second.png", + }); + + const restarted = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ + totalAssetBytes: firstBytes.byteLength, + warningAssetBytes: firstBytes.byteLength, + }), + }); + assert.equal((await restarted.status()).healthy, false); + await assert.rejects( + restarted.list(), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_repair_required", + ); + }); +}); + +test("repair stops before deep-decoding beyond the aggregate asset quota", async () => { + await withRoot(async (root) => { + const firstBytes = makePng(1, 1, 1); + const secondBytes = makePng(1, 1, 2); + const store = createStore(root); + await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }); + await store.ingest(chunks(secondBytes), { + origin: { kind: "import" }, + displayName: "second.png", + }); + await fs.writeFile(path.join(root, "asset-index.json"), "{broken", "utf8"); + + let decodeCalls = 0; + const restarted = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ + totalAssetBytes: firstBytes.byteLength, + warningAssetBytes: firstBytes.byteLength, + }), + deepValidator: { + async validate({ descriptor }) { + decodeCalls += 1; + return { width: descriptor.width, height: descriptor.height }; + }, + }, + }); + await assert.rejects( + restarted.repair({ apply: false }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_quota_exceeded", + ); + assert.equal(decodeCalls, 1); + }); +}); + +test("persists bounded reference accounting and rebuilds it from the authority", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + const firstStore = createStore(root, authority); + const imported = await firstStore.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await firstStore.replaceReferences({ kind: "workflow", id: "workflow-1" }, [ + imported.asset.assetId, + ]); + assert.equal((await firstStore.get(imported.asset.assetId))?.referenceCount, 1); + + const restarted = createStore(root, authority); + assert.equal((await restarted.get(imported.asset.assetId))?.referenceCount, 1); + authority.snapshot = { + epoch: "epoch-1", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "run", id: "run-1", assetIds: [imported.asset.assetId] }], + }; + assert.deepEqual(await restarted.rebuildReferenceAccounting(), { + missingAssetIds: [], + revision: 3, + }); + assert.equal((await restarted.get(imported.asset.assetId))?.referenceCount, 1); + }); +}); + +test("rejects reference snapshots beyond configured owner/link bounds", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + authority.snapshot = { + epoch: "epoch-1", + completeKinds: ["workflow", "run", "export"], + records: [ + { kind: "workflow", id: "workflow-1", assetIds: [] }, + { kind: "run", id: "run-1", assetIds: [] }, + ], + }; + const store = createStore(root, authority, { + limits: limits({ maxReferenceRecords: 1, maxReferenceLinks: 1 }), + }); + await assert.rejects(store.rebuildReferenceAccounting(), /too many owners/u); + }); +}); + +test("preview leases are owner-bound, expiring, byte-bounded, and contain no path", async () => { + await withRoot(async (root) => { + let now = 10_000; + const store = createStore(root, new FakeReferenceAuthority(), { now: () => now }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + const lease = await store.acquirePreviewLease(imported.asset.assetId, "document-1", 1_000); + await assert.rejects( + store.readPreview(lease.token, "document-2"), + (error: unknown) => + error instanceof AssetStoreError && error.code === "preview_lease_invalid", + ); + await assert.rejects( + store.readPreview(lease.token, "document-1", imported.asset.byteLength - 1), + (error: unknown) => error instanceof AssetStoreError && error.code === "preview_too_large", + ); + const preview = await store.readPreview(lease.token, "document-1"); + assert.equal(JSON.stringify(preview.asset).includes(root), false); + assert.equal(preview.bytes.byteLength, imported.asset.byteLength); + now += 1_001; + await assert.rejects( + store.readPreview(lease.token, "document-1"), + (error: unknown) => + error instanceof AssetStoreError && error.code === "preview_lease_invalid", + ); + }); +}); + +test("thumbnail generation is validated, persisted, and cached under an exact byte budget", async () => { + await withRoot(async (root) => { + let generated = 0; + const thumbnailGenerator: AssetThumbnailGenerator = { + async generate({ maxDimension }) { + generated += 1; + return { + bytes: makePng(maxDimension, maxDimension), + width: maxDimension, + height: maxDimension, + mediaType: "image/png", + }; + }, + }; + const store = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ thumbnailCacheBytes: 65, thumbnailSizes: [128, 256] }), + thumbnailGenerator, + }); + const first = await store.ingest(chunks(makePng(1, 1, 1)), { + origin: { kind: "import" }, + displayName: "one.png", + }); + const second = await store.ingest(chunks(makePng(1, 1, 2)), { + origin: { kind: "import" }, + displayName: "two.png", + }); + assert.equal((await store.getThumbnail(first.asset.assetId, 128)).byteLength, 65); + assert.equal((await store.getThumbnail(first.asset.assetId, 128)).byteLength, 65); + assert.equal(generated, 1); + await store.getThumbnail(second.asset.assetId, 128); + assert.deepEqual(store.thumbnailCacheStatus(), { entries: 1, byteLength: 65, maxBytes: 65 }); + assert.equal(generated, 2); + + const restarted = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ thumbnailCacheBytes: 65, thumbnailSizes: [128, 256] }), + thumbnailGenerator, + }); + await restarted.getThumbnail(first.asset.assetId, 128); + assert.equal(generated, 2, "restart should use the validated derived file"); + }); +}); + +test("rejects unsafe thumbnail generator output without publishing metadata", async () => { + await withRoot(async (root) => { + const store = createStore(root, new FakeReferenceAuthority(), { + thumbnailGenerator: { + async generate() { + return { + bytes: new TextEncoder().encode("not a PNG image"), + width: 10, + height: 10, + mediaType: "image/png", + }; + }, + }, + }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await assert.rejects( + store.getThumbnail(imported.asset.assetId, 128), + (error: unknown) => + error instanceof AssetStoreError && error.code === "thumbnail_unavailable", + ); + assert.deepEqual((await store.get(imported.asset.assetId))?.thumbnailSizes, []); + }); +}); + +test("requires generated thumbnails to pass the injected deep decoder", async () => { + await withRoot(async (root) => { + const store = createStore(root, new FakeReferenceAuthority(), { + deepValidator: { + async validate({ descriptor, filePath }) { + if (filePath.includes(`${path.sep}thumbnails${path.sep}`)) { + throw new Error("decoder rejected thumbnail"); + } + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate({ maxDimension }) { + return { + bytes: makePng(maxDimension, maxDimension), + width: maxDimension, + height: maxDimension, + mediaType: "image/png", + }; + }, + }, + }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await assert.rejects( + store.getThumbnail(imported.asset.assetId, 128), + (error: unknown) => + error instanceof AssetStoreError && error.code === "thumbnail_unavailable", + ); + assert.deepEqual((await store.get(imported.asset.assetId))?.thumbnailSizes, []); + }); +}); + +test("GC plans are dry runs, stale on reference races, and recheck preview leases", async () => { + await withRoot(async (root) => { + let now = 1_000; + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority, { now: () => now }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + now = 3_000; + const leasePlan = await store.planGarbageCollection(1_000); + assert.deepEqual(leasePlan.candidateAssetIds, [imported.asset.assetId]); + assert.ok(await store.get(imported.asset.assetId), "dry run does not delete"); + const lease = await store.acquirePreviewLease(imported.asset.assetId, "document-1", 10_000); + const leaseResult = await store.applyGarbageCollection(leasePlan.planId); + assert.deepEqual(leaseResult.skipped, [ + { assetId: imported.asset.assetId, reason: "lease_active" }, + ]); + assert.ok(await store.get(imported.asset.assetId)); + await store.releasePreviewLease(lease.token, "document-1"); + + const racedPlan = await store.planGarbageCollection(1_000); + authority.snapshot = { + epoch: "epoch-1", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "workflow", id: "workflow-1", assetIds: [imported.asset.assetId] }], + }; + assert.equal((await store.applyGarbageCollection(racedPlan.planId)).stale, true); + assert.ok(await store.get(imported.asset.assetId)); + + authority.snapshot = { + epoch: "epoch-2", + completeKinds: ["workflow", "run", "export"], + records: [], + }; + const finalPlan = await store.planGarbageCollection(1_000); + const applied = await store.applyGarbageCollection(finalPlan.planId); + assert.deepEqual(applied.deletedAssetIds, [imported.asset.assetId]); + assert.equal(await store.get(imported.asset.assetId), undefined); + }); +}); + +test("an opaque protocol grant keeps an unreferenced imported asset out of GC until revoke", async () => { + await withRoot(async (root) => { + let now = 1_000; + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority, { now: () => now }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + const owner: RendererDocumentOwner = { + id: 42, + documentId: "document-42", + isDestroyed: () => false, + send: () => undefined, + onInvalidated: () => () => undefined, + }; + const leaseOwnerId = "document-42"; + const lease = await store.acquirePreviewLease(imported.asset.assetId, leaseOwnerId, 10_000); + let releasePromise: Promise | undefined; + const grants = new AssetDeliveryGrantRegistry(() => now, 10_000); + const grant = grants.mint(owner, imported.asset.assetId, () => true, { + expiresAt: lease.expiresAt, + release: () => { + releasePromise = store.releasePreviewLease(lease.token, leaseOwnerId); + }, + }); + + now = 3_000; + assert.deepEqual((await store.planGarbageCollection(1_000)).candidateAssetIds, []); + assert.equal(grants.revoke(grant.token, owner), true); + await releasePromise; + assert.deepEqual((await store.planGarbageCollection(1_000)).candidateAssetIds, [ + imported.asset.assetId, + ]); + }); +}); + +test("repair dry-run rebuilds a corrupt index, quarantines hostile entries, and survives restart", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + const hostileDirectory = path.join(root, "assets", "sha256", "zz"); + await fs.mkdir(hostileDirectory, { recursive: true }); + await fs.writeFile(path.join(hostileDirectory, "evil.svg"), ""); + await fs.writeFile(path.join(root, "asset-index.json"), "{broken", "utf8"); + + const restarted = createStore(root, authority); + assert.equal((await restarted.status()).healthy, false); + await assert.rejects( + restarted.list(), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_repair_required", + ); + const dryRun = await restarted.repair({ apply: false }); + assert.equal(dryRun.applied, false); + assert.deepEqual(dryRun.addedAssetIds, [imported.asset.assetId]); + assert.equal( + dryRun.invalidEntries.some((entry) => entry.entryId === "zz"), + true, + ); + assert.equal((await restarted.status()).healthy, false); + + const applied = await restarted.repair({ apply: true }); + assert.equal(applied.quarantinedEntryIds.includes("zz"), true); + assert.equal((await restarted.status()).healthy, true); + assert.ok(await restarted.get(imported.asset.assetId)); + const rootNames = await fs.readdir(root); + assert.equal( + rootNames.some((name) => name.startsWith("asset-index.json.invalid-")), + true, + ); + + const secondRestart = createStore(root, authority); + assert.ok(await secondRestart.get(imported.asset.assetId)); + }); +}); + +test("repair reports an indexed asset whose binary disappeared", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + authority.snapshot = { + epoch: "missing-source", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "workflow", id: "workflow-1", assetIds: [imported.asset.assetId] }], + }; + await fs.rm( + path.join( + root, + "assets", + "sha256", + imported.asset.assetId.slice(0, 2), + `${imported.asset.assetId}.png`, + ), + ); + assert.deepEqual((await store.rebuildReferenceAccounting()).missingAssetIds, [ + imported.asset.assetId, + ]); + assert.equal(await store.getAvailable(imported.asset.assetId), undefined); + await assert.rejects( + () => store.acquirePreviewLease(imported.asset.assetId, "document-1"), + (error: unknown) => error instanceof AssetStoreError && error.code === "asset_source_missing", + ); + const report = await store.repair({ apply: false }); + assert.deepEqual(report.removedAssetIds, [imported.asset.assetId]); + + const healed = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "restored-original.png", + }); + assert.equal(healed.deduplicated, true); + assert.equal( + (await store.getAvailable(imported.asset.assetId))?.assetId, + imported.asset.assetId, + ); + assert.deepEqual((await store.rebuildReferenceAccounting()).missingAssetIds, []); + }); +}); + +test("exports a verified asset through a main-owned absolute destination", async () => { + await withRoot(async (root) => { + const store = createStore(root); + const bytes = makePng(3, 2, 7); + const imported = await store.ingest(chunks(bytes), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "reference.png", + }); + const exportDirectory = path.join(root, "native-save-dialog-destination"); + await fs.mkdir(exportDirectory, { mode: 0o700 }); + const destination = path.join(exportDirectory, "saved-reference.png"); + + const exported = await store.exportAssetToFile(imported.asset.assetId, destination); + + assert.deepEqual(exported, imported.asset); + assert.deepEqual(await fs.readFile(destination), Buffer.from(bytes)); + assert.equal((await fs.stat(destination)).mode & 0o777, 0o600); + assert.equal(Object.prototype.hasOwnProperty.call(exported, "filePath"), false); + await assert.rejects( + store.exportAssetToFile(imported.asset.assetId, "relative-output.png"), + (error: unknown) => + error instanceof AssetStoreError && error.code === "invalid_asset_request", + ); + }); +}); diff --git a/main/services/create-images/asset-store-core.ts b/main/services/create-images/asset-store-core.ts new file mode 100644 index 00000000..8d64752b --- /dev/null +++ b/main/services/create-images/asset-store-core.ts @@ -0,0 +1,2032 @@ +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { DataStore, DataStoreUnsafeWriteError } from "../data-store.js"; +import { CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES } from "../../../renderer/shared/create-images/schema.js"; +import { + AssetImageValidationError, + type AssetImageLimits, + type SafeAssetExtension, + type SafeAssetMediaType, + type ValidatedImageDescriptor, + sanitizeAssetDisplayName, + validateImageBytes, +} from "./asset-image-validation-core.js"; +import { ByteBoundedLru } from "./asset-thumbnail-cache-core.js"; + +const ASSET_ID = /^[a-f0-9]{64}$/u; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const PREVIEW_TOKEN = /^[A-Za-z0-9_-]{43}$/u; +const REQUIRED_REFERENCE_KINDS = ["export", "run", "workflow"] as const; +const THUMBNAIL_MEDIA_TYPE = "image/png" as const; + +export type AssetReferenceKind = (typeof REQUIRED_REFERENCE_KINDS)[number]; + +export type AssetOrigin = + | { kind: "import" } + | { kind: "annotation"; sourceAssetId: string } + | { kind: "provider"; providerId: string; modelId: string; runId: string } + | { kind: "repair" }; + +export interface AssetReferenceOwner { + kind: AssetReferenceKind; + id: string; +} + +export interface AssetReferenceRecord extends AssetReferenceOwner { + assetIds: readonly string[]; +} + +export interface AssetReferenceSnapshot { + /** Monotonic authority epoch owned by the workflow/run/export stores. */ + epoch: string; + completeKinds: readonly AssetReferenceKind[]; + records: readonly AssetReferenceRecord[]; +} + +/** + * The implementation must hold its mutation/read lock for the full callback. + * GC relies on this fence remaining held through the final asset unlink. + */ +export interface AssetReferenceAuthority { + withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise; +} + +export interface AssetDeepValidator { + validate(input: { + /** Main-process-only quarantine path; it must never cross IPC. */ + filePath: string; + descriptor: ValidatedImageDescriptor; + byteLength: number; + }): Promise<{ width: number; height: number }>; +} + +export interface AssetThumbnailGenerator { + generate(input: { + /** Main-process-only immutable asset path; it must never cross IPC. */ + sourcePath: string; + source: ValidatedImageDescriptor; + maxDimension: number; + maxOutputBytes: number; + }): Promise<{ bytes: Uint8Array; width: number; height: number; mediaType: "image/png" }>; +} + +export interface AssetStoreLimits extends AssetImageLimits { + maxImportBytes: number; + maxProviderResponseBytes: number; + totalAssetBytes: number; + warningAssetBytes: number; + maxAssets: number; + maxReferenceRecords: number; + maxReferenceLinks: number; + maxIndexBytes: number; + maxRepairEntries: number; + maxPreviewReadBytes: number; + maxPreviewLeases: number; + maxThumbnailBytes: number; + thumbnailCacheBytes: number; + thumbnailSizes: readonly number[]; +} + +export const DEFAULT_ASSET_STORE_LIMITS: Readonly = Object.freeze({ + maxImportBytes: 64 * 1024 * 1024, + maxProviderResponseBytes: 64 * 1024 * 1024, + maxWidth: 32_768, + maxHeight: 32_768, + // A decode can still approach 64 MiB as RGBA. Codec work is isolated in a + // disposable sandboxed renderer, and this ceiling keeps one decoder bounded on + // supported hardware even for highly compressed images. + maxPixels: 16_000_000, + totalAssetBytes: CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES, + warningAssetBytes: 8 * 1024 * 1024 * 1024, + maxAssets: 100_000, + maxReferenceRecords: 100_000, + maxReferenceLinks: 1_000_000, + maxIndexBytes: 64 * 1024 * 1024, + maxRepairEntries: 200_000, + maxPreviewReadBytes: 64 * 1024 * 1024, + maxPreviewLeases: 4_096, + maxThumbnailBytes: 4 * 1024 * 1024, + thumbnailCacheBytes: 64 * 1024 * 1024, + thumbnailSizes: [128, 256, 512], +}); + +export interface AssetMetadataDto { + assetId: string; + mediaType: SafeAssetMediaType; + byteLength: number; + width: number; + height: number; + createdAt: string; + displayName?: string; + origin: AssetOrigin; + generationMetadata?: Readonly>; + referenceCount: number; + thumbnailSizes: number[]; +} + +export interface AssetIngestRequest { + origin: Exclude; + declaredMimeType?: string; + displayName?: string; + /** + * Main-owned canonical filename used only for content/extension validation. + * Normalized imports keep their original display name while stored bytes use + * Aiden's canonical PNG extension. + */ + validationDisplayName?: string; + generationMetadata?: Readonly>; +} + +export interface AssetIngestResult { + asset: AssetMetadataDto; + deduplicated: boolean; + quotaWarning: boolean; + totalAssetBytes: number; +} + +export interface ValidatedQuarantinedAsset { + sha256: string; + mediaType: SafeAssetMediaType; + byteLength: number; + width: number; + height: number; +} + +export interface AssetPreviewLeaseDto { + token: string; + assetId: string; + expiresAt: number; +} + +export interface AssetPreviewDto { + asset: AssetMetadataDto; + bytes: Uint8Array; +} + +export interface AssetThumbnailDto { + assetId: string; + mediaType: typeof THUMBNAIL_MEDIA_TYPE; + width: number; + height: number; + byteLength: number; + bytes: Uint8Array; +} + +export interface AssetRepairReport { + applied: boolean; + indexWasUnhealthy: boolean; + addedAssetIds: string[]; + removedAssetIds: string[]; + correctedAssetIds: string[]; + quarantinedEntryIds: string[]; + invalidEntries: Array<{ entryId: string; reason: string }>; + missingReferenceAssetIds: string[]; +} + +export interface AssetGarbageCollectionPlanDto { + planId: string; + createdAt: number; + expiresAt: number; + indexRevision: number; + referenceEpoch: string; + candidateAssetIds: string[]; + reclaimableBytes: number; +} + +export interface AssetGarbageCollectionResult { + applied: boolean; + stale: boolean; + deletedAssetIds: string[]; + reclaimedBytes: number; + skipped: Array<{ + assetId: string; + reason: "lease_active" | "not_found" | "referenced" | "too_new"; + }>; +} + +export class AssetStoreError extends Error { + constructor( + public readonly code: + | "asset_not_found" + | "asset_source_missing" + | "asset_store_repair_required" + | "asset_store_quota_exceeded" + | "asset_ingest_too_large" + | "asset_index_limit_exceeded" + | "invalid_asset_request" + | "preview_lease_invalid" + | "preview_too_large" + | "thumbnail_unavailable", + message: string, + ) { + super(message); + this.name = "AssetStoreError"; + } +} + +interface StoredThumbnail { + mediaType: typeof THUMBNAIL_MEDIA_TYPE; + byteLength: number; + width: number; + height: number; + updatedAt: string; +} + +interface StoredAsset { + assetId: string; + extension: SafeAssetExtension; + mediaType: SafeAssetMediaType; + byteLength: number; + width: number; + height: number; + createdAt: string; + displayName?: string; + origin: AssetOrigin; + generationMetadata?: Record; + referenceOwners: string[]; + unreferencedAt?: string; + thumbnails: Record; +} + +interface AssetIndexV1 { + schemaVersion: 1; + revision: number; + assets: Record; +} + +interface PreviewLease { + token: string; + assetId: string; + ownerId: string; + expiresAt: number; +} + +interface InternalGcPlan extends AssetGarbageCollectionPlanDto { + graceMs: number; + referenceFingerprint: string; +} + +interface ThumbnailCacheEntry { + mediaType: typeof THUMBNAIL_MEDIA_TYPE; + width: number; + height: number; + byteLength: number; + bytes: Uint8Array; +} + +interface ScannedAsset { + entryId: string; + filePath: string; + descriptor: ValidatedImageDescriptor; + byteLength: number; + createdAt: string; +} + +interface InvalidScannedEntry { + entryId: string; + filePath: string; + reason: string; +} + +const EMPTY_INDEX: AssetIndexV1 = { schemaVersion: 1, revision: 0, assets: {} }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStoredAsset( + value: unknown, + key: string, + limits: AssetStoreLimits, +): value is StoredAsset { + if (!isRecord(value)) return false; + if (value.assetId !== key || !ASSET_ID.test(key)) return false; + if (value.extension !== "jpg" && value.extension !== "png") return false; + if (value.mediaType !== "image/jpeg" && value.mediaType !== "image/png") return false; + if ( + (value.extension === "png" && value.mediaType !== "image/png") || + (value.extension === "jpg" && value.mediaType !== "image/jpeg") + ) { + return false; + } + const createdAt = typeof value.createdAt === "string" ? Date.parse(value.createdAt) : Number.NaN; + const unreferencedAt = + value.unreferencedAt === undefined + ? undefined + : typeof value.unreferencedAt === "string" + ? Date.parse(value.unreferencedAt) + : Number.NaN; + const referenceOwners = value.referenceOwners; + if ( + !Number.isSafeInteger(value.byteLength) || + (value.byteLength as number) < 1 || + (value.byteLength as number) > + Math.max(limits.maxImportBytes, limits.maxProviderResponseBytes) || + !Number.isSafeInteger(value.width) || + (value.width as number) < 1 || + (value.width as number) > limits.maxWidth || + !Number.isSafeInteger(value.height) || + (value.height as number) < 1 || + (value.height as number) > limits.maxHeight || + (value.width as number) * (value.height as number) > limits.maxPixels || + !Number.isFinite(createdAt) || + (unreferencedAt !== undefined && !Number.isFinite(unreferencedAt)) || + !Array.isArray(referenceOwners) || + referenceOwners.length > limits.maxReferenceRecords || + new Set(referenceOwners).size !== referenceOwners.length || + !referenceOwners.every( + (owner) => + typeof owner === "string" && + /^(?:workflow|run|export):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(owner), + ) || + !isRecord(value.thumbnails) + ) { + return false; + } + if ( + value.displayName !== undefined && + (typeof value.displayName !== "string" || + sanitizeAssetDisplayName(value.displayName) !== value.displayName) + ) { + return false; + } + try { + validateOrigin(value.origin as AssetOrigin); + validateGenerationMetadata( + value.generationMetadata as + | Readonly> + | undefined, + ); + } catch { + return false; + } + for (const [size, thumbnail] of Object.entries(value.thumbnails)) { + if (!limits.thumbnailSizes.includes(Number(size)) || !isRecord(thumbnail)) return false; + if ( + thumbnail.mediaType !== THUMBNAIL_MEDIA_TYPE || + !Number.isSafeInteger(thumbnail.byteLength) || + (thumbnail.byteLength as number) < 33 || + (thumbnail.byteLength as number) > limits.maxThumbnailBytes || + !Number.isSafeInteger(thumbnail.width) || + (thumbnail.width as number) < 1 || + (thumbnail.width as number) > Number(size) || + !Number.isSafeInteger(thumbnail.height) || + (thumbnail.height as number) < 1 || + (thumbnail.height as number) > Number(size) || + typeof thumbnail.updatedAt !== "string" || + !Number.isFinite(Date.parse(thumbnail.updatedAt)) + ) { + return false; + } + } + return true; +} + +function isAssetIndex(value: unknown, limits: AssetStoreLimits): value is AssetIndexV1 { + if (!isRecord(value) || value.schemaVersion !== 1 || !Number.isSafeInteger(value.revision)) { + return false; + } + if (!isRecord(value.assets)) return false; + const entries = Object.entries(value.assets); + if (entries.length > limits.maxAssets) return false; + let aggregateBytes = 0; + for (const [key, asset] of entries) { + if (!isStoredAsset(asset, key, limits)) return false; + if (asset.byteLength > limits.totalAssetBytes - aggregateBytes) return false; + aggregateBytes += asset.byteLength; + } + return true; +} + +function cloneEmptyIndex(): AssetIndexV1 { + return structuredClone(EMPTY_INDEX); +} + +function assertSafeId(value: string, label: string): void { + if (!SAFE_ID.test(value)) { + throw new AssetStoreError( + "invalid_asset_request", + `${label} must be a short opaque identifier.`, + ); + } +} + +function assertAssetId(assetId: string): void { + if (!ASSET_ID.test(assetId)) { + throw new AssetStoreError("invalid_asset_request", "Asset IDs must be SHA-256 identifiers."); + } +} + +function validateOrigin(origin: AssetOrigin): AssetOrigin { + if (origin.kind === "import" || origin.kind === "repair") return { kind: origin.kind }; + if (origin.kind === "annotation") { + assertAssetId(origin.sourceAssetId); + return { kind: "annotation", sourceAssetId: origin.sourceAssetId }; + } + if (origin.kind === "provider") { + assertSafeId(origin.providerId, "Provider ID"); + assertSafeId(origin.modelId, "Model ID"); + assertSafeId(origin.runId, "Run ID"); + return { + kind: "provider", + providerId: origin.providerId, + modelId: origin.modelId, + runId: origin.runId, + }; + } + throw new AssetStoreError("invalid_asset_request", "The asset origin is unsupported."); +} + +function validateGenerationMetadata( + value: Readonly> | undefined, +): Record | undefined { + if (value === undefined) return undefined; + const entries = Object.entries(value); + if (entries.length > 32) { + throw new AssetStoreError("invalid_asset_request", "Generation metadata has too many fields."); + } + const result: Record = {}; + for (const [key, item] of entries) { + if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(key)) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains an invalid key.", + ); + } + if (typeof item === "string" && item.length > 1_024) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains a long string.", + ); + } + if (typeof item === "number" && !Number.isFinite(item)) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains a non-finite number.", + ); + } + if (item !== null && !["string", "number", "boolean"].includes(typeof item)) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains an invalid value.", + ); + } + result[key] = item; + } + return result; +} + +function validateLimits(limits: AssetStoreLimits): void { + const integerKeys: Array = [ + "maxImportBytes", + "maxProviderResponseBytes", + "maxWidth", + "maxHeight", + "maxPixels", + "totalAssetBytes", + "warningAssetBytes", + "maxAssets", + "maxReferenceRecords", + "maxReferenceLinks", + "maxIndexBytes", + "maxRepairEntries", + "maxPreviewReadBytes", + "maxPreviewLeases", + "maxThumbnailBytes", + "thumbnailCacheBytes", + ]; + if ( + integerKeys.some((key) => !Number.isSafeInteger(limits[key]) || (limits[key] as number) < 1) + ) { + throw new Error("Asset store limits must be positive safe integers."); + } + if (limits.warningAssetBytes > limits.totalAssetBytes) { + throw new Error("The asset warning threshold cannot exceed the total quota."); + } + if ( + limits.thumbnailSizes.length < 1 || + limits.thumbnailSizes.length > 16 || + limits.thumbnailSizes.some( + (size) => !Number.isSafeInteger(size) || size < 16 || size > 4_096, + ) || + new Set(limits.thumbnailSizes).size !== limits.thumbnailSizes.length + ) { + throw new Error("Thumbnail sizes must be a bounded list of unique dimensions."); + } +} + +function ownerKey(owner: AssetReferenceOwner): string { + if (!REQUIRED_REFERENCE_KINDS.includes(owner.kind)) { + throw new AssetStoreError("invalid_asset_request", "The asset reference kind is unsupported."); + } + assertSafeId(owner.id, "Reference owner ID"); + return `${owner.kind}:${owner.id}`; +} + +function descriptorFor(asset: StoredAsset): ValidatedImageDescriptor { + return { + mediaType: asset.mediaType, + extension: asset.extension, + width: asset.width, + height: asset.height, + pixels: asset.width * asset.height, + }; +} + +function metadataDto(asset: StoredAsset): AssetMetadataDto { + return { + assetId: asset.assetId, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + width: asset.width, + height: asset.height, + createdAt: asset.createdAt, + ...(asset.displayName ? { displayName: asset.displayName } : {}), + origin: structuredClone(asset.origin), + ...(asset.generationMetadata + ? { generationMetadata: structuredClone(asset.generationMetadata) } + : {}), + referenceCount: asset.referenceOwners.length, + thumbnailSizes: Object.keys(asset.thumbnails) + .map(Number) + .sort((left, right) => left - right), + }; +} + +function thumbnailCacheEntry( + metadata: Pick, + bytes: Uint8Array, +): ThumbnailCacheEntry { + return { + mediaType: metadata.mediaType, + byteLength: metadata.byteLength, + width: metadata.width, + height: metadata.height, + bytes: bytes.slice(), + }; +} + +function totalBytes(index: AssetIndexV1): number { + return Object.values(index.assets).reduce((sum, asset) => sum + asset.byteLength, 0); +} + +function validateReferenceSnapshot( + snapshot: AssetReferenceSnapshot, + limits: AssetStoreLimits, +): Map> { + if (!SAFE_ID.test(snapshot.epoch)) { + throw new AssetStoreError("invalid_asset_request", "The reference snapshot epoch is invalid."); + } + const kinds = [...new Set(snapshot.completeKinds)].sort(); + if ( + kinds.length !== REQUIRED_REFERENCE_KINDS.length || + !REQUIRED_REFERENCE_KINDS.every((kind, index) => kind === kinds[index]) + ) { + throw new AssetStoreError( + "invalid_asset_request", + "Asset reference snapshots must cover workflows, runs, and exports.", + ); + } + const byAsset = new Map>(); + const owners = new Set(); + let referenceLinks = 0; + if (snapshot.records.length > limits.maxReferenceRecords) { + throw new AssetStoreError( + "invalid_asset_request", + "The reference snapshot has too many owners.", + ); + } + for (const record of snapshot.records) { + const key = ownerKey(record); + if (owners.has(key)) { + throw new AssetStoreError( + "invalid_asset_request", + "The reference snapshot repeats an owner.", + ); + } + owners.add(key); + referenceLinks += record.assetIds.length; + if ( + referenceLinks > limits.maxReferenceLinks || + record.assetIds.length > limits.maxAssets || + new Set(record.assetIds).size !== record.assetIds.length + ) { + throw new AssetStoreError( + "invalid_asset_request", + "The reference snapshot contains invalid asset IDs.", + ); + } + for (const assetId of record.assetIds) { + assertAssetId(assetId); + const assetOwners = byAsset.get(assetId) ?? new Set(); + assetOwners.add(key); + byAsset.set(assetId, assetOwners); + } + } + return byAsset; +} + +function referenceFingerprint(snapshot: AssetReferenceSnapshot): string { + const canonical = snapshot.records + .map((record) => [ownerKey(record), [...record.assetIds].sort()] as const) + .sort(([left], [right]) => left.localeCompare(right)); + return createHash("sha256") + .update(JSON.stringify([snapshot.epoch, [...snapshot.completeKinds].sort(), canonical])) + .digest("hex"); +} + +async function readBoundedRegularFile(filePath: string, maxBytes: number): Promise { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = await fs.open(filePath, constants.O_RDONLY | noFollow); + try { + const before = await handle.stat(); + if (!before.isFile() || before.size < 1 || before.size > maxBytes) { + throw new Error("The asset is not a bounded regular file."); + } + const chunks: Buffer[] = []; + let total = 0; + while (total <= maxBytes) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, total); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + if (total > maxBytes) throw new Error("The asset grew beyond its byte limit while reading."); + const bytes = Buffer.concat(chunks, total); + const after = await handle.stat(); + if (bytes.byteLength !== before.size || after.size !== before.size) { + throw new Error("The asset changed while it was being read."); + } + return bytes; + } finally { + await handle.close(); + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function ensureSafeDirectory(directory: string): Promise { + const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new AssetStoreError( + "asset_store_repair_required", + "The asset store contains an unsafe directory entry.", + ); + } + if (created !== undefined) await syncDirectory(path.dirname(directory)); +} + +function isSafeThumbnailPng(bytes: Uint8Array, width: number, height: number): boolean { + try { + const descriptor = validateImageBytes(bytes, "image/png", "thumbnail.png", { + maxWidth: DEFAULT_ASSET_STORE_LIMITS.maxWidth, + maxHeight: DEFAULT_ASSET_STORE_LIMITS.maxHeight, + maxPixels: DEFAULT_ASSET_STORE_LIMITS.maxPixels, + }); + return ( + descriptor.mediaType === "image/png" && + descriptor.width === width && + descriptor.height === height + ); + } catch { + return false; + } +} + +export class ContentAddressedAssetStore { + private readonly limits: AssetStoreLimits; + private readonly indexStore: DataStore; + private readonly cache: ByteBoundedLru; + private readonly leases = new Map(); + private readonly gcPlans = new Map(); + private index = cloneEmptyIndex(); + private indexHealthy = true; + private initializePromise: Promise | undefined; + private mutationTail: Promise = Promise.resolve(); + + constructor( + private readonly rootDirectory: string, + private readonly referenceAuthority: AssetReferenceAuthority, + private readonly options: { + limits?: AssetStoreLimits; + now?: () => number; + deepValidator: AssetDeepValidator; + thumbnailGenerator?: AssetThumbnailGenerator; + /** Best-effort notification after the asset-store mutation lock is released. */ + onAssetPublished?: (asset: AssetMetadataDto) => Promise | void; + }, + ) { + if (!path.isAbsolute(rootDirectory)) throw new Error("The asset store root must be absolute."); + this.limits = structuredClone(options.limits ?? DEFAULT_ASSET_STORE_LIMITS); + validateLimits(this.limits); + this.cache = new ByteBoundedLru(this.limits.thumbnailCacheBytes); + this.indexStore = new DataStore( + "asset-index.json", + cloneEmptyIndex(), + () => this.rootDirectory, + { + maxBytes: this.limits.maxIndexBytes, + preserveCorruptFile: true, + normalize: (value) => (isAssetIndex(value, this.limits) ? value : cloneEmptyIndex()), + isSafe: (value) => isAssetIndex(value, this.limits), + rejectUnsafeWrite: false, + reloadBeforeWrite: true, + rejectExternalChanges: true, + }, + ); + } + + private get now(): () => number { + return this.options.now ?? Date.now; + } + + private async initialize(): Promise { + if (!this.initializePromise) { + this.initializePromise = (async () => { + await ensureSafeDirectory(this.rootDirectory); + await ensureSafeDirectory(path.join(this.rootDirectory, "assets")); + await ensureSafeDirectory(this.assetsDirectory); + await ensureSafeDirectory(this.thumbnailDirectory); + await ensureSafeDirectory(this.quarantineDirectory); + this.index = structuredClone(await this.indexStore.load()); + this.indexHealthy = + !(await this.indexStore.loadedFromCorruptFile()) && + !(await this.indexStore.loadedFromUnsafeFile()); + })(); + } + await this.initializePromise; + } + + private serialized(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation, operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private get assetsDirectory(): string { + return path.join(this.rootDirectory, "assets", "sha256"); + } + + private get thumbnailDirectory(): string { + return path.join(this.rootDirectory, "thumbnails"); + } + + private get quarantineDirectory(): string { + return path.join(this.rootDirectory, "asset-quarantine"); + } + + private assetPath(assetId: string, extension: SafeAssetExtension): string { + return path.join(this.assetsDirectory, assetId.slice(0, 2), `${assetId}.${extension}`); + } + + private thumbnailPath(assetId: string, size: number): string { + return path.join(this.thumbnailDirectory, assetId, `${size}.png`); + } + + private ensureHealthy(): void { + if (!this.indexHealthy) { + throw new AssetStoreError( + "asset_store_repair_required", + "The Create Images asset index needs repair before it can be changed or served.", + ); + } + } + + private async saveIndex(next: AssetIndexV1): Promise { + if (Object.keys(next.assets).length > this.limits.maxAssets) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset index reached its entry limit.", + ); + } + try { + await this.indexStore.save(next); + } catch (error) { + if (error instanceof DataStoreUnsafeWriteError) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset metadata index exceeds its configured byte limit.", + ); + } + throw error; + } + this.index = next; + this.indexHealthy = true; + } + + private pruneRuntimeState(): void { + const now = this.now(); + for (const [token, lease] of this.leases) { + if (lease.expiresAt <= now) this.leases.delete(token); + } + for (const [planId, plan] of this.gcPlans) { + if (plan.expiresAt <= now) this.gcPlans.delete(planId); + } + } + + async status(): Promise<{ + healthy: boolean; + assetCount: number; + totalAssetBytes: number; + quotaWarning: boolean; + revision: number; + }> { + await this.initialize(); + const bytes = totalBytes(this.index); + return { + healthy: this.indexHealthy, + assetCount: Object.keys(this.index.assets).length, + totalAssetBytes: bytes, + quotaWarning: bytes >= this.limits.warningAssetBytes, + revision: this.index.revision, + }; + } + + async list(): Promise { + await this.initialize(); + this.ensureHealthy(); + return Object.values(this.index.assets) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .map(metadataDto); + } + + async get(assetId: string): Promise { + assertAssetId(assetId); + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + return asset ? metadataDto(asset) : undefined; + } + + private async publishedAssetAvailable(asset: StoredAsset): Promise { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open( + this.assetPath(asset.assetId, asset.extension), + constants.O_RDONLY | constants.O_NONBLOCK | noFollow, + ); + const info = await handle.stat(); + return info.isFile() && info.size === asset.byteLength; + } catch { + return false; + } finally { + await handle?.close().catch(() => undefined); + } + } + + async getAvailable(assetId: string): Promise { + assertAssetId(assetId); + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + return asset && (await this.publishedAssetAvailable(asset)) ? metadataDto(asset) : undefined; + } + + async ingest( + source: AsyncIterable, + request: AssetIngestRequest, + ): Promise { + const result = await this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + const origin = validateOrigin(request.origin); + if (origin.kind === "annotation" && !this.index.assets[origin.sourceAssetId]) { + throw new AssetStoreError("asset_not_found", "The annotation source asset does not exist."); + } + const generationMetadata = validateGenerationMetadata(request.generationMetadata); + const displayName = sanitizeAssetDisplayName(request.displayName); + const validationDisplayName = sanitizeAssetDisplayName(request.validationDisplayName); + const maxBytes = + origin.kind === "provider" + ? this.limits.maxProviderResponseBytes + : this.limits.maxImportBytes; + const tempPath = path.join(this.quarantineDirectory, `.ingest-${randomUUID()}.tmp`); + const handle = await fs.open(tempPath, "wx", 0o600); + const hash = createHash("sha256"); + let byteLength = 0; + try { + try { + for await (const rawChunk of source) { + if (!(rawChunk instanceof Uint8Array)) { + throw new AssetStoreError( + "invalid_asset_request", + "Asset ingest accepts byte chunks only.", + ); + } + if (rawChunk.byteLength === 0) continue; + const chunk = new Uint8Array(rawChunk.buffer, rawChunk.byteOffset, rawChunk.byteLength); + byteLength += chunk.byteLength; + if (byteLength > maxBytes) { + throw new AssetStoreError( + "asset_ingest_too_large", + `The image exceeds the ${maxBytes}-byte ingest limit.`, + ); + } + hash.update(chunk); + let written = 0; + while (written < chunk.byteLength) { + const result = await handle.write(chunk, written, chunk.byteLength - written, null); + if (result.bytesWritten < 1) throw new Error("The asset write made no progress."); + written += result.bytesWritten; + } + } + if (byteLength < 1) { + throw new AssetStoreError("invalid_asset_request", "The imported image is empty."); + } + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } + try { + const bytes = await readBoundedRegularFile(tempPath, maxBytes); + const descriptor = validateImageBytes( + bytes, + request.declaredMimeType, + validationDisplayName ?? displayName, + this.limits, + ); + const decoded = await this.options.deepValidator + .validate({ filePath: tempPath, descriptor, byteLength }) + .catch(() => { + throw new AssetStoreError( + "invalid_asset_request", + "The safe image decoder rejected the imported image.", + ); + }); + if (decoded.width !== descriptor.width || decoded.height !== descriptor.height) { + throw new AssetStoreError( + "invalid_asset_request", + "The image decoder dimensions do not match its validated header.", + ); + } + const assetId = hash.digest("hex"); + const decoderCheckedBytes = await readBoundedRegularFile(tempPath, maxBytes); + if ( + decoderCheckedBytes.byteLength !== byteLength || + createHash("sha256").update(decoderCheckedBytes).digest("hex") !== assetId + ) { + throw new AssetStoreError( + "invalid_asset_request", + "The quarantined image changed during validation.", + ); + } + const existing = this.index.assets[assetId]; + if (existing) { + if ( + existing.byteLength !== byteLength || + existing.extension !== descriptor.extension || + existing.mediaType !== descriptor.mediaType || + existing.width !== descriptor.width || + existing.height !== descriptor.height + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "Existing asset metadata does not match the re-imported image.", + ); + } + try { + await this.verifyPublishedAsset(existing); + } catch (verificationError) { + const destination = this.assetPath(assetId, existing.extension); + try { + await fs.lstat(destination); + throw verificationError; + } catch (inspectionError) { + if ((inspectionError as NodeJS.ErrnoException).code !== "ENOENT") { + throw inspectionError; + } + } + await ensureSafeDirectory(path.dirname(destination)); + let republished = false; + try { + try { + await fs.link(tempPath, destination); + republished = true; + await syncDirectory(path.dirname(destination)); + } catch (publishError) { + if ((publishError as NodeJS.ErrnoException).code !== "EEXIST") throw publishError; + } + await this.verifyPublishedAsset(existing); + } catch (publishError) { + if (republished) { + await fs.rm(destination, { force: true }).catch(() => undefined); + await syncDirectory(path.dirname(destination)).catch(() => undefined); + } + throw publishError; + } + } + const bytesUsed = totalBytes(this.index); + return { + asset: metadataDto(existing), + deduplicated: true, + quotaWarning: bytesUsed >= this.limits.warningAssetBytes, + totalAssetBytes: bytesUsed, + }; + } + const currentBytes = totalBytes(this.index); + if (Object.keys(this.index.assets).length >= this.limits.maxAssets) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset index reached its entry limit.", + ); + } + if (currentBytes + byteLength > this.limits.totalAssetBytes) { + throw new AssetStoreError( + "asset_store_quota_exceeded", + "The Create Images asset storage quota is full.", + ); + } + const destination = this.assetPath(assetId, descriptor.extension); + await ensureSafeDirectory(path.dirname(destination)); + let publishedByThisIngest = false; + try { + await fs.link(tempPath, destination); + publishedByThisIngest = true; + await syncDirectory(path.dirname(destination)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const published = await readBoundedRegularFile(destination, maxBytes); + if ( + createHash("sha256").update(published).digest("hex") !== assetId || + published.byteLength !== byteLength + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "A published content-addressed asset does not match its identifier.", + ); + } + } + const createdAt = new Date(this.now()).toISOString(); + const stored: StoredAsset = { + assetId, + extension: descriptor.extension, + mediaType: descriptor.mediaType, + byteLength, + width: descriptor.width, + height: descriptor.height, + createdAt, + ...(displayName ? { displayName } : {}), + origin, + ...(generationMetadata ? { generationMetadata } : {}), + referenceOwners: [], + unreferencedAt: createdAt, + thumbnails: {}, + }; + const next = structuredClone(this.index); + next.revision += 1; + next.assets[assetId] = stored; + try { + await this.saveIndex(next); + } catch (error) { + if (publishedByThisIngest) { + await fs.rm(destination, { force: true }).catch(() => undefined); + } + throw error; + } + const bytesUsed = currentBytes + byteLength; + return { + asset: metadataDto(stored), + deduplicated: false, + quotaWarning: bytesUsed >= this.limits.warningAssetBytes, + totalAssetBytes: bytesUsed, + }; + } finally { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } + }); + // Workspace materialization and other observers must not run under the + // asset-store mutation fence. A durable CAS publication remains successful + // even when an optional Finder mirror is unavailable or has drifted. + try { + await this.options.onAssetPublished?.(result.asset); + } catch { + // The canonical asset is already durable; observers are best effort. + } + return result; + } + + private async verifyPublishedAsset(asset: StoredAsset): Promise { + const bytes = await readBoundedRegularFile( + this.assetPath(asset.assetId, asset.extension), + Math.max(this.limits.maxImportBytes, this.limits.maxProviderResponseBytes), + ).catch(() => undefined); + if ( + !bytes || + bytes.byteLength !== asset.byteLength || + createHash("sha256").update(bytes).digest("hex") !== asset.assetId + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "A published asset is missing or does not match its content identifier.", + ); + } + let descriptor: ValidatedImageDescriptor; + try { + descriptor = validateImageBytes( + bytes, + asset.mediaType, + `${asset.assetId}.${asset.extension}`, + this.limits, + ); + } catch { + throw new AssetStoreError( + "asset_store_repair_required", + "A published asset no longer passes image validation.", + ); + } + if ( + descriptor.width !== asset.width || + descriptor.height !== asset.height || + descriptor.extension !== asset.extension + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "Published asset metadata does not match its image contents.", + ); + } + } + + async replaceReferences(owner: AssetReferenceOwner, assetIds: readonly string[]): Promise { + return this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + const key = ownerKey(owner); + if (assetIds.length > this.limits.maxAssets || new Set(assetIds).size !== assetIds.length) { + throw new AssetStoreError("invalid_asset_request", "The asset reference list is invalid."); + } + for (const assetId of assetIds) { + assertAssetId(assetId); + if (!this.index.assets[assetId]) { + throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + } + } + const desired = new Set(assetIds); + const next = structuredClone(this.index); + let changed = false; + const timestamp = new Date(this.now()).toISOString(); + for (const asset of Object.values(next.assets)) { + const had = asset.referenceOwners.includes(key); + const wants = desired.has(asset.assetId); + if (had === wants) continue; + changed = true; + asset.referenceOwners = wants + ? [...asset.referenceOwners, key].sort() + : asset.referenceOwners.filter((candidate) => candidate !== key); + if (asset.referenceOwners.length === 0) asset.unreferencedAt = timestamp; + else delete asset.unreferencedAt; + } + if (!changed) return; + next.revision += 1; + await this.saveIndex(next); + }); + } + + async rebuildReferenceAccounting(): Promise<{ missingAssetIds: string[]; revision: number }> { + return this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const byAsset = validateReferenceSnapshot(snapshot, this.limits); + const referencedAssetIds = [...byAsset.keys()]; + const missingAssetIds: string[] = []; + let cursor = 0; + await Promise.all( + Array.from({ length: Math.min(16, referencedAssetIds.length) }, async () => { + while (cursor < referencedAssetIds.length) { + const assetId = referencedAssetIds[cursor++]; + if (!assetId) continue; + const asset = this.index.assets[assetId]; + if (!asset || !(await this.publishedAssetAvailable(asset))) { + missingAssetIds.push(assetId); + } + } + }), + ); + missingAssetIds.sort(); + const next = structuredClone(this.index); + const timestamp = new Date(this.now()).toISOString(); + for (const asset of Object.values(next.assets)) { + const previousCount = asset.referenceOwners.length; + asset.referenceOwners = [...(byAsset.get(asset.assetId) ?? [])].sort(); + if (asset.referenceOwners.length === 0) { + asset.unreferencedAt ??= timestamp; + } else { + delete asset.unreferencedAt; + } + if (previousCount > 0 && asset.referenceOwners.length === 0) + asset.unreferencedAt = timestamp; + } + next.revision += 1; + await this.saveIndex(next); + return { missingAssetIds, revision: next.revision }; + }); + }); + } + + async acquirePreviewLease( + assetId: string, + ownerId: string, + ttlMs = 60_000, + ): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + assertSafeId(ownerId, "Preview owner ID"); + if (!Number.isSafeInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 5 * 60_000) { + throw new AssetStoreError( + "invalid_asset_request", + "Preview leases last between 1 and 300 seconds.", + ); + } + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) { + throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + } + if (!(await this.publishedAssetAvailable(asset))) { + throw new AssetStoreError( + "asset_source_missing", + `Asset ${assetId} is missing its source.`, + ); + } + this.pruneRuntimeState(); + if (this.leases.size >= this.limits.maxPreviewLeases) { + throw new AssetStoreError("invalid_asset_request", "The preview lease limit is reached."); + } + const token = randomBytes(32).toString("base64url"); + const lease = { token, assetId, ownerId, expiresAt: this.now() + ttlMs }; + this.leases.set(token, lease); + return { token, assetId, expiresAt: lease.expiresAt }; + }); + } + + async readPreview( + token: string, + ownerId: string, + maxBytes = this.limits.maxPreviewReadBytes, + ): Promise { + return this.serialized(async () => { + if (!PREVIEW_TOKEN.test(token)) { + throw new AssetStoreError( + "preview_lease_invalid", + "The preview lease is invalid or expired.", + ); + } + assertSafeId(ownerId, "Preview owner ID"); + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes < 1 || + maxBytes > this.limits.maxPreviewReadBytes + ) { + throw new AssetStoreError("invalid_asset_request", "The preview byte limit is invalid."); + } + await this.initialize(); + this.ensureHealthy(); + this.pruneRuntimeState(); + const lease = this.leases.get(token); + if (!lease || lease.ownerId !== ownerId) { + throw new AssetStoreError( + "preview_lease_invalid", + "The preview lease is invalid or expired.", + ); + } + const asset = this.index.assets[lease.assetId]; + if (!asset) + throw new AssetStoreError("asset_not_found", "The preview asset no longer exists."); + if (asset.byteLength > maxBytes) { + throw new AssetStoreError( + "preview_too_large", + "The selected asset exceeds the preview byte limit.", + ); + } + const bytes = await readBoundedRegularFile( + this.assetPath(asset.assetId, asset.extension), + maxBytes, + ).catch(() => { + throw new AssetStoreError( + "asset_store_repair_required", + "The preview asset is missing or unsafe.", + ); + }); + if (createHash("sha256").update(bytes).digest("hex") !== asset.assetId) { + throw new AssetStoreError( + "asset_store_repair_required", + "The preview asset failed integrity validation.", + ); + } + try { + const descriptor = validateImageBytes( + bytes, + asset.mediaType, + `${asset.assetId}.${asset.extension}`, + this.limits, + ); + if ( + descriptor.width !== asset.width || + descriptor.height !== asset.height || + descriptor.extension !== asset.extension + ) { + throw new Error("metadata_mismatch"); + } + } catch { + throw new AssetStoreError( + "asset_store_repair_required", + "The preview asset metadata does not match its image contents.", + ); + } + return { asset: metadataDto(asset), bytes: bytes.slice() }; + }); + } + + async releasePreviewLease(token: string, ownerId: string): Promise { + return this.serialized(async () => { + if (!PREVIEW_TOKEN.test(token)) return false; + assertSafeId(ownerId, "Preview owner ID"); + const lease = this.leases.get(token); + if (!lease || lease.ownerId !== ownerId) return false; + return this.leases.delete(token); + }); + } + + async releasePreviewOwner(ownerId: string): Promise { + return this.serialized(async () => { + assertSafeId(ownerId, "Preview owner ID"); + let released = 0; + for (const [token, lease] of this.leases) { + if (lease.ownerId !== ownerId) continue; + this.leases.delete(token); + released += 1; + } + return released; + }); + } + + async getThumbnail(assetId: string, size: number): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + if (!this.limits.thumbnailSizes.includes(size)) { + throw new AssetStoreError("invalid_asset_request", "The thumbnail size is not allowed."); + } + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + if (!(await this.publishedAssetAvailable(asset))) { + throw new AssetStoreError( + "asset_source_missing", + `Asset ${assetId} is missing its source.`, + ); + } + const key = `${assetId}:${size}`; + const cached = this.cache.get(key); + if (cached) return { assetId, ...cached, bytes: cached.bytes.slice() }; + const thumbnailPath = this.thumbnailPath(assetId, size); + const stored = asset.thumbnails[String(size)]; + if (stored) { + try { + const bytes = await this.readValidatedThumbnail(thumbnailPath, stored); + const entry = thumbnailCacheEntry(stored, bytes); + this.cache.set(key, entry); + return { assetId, ...entry, bytes: entry.bytes.slice() }; + } catch { + // A derived thumbnail is regenerable. Its immutable source remains untouched. + } + } + if (!this.options.thumbnailGenerator) { + throw new AssetStoreError( + "thumbnail_unavailable", + "No safe thumbnail generator is configured.", + ); + } + await this.verifyPublishedAsset(asset); + const generated = await this.options.thumbnailGenerator.generate({ + sourcePath: this.assetPath(assetId, asset.extension), + source: descriptorFor(asset), + maxDimension: size, + maxOutputBytes: this.limits.maxThumbnailBytes, + }); + if ( + generated.mediaType !== THUMBNAIL_MEDIA_TYPE || + !(generated.bytes instanceof Uint8Array) || + generated.bytes.byteLength < 33 || + generated.bytes.byteLength > this.limits.maxThumbnailBytes || + !isSafeThumbnailPng(generated.bytes, generated.width, generated.height) || + !Number.isSafeInteger(generated.width) || + !Number.isSafeInteger(generated.height) || + generated.width < 1 || + generated.height < 1 || + generated.width > size || + generated.height > size + ) { + throw new AssetStoreError( + "thumbnail_unavailable", + "The thumbnail generator returned unsafe output.", + ); + } + await ensureSafeDirectory(path.dirname(thumbnailPath)); + const temp = `${thumbnailPath}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temp, generated.bytes, { flag: "wx", mode: 0o600 }); + const handle = await fs.open(temp, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + const decoded = await this.options.deepValidator + .validate({ + filePath: temp, + descriptor: { + mediaType: "image/png", + extension: "png", + width: generated.width, + height: generated.height, + pixels: generated.width * generated.height, + }, + byteLength: generated.bytes.byteLength, + }) + .catch(() => { + throw new AssetStoreError( + "thumbnail_unavailable", + "The safe image decoder rejected the generated thumbnail.", + ); + }); + if (decoded.width !== generated.width || decoded.height !== generated.height) { + throw new AssetStoreError( + "thumbnail_unavailable", + "The decoded thumbnail dimensions do not match its metadata.", + ); + } + await fs.rename(temp, thumbnailPath); + await syncDirectory(path.dirname(thumbnailPath)); + } finally { + await fs.rm(temp, { force: true }).catch(() => undefined); + } + const updatedAt = new Date(this.now()).toISOString(); + const metadata: StoredThumbnail = { + mediaType: THUMBNAIL_MEDIA_TYPE, + byteLength: generated.bytes.byteLength, + width: generated.width, + height: generated.height, + updatedAt, + }; + const next = structuredClone(this.index); + next.revision += 1; + next.assets[assetId]!.thumbnails[String(size)] = metadata; + await this.saveIndex(next); + const entry = thumbnailCacheEntry(metadata, generated.bytes); + this.cache.set(key, entry); + return { assetId, ...entry, bytes: entry.bytes.slice() }; + }); + } + + thumbnailCacheStatus(): { entries: number; byteLength: number; maxBytes: number } { + return { + entries: this.cache.size, + byteLength: this.cache.byteLength, + maxBytes: this.limits.thumbnailCacheBytes, + }; + } + + /** + * Main-only seam for protocol/export code. The callback must finish consuming + * the file before it resolves; the asset-store mutation lock (and therefore + * GC exclusion) is held for that full interval. Never forward filePath over IPC. + */ + async withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: SafeAssetMediaType; + }) => Promise, + ): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + await this.verifyPublishedAsset(asset); + return callback({ + filePath: this.assetPath(assetId, asset.extension), + asset: metadataDto(asset), + byteLength: asset.byteLength, + mediaType: asset.mediaType, + }); + }); + } + + /** + * Main-only validation seam for native archive quarantine files. This does + * not publish bytes or mutate the asset index. Callers must keep the path in + * a private main-owned directory and must never forward it over IPC. + */ + async validateQuarantinedAssetFile( + filePath: string, + input: { declaredMimeType: string; displayName: string }, + ): Promise { + const bytes = await readBoundedRegularFile(filePath, this.limits.maxImportBytes); + const descriptor = validateImageBytes( + bytes, + input.declaredMimeType, + sanitizeAssetDisplayName(input.displayName), + this.limits, + ); + const decoded = await this.options.deepValidator + .validate({ filePath, descriptor, byteLength: bytes.byteLength }) + .catch(() => { + throw new AssetStoreError( + "invalid_asset_request", + "The safe image decoder rejected the archived image.", + ); + }); + if (decoded.width !== descriptor.width || decoded.height !== descriptor.height) { + throw new AssetStoreError( + "invalid_asset_request", + "The archived image decoder dimensions do not match its validated header.", + ); + } + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + mediaType: descriptor.mediaType, + byteLength: bytes.byteLength, + width: descriptor.width, + height: descriptor.height, + }; + } + + /** Main-dialog-only durable export. The destination must never come from a + * renderer payload; callers are responsible for obtaining it from a native + * save dialog. */ + async exportAssetToFile(assetId: string, destination: string): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + if (!path.isAbsolute(destination) || destination.includes("\0")) { + throw new AssetStoreError("invalid_asset_request", "The asset export path is invalid."); + } + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) throw new AssetStoreError("asset_not_found", "The exported asset is missing."); + await this.verifyPublishedAsset(asset); + const directory = path.dirname(destination); + const temp = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`); + try { + await fs.copyFile(this.assetPath(assetId, asset.extension), temp, constants.COPYFILE_EXCL); + await fs.chmod(temp, 0o600); + const handle = await fs.open(temp, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temp, destination); + await syncDirectory(directory); + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined); + throw error; + } + return metadataDto(asset); + }); + } + + async planGarbageCollection(graceMs: number): Promise { + return this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + this.pruneRuntimeState(); + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const references = validateReferenceSnapshot(snapshot, this.limits); + const missing = [...references.keys()].filter((assetId) => !this.index.assets[assetId]); + if (missing.length > 0) { + throw new AssetStoreError( + "asset_store_repair_required", + "Reference accounting includes missing assets; repair is required before collection.", + ); + } + const now = this.now(); + const leased = new Set([...this.leases.values()].map((lease) => lease.assetId)); + const candidates = Object.values(this.index.assets) + .filter((asset) => { + const unreferencedAt = Date.parse(asset.unreferencedAt ?? asset.createdAt); + return ( + !references.has(asset.assetId) && + !leased.has(asset.assetId) && + Number.isFinite(unreferencedAt) && + unreferencedAt <= now - graceMs + ); + }) + .sort((left, right) => left.assetId.localeCompare(right.assetId)); + while (this.gcPlans.size >= 32) { + const oldest = this.gcPlans.keys().next().value as string | undefined; + if (!oldest) break; + this.gcPlans.delete(oldest); + } + const plan: InternalGcPlan = { + planId: randomBytes(24).toString("base64url"), + createdAt: now, + expiresAt: now + 5 * 60_000, + indexRevision: this.index.revision, + referenceEpoch: snapshot.epoch, + referenceFingerprint: referenceFingerprint(snapshot), + graceMs, + candidateAssetIds: candidates.map((asset) => asset.assetId), + reclaimableBytes: candidates.reduce((sum, asset) => sum + asset.byteLength, 0), + }; + this.gcPlans.set(plan.planId, plan); + return { + planId: plan.planId, + createdAt: plan.createdAt, + expiresAt: plan.expiresAt, + indexRevision: plan.indexRevision, + referenceEpoch: plan.referenceEpoch, + candidateAssetIds: [...plan.candidateAssetIds], + reclaimableBytes: plan.reclaimableBytes, + }; + }); + }); + } + + async applyGarbageCollection(planId: string): Promise { + return this.serialized(async () => { + if (!/^[A-Za-z0-9_-]{24,128}$/u.test(planId)) { + throw new AssetStoreError( + "invalid_asset_request", + "The garbage-collection plan ID is invalid.", + ); + } + await this.initialize(); + this.ensureHealthy(); + this.pruneRuntimeState(); + const plan = this.gcPlans.get(planId); + if (!plan) { + return { + applied: false, + stale: true, + deletedAssetIds: [], + reclaimedBytes: 0, + skipped: [], + }; + } + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const references = validateReferenceSnapshot(snapshot, this.limits); + if ( + plan.expiresAt <= this.now() || + plan.indexRevision !== this.index.revision || + plan.referenceEpoch !== snapshot.epoch || + plan.referenceFingerprint !== referenceFingerprint(snapshot) + ) { + this.gcPlans.delete(planId); + return { + applied: false, + stale: true, + deletedAssetIds: [], + reclaimedBytes: 0, + skipped: [], + }; + } + const now = this.now(); + const leased = new Set([...this.leases.values()].map((lease) => lease.assetId)); + const skipped: AssetGarbageCollectionResult["skipped"] = []; + const removable: StoredAsset[] = []; + for (const assetId of plan.candidateAssetIds) { + const asset = this.index.assets[assetId]; + if (!asset) { + skipped.push({ assetId, reason: "not_found" }); + continue; + } + if (references.has(assetId)) { + skipped.push({ assetId, reason: "referenced" }); + continue; + } + if (leased.has(assetId)) { + skipped.push({ assetId, reason: "lease_active" }); + continue; + } + const unreferencedAt = Date.parse(asset.unreferencedAt ?? asset.createdAt); + if (!Number.isFinite(unreferencedAt) || unreferencedAt > now - plan.graceMs) { + skipped.push({ assetId, reason: "too_new" }); + continue; + } + removable.push(asset); + } + const staged: Array<{ asset: StoredAsset; stagedPath: string; destination: string }> = []; + try { + for (const asset of removable) { + const destination = this.assetPath(asset.assetId, asset.extension); + const stagedPath = path.join( + this.quarantineDirectory, + `.gc-${asset.assetId}-${randomUUID()}.${asset.extension}`, + ); + try { + const info = await fs.lstat(destination); + if (!info.isFile() || info.isSymbolicLink()) { + skipped.push({ assetId: asset.assetId, reason: "not_found" }); + continue; + } + await fs.rename(destination, stagedPath); + staged.push({ asset, stagedPath, destination }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + skipped.push({ assetId: asset.assetId, reason: "not_found" }); + continue; + } + throw error; + } + } + if (staged.length > 0) { + const next = structuredClone(this.index); + next.revision += 1; + for (const { asset } of staged) delete next.assets[asset.assetId]; + await this.saveIndex(next); + } + } catch (error) { + for (const item of staged.reverse()) { + await fs.rename(item.stagedPath, item.destination).catch(() => undefined); + } + throw error; + } + for (const { asset, stagedPath } of staged) { + await fs.rm(stagedPath, { force: true }).catch(() => undefined); + await fs + .rm(path.join(this.thumbnailDirectory, asset.assetId), { + recursive: true, + force: true, + }) + .catch(() => undefined); + this.cache.deletePrefix(`${asset.assetId}:`); + } + this.gcPlans.delete(planId); + return { + applied: true, + stale: false, + deletedAssetIds: staged.map(({ asset }) => asset.assetId), + reclaimedBytes: staged.reduce((sum, { asset }) => sum + asset.byteLength, 0), + skipped, + }; + }); + }); + } + + async repair(options: { apply: boolean }): Promise { + return this.serialized(async () => { + await this.initialize(); + const indexWasUnhealthy = !this.indexHealthy; + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const references = validateReferenceSnapshot(snapshot, this.limits); + const { scanned, invalid } = await this.scanPublishedAssets(); + if (scanned.size > this.limits.maxAssets) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The repaired asset index would exceed its entry limit.", + ); + } + const addedAssetIds: string[] = []; + const correctedAssetIds: string[] = []; + const removedAssetIds = Object.keys(this.index.assets) + .filter((assetId) => !scanned.has(assetId)) + .sort(); + const repaired = cloneEmptyIndex(); + repaired.revision = this.index.revision + 1; + const timestamp = new Date(this.now()).toISOString(); + for (const [assetId, entry] of [...scanned.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { + const existing = this.index.assets[assetId]; + if (!existing) addedAssetIds.push(assetId); + const owners = [...(references.get(assetId) ?? [])].sort(); + const baseMatches = + existing && + existing.extension === entry.descriptor.extension && + existing.mediaType === entry.descriptor.mediaType && + existing.byteLength === entry.byteLength && + existing.width === entry.descriptor.width && + existing.height === entry.descriptor.height; + if (existing && !baseMatches) correctedAssetIds.push(assetId); + const thumbnails = existing && baseMatches ? await this.validThumbnails(existing) : {}; + repaired.assets[assetId] = { + assetId, + extension: entry.descriptor.extension, + mediaType: entry.descriptor.mediaType, + byteLength: entry.byteLength, + width: entry.descriptor.width, + height: entry.descriptor.height, + createdAt: existing?.createdAt ?? entry.createdAt, + ...(existing?.displayName ? { displayName: existing.displayName } : {}), + origin: existing?.origin ?? { kind: "repair" }, + ...(existing?.generationMetadata + ? { generationMetadata: structuredClone(existing.generationMetadata) } + : {}), + referenceOwners: owners, + ...(owners.length === 0 + ? { unreferencedAt: existing?.unreferencedAt ?? timestamp } + : {}), + thumbnails, + }; + } + const missingReferenceAssetIds = [...references.keys()] + .filter((assetId) => !scanned.has(assetId)) + .sort(); + const report: AssetRepairReport = { + applied: options.apply, + indexWasUnhealthy, + addedAssetIds: addedAssetIds.sort(), + removedAssetIds, + correctedAssetIds: correctedAssetIds.sort(), + quarantinedEntryIds: [], + invalidEntries: invalid.map(({ entryId, reason }) => ({ entryId, reason })), + missingReferenceAssetIds, + }; + if (!options.apply) return report; + await ensureSafeDirectory(this.quarantineDirectory); + for (const item of invalid) { + const destination = path.join( + this.quarantineDirectory, + `repair-${randomUUID()}-${path.basename(item.filePath).slice(0, 80)}`, + ); + try { + await fs.rename(item.filePath, destination); + report.quarantinedEntryIds.push(item.entryId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + await this.saveIndex(repaired); + this.cache.clear(); + return report; + }); + }); + } + + private async validThumbnails(asset: StoredAsset): Promise> { + const valid: Record = {}; + for (const [size, metadata] of Object.entries(asset.thumbnails)) { + if (!this.limits.thumbnailSizes.includes(Number(size))) continue; + try { + await this.readValidatedThumbnail( + this.thumbnailPath(asset.assetId, Number(size)), + metadata, + ); + valid[size] = metadata; + } catch { + // Derived content is omitted from the repaired index and regenerated lazily. + } + } + return valid; + } + + private async readValidatedThumbnail( + filePath: string, + metadata: Pick, + ): Promise { + const bytes = await readBoundedRegularFile(filePath, this.limits.maxThumbnailBytes); + if ( + bytes.byteLength !== metadata.byteLength || + !isSafeThumbnailPng(bytes, metadata.width, metadata.height) + ) { + throw new Error("The thumbnail file does not match its metadata."); + } + const decoded = await this.options.deepValidator.validate({ + filePath, + descriptor: { + mediaType: "image/png", + extension: "png", + width: metadata.width, + height: metadata.height, + pixels: metadata.width * metadata.height, + }, + byteLength: metadata.byteLength, + }); + if (decoded.width !== metadata.width || decoded.height !== metadata.height) { + throw new Error("The decoded thumbnail dimensions do not match its metadata."); + } + return bytes; + } + + private async scanPublishedAssets(): Promise<{ + scanned: Map; + invalid: InvalidScannedEntry[]; + }> { + const scanned = new Map(); + const invalid: InvalidScannedEntry[] = []; + let entryCount = 0; + let scannedBytes = 0; + const prefixDirectory = await fs.opendir(this.assetsDirectory); + for await (const prefix of prefixDirectory) { + entryCount += 1; + if (entryCount > this.limits.maxRepairEntries) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset tree has too many entries.", + ); + } + const prefixPath = path.join(this.assetsDirectory, prefix.name); + if (!prefix.isDirectory() || prefix.isSymbolicLink() || !/^[a-f0-9]{2}$/u.test(prefix.name)) { + invalid.push({ + entryId: prefix.name.slice(0, 255), + filePath: prefixPath, + reason: "invalid_prefix", + }); + continue; + } + const assetDirectory = await fs.opendir(prefixPath); + for await (const entry of assetDirectory) { + entryCount += 1; + if (entryCount > this.limits.maxRepairEntries) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset tree has too many entries.", + ); + } + const entryId = `${prefix.name}/${entry.name.slice(0, 255)}`; + const filePath = path.join(prefixPath, entry.name); + const match = /^([a-f0-9]{64})\.(png|jpg)$/u.exec(entry.name); + if ( + !entry.isFile() || + entry.isSymbolicLink() || + !match || + match[1]!.slice(0, 2) !== prefix.name + ) { + invalid.push({ entryId, filePath, reason: "invalid_asset_entry" }); + continue; + } + try { + const bytes = await readBoundedRegularFile( + filePath, + Math.max(this.limits.maxImportBytes, this.limits.maxProviderResponseBytes), + ); + if (bytes.byteLength > this.limits.totalAssetBytes - scannedBytes) { + throw new AssetStoreError( + "asset_store_quota_exceeded", + "The asset tree exceeds its aggregate byte quota.", + ); + } + scannedBytes += bytes.byteLength; + const assetId = createHash("sha256").update(bytes).digest("hex"); + if (assetId !== match[1]) throw new Error("digest_mismatch"); + const descriptor = validateImageBytes(bytes, undefined, entry.name, this.limits); + if (descriptor.extension !== match[2]) throw new Error("extension_mismatch"); + const decoded = await this.options.deepValidator.validate({ + filePath, + descriptor, + byteLength: bytes.byteLength, + }); + if (decoded.width !== descriptor.width || decoded.height !== descriptor.height) { + throw new Error("decoder_dimension_mismatch"); + } + const info = await fs.lstat(filePath); + scanned.set(assetId, { + entryId, + filePath, + descriptor, + byteLength: bytes.byteLength, + createdAt: new Date(info.birthtimeMs || info.mtimeMs).toISOString(), + }); + } catch (error) { + if (error instanceof AssetStoreError && error.code === "asset_store_quota_exceeded") { + throw error; + } + invalid.push({ + entryId, + filePath, + reason: + error instanceof AssetImageValidationError + ? error.code + : error instanceof Error && + [ + "digest_mismatch", + "extension_mismatch", + "decoder_dimension_mismatch", + ].includes(error.message) + ? error.message + : "decoder_or_file_validation_failed", + }); + } + } + } + return { scanned, invalid }; + } +} diff --git a/main/services/create-images/asset-thumbnail-cache-core.ts b/main/services/create-images/asset-thumbnail-cache-core.ts new file mode 100644 index 00000000..88c008aa --- /dev/null +++ b/main/services/create-images/asset-thumbnail-cache-core.ts @@ -0,0 +1,69 @@ +export interface ByteSizedValue { + byteLength: number; +} + +/** Strict byte-bounded LRU. Values larger than the entire budget are never retained. */ +export class ByteBoundedLru { + private readonly entries = new Map(); + private retainedBytes = 0; + + constructor(private readonly maxBytes: number) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error("Thumbnail cache capacity must be a positive integer byte count."); + } + } + + get(key: string): Value | undefined { + const value = this.entries.get(key); + if (!value) return undefined; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: Value): void { + if (!Number.isSafeInteger(value.byteLength) || value.byteLength < 0) { + throw new Error("Cached values require an exact non-negative byte length."); + } + this.delete(key); + if (value.byteLength > this.maxBytes) return; + while (this.retainedBytes + value.byteLength > this.maxBytes) { + const oldest = this.entries.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.delete(oldest); + } + this.entries.set(key, value); + this.retainedBytes += value.byteLength; + } + + delete(key: string): boolean { + const value = this.entries.get(key); + if (!value) return false; + this.entries.delete(key); + this.retainedBytes -= value.byteLength; + return true; + } + + deletePrefix(prefix: string): number { + let deleted = 0; + for (const key of [...this.entries.keys()]) { + if (!key.startsWith(prefix)) continue; + this.delete(key); + deleted += 1; + } + return deleted; + } + + clear(): void { + this.entries.clear(); + this.retainedBytes = 0; + } + + get size(): number { + return this.entries.size; + } + + get byteLength(): number { + return this.retainedBytes; + } +} diff --git a/main/services/create-images/create-images-service.ts b/main/services/create-images/create-images-service.ts new file mode 100644 index 00000000..c0ef1946 --- /dev/null +++ b/main/services/create-images/create-images-service.ts @@ -0,0 +1,641 @@ +import path from "node:path"; +import * as electron from "electron"; +import type { + AssetDeepValidator, + AssetPreviewLeaseDto, + AssetReferenceAuthority, + AssetReferenceSnapshot, + AssetThumbnailGenerator, +} from "./asset-store-core.js"; +import { AssetStoreError, ContentAddressedAssetStore } from "./asset-store-core.js"; +import { + ASSET_DELIVERY_GRANT_TTL_MS, + AssetDeliveryGrantRegistry, + type AssetDeliveryGrantView, +} from "./asset-delivery-core.js"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; +import { + WorkflowManifestStore, + type WorkflowManifestDurability, +} from "./workflow-manifest-store.js"; +import { + CreateImagesRunService, + type CreateImagesRunReferenceAuthority, + type CreateImagesRunReferenceReservation, +} from "./run-service.js"; +import type { CreateImagesRunJournalStore } from "./run-journal-store.js"; +import { resolveCreateImagesGeminiApiKeyAuth } from "./gemini-provider-status-core.js"; +import { CreateImagesNativeArchiveService } from "./native-archive-service.js"; +import { CreateImagesNodeBananaImportService } from "./node-banana-import-service.js"; +import { CreateImagesWorkspaceStore, type CreateImagesWorkspaceStatus } from "./workspace-store.js"; +import { CreateImagesWorkflowProposalService } from "./workflow-proposal-service.js"; +import { CreateImagesPresentationStore } from "./presentation-store.js"; + +const defaultAssetDeepValidator: AssetDeepValidator = { + async validate(input) { + const { electronAssetDeepValidator } = await import("./electron-asset-images.js"); + return electronAssetDeepValidator.validate(input); + }, +}; + +const defaultAssetThumbnailGenerator: AssetThumbnailGenerator = { + async generate(input) { + const { electronAssetThumbnailGenerator } = await import("./electron-asset-images.js"); + return electronAssetThumbnailGenerator.generate(input); + }, +}; + +interface WorkflowReferenceReservation { + workflowId: string; + next: ReadonlySet; + active: boolean; +} + +class CreateImagesReferenceAuthority + implements AssetReferenceAuthority, CreateImagesRunReferenceAuthority +{ + private readonly workflows = new Map>(); + private readonly runs = new Map>(); + private readonly runReservations = new Map>(); + private tail: Promise = Promise.resolve(); + private epoch = 0; + private workflowsComplete = false; + private runsComplete = false; + + private serialized(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async initializeWorkflowsInsideFence(store: WorkflowManifestStore): Promise { + this.workflowsComplete = false; + this.epoch += 1; + try { + const inventory = await store.referenceInventory(); + this.workflows.clear(); + for (const record of inventory.records) { + this.workflows.set(record.workflowId, new Set(record.assetIds)); + } + this.workflowsComplete = inventory.complete; + this.epoch += 1; + return inventory.complete; + } catch (error) { + this.workflowsComplete = false; + this.epoch += 1; + throw error; + } + } + + private async initializeRunsInsideFence(store: CreateImagesRunJournalStore): Promise { + this.runsComplete = false; + this.epoch += 1; + try { + const inventory = await store.referenceInventory(); + this.runs.clear(); + for (const record of inventory.records) { + this.runs.set(record.runId, new Set(record.assetIds)); + } + for (const [runId, reservations] of this.runReservations) { + const protectedIds = new Set(this.runs.get(runId) ?? []); + for (const reservation of reservations) { + if (!reservation.active) continue; + for (const assetId of reservation.next) protectedIds.add(assetId); + } + if (protectedIds.size > 0) this.runs.set(runId, protectedIds); + } + this.runsComplete = inventory.complete; + this.epoch += 1; + return inventory.complete; + } catch (error) { + this.runsComplete = false; + this.epoch += 1; + throw error; + } + } + + async initialize( + workflows: WorkflowManifestStore, + runs: CreateImagesRunJournalStore, + ): Promise { + return this.serialized(async () => { + const workflowsComplete = await this.initializeWorkflowsInsideFence(workflows); + const runsComplete = await this.initializeRunsInsideFence(runs); + return workflowsComplete && runsComplete; + }); + } + + async reserve( + workflowId: string, + assetIds: readonly string[], + ): Promise { + return this.serialized(async () => { + const previous = this.workflows.get(workflowId) ?? new Set(); + const next = new Set(assetIds); + this.workflows.set(workflowId, new Set([...previous, ...next])); + this.epoch += 1; + return { workflowId, next, active: true }; + }); + } + + async commit(reservation: WorkflowReferenceReservation): Promise { + if (!reservation.active) return; + await this.serialized(async () => { + if (!reservation.active) return; + reservation.active = false; + if (reservation.next.size === 0) this.workflows.delete(reservation.workflowId); + else this.workflows.set(reservation.workflowId, new Set(reservation.next)); + this.epoch += 1; + }); + } + + async reconcileFailedMutation( + reservation: WorkflowReferenceReservation, + store: WorkflowManifestStore, + ): Promise { + return this.serialized(async () => { + if (reservation.active) { + reservation.active = false; + // Keep the reservation's previous+next union protected until the + // durable current/LKG/journal inventory replaces it below. If that + // inventory cannot be read safely, initializeInsideFence leaves GC + // fail-closed while this conservative union remains available. + this.epoch += 1; + } + return this.initializeWorkflowsInsideFence(store); + }); + } + + async reserveRun( + runId: string, + assetIds: readonly string[], + ): Promise { + return this.serialized(async () => { + const previous = this.runs.get(runId) ?? new Set(); + const next = new Set([...previous, ...assetIds]); + this.runs.set(runId, next); + this.epoch += 1; + const reservation = { runId, next, active: true }; + const reservations = this.runReservations.get(runId) ?? new Set(); + reservations.add(reservation); + this.runReservations.set(runId, reservations); + return reservation; + }); + } + + async commitRun(reservation: CreateImagesRunReferenceReservation): Promise { + if (!reservation.active) return; + await this.serialized(async () => { + if (!reservation.active) return; + reservation.active = false; + const reservations = this.runReservations.get(reservation.runId); + reservations?.delete(reservation); + if (reservations?.size === 0) this.runReservations.delete(reservation.runId); + const current = this.runs.get(reservation.runId) ?? new Set(); + const committed = new Set([...current, ...reservation.next]); + if (committed.size === 0) this.runs.delete(reservation.runId); + else this.runs.set(reservation.runId, committed); + this.epoch += 1; + }); + } + + async releaseRunReservations(runId: string): Promise { + await this.serialized(async () => { + const reservations = this.runReservations.get(runId); + if (!reservations) return; + for (const reservation of reservations) reservation.active = false; + this.runReservations.delete(runId); + this.epoch += 1; + }); + } + + async reconcileRuns(store: CreateImagesRunJournalStore): Promise { + return this.serialized(() => this.initializeRunsInsideFence(store)); + } + + isWorkflowAssetReferenced(workflowId: string, assetId: string): boolean { + return this.workflows.get(workflowId)?.has(assetId) ?? false; + } + + isRunAssetReferenced(runId: string, assetId: string): boolean { + return this.runs.get(runId)?.has(assetId) ?? false; + } + + workflowAssetIds(workflowId: string): string[] { + return [...(this.workflows.get(workflowId) ?? [])].sort(); + } + + allReferencedAssetIds(): Set { + return new Set( + [...this.workflows.values(), ...this.runs.values()].flatMap((assetIds) => [...assetIds]), + ); + } + + async withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise { + return this.serialized(async () => { + if (!this.workflowsComplete || !this.runsComplete) { + throw new Error( + "Asset collection is disabled until every workflow recovery issue is resolved.", + ); + } + return callback({ + epoch: String(this.epoch), + completeKinds: ["export", "run", "workflow"], + records: [ + ...[...this.workflows.entries()].map(([id, assetIds]) => ({ + kind: "workflow" as const, + id, + assetIds: [...assetIds].sort(), + })), + ...[...this.runs.entries()].map(([id, assetIds]) => ({ + kind: "run" as const, + id, + assetIds: [...assetIds].sort(), + })), + ], + }); + }); + } +} + +export interface CreateImagesServiceOptions { + workflowDurability?: WorkflowManifestDurability; + /** Production requires first-open Finder workspace setup; isolated stores/tests may opt out. */ + workspaceRequired?: boolean; + assetStore?: { + now?: () => number; + deepValidator?: AssetDeepValidator; + thumbnailGenerator?: AssetThumbnailGenerator; + }; + runService?: Pick< + ConstructorParameters[0], + "resolveGeminiAuth" | "createGeminiProvider" | "annotationRasterizer" + >; +} + +export type CreateImagesDeleteWorkflowResult = + | { status: "deleted" } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export class CreateImagesService { + readonly workflows: WorkflowManifestStore; + readonly runs: CreateImagesRunService; + readonly archives: CreateImagesNativeArchiveService; + readonly nodeBananaImports: CreateImagesNodeBananaImportService; + readonly workspace: CreateImagesWorkspaceStore; + readonly proposals = new CreateImagesWorkflowProposalService(); + readonly presentation: CreateImagesPresentationStore; + readonly grants = new AssetDeliveryGrantRegistry(); + readonly assets: ContentAddressedAssetStore; + readonly references = new CreateImagesReferenceAuthority(); + private readonly workspaceRequired: boolean; + private initializePromise: Promise | undefined; + private missingAssetIds = new Set(); + + private pruneResolvedMissingAssets(): void { + const referenced = this.references.allReferencedAssetIds(); + for (const assetId of this.missingAssetIds) { + if (!referenced.has(assetId)) this.missingAssetIds.delete(assetId); + } + } + + constructor(rootDirectory: string, options: CreateImagesServiceOptions = {}) { + this.workspaceRequired = options.workspaceRequired ?? false; + this.workflows = new WorkflowManifestStore(() => rootDirectory, options.workflowDurability); + this.presentation = new CreateImagesPresentationStore(rootDirectory); + let workspaceStore: CreateImagesWorkspaceStore | undefined; + this.assets = new ContentAddressedAssetStore(rootDirectory, this.references, { + deepValidator: options.assetStore?.deepValidator ?? defaultAssetDeepValidator, + thumbnailGenerator: options.assetStore?.thumbnailGenerator ?? defaultAssetThumbnailGenerator, + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + onAssetPublished: async (asset) => { + await workspaceStore?.syncAsset(asset.assetId); + }, + }); + this.workspace = new CreateImagesWorkspaceStore(rootDirectory, this.assets, { + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + }); + workspaceStore = this.workspace; + this.runs = new CreateImagesRunService({ + rootResolver: () => rootDirectory, + workflows: this.workflows, + assets: this.assets, + references: this.references, + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + workspaceStatus: (): Promise> => + this.workspace.status(), + workspaceRequired: options.workspaceRequired ?? false, + ...options.runService, + annotationRasterizer: options.runService?.annotationRasterizer ?? { + async rasterize(input) { + const { electronAnnotationRasterizer } = await import("./electron-asset-images.js"); + return electronAnnotationRasterizer.rasterize(input); + }, + }, + }); + this.archives = new CreateImagesNativeArchiveService({ + rootDirectory, + workflows: this.workflows, + assets: this.assets, + publishImportedWorkflow: (workflow, isCurrent) => + this.mutateWorkflow(workflow.id, workflow.assetRefs, () => + this.workflows.create(workflow, isCurrent), + ), + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + }); + this.nodeBananaImports = new CreateImagesNodeBananaImportService({ + rootDirectory, + assets: this.assets, + publishImportedWorkflow: (workflow, isCurrent) => + this.mutateWorkflow(workflow.id, workflow.assetRefs, () => + this.workflows.create(workflow, isCurrent), + ), + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + }); + } + + async initialize(): Promise { + this.initializePromise ??= (async () => { + const workspace = await this.workspace.status(); + if (this.workspaceRequired && (!workspace.configured || workspace.state !== "ready")) { + throw new Error("Configure a writable Create Images workspace before continuing."); + } + const summaries = await this.workflows.initialize(); + await this.runs.initialize(); + const referencesComplete = await this.references.initialize( + this.workflows, + this.runs.journals, + ); + const status = await this.assets.status(); + if ( + status.healthy && + referencesComplete && + summaries.every((summary) => summary.health === "healthy") + ) { + const rebuilt = await this.assets.rebuildReferenceAccounting(); + this.missingAssetIds = new Set(rebuilt.missingAssetIds); + } else { + this.missingAssetIds.clear(); + } + })(); + try { + await this.initializePromise; + } catch (error) { + this.initializePromise = undefined; + throw error; + } + } + + /** + * Initializes the read-only workflow library even when a future run-index + * schema prevents the run service from opening. This fallback is deliberately + * limited to an explicitly unsafe run index: every mutating/run path continues + * to use initialize() and therefore remains fail-closed. + */ + async initializeReadOnlyLibrary(): Promise { + try { + await this.initialize(); + } catch (error) { + const [, runIndex] = await Promise.all([ + this.workflows.initialize(), + this.runs.journals.indexHealth(), + ]); + if (runIndex.status !== "unsafe") throw error; + this.missingAssetIds.clear(); + } + } + + async mutateWorkflow( + workflowId: string, + assetIds: readonly string[], + operation: () => Promise, + options: { allowMissingAssetIds?: readonly string[] } = {}, + ): Promise { + await this.initialize(); + const current = assetIds.length > 0 ? await this.workflows.get(workflowId) : undefined; + const allowedMissingAssetIds = new Set([ + ...(current?.assetRefs ?? []), + ...(options.allowMissingAssetIds ?? []), + ]); + const reservation = await this.references.reserve(workflowId, assetIds); + const presentAssetIds: string[] = []; + try { + for (const assetId of assetIds) { + if (await this.assets.getAvailable(assetId)) presentAssetIds.push(assetId); + else if (!allowedMissingAssetIds.has(assetId)) + throw new Error(`Asset ${assetId} does not exist.`); + else this.missingAssetIds.add(assetId); + } + const result = await operation(); + await this.references.commit(reservation); + for (const assetId of presentAssetIds) this.missingAssetIds.delete(assetId); + this.pruneResolvedMissingAssets(); + try { + await this.assets.replaceReferences({ kind: "workflow", id: workflowId }, presentAssetIds); + } catch { + // The workflow and the in-memory reference authority are already + // committed. Persisted accounting is rebuildable and GC still consults + // the authoritative snapshot, so do not misreport a successful save as + // a CAS failure that the renderer should retry. + console.warn("[create-images] Asset reference accounting needs a rebuild."); + } + return result; + } catch (error) { + await this.references.reconcileFailedMutation(reservation, this.workflows).catch(() => { + // Reconciliation marks the authority incomplete before reading disk, so + // collection remains fail-closed even when the inventory itself is unsafe. + }); + throw error; + } + } + + async deleteWorkflow( + workflowId: string, + expectedRevision: number, + isRendererCurrent: () => boolean, + ): Promise { + await this.initialize(); + const guarded = await this.runs.deleteWorkflowIfRunLifecycleEmpty(workflowId, async () => { + await this.mutateWorkflow(workflowId, [], () => + this.workflows.delete(workflowId, expectedRevision, isRendererCurrent), + ); + }); + if (guarded.status !== "allowed") return guarded; + return { status: "deleted" }; + } + + async refreshReferenceAuthority(): Promise { + const referencesComplete = await this.references.initialize(this.workflows, this.runs.journals); + if (referencesComplete) { + const rebuilt = await this.assets.rebuildReferenceAccounting(); + this.missingAssetIds = new Set(rebuilt.missingAssetIds); + } else { + this.missingAssetIds.clear(); + } + } + + missingAssetIdsForWorkflow(workflowId: string): string[] { + return this.references + .workflowAssetIds(workflowId) + .filter((assetId) => this.missingAssetIds.has(assetId)); + } + + missingAssetCount(): number { + return this.missingAssetIds.size; + } + + noteAssetAvailable(assetId: string): void { + this.missingAssetIds.delete(assetId); + } + + noteAssetMissing(assetId: string): void { + this.missingAssetIds.add(assetId); + } + + async assetResponse( + assetId: string, + rendition: "preview" | "preview-128" | "preview-256" | "preview-512" | "original" = "preview", + ): Promise { + await this.initialize(); + let preview: { + bytes: Uint8Array; + byteLength: number; + mediaType: "image/jpeg" | "image/png"; + }; + if (rendition === "original") { + const ownerId = "asset-protocol-original"; + let lease: AssetPreviewLeaseDto | undefined; + try { + lease = await this.assets.acquirePreviewLease(assetId, ownerId, 1_000); + const original = await this.assets.readPreview(lease.token, ownerId); + preview = { + bytes: original.bytes, + byteLength: original.bytes.byteLength, + mediaType: original.asset.mediaType, + }; + } catch (fallbackError) { + if ( + fallbackError instanceof AssetStoreError && + fallbackError.code === "asset_source_missing" + ) { + this.noteAssetMissing(assetId); + return undefined; + } + throw fallbackError; + } finally { + if (lease) await this.assets.releasePreviewLease(lease.token, ownerId).catch(() => false); + } + } else { + try { + const size = + rendition === "preview-128" ? 128 : rendition === "preview-256" ? 256 : 512; + preview = await this.assets.getThumbnail(assetId, size); + } catch (error) { + if (error instanceof AssetStoreError && error.code === "asset_source_missing") { + this.noteAssetMissing(assetId); + return undefined; + } + if (!(error instanceof AssetStoreError) || error.code !== "thumbnail_unavailable") { + throw error; + } + + // A derived thumbnail is an optimization, not the authority for whether + // an otherwise-valid reference can be shown. When the isolated thumbnail + // worker is temporarily unavailable, stream the already-validated + // canonical PNG/JPEG through the same opaque protocol grant instead of + // leaving the canvas with a permanent blank preview. + const ownerId = "asset-protocol-fallback"; + let lease: AssetPreviewLeaseDto | undefined; + try { + lease = await this.assets.acquirePreviewLease(assetId, ownerId, 1_000); + const original = await this.assets.readPreview(lease.token, ownerId); + preview = { + bytes: original.bytes, + byteLength: original.bytes.byteLength, + mediaType: original.asset.mediaType, + }; + } catch (fallbackError) { + if ( + fallbackError instanceof AssetStoreError && + fallbackError.code === "asset_source_missing" + ) { + this.noteAssetMissing(assetId); + return undefined; + } + throw fallbackError; + } finally { + if (lease) await this.assets.releasePreviewLease(lease.token, ownerId).catch(() => false); + } + } + } + const body = new Uint8Array(preview.bytes.byteLength); + body.set(preview.bytes); + return new Response(body.buffer, { + headers: { + "Content-Length": String(preview.byteLength), + "Content-Type": preview.mediaType, + }, + }); + } + + async grantAsset( + owner: RendererDocumentOwner, + assetId: string, + isAuthorized: (assetId: string) => boolean, + ): Promise { + await this.initialize(); + if (!isAuthorized(assetId)) { + throw new Error("The renderer document is not authorized to access this asset."); + } + const leaseOwnerId = `document-${owner.id}`; + const lease = await this.assets.acquirePreviewLease( + assetId, + leaseOwnerId, + ASSET_DELIVERY_GRANT_TTL_MS, + ); + let released = false; + const release = (): void => { + if (released) return; + released = true; + void this.assets.releasePreviewLease(lease.token, leaseOwnerId).catch(() => { + console.warn("[create-images] Asset preview lease cleanup needs reconciliation."); + }); + }; + try { + return this.grants.mint(owner, assetId, isAuthorized, { + expiresAt: lease.expiresAt, + release, + }); + } catch (error) { + release(); + throw error; + } + } +} + +let singleton: CreateImagesService | undefined; + +export function createImagesService(): CreateImagesService { + singleton ??= new CreateImagesService( + path.join(electron.app.getPath("userData"), "create-images"), + { + workspaceRequired: true, + runService: { + resolveGeminiAuth: async () => { + const { providerRegistry } = await import("../provider-registry.js"); + return resolveCreateImagesGeminiApiKeyAuth({ + credentialKind: () => providerRegistry.getBuiltinCredentialKind("google"), + requestAuth: () => providerRegistry.getBuiltinRequestAuth("google"), + }); + }, + }, + }, + ); + return singleton; +} diff --git a/main/services/create-images/electron-asset-image-utility.ts b/main/services/create-images/electron-asset-image-utility.ts new file mode 100644 index 00000000..68ac10d3 --- /dev/null +++ b/main/services/create-images/electron-asset-image-utility.ts @@ -0,0 +1,130 @@ +import { randomBytes } from "node:crypto"; +import path from "node:path"; +import { app, BrowserWindow, MessageChannelMain } from "electron"; +import { readRegularFile } from "../regular-file-read.js"; +import type { CreateImagesAnnotationShape } from "../../../renderer/shared/create-images/schema.js"; + +const IMAGE_UTILITY_TIMEOUT_MS = 20_000; +const IMAGE_UTILITY_MAX_INPUT_BYTES = 64 * 1024 * 1024; +const DECODER_CHANNEL = "create-images:image-decoder-port"; + +interface ImageUtilityRequest { + operation: "normalize" | "thumbnail" | "validate" | "annotate"; + filePath: string; + maxInputBytes?: number; + maxDimension?: number; + maxWidth?: number; + maxHeight?: number; + maxPixels?: number; + maxOutputBytes?: number; + shapes?: readonly CreateImagesAnnotationShape[]; +} + +interface ImageUtilitySuccess { + id: string; + ok: true; + width: number; + height: number; + bytes?: Uint8Array; +} + +function preloadPath(): string { + const developmentOverride = process.env.AIDEN_CREATE_IMAGES_DECODER_PRELOAD; + if (!app.isPackaged && developmentOverride) return path.resolve(developmentOverride); + return path.join(app.getAppPath(), "build", "preload", "create-images-image-decoder.cjs"); +} + +/** + * Decode one untrusted image in a disposable, sandboxed Chromium renderer. + * Codec work and decoded pixels therefore live outside the privileged browser + * process. The decoder page has default-src 'none', no Node integration, no + * generic Aiden preload bridge, and receives bounded bytes rather than a path. + */ +export async function runImageUtility(request: ImageUtilityRequest): Promise { + const id = randomBytes(18).toString("base64url"); + const maxInputBytes = request.maxInputBytes ?? IMAGE_UTILITY_MAX_INPUT_BYTES; + if ( + !Number.isSafeInteger(maxInputBytes) || + maxInputBytes < 1 || + maxInputBytes > IMAGE_UTILITY_MAX_INPUT_BYTES + ) { + throw new Error("The image decoder input limit is invalid."); + } + const fileBytes = await readRegularFile(request.filePath, maxInputBytes); + const window = new BrowserWindow({ + show: false, + width: 1, + height: 1, + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + preload: preloadPath(), + sandbox: true, + webSecurity: true, + }, + }); + window.setMenuBarVisibility(false); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + try { + await window.loadURL( + "data:text/html;charset=utf-8," + + encodeURIComponent( + 'Aiden Image Decoder', + ), + ); + return await new Promise((resolve, reject) => { + const { port1, port2 } = new MessageChannelMain(); + let settled = false; + const finish = (error?: Error, result?: ImageUtilitySuccess) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + port1.close(); + if (error || !result) reject(error ?? new Error("The image decoder returned no result.")); + else resolve(result); + }; + const timeout = setTimeout( + () => finish(new Error("The image decoder exceeded its time limit.")), + IMAGE_UTILITY_TIMEOUT_MS, + ); + port1.on("message", (event) => { + const value = event.data as unknown; + if (typeof value !== "object" || value === null) return; + const response = value as Partial & { ok?: boolean }; + if (response.id !== id) return; + if ( + response.ok !== true || + !Number.isSafeInteger(response.width) || + !Number.isSafeInteger(response.height) + ) { + finish(new Error("The browser image decoder rejected the image.")); + return; + } + finish(undefined, response as ImageUtilitySuccess); + }); + port1.start(); + window.webContents.postMessage(DECODER_CHANNEL, null, [port2]); + port1.postMessage({ + id, + operation: request.operation, + // MessagePort performs the one required structured-clone copy into the + // sandboxed renderer. Do not first duplicate the bounded file buffer in + // the privileged main process. + bytes: new Uint8Array(fileBytes.buffer, fileBytes.byteOffset, fileBytes.byteLength), + ...(request.maxDimension ? { maxDimension: request.maxDimension } : {}), + ...(request.maxWidth ? { maxWidth: request.maxWidth } : {}), + ...(request.maxHeight ? { maxHeight: request.maxHeight } : {}), + ...(request.maxPixels ? { maxPixels: request.maxPixels } : {}), + ...(request.maxOutputBytes ? { maxOutputBytes: request.maxOutputBytes } : {}), + ...(request.shapes ? { shapes: structuredClone(request.shapes) } : {}), + }); + window.webContents.once("render-process-gone", () => + finish(new Error("The sandboxed image decoder crashed.")), + ); + }); + } finally { + if (!window.isDestroyed()) window.destroy(); + } +} diff --git a/main/services/create-images/electron-asset-images.ts b/main/services/create-images/electron-asset-images.ts new file mode 100644 index 00000000..79747c98 --- /dev/null +++ b/main/services/create-images/electron-asset-images.ts @@ -0,0 +1,56 @@ +import type { + AssetDeepValidator, + AssetThumbnailGenerator, +} from "./asset-store-core.js"; +import { runImageUtility } from "./electron-asset-image-utility.js"; +import type { CreateImagesAnnotationRasterizer } from "./run-service.js"; + +export const electronAssetDeepValidator: AssetDeepValidator = { + async validate({ filePath }) { + const size = await runImageUtility({ operation: "validate", filePath }); + if (!Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height)) { + throw new Error("The native image decoder returned invalid dimensions."); + } + return size; + }, +}; + +export const electronAssetThumbnailGenerator: AssetThumbnailGenerator = { + async generate({ sourcePath, maxDimension, maxOutputBytes }) { + const result = await runImageUtility({ + operation: "thumbnail", + filePath: sourcePath, + maxDimension, + maxOutputBytes, + }); + const bytes = result.bytes; + if (!bytes) throw new Error("The image decoder returned no thumbnail bytes."); + if (bytes.byteLength < 1 || bytes.byteLength > maxOutputBytes) { + throw new Error("The generated thumbnail exceeds its byte limit."); + } + return { + bytes: bytes.slice(), + width: result.width, + height: result.height, + mediaType: "image/png", + }; + }, +}; + +export const electronAnnotationRasterizer: CreateImagesAnnotationRasterizer = { + async rasterize({ sourcePath, shapes }) { + const result = await runImageUtility({ + operation: "annotate", + filePath: sourcePath, + shapes, + maxPixels: 64_000_000, + maxOutputBytes: 64 * 1024 * 1024, + }); + if (!result.bytes) throw new Error("The annotation rasterizer returned no PNG bytes."); + return { + bytes: result.bytes.slice(), + width: result.width, + height: result.height, + }; + }, +}; diff --git a/main/services/create-images/electron-asset-import.test.ts b/main/services/create-images/electron-asset-import.test.ts new file mode 100644 index 00000000..eb779d87 --- /dev/null +++ b/main/services/create-images/electron-asset-import.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AssetImageValidationError } from "./asset-image-validation-core.js"; +import type { AssetIngestRequest, AssetIngestResult } from "./asset-store-core.js"; +import { + CreateImagesImageImportError, + ingestCreateImagesImageFile, + type CreateImagesImageNormalizer, +} from "./electron-asset-import.js"; + +async function collect(source: AsyncIterable): Promise { + const chunks: Uint8Array[] = []; + let length = 0; + for await (const chunk of source) { + chunks.push(chunk.slice()); + length += chunk.byteLength; + } + const result = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +function result(request: AssetIngestRequest, bytes: Uint8Array): AssetIngestResult { + return { + asset: { + assetId: "a".repeat(64), + mediaType: "image/png", + byteLength: bytes.byteLength, + width: 1, + height: 1, + createdAt: "2026-08-18T00:00:00.000Z", + ...(request.displayName ? { displayName: request.displayName } : {}), + origin: request.origin, + referenceCount: 0, + thumbnailSizes: [], + }, + deduplicated: false, + quotaWarning: false, + totalAssetBytes: bytes.byteLength, + }; +} + +async function fixture( + name: string, + contents: Uint8Array, +): Promise<{ directory: string; file: string }> { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-image-import-")); + const file = path.join(directory, name); + await fs.writeFile(file, contents); + return { directory, file }; +} + +test("keeps a canonical import on the direct content-addressed path", async (context) => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("direct.png", png); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + let normalizeCalls = 0; + const store = { + async ingest(input: AsyncIterable, request: AssetIngestRequest) { + const imported = await collect(input); + assert.deepEqual(imported, png); + assert.equal(request.displayName, "direct.png"); + return result(request, imported); + }, + }; + await ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + normalizeCalls += 1; + throw new Error("unused"); + }, + }, + }); + assert.equal(normalizeCalls, 0); +}); + +test("normalizes a supported static raster in the isolated decoder and preserves its label", async (context) => { + const webp = new TextEncoder().encode("RIFF\u0004\u0000\u0000\u0000WEBPVP8 "); + const normalized = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("reference.webp", webp); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + const requests: AssetIngestRequest[] = []; + const seen: Uint8Array[] = []; + const store = { + async ingest(input: AsyncIterable, request: AssetIngestRequest) { + requests.push(request); + seen.push(await collect(input)); + if (requests.length === 1) { + throw new AssetImageValidationError("unsupported_format", "unsupported"); + } + return result(request, seen[seen.length - 1]!); + }, + }; + const normalizer: CreateImagesImageNormalizer = { + async normalize(filePath) { + assert.equal(filePath, source.file); + return { bytes: normalized, width: 1, height: 1 }; + }, + }; + const imported = await ingestCreateImagesImageFile(store, source.file, { normalizer }); + assert.equal(imported.asset.displayName, "reference.webp"); + assert.deepEqual(seen, [webp, normalized]); + assert.deepEqual(requests[1], { + origin: { kind: "import" }, + displayName: "reference.webp", + declaredMimeType: "image/png", + validationDisplayName: "reference.png", + }); +}); + +test("corrects a canonical extension mismatch without invoking conversion", async (context) => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("mislabeled.webp", png); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + let call = 0; + let secondRequest: AssetIngestRequest | undefined; + const store = { + async ingest(input: AsyncIterable, request: AssetIngestRequest) { + const imported = await collect(input); + call += 1; + if (call === 1) throw new AssetImageValidationError("extension_mismatch", "mismatch"); + secondRequest = request; + return result(request, imported); + }, + }; + await ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + throw new Error("must not normalize"); + }, + }, + }); + assert.equal(secondRequest?.validationDisplayName, "mislabeled.png"); +}); + +test("rejects vector and animated images before conversion", async (context) => { + for (const [name, contents, code] of [ + ["vector.svg", new TextEncoder().encode(""), "vector_image"], + [ + "animated.webp", + new TextEncoder().encode("RIFF\u0004\u0000\u0000\u0000WEBPANIM\u0000\u0000\u0000\u0000"), + "animated_image", + ], + ] as const) { + const source = await fixture(name, contents); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + const store = { + async ingest(input: AsyncIterable) { + await collect(input); + throw new AssetImageValidationError("unsupported_format", "unsupported"); + }, + }; + await assert.rejects( + ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + throw new Error("must not normalize"); + }, + }, + }), + (error: unknown) => error instanceof CreateImagesImageImportError && error.code === code, + ); + } +}); + +test("does not convert malformed canonical images or accept invalid normalized bounds", async (context) => { + const malformed = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("malformed.png", malformed); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + const store = { + async ingest(input: AsyncIterable) { + await collect(input); + throw new AssetImageValidationError("malformed_image", "malformed"); + }, + }; + await assert.rejects( + ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + throw new Error("must not normalize"); + }, + }, + }), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "malformed_image", + ); +}); diff --git a/main/services/create-images/electron-asset-import.ts b/main/services/create-images/electron-asset-import.ts new file mode 100644 index 00000000..0fbf68fb --- /dev/null +++ b/main/services/create-images/electron-asset-import.ts @@ -0,0 +1,173 @@ +import { constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { readRegularFile } from "../regular-file-read.js"; +import { + AssetImageValidationError, + sanitizeAssetDisplayName, +} from "./asset-image-validation-core.js"; +import { + AssetStoreError, + DEFAULT_ASSET_STORE_LIMITS, + type AssetIngestRequest, + type AssetIngestResult, +} from "./asset-store-core.js"; +import { + createImagesCanonicalValidationName, + createImagesImportSourcePolicy, +} from "./asset-import-normalization-core.js"; + +interface CreateImagesImportAssetStore { + ingest( + source: AsyncIterable, + request: AssetIngestRequest, + ): Promise; +} + +export class CreateImagesImageImportError extends Error { + constructor( + public readonly code: "animated_image" | "normalization_failed" | "vector_image", + message: string, + ) { + super(message); + this.name = "CreateImagesImageImportError"; + } +} + +export interface CreateImagesImageNormalizer { + normalize(filePath: string): Promise<{ bytes: Uint8Array; width: number; height: number }>; +} + +const defaultNormalizer: CreateImagesImageNormalizer = { + async normalize(filePath) { + // Keep Electron's privileged module out of pure Node test/runtime imports; + // conversion is loaded only when a non-canonical raster actually needs it. + const { runImageUtility } = await import("./electron-asset-image-utility.js"); + let result: { bytes?: Uint8Array; width: number; height: number }; + try { + result = await runImageUtility({ + operation: "normalize", + filePath, + maxInputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + maxWidth: DEFAULT_ASSET_STORE_LIMITS.maxWidth, + maxHeight: DEFAULT_ASSET_STORE_LIMITS.maxHeight, + maxPixels: DEFAULT_ASSET_STORE_LIMITS.maxPixels, + maxOutputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + }); + } catch (chromiumError) { + if (process.platform !== "darwin") throw chromiumError; + const { normalizeImageWithMacosImageIo } = await import("./macos-image-normalizer.js"); + return normalizeImageWithMacosImageIo(filePath, { + maxInputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + maxOutputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + maxWidth: DEFAULT_ASSET_STORE_LIMITS.maxWidth, + maxHeight: DEFAULT_ASSET_STORE_LIMITS.maxHeight, + maxPixels: DEFAULT_ASSET_STORE_LIMITS.maxPixels, + }); + } + if (!result.bytes) throw new Error("The image normalizer returned no bytes."); + return { bytes: result.bytes.slice(), width: result.width, height: result.height }; + }, +}; + +async function* selectedFile(filePath: string): AsyncGenerator { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NONBLOCK | noFollow); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new Error("The selected image is not a regular file."); + const stream = handle.createReadStream({ autoClose: false }); + for await (const chunk of stream) { + yield new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + } finally { + await handle.close().catch(() => undefined); + } +} + +async function* bytesSource(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function importRequest(displayName: string | undefined): AssetIngestRequest { + return { origin: { kind: "import" }, ...(displayName ? { displayName } : {}) }; +} + +export async function ingestCreateImagesImageFile( + assets: CreateImagesImportAssetStore, + filePath: string, + options: { + normalizer?: CreateImagesImageNormalizer; + maxInputBytes?: number; + } = {}, +): Promise { + const displayName = sanitizeAssetDisplayName(path.basename(filePath)); + try { + return await assets.ingest(selectedFile(filePath), importRequest(displayName)); + } catch (error) { + if (!(error instanceof AssetImageValidationError)) throw error; + const maxInputBytes = options.maxInputBytes ?? DEFAULT_ASSET_STORE_LIMITS.maxImportBytes; + let original: Uint8Array; + try { + original = await readRegularFile(filePath, maxInputBytes); + } catch (readError) { + if ((readError as NodeJS.ErrnoException).code === "EFBIG") { + throw new AssetStoreError( + "asset_ingest_too_large", + `The image exceeds the ${maxInputBytes}-byte ingest limit.`, + ); + } + throw readError; + } + const policy = createImagesImportSourcePolicy(original, displayName); + if (policy.kind === "reject") { + throw new CreateImagesImageImportError( + policy.reason === "animated" ? "animated_image" : "vector_image", + policy.reason === "animated" + ? "Animated images are not supported." + : "Vector images are not supported.", + ); + } + if (policy.kind === "canonical") { + if (error.code !== "extension_mismatch") throw error; + return assets.ingest(selectedFile(filePath), { + ...importRequest(displayName), + validationDisplayName: createImagesCanonicalValidationName( + displayName, + policy.format === "jpeg" ? "jpg" : "png", + ), + }); + } + if (error.code !== "unsupported_format" && error.code !== "extension_mismatch") throw error; + let normalized: { bytes: Uint8Array; width: number; height: number }; + try { + normalized = await (options.normalizer ?? defaultNormalizer).normalize(filePath); + } catch { + throw new CreateImagesImageImportError( + "normalization_failed", + "The isolated image converter could not decode this file.", + ); + } + if ( + normalized.bytes.byteLength < 1 || + normalized.bytes.byteLength > DEFAULT_ASSET_STORE_LIMITS.maxImportBytes || + !Number.isSafeInteger(normalized.width) || + !Number.isSafeInteger(normalized.height) || + normalized.width < 1 || + normalized.height < 1 || + normalized.width > DEFAULT_ASSET_STORE_LIMITS.maxWidth || + normalized.height > DEFAULT_ASSET_STORE_LIMITS.maxHeight || + normalized.width * normalized.height > DEFAULT_ASSET_STORE_LIMITS.maxPixels + ) { + throw new CreateImagesImageImportError( + "normalization_failed", + "The isolated image converter returned an invalid image.", + ); + } + return assets.ingest(bytesSource(normalized.bytes), { + ...importRequest(displayName), + declaredMimeType: "image/png", + validationDisplayName: createImagesCanonicalValidationName(displayName, "png"), + }); + } +} diff --git a/main/services/create-images/feature-flag.ts b/main/services/create-images/feature-flag.ts new file mode 100644 index 00000000..ae3bd809 --- /dev/null +++ b/main/services/create-images/feature-flag.ts @@ -0,0 +1,18 @@ +export const CREATE_IMAGES_FEATURE_FLAG = "AIDEN_CREATE_IMAGES_ENABLED"; + +/** + * Create Images remains fail-closed until its packaged release gates pass. + * Every renderer route and main-process handler must check the same capability. + */ +export function createImagesEnabled( + environment: Readonly> = process.env, +): boolean { + return environment[CREATE_IMAGES_FEATURE_FLAG]?.trim() === "1"; +} + +export function createWhenImagesEnabled( + factory: () => T, + environment: Readonly> = process.env, +): T | undefined { + return createImagesEnabled(environment) ? factory() : undefined; +} diff --git a/main/services/create-images/gemini-provider-status-core.test.ts b/main/services/create-images/gemini-provider-status-core.test.ts new file mode 100644 index 00000000..4c03d062 --- /dev/null +++ b/main/services/create-images/gemini-provider-status-core.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createImagesGeminiProviderStatus, + resolveCreateImagesGeminiApiKeyAuth, + type GeminiProviderCredentialAuthority, +} from "./gemini-provider-status-core.js"; + +function authority( + kind: "api_key" | "oauth" | undefined, + apiKey?: string, +): GeminiProviderCredentialAuthority { + return { + credentialKind: async () => kind, + requestAuth: async () => (apiKey === undefined ? undefined : { auth: { apiKey } }), + }; +} + +test("Gemini image status requires an exact stored API-key credential", async () => { + assert.equal( + (await createImagesGeminiProviderStatus(authority(undefined))).connectionState, + "disconnected", + ); + assert.deepEqual(await createImagesGeminiProviderStatus(authority("oauth", "oauth-token")), { + schemaVersion: 1, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "credential-scope-unverified", + }); + assert.equal( + (await createImagesGeminiProviderStatus(authority("api_key"))).safeErrorCode, + "credential-invalid", + ); + const connected = await createImagesGeminiProviderStatus(authority("api_key", "test-key")); + assert.equal(connected.connectionState, "connected"); + assert.equal(connected.credentialKind, "google-api-key"); + assert.equal(connected.capabilitySnapshot?.models.length, 3); + assert.equal(JSON.stringify(connected).includes("test-key"), false); +}); + +test("Gemini request auth rejects missing, OAuth, and malformed credentials", async () => { + await assert.rejects(resolveCreateImagesGeminiApiKeyAuth(authority(undefined)), /Connect/u); + await assert.rejects(resolveCreateImagesGeminiApiKeyAuth(authority("oauth", "token")), /OAuth/u); + await assert.rejects( + resolveCreateImagesGeminiApiKeyAuth(authority("api_key", "bad key")), + /invalid/u, + ); + assert.deepEqual(await resolveCreateImagesGeminiApiKeyAuth(authority("api_key", "valid-key")), { + auth: { apiKey: "valid-key" }, + }); +}); diff --git a/main/services/create-images/gemini-provider-status-core.ts b/main/services/create-images/gemini-provider-status-core.ts new file mode 100644 index 00000000..f72eee0c --- /dev/null +++ b/main/services/create-images/gemini-provider-status-core.ts @@ -0,0 +1,102 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import { + CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + CREATE_IMAGES_PROVIDER_STATUS_VERSION, + type CreateImagesProviderStatus, +} from "../../../renderer/shared/create-images/providers.js"; + +export const CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID = "google" as const; + +export interface GeminiProviderCredentialAuthority { + credentialKind(): Promise<"api_key" | "oauth" | undefined>; + requestAuth(): Promise; +} + +function usableApiKey(auth: AuthResult | undefined): boolean { + const key = auth?.auth.apiKey; + return typeof key === "string" && /^[\x21-\x7e]{1,512}$/u.test(key); +} + +export async function createImagesGeminiProviderStatus( + authority: GeminiProviderCredentialAuthority, +): Promise { + let kind: "api_key" | "oauth" | undefined; + try { + kind = await authority.credentialKind(); + } catch { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "feature-unavailable", + }; + } + if (kind === undefined) { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "disconnected", + safeErrorCode: "credential-missing", + }; + } + if (kind !== "api_key") { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "credential-scope-unverified", + }; + } + let auth: AuthResult | undefined; + try { + auth = await authority.requestAuth(); + } catch { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "invalid", + credentialKind: "google-api-key", + safeErrorCode: "credential-invalid", + }; + } + if (!usableApiKey(auth)) { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "invalid", + credentialKind: "google-api-key", + safeErrorCode: "credential-invalid", + }; + } + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "connected", + credentialKind: "google-api-key", + capabilitySnapshot: CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + }; +} + +export async function resolveCreateImagesGeminiApiKeyAuth( + authority: GeminiProviderCredentialAuthority, +): Promise { + const kind = await authority.credentialKind(); + if (kind !== "api_key") { + throw new Error( + kind === undefined + ? "Connect a Google Gemini API key in Settings before starting this run." + : "Create Images requires a Google API-key connection; OAuth is not authorized for this request.", + ); + } + const auth = await authority.requestAuth(); + if (!usableApiKey(auth)) { + throw new Error("The configured Google API key is unavailable or invalid."); + } + return auth!; +} diff --git a/main/services/create-images/image-decoder-boundary.test.ts b/main/services/create-images/image-decoder-boundary.test.ts new file mode 100644 index 00000000..19e78964 --- /dev/null +++ b/main/services/create-images/image-decoder-boundary.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { DEFAULT_ASSET_STORE_LIMITS } from "./asset-store-core.js"; + +function source(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +test("untrusted image codecs run in a disposable sandboxed renderer", () => { + const adapter = source("./electron-asset-images.ts"); + const decoder = source("./electron-asset-image-utility.ts"); + const preload = source("../../../renderer/preload-create-images-image-decoder.ts"); + const build = source("../../../scripts/build-electron.mjs"); + + assert.doesNotMatch(adapter, /nativeImage/u); + assert.match(decoder, /new BrowserWindow/u); + assert.match(decoder, /sandbox: true/u); + assert.match(decoder, /contextIsolation: true/u); + assert.match(decoder, /nodeIntegration: false/u); + assert.match(decoder, /default-src 'none'/u); + assert.match(decoder, /readRegularFile\(request\.filePath, maxInputBytes\)/u); + assert.match(preload, /createImageBitmap/u); + assert.match(preload, /OffscreenCanvas/u); + assert.match(preload, /operation === "normalize"/u); + assert.match(preload, /bitmap\.width \* bitmap\.height > request\.maxPixels/u); + assert.match(preload, /convertToBlob\(\{ type: "image\/png" \}\)/u); + assert.match(decoder, /operation: "normalize" \| "thumbnail" \| "validate" \| "annotate"/u); + assert.match(preload, /request\.operation === "annotate"/u); + assert.match(preload, /request\.shapes\.length <= 256/u); + assert.match(decoder, /maxPixels/u); + assert.match(build, /preload-create-images-image-decoder\.ts/u); + assert.equal(DEFAULT_ASSET_STORE_LIMITS.maxPixels, 16_000_000); +}); + +test("asset reads allocate through a maxBytes plus one descriptor loop", () => { + const assetStore = source("./asset-store-core.ts"); + const helper = assetStore.match( + /async function readBoundedRegularFile[\s\S]*?\n\}\n\nasync function syncDirectory/u, + )?.[0]; + assert.ok(helper); + assert.match(helper, /maxBytes \+ 1 - total/u); + assert.match(helper, /if \(total > maxBytes\)/u); + assert.doesNotMatch(helper, /handle\.readFile/u); +}); diff --git a/main/services/create-images/image-provider-execution-core.test.ts b/main/services/create-images/image-provider-execution-core.test.ts new file mode 100644 index 00000000..37f1c151 --- /dev/null +++ b/main/services/create-images/image-provider-execution-core.test.ts @@ -0,0 +1,975 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ImageProviderModelCapabilities } from "./provider-contract.js"; +import { + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + CreateImagesProviderAdmissionError, + CreateImagesProviderAdmissionGate, + admitCreateImagesProviderExecution, + createCreateImagesMainCredentialBinding, + createCreateImagesProviderAttemptEvent, + createCreateImagesProviderAttemptProjection, + createCreateImagesProviderCapabilitySnapshot, + decideCreateImagesProviderAttemptRecovery, + executeCreateImagesProviderSubmission, + parseCreateImagesProviderConsentClaim, + prepareCreateImagesProviderExecutionConsent, + reduceCreateImagesProviderAttemptEvent, + type CreateImagesProviderAttemptEventV1, + type CreateImagesProviderAttemptProjectionV1, + type CreateImagesProviderCapabilitySnapshotV1, + type CreateImagesProviderExecutionAuthorizationV1, +} from "./image-provider-execution-core.js"; + +const AUTHORITY = { secret: new Uint8Array(32).fill(0x5a) }; +const CREATED_AT = "2026-08-11T12:00:00.000Z"; +const EXPIRES_AT = "2026-08-11T12:05:00.000Z"; +const NOW = "2026-08-11T12:01:00.000Z"; +const SOURCE_FINGERPRINT = "a".repeat(64); +const ASSET_A = "1".repeat(64); +const ASSET_B = "2".repeat(64); + +function model( + overrides: Partial = {}, +): ImageProviderModelCapabilities { + return { + id: "gemini-3.1-flash-image", + label: "Nano Banana 2", + providerId: "gemini", + aspectRatios: ["1:1", "16:9"], + imageSizes: ["1K", "2K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + ...overrides, + }; +} + +function capability( + overrides: { + catalogRevision?: number; + observedAt?: string; + model?: Partial; + transport?: { + kind: "synchronous" | "asynchronous"; + supportsIdempotency: boolean; + supportsReconciliation: boolean; + }; + } = {}, +): CreateImagesProviderCapabilitySnapshotV1 { + return createCreateImagesProviderCapabilitySnapshot({ + catalogRevision: overrides.catalogRevision ?? 7, + observedAt: overrides.observedAt ?? CREATED_AT, + model: model(overrides.model), + transport: overrides.transport ?? { + kind: "synchronous", + supportsIdempotency: false, + supportsReconciliation: false, + }, + }); +} + +function localCapability(): CreateImagesProviderCapabilitySnapshotV1 { + return createCreateImagesProviderCapabilitySnapshot({ + catalogRevision: 1, + observedAt: CREATED_AT, + model: model({ + id: "deterministic-v1", + label: "Deterministic Phase 3", + providerId: "local-mock", + maxReferenceImages: 14, + maxOutputs: 4, + supportsCancellation: true, + }), + transport: { + kind: "local", + supportsIdempotency: true, + supportsReconciliation: false, + }, + }); +} + +const credential = createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 4, + authKind: "api-key", +}); + +function prepareRemote( + overrides: Partial[0]> = {}, +) { + const selectedCapability = overrides.capability ?? capability(); + return prepareCreateImagesProviderExecutionConsent( + { + authorizationId: "authorization-1", + workflowId: "workflow-1", + workflowRevision: 7, + executionMode: "gemini", + capability: selectedCapability, + credentialBinding: credential, + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 1, + referenceImageBytes: 1_024, + requestedOutputs: 1, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + maximumAttempts: 1, + estimate: { + kind: "best-effort", + amountMicros: 25_000, + currency: "USD", + estimatedAt: CREATED_AT, + sourceFingerprint: SOURCE_FINGERPRINT, + }, + createdAt: CREATED_AT, + expiresAt: EXPIRES_AT, + ...overrides, + }, + AUTHORITY, + ); +} + +function remoteClaim(prepared = prepareRemote()) { + return { + version: 1 as const, + authorizationId: prepared.rendererPlan.authorizationId, + consentFingerprint: prepared.rendererPlan.consentFingerprint, + token: prepared.rendererPlan.token!, + reviewed: true as const, + }; +} + +function authorizeRemote( + prepared = prepareRemote(), + overrides: Partial[0]> = {}, +): CreateImagesProviderExecutionAuthorizationV1 { + return admitCreateImagesProviderExecution({ + mainPlan: prepared.mainPlan, + claim: remoteClaim(prepared), + authority: AUTHORITY, + currentCapability: prepared.mainPlan.capability, + currentCredential: credential, + now: NOW, + ...overrides, + }); +} + +function attempt(authorization = authorizeRemote()): CreateImagesProviderAttemptProjectionV1 { + return createCreateImagesProviderAttemptProjection(authorization, { + runId: "run-1", + nodeId: "generate-1", + attempt: 1, + }); +} + +function apply( + projection: CreateImagesProviderAttemptProjectionV1, + event: CreateImagesProviderAttemptEventV1, +): CreateImagesProviderAttemptProjectionV1 { + const reduced = reduceCreateImagesProviderAttemptEvent(projection, event); + assert.equal(reduced.accepted, true); + return reduced.projection; +} + +function expectAdmissionCode(operation: () => unknown, code: string): void { + assert.throws(operation, (error) => { + assert.ok(error instanceof CreateImagesProviderAdmissionError); + assert.equal(error.code, code); + return true; + }); +} + +function gate( + overrides: Partial< + ConstructorParameters[0][number] + > = {}, +) { + return new CreateImagesProviderAdmissionGate([ + { + providerId: "gemini", + maxConcurrency: 1, + maxStartsPerWindow: 3, + windowMs: 1_000, + minimumStartIntervalMs: 0, + ...overrides, + }, + ]); +} + +test("capability snapshots are immutable, content-fingerprinted, and provider/model bound", () => { + const snapshot = capability(); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(snapshot.model.aspectRatios), true); + assert.equal(snapshot.providerId, snapshot.model.providerId); + assert.match(snapshot.fingerprint, /^[a-f0-9]{64}$/u); + assert.notEqual(capability({ catalogRevision: 8 }).fingerprint, snapshot.fingerprint); + assert.throws(() => { + (snapshot.model.aspectRatios as string[]).push("21:9"); + }); + expectAdmissionCode( + () => capability({ model: { aspectRatios: ["https://attacker.invalid" as "1:1"] } }), + "invalid-input", + ); +}); + +test("renderer consent exposes accounting and a token but no credential record or provider endpoint", () => { + const prepared = prepareRemote(); + const rendererJson = JSON.stringify(prepared.rendererPlan); + assert.match(prepared.rendererPlan.token!, /^[a-f0-9]{64}$/u); + assert.equal(prepared.rendererPlan.accounting.retryPolicy, "manual-new-consent"); + assert.equal(prepared.rendererPlan.accounting.maximumAttempts, 1); + assert.equal(prepared.rendererPlan.accounting.initialRequestCount, 1); + assert.equal(prepared.rendererPlan.accounting.dataLeavesDevice, true); + assert.doesNotMatch(rendererJson, /google-images-primary/u); + assert.doesNotMatch(rendererJson, /api-key/u); + assert.doesNotMatch(rendererJson, /https?:\/\//u); +}); + +test("strict renderer claims reject credentials, URLs, model overrides, and unreviewed input", () => { + const claim = remoteClaim(); + assert.deepEqual(parseCreateImagesProviderConsentClaim(claim), claim); + for (const extra of [ + { apiKey: "secret" }, + { url: "https://attacker.invalid" }, + { modelId: "forged-model" }, + ]) { + expectAdmissionCode( + () => parseCreateImagesProviderConsentClaim({ ...claim, ...extra }), + "invalid-consent", + ); + } + expectAdmissionCode( + () => parseCreateImagesProviderConsentClaim({ ...claim, reviewed: false }), + "invalid-consent", + ); +}); + +test("forged fingerprints and tokens cannot authorize remote work", () => { + const prepared = prepareRemote(); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + claim: { ...remoteClaim(prepared), consentFingerprint: "b".repeat(64) }, + }), + "forged-consent", + ); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + claim: { ...remoteClaim(prepared), token: "b".repeat(64) }, + }), + "forged-consent", + ); + const forgedPlan = structuredClone(prepared.mainPlan); + forgedPlan.accounting.maximumAttempts = 2; + expectAdmissionCode(() => authorizeRemote(prepared, { mainPlan: forgedPlan }), "forged-consent"); +}); + +test("expired and not-yet-valid consent fail before admission", () => { + const prepared = prepareRemote(); + for (const now of ["2026-08-11T11:59:59.000Z", "2026-08-11T12:05:00.001Z"]) { + expectAdmissionCode(() => authorizeRemote(prepared, { now }), "stale-consent"); + } +}); + +test("capability catalog, model, and option drift invalidate reviewed consent", () => { + const prepared = prepareRemote(); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + currentCapability: capability({ catalogRevision: 8 }), + }), + "capability-drift", + ); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + currentCapability: capability({ model: { imageSizes: ["1K"] } }), + }), + "capability-drift", + ); +}); + +test("main-owned credential record, revision, and auth kind are exact admission bindings", () => { + const prepared = prepareRemote(); + const drifted = createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 5, + authKind: "api-key", + }); + expectAdmissionCode( + () => authorizeRemote(prepared, { currentCredential: drifted }), + "credential-drift", + ); + expectAdmissionCode( + () => authorizeRemote(prepared, { currentCredential: undefined }), + "credential-required", + ); + expectAdmissionCode( + () => + createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 4, + authKind: "oauth" as "api-key", + }), + "invalid-input", + ); +}); + +test("remote request, output, byte, and maximum-attempt accounting fails closed", () => { + expectAdmissionCode(() => prepareRemote({ maximumAttempts: 2 }), "unsafe-accounting"); + expectAdmissionCode( + () => + prepareRemote({ + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 0, + referenceImageBytes: 1, + requestedOutputs: 1, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + }), + "unsafe-accounting", + ); + expectAdmissionCode( + () => + prepareRemote({ + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 1, + referenceImageBytes: CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + requestedOutputs: 1, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + }), + "unsafe-accounting", + ); + expectAdmissionCode( + () => + prepareRemote({ + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 1, + referenceImageBytes: 1, + requestedOutputs: 2, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + }), + "unsafe-accounting", + ); +}); + +test("local mock stays credential-free and retains only its bounded automatic retry policy", () => { + const selectedCapability = localCapability(); + const prepared = prepareCreateImagesProviderExecutionConsent( + { + authorizationId: "local-authorization", + workflowId: "workflow-1", + workflowRevision: 7, + executionMode: "local-mock", + capability: selectedCapability, + invocations: [ + { + nodeId: "generate-1", + promptBytes: 10, + referenceImageCount: 0, + referenceImageBytes: 0, + requestedOutputs: 2, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + maximumAttempts: 3, + estimate: { + kind: "mock", + amountMicros: 0, + currency: "USD", + estimatedAt: CREATED_AT, + sourceFingerprint: SOURCE_FINGERPRINT, + }, + createdAt: CREATED_AT, + expiresAt: EXPIRES_AT, + }, + AUTHORITY, + ); + assert.equal(prepared.rendererPlan.token, undefined); + const authorization = admitCreateImagesProviderExecution({ + mainPlan: prepared.mainPlan, + authority: AUTHORITY, + currentCapability: selectedCapability, + now: NOW, + }); + assert.equal(authorization.credentialBinding, undefined); + assert.equal(authorization.accounting.retryPolicy, "bounded-local-automatic"); + assert.equal(authorization.accounting.dataLeavesDevice, false); + assert.equal( + createCreateImagesProviderAttemptProjection(authorization, { + runId: "run-local", + nodeId: "generate-1", + attempt: 3, + }).attempt, + 3, + ); +}); + +test("paid Gemini attempts are single-attempt and cannot manufacture an automatic retry", () => { + const authorization = authorizeRemote(); + assert.equal(authorization.accounting.retryPolicy, "manual-new-consent"); + assert.equal(authorization.accounting.maximumAttempts, 1); + expectAdmissionCode( + () => + createCreateImagesProviderAttemptProjection(authorization, { + runId: "run-1", + nodeId: "generate-1", + attempt: 2, + }), + "unsafe-accounting", + ); +}); + +test("provider gates enforce exact concurrency ownership and reject forged or double release", () => { + const admissionGate = gate(); + const first = admissionGate.tryAcquire("gemini", 100); + assert.equal(first.status, "acquired"); + assert.deepEqual(admissionGate.tryAcquire("gemini", 101), { + status: "deferred", + reason: "concurrency", + retryAfterMs: 0, + }); + if (first.status !== "acquired") return; + assert.equal(admissionGate.release({ ...first.lease }), false); + assert.equal(admissionGate.release(first.lease), true); + assert.equal(admissionGate.release(first.lease), false); + assert.equal(admissionGate.snapshot("gemini").active, 0); +}); + +test("provider gates enforce minimum intervals and bounded rolling windows", () => { + const admissionGate = gate({ + maxConcurrency: 2, + maxStartsPerWindow: 2, + windowMs: 1_000, + minimumStartIntervalMs: 100, + }); + const first = admissionGate.tryAcquire("gemini", 100); + assert.equal(first.status, "acquired"); + if (first.status === "acquired") admissionGate.release(first.lease); + assert.deepEqual(admissionGate.tryAcquire("gemini", 150), { + status: "deferred", + reason: "rate", + retryAfterMs: 50, + }); + const second = admissionGate.tryAcquire("gemini", 200); + assert.equal(second.status, "acquired"); + if (second.status === "acquired") admissionGate.release(second.lease); + assert.deepEqual(admissionGate.tryAcquire("gemini", 300), { + status: "deferred", + reason: "rate", + retryAfterMs: 800, + }); + assert.equal(admissionGate.tryAcquire("gemini", 1_100).status, "acquired"); +}); + +test("attempt events are identity-bound, contiguous, and output-count checked", () => { + const initial = attempt(); + const preparedEvent = createCreateImagesProviderAttemptEvent(initial, { + kind: "submission-prepared", + }); + const prepared = apply(initial, preparedEvent); + assert.equal(prepared.status, "prepared"); + assert.equal(reduceCreateImagesProviderAttemptEvent(prepared, preparedEvent).accepted, false); + assert.equal( + reduceCreateImagesProviderAttemptEvent(prepared, { + ...createCreateImagesProviderAttemptEvent(prepared, { + kind: "output-published", + outputAssetIds: [ASSET_A], + }), + runId: "run-other", + }).accepted, + false, + ); + assert.equal( + reduceCreateImagesProviderAttemptEvent(prepared, { + ...createCreateImagesProviderAttemptEvent(prepared, { + kind: "output-published", + outputAssetIds: [ASSET_A], + }), + sequence: prepared.lastSequence + 2, + }).accepted, + false, + ); + const mismatched = reduceCreateImagesProviderAttemptEvent( + prepared, + createCreateImagesProviderAttemptEvent(prepared, { + kind: "output-published", + outputAssetIds: [ASSET_A, ASSET_B], + }), + ); + assert.deepEqual(mismatched, { + accepted: false, + projection: prepared, + reason: "output-mismatch", + }); +}); + +test("usage projection is aggregate-only, bounded, and marks reported billing", () => { + let projection = attempt(); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "output-published", + outputAssetIds: [ASSET_A], + usage: { + inputUnits: 10, + outputUnits: 20, + totalUnits: 30, + billedRequestCount: 1, + costMicros: 25_000, + currency: "USD", + }, + }), + ); + assert.equal(projection.status, "succeeded"); + assert.deepEqual(projection.usage, { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + requestCount: 1, + outputCount: 1, + billingStatus: "provider-reported", + reported: { + inputUnits: 10, + outputUnits: 20, + totalUnits: 30, + billedRequestCount: 1, + costMicros: 25_000, + currency: "USD", + }, + }); +}); + +test("synchronous prepared or unknown Gemini work needs attention and is never resubmitted", async () => { + let projection = attempt(); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "needs-attention", + reason: "prepared-or-unknown", + }); + let submitCalls = 0; + const outcome = await executeCreateImagesProviderSubmission({ + projection, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async () => { + throw new Error("must not persist again"); + }, + submit: async () => { + submitCalls += 1; + return { kind: "completed", output: "impossible", outputCount: 1 }; + }, + }); + assert.deepEqual(outcome, { + kind: "recovery", + decision: { action: "needs-attention", reason: "prepared-or-unknown" }, + }); + assert.equal(submitCalls, 0); + + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "submission-unknown", + errorCode: "transport-unknown", + }), + ); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.usage.billingStatus, "possibly-billable"); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "needs-attention", + reason: "prepared-or-unknown", + }); +}); + +test("execution persists prepared before resolving main credentials and invokes the adapter once", async () => { + const calls: string[] = []; + const initial = attempt(); + let durable = initial; + const output = { stagedAsset: "opaque-staging-record" }; + const outcome = await executeCreateImagesProviderSubmission<{ apiKey: string }, typeof output>({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + calls.push("persist-prepared"); + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => { + calls.push("resolve-credential"); + return { binding, credential: { apiKey: "super-secret-key" } }; + }, + submit: async ({ credential: resolved, idempotencyKey }) => { + calls.push("submit"); + assert.equal(resolved?.apiKey, "super-secret-key"); + assert.match(idempotencyKey, /^aiden-ci-[a-f0-9]{64}$/u); + return { + kind: "completed", + output, + outputCount: 1, + usage: { billedRequestCount: 1 }, + }; + }, + }); + assert.deepEqual(calls, ["persist-prepared", "resolve-credential", "submit"]); + assert.deepEqual(outcome, { + kind: "completed", + output, + outputCount: 1, + usage: { billedRequestCount: 1 }, + }); + assert.equal(durable.status, "prepared"); +}); + +test("credential drift after durable preparation is confirmed not sent and requires new consent", async () => { + const initial = attempt(); + let durable = initial; + let submitted = false; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async () => ({ + binding: createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 99, + authKind: "api-key", + }), + credential: "changed-secret", + }), + submit: async () => { + submitted = true; + return { kind: "completed" as const, output: "bad", outputCount: 1 }; + }, + }); + assert.equal(submitted, false); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.retry, "new-consent-required"); + const failed = apply(durable, outcome.event); + assert.equal(failed.submission, "confirmed-not-sent"); + assert.equal(failed.usage.billingStatus, "not-submitted"); +}); + +test("post-send transport loss is ambiguous, possibly billable, and never automatically retried", async () => { + const initial = attempt(); + let durable = initial; + let submitCalls = 0; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => { + submitCalls += 1; + throw new Error("socket reset after request write"); + }, + }); + assert.equal(submitCalls, 1); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(outcome.retry, "none"); + const ambiguous = apply(durable, outcome.event); + assert.equal(ambiguous.status, "needs_attention"); + assert.equal(ambiguous.usage.requestCount, 1); + assert.equal(ambiguous.usage.billingStatus, "possibly-billable"); +}); + +test("rate limits and provider failures are terminal for this paid consent", async () => { + for (const kind of ["rate-limited", "failed"] as const) { + const initial = attempt(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => ({ + kind, + errorCode: kind === "rate-limited" ? "rate-limited" : "refused", + }), + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") continue; + assert.equal(outcome.retry, "new-consent-required"); + const failed = apply(durable, outcome.event); + assert.equal(failed.status, "failed"); + assert.equal(failed.usage.requestCount, 1); + } +}); + +test("malformed provider billing metadata becomes a contract ambiguity instead of disappearing", async () => { + const initial = attempt(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => ({ + kind: "failed" as const, + errorCode: "provider-failed", + usage: { billedRequestCount: 2 }, + }), + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(outcome.retry, "none"); + assert.equal(apply(durable, outcome.event).status, "needs_attention"); +}); + +test("cancellation before the adapter call is known not sent and does not consume provider work", async () => { + const initial = attempt(); + const controller = new AbortController(); + controller.abort(); + let persisted = false; + let submitted = false; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + signal: controller.signal, + persistPrepared: async () => { + persisted = true; + return initial; + }, + submit: async () => { + submitted = true; + return { kind: "completed" as const, output: "bad", outputCount: 1 }; + }, + }); + assert.equal(persisted, false); + assert.equal(submitted, false); + assert.equal(outcome.kind, "cancelled-before-submit"); + if (outcome.kind !== "cancelled-before-submit") return; + let cancelled = apply(initial, outcome.events[0]); + cancelled = apply(cancelled, outcome.events[1]); + assert.equal(cancelled.status, "cancelled"); + assert.equal(cancelled.usage.billingStatus, "not-submitted"); +}); + +test("cancellation after submit begins becomes unknown unless the adapter proves non-submission", async () => { + const initial = attempt(); + const controller = new AbortController(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + signal: controller.signal, + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => { + controller.abort(); + return { kind: "completed" as const, output: "provider-output", outputCount: 1 }; + }, + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(outcome.retry, "none"); + assert.equal(apply(durable, outcome.event).status, "needs_attention"); +}); + +test("synchronous Gemini rejects an async acceptance contract without trusting its job ID", async () => { + const initial = attempt(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => ({ kind: "accepted" as const, providerJobId: "unexpected-job" }), + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(apply(durable, outcome.event).providerJobId, undefined); +}); + +test("asynchronous accepted jobs reconcile or cancel by durable job ID without resubmission", () => { + const prepared = prepareRemote({ + capability: capability({ + transport: { + kind: "asynchronous", + supportsIdempotency: true, + supportsReconciliation: true, + }, + model: { supportsCancellation: true }, + }), + }); + let projection = attempt(authorizeRemote(prepared)); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "reconcile-only", + }); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "submission-accepted", + providerJobId: "job-1", + }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "reconcile-only", + providerJobId: "job-1", + }); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "cancellation-requested", + reason: "user", + }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "cancel-only", + providerJobId: "job-1", + }); +}); + +test("late valid outputs stay attached to the exact cancelled attempt without becoming success", () => { + let projection = attempt(); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "cancellation-requested", + reason: "user", + }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "cancelled" }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "late-output-published", + outputAssetIds: [ASSET_A], + }), + ); + assert.equal(projection.status, "cancelled"); + assert.deepEqual(projection.outputAssetIds, []); + assert.deepEqual(projection.lateOutputAssetIds, [ASSET_A]); + assert.equal(projection.usage.billingStatus, "possibly-billable"); + + const forged = reduceCreateImagesProviderAttemptEvent(projection, { + ...createCreateImagesProviderAttemptEvent(projection, { + kind: "late-output-published", + outputAssetIds: [ASSET_A], + }), + nodeId: "generate-other", + }); + assert.equal(forged.accepted, false); +}); + +test("durable authorization and projection contain no consent token, secret, prompt, URL, or raw output", async () => { + const prepared = prepareRemote(); + const authorization = authorizeRemote(prepared); + const initial = attempt(authorization); + let durable = initial; + const sensitive = { + apiKey: "super-secret-key", + prompt: "private prompt text", + signedUrl: "https://storage.invalid/private-token", + }; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ + binding, + credential: sensitive.apiKey, + }), + submit: async () => ({ kind: "completed", output: sensitive, outputCount: 1 }), + }); + assert.equal(outcome.kind, "completed"); + const durableJson = JSON.stringify({ authorization, projection: durable }); + for (const forbidden of [ + prepared.rendererPlan.token!, + sensitive.apiKey, + sensitive.prompt, + sensitive.signedUrl, + "https://", + ]) { + assert.equal(durableJson.includes(forbidden), false); + } + assert.equal(durableJson.includes("promptBytes"), true); + assert.equal(durableJson.includes("referenceImageBytes"), true); +}); diff --git a/main/services/create-images/image-provider-execution-core.ts b/main/services/create-images/image-provider-execution-core.ts new file mode 100644 index 00000000..65dc330c --- /dev/null +++ b/main/services/create-images/image-provider-execution-core.ts @@ -0,0 +1,1796 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import type { + CreateImagesAspectRatio, + CreateImagesImageSize, + CreateImagesOutputMime, +} from "../../../renderer/shared/create-images/schema.js"; +import { CREATE_IMAGES_ASSET_ID_PATTERN } from "../../../renderer/shared/create-images/schema.js"; +import type { ImageProviderModelCapabilities } from "./provider-contract.js"; + +export const CREATE_IMAGES_PROVIDER_EXECUTION_VERSION = 1 as const; +export const CREATE_IMAGES_PROVIDER_CONSENT_VERSION = 1 as const; +export const CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS = 500; +export const CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES = 512 * 1024 * 1024; +export const CREATE_IMAGES_MAX_PROVIDER_REQUEST_BYTES = 64 * 1024 * 1024; +export const CREATE_IMAGES_MAX_PROMPT_BYTES = 128 * 1024; +export const CREATE_IMAGES_MAX_PROVIDER_ATTEMPTS = 1_500; +export const CREATE_IMAGES_MAX_CONSENT_LIFETIME_MS = 30 * 60_000; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; +const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/u; +const TOKEN_PATTERN = /^[a-f0-9]{64}$/u; +const SAFE_ERROR_CODE_PATTERN = /^[a-z][a-z0-9-]{0,95}$/u; +const CURRENCY_PATTERN = /^[A-Z]{3}$/u; +const ALLOWED_ASPECT_RATIOS = new Set([ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]); +const ALLOWED_IMAGE_SIZES = new Set(["1K", "2K", "4K"]); +const ALLOWED_OUTPUT_MIMES = new Set(["image/png", "image/jpeg"]); +const MAX_PROVIDER_USAGE_UNITS = 1_000_000_000; + +export type CreateImagesExecutionMode = "local-mock" | "gemini"; +export type CreateImagesProviderTransportKind = "local" | "synchronous" | "asynchronous"; + +export interface CreateImagesProviderTransportCapabilities { + kind: CreateImagesProviderTransportKind; + supportsIdempotency: boolean; + supportsReconciliation: boolean; +} + +export interface CreateImagesProviderCapabilitySnapshotV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + catalogRevision: number; + observedAt: string; + providerId: string; + model: ImageProviderModelCapabilities; + transport: CreateImagesProviderTransportCapabilities; + fingerprint: string; +} + +export interface CreateImagesMainCredentialBindingV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + providerId: "gemini"; + recordId: string; + revision: number; + authKind: "api-key"; +} + +export interface CreateImagesProviderInvocationFactsV1 { + nodeId: string; + promptBytes: number; + referenceImageCount: number; + referenceImageBytes: number; + requestedOutputs: number; + aspectRatio: CreateImagesAspectRatio; + imageSize: CreateImagesImageSize; + outputMime: CreateImagesOutputMime; +} + +export interface CreateImagesProviderExecutionAccountingV1 { + initialRequestCount: number; + expectedOutputCount: number; + maximumAttempts: number; + promptBytes: number; + referenceImageCount: number; + referenceImageBytes: number; + initialProviderInputBytes: number; + dataLeavesDevice: boolean; + retryPolicy: "bounded-local-automatic" | "manual-new-consent"; +} + +export type CreateImagesProviderEstimateV1 = + | { + kind: "mock" | "best-effort"; + amountMicros: number; + currency: string; + estimatedAt: string; + sourceFingerprint: string; + } + | { + kind: "unavailable"; + estimatedAt: string; + sourceFingerprint: string; + }; + +export interface CreateImagesProviderExecutionConsentPlanV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + capability: CreateImagesProviderCapabilitySnapshotV1; + credentialBinding?: CreateImagesMainCredentialBindingV1; + invocations: readonly CreateImagesProviderInvocationFactsV1[]; + accounting: CreateImagesProviderExecutionAccountingV1; + estimate: CreateImagesProviderEstimateV1; + createdAt: string; + expiresAt: string; + consentFingerprint: string; +} + +export interface CreateImagesProviderRendererConsentPlanV1 { + version: typeof CREATE_IMAGES_PROVIDER_CONSENT_VERSION; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + providerId: string; + providerLabel: string; + modelId: string; + modelLabel: string; + accounting: CreateImagesProviderExecutionAccountingV1; + estimate: CreateImagesProviderEstimateV1; + createdAt: string; + expiresAt: string; + consentFingerprint: string; + /** Present only for a remote plan. The renderer may echo it but cannot mint it. */ + token?: string; +} + +export interface CreateImagesProviderConsentClaimV1 { + version: typeof CREATE_IMAGES_PROVIDER_CONSENT_VERSION; + authorizationId: string; + consentFingerprint: string; + token: string; + reviewed: true; +} + +export interface CreateImagesProviderConsentAuthority { + /** Main-owned process secret. Never persist it or expose it to a renderer. */ + secret: Uint8Array; +} + +export interface CreateImagesPrepareProviderExecutionConsentInput { + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + capability: CreateImagesProviderCapabilitySnapshotV1; + credentialBinding?: CreateImagesMainCredentialBindingV1; + invocations: readonly CreateImagesProviderInvocationFactsV1[]; + maximumAttempts: number; + estimate: CreateImagesProviderEstimateV1; + createdAt: string; + expiresAt: string; +} + +export interface CreateImagesPreparedProviderExecutionConsent { + mainPlan: CreateImagesProviderExecutionConsentPlanV1; + rendererPlan: CreateImagesProviderRendererConsentPlanV1; +} + +export type CreateImagesProviderAdmissionErrorCode = + | "invalid-input" + | "invalid-consent" + | "forged-consent" + | "stale-consent" + | "capability-drift" + | "credential-drift" + | "credential-required" + | "unsafe-accounting"; + +export class CreateImagesProviderAdmissionError extends Error { + constructor( + readonly code: CreateImagesProviderAdmissionErrorCode, + message: string, + ) { + super(message); + this.name = "CreateImagesProviderAdmissionError"; + } +} + +export interface CreateImagesProviderExecutionAuthorizationV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + capability: CreateImagesProviderCapabilitySnapshotV1; + credentialBinding?: CreateImagesMainCredentialBindingV1; + invocations: readonly CreateImagesProviderInvocationFactsV1[]; + accounting: CreateImagesProviderExecutionAccountingV1; + estimate: CreateImagesProviderEstimateV1; + consentFingerprint: string; + authorizedAt: string; + expiresAt: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function fingerprint(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function canonicalTimestamp(value: string, label: string): string { + let canonical = false; + if (typeof value === "string" && value.length > 0 && value.length <= 64) { + try { + canonical = new Date(value).toISOString() === value; + } catch { + canonical = false; + } + } + if (!canonical) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + `${label} must be a canonical ISO-8601 timestamp.`, + ); + } + return value; +} + +function opaqueId(value: string, label: string): string { + if (!OPAQUE_ID_PATTERN.test(value)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + `${label} must be an opaque identifier.`, + ); + } + return value; +} + +function safeInteger(value: number, minimum: number, maximum: number, label: string): number { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + `${label} must be an integer from ${minimum} through ${maximum}.`, + ); + } + return value; +} + +function safeAdd(left: number, right: number, maximum: number, label: string): number { + if (!Number.isSafeInteger(left) || !Number.isSafeInteger(right) || right > maximum - left) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + `${label} exceeds its safe aggregate bound.`, + ); + } + return left + right; +} + +function uniqueStrings(values: readonly string[], label: string): readonly string[] { + if (values.length === 0 || new Set(values).size !== values.length) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + `${label} must be a non-empty unique list.`, + ); + } + return Object.freeze([...values]); +} + +function capabilityPayload( + input: Omit, +): Omit { + return { + version: input.version, + catalogRevision: input.catalogRevision, + observedAt: input.observedAt, + providerId: input.providerId, + model: input.model, + transport: input.transport, + }; +} + +export function createCreateImagesProviderCapabilitySnapshot(input: { + catalogRevision: number; + observedAt: string; + model: ImageProviderModelCapabilities; + transport: CreateImagesProviderTransportCapabilities; +}): CreateImagesProviderCapabilitySnapshotV1 { + const providerId = input.model.providerId; + if (!PROVIDER_ID_PATTERN.test(providerId) || !["local-mock", "gemini"].includes(providerId)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The execution core supports only local-mock and Gemini provider snapshots.", + ); + } + if (!MODEL_ID_PATTERN.test(input.model.id)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Model ID is invalid."); + } + safeInteger(input.catalogRevision, 1, Number.MAX_SAFE_INTEGER, "Catalog revision"); + canonicalTimestamp(input.observedAt, "Capability observation time"); + if (!(["local", "synchronous", "asynchronous"] as const).includes(input.transport.kind)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Provider transport is invalid."); + } + if (providerId === "local-mock" && input.transport.kind !== "local") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The local mock requires a local transport snapshot.", + ); + } + if (providerId === "gemini" && input.transport.kind === "local") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Gemini requires a remote transport snapshot.", + ); + } + if (input.transport.supportsReconciliation && input.transport.kind === "local") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A local transport cannot advertise remote reconciliation.", + ); + } + const model: ImageProviderModelCapabilities = { + id: input.model.id, + label: input.model.label.trim(), + providerId, + aspectRatios: uniqueStrings( + input.model.aspectRatios, + "Aspect ratios", + ) as readonly CreateImagesAspectRatio[], + imageSizes: uniqueStrings( + input.model.imageSizes, + "Image sizes", + ) as readonly CreateImagesImageSize[], + outputMimes: uniqueStrings( + input.model.outputMimes, + "Output MIME types", + ) as readonly CreateImagesOutputMime[], + maxReferenceImages: safeInteger( + input.model.maxReferenceImages, + 0, + 64, + "Maximum reference images", + ), + maxOutputs: safeInteger(input.model.maxOutputs, 1, 4, "Maximum outputs"), + supportsEditing: input.model.supportsEditing === true, + supportsCancellation: input.model.supportsCancellation === true, + }; + if ( + model.aspectRatios.some((value) => !ALLOWED_ASPECT_RATIOS.has(value)) || + model.imageSizes.some((value) => !ALLOWED_IMAGE_SIZES.has(value)) || + model.outputMimes.some((value) => !ALLOWED_OUTPUT_MIMES.has(value)) + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The provider snapshot contains an unsupported image option.", + ); + } + if (!model.label || model.label.length > 128) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Model label is invalid."); + } + const base = deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + catalogRevision: input.catalogRevision, + observedAt: input.observedAt, + providerId, + model, + transport: { + kind: input.transport.kind, + supportsIdempotency: input.transport.supportsIdempotency === true, + supportsReconciliation: input.transport.supportsReconciliation === true, + }, + }); + return deepFreeze({ ...base, fingerprint: fingerprint(base) }); +} + +export function createCreateImagesMainCredentialBinding(input: { + providerId: "gemini"; + recordId: string; + revision: number; + authKind: "api-key"; +}): CreateImagesMainCredentialBindingV1 { + if (input.providerId !== "gemini" || input.authKind !== "api-key") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Gemini image execution requires an exact main-owned API-key credential binding.", + ); + } + return deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + providerId: input.providerId, + recordId: opaqueId(input.recordId, "Credential record ID"), + revision: safeInteger(input.revision, 1, Number.MAX_SAFE_INTEGER, "Credential revision"), + authKind: input.authKind, + }); +} + +function validateEstimate( + estimate: CreateImagesProviderEstimateV1, +): CreateImagesProviderEstimateV1 { + canonicalTimestamp(estimate.estimatedAt, "Estimate time"); + if (!FINGERPRINT_PATTERN.test(estimate.sourceFingerprint)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Estimate source fingerprint is invalid.", + ); + } + if (estimate.kind === "unavailable") return deepFreeze({ ...estimate }); + safeInteger(estimate.amountMicros, 0, Number.MAX_SAFE_INTEGER, "Estimate amount"); + if (!CURRENCY_PATTERN.test(estimate.currency)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Estimate currency is invalid."); + } + return deepFreeze({ ...estimate }); +} + +function validateInvocation( + invocation: CreateImagesProviderInvocationFactsV1, + capability: CreateImagesProviderCapabilitySnapshotV1, +): CreateImagesProviderInvocationFactsV1 { + opaqueId(invocation.nodeId, "Invocation node ID"); + safeInteger(invocation.promptBytes, 1, CREATE_IMAGES_MAX_PROMPT_BYTES, "Prompt bytes"); + safeInteger( + invocation.referenceImageCount, + 0, + capability.model.maxReferenceImages, + "Reference image count", + ); + safeInteger( + invocation.referenceImageBytes, + 0, + CREATE_IMAGES_MAX_PROVIDER_REQUEST_BYTES, + "Reference image bytes", + ); + if (invocation.referenceImageCount === 0 && invocation.referenceImageBytes !== 0) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Reference bytes require at least one reference image.", + ); + } + if (invocation.referenceImageCount > 0 && invocation.referenceImageBytes === 0) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Reference images require a positive byte count.", + ); + } + safeInteger(invocation.requestedOutputs, 1, capability.model.maxOutputs, "Requested outputs"); + if (!capability.model.aspectRatios.includes(invocation.aspectRatio)) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The consent plan requests an unsupported aspect ratio.", + ); + } + if (!capability.model.imageSizes.includes(invocation.imageSize)) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The consent plan requests an unsupported image size.", + ); + } + if (!capability.model.outputMimes.includes(invocation.outputMime)) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The consent plan requests an unsupported output type.", + ); + } + if ( + invocation.promptBytes > + CREATE_IMAGES_MAX_PROVIDER_REQUEST_BYTES - invocation.referenceImageBytes + ) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "A provider invocation exceeds its input byte bound.", + ); + } + return deepFreeze({ ...invocation }); +} + +function executionPlanPayload( + plan: Omit, +): Omit { + return { + version: plan.version, + authorizationId: plan.authorizationId, + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + executionMode: plan.executionMode, + capability: plan.capability, + ...(plan.credentialBinding ? { credentialBinding: plan.credentialBinding } : {}), + invocations: plan.invocations, + accounting: plan.accounting, + estimate: plan.estimate, + createdAt: plan.createdAt, + expiresAt: plan.expiresAt, + }; +} + +function consentToken( + authority: CreateImagesProviderConsentAuthority, + consentFingerprint: string, +): string { + if (!(authority.secret instanceof Uint8Array) || authority.secret.byteLength < 32) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Consent authority requires at least 32 bytes of main-owned secret material.", + ); + } + return createHmac("sha256", authority.secret) + .update("aiden-create-images-provider-consent-v1\0") + .update(consentFingerprint) + .digest("hex"); +} + +export function prepareCreateImagesProviderExecutionConsent( + input: CreateImagesPrepareProviderExecutionConsentInput, + authority: CreateImagesProviderConsentAuthority, +): CreateImagesPreparedProviderExecutionConsent { + opaqueId(input.authorizationId, "Authorization ID"); + opaqueId(input.workflowId, "Workflow ID"); + safeInteger(input.workflowRevision, 0, Number.MAX_SAFE_INTEGER, "Workflow revision"); + if (!(["local-mock", "gemini"] as const).includes(input.executionMode)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Execution mode is invalid."); + } + const createdAt = canonicalTimestamp(input.createdAt, "Consent creation time"); + const expiresAt = canonicalTimestamp(input.expiresAt, "Consent expiry time"); + const createdMs = Date.parse(createdAt); + const expiresMs = Date.parse(expiresAt); + if (expiresMs <= createdMs || expiresMs - createdMs > CREATE_IMAGES_MAX_CONSENT_LIFETIME_MS) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Consent expiry must be after creation and within the bounded lifetime.", + ); + } + const capability = input.capability; + const expectedCapabilityFingerprint = fingerprint(capabilityPayload(capability)); + if (capability.fingerprint !== expectedCapabilityFingerprint) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The provider capability snapshot fingerprint is invalid.", + ); + } + if ( + (input.executionMode === "local-mock" && capability.providerId !== "local-mock") || + (input.executionMode === "gemini" && capability.providerId !== "gemini") + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Execution mode does not match the provider snapshot.", + ); + } + if (input.executionMode === "gemini") { + if ( + !input.credentialBinding || + input.credentialBinding.providerId !== "gemini" || + input.credentialBinding.authKind !== "api-key" + ) { + throw new CreateImagesProviderAdmissionError( + "credential-required", + "Remote Gemini execution requires a main-owned API-key binding.", + ); + } + } else if (input.credentialBinding) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The local mock cannot carry a remote credential binding.", + ); + } + if ( + !Array.isArray(input.invocations) || + input.invocations.length < 1 || + input.invocations.length > CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS + ) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Provider invocation count is outside its bounded range.", + ); + } + const invocations = input.invocations.map((invocation) => + validateInvocation(invocation, capability), + ); + if (new Set(invocations.map((invocation) => invocation.nodeId)).size !== invocations.length) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A consent plan cannot contain duplicate provider node IDs.", + ); + } + let expectedOutputCount = 0; + let promptBytes = 0; + let referenceImageCount = 0; + let referenceImageBytes = 0; + for (const invocation of invocations) { + expectedOutputCount = safeAdd( + expectedOutputCount, + invocation.requestedOutputs, + CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS * 4, + "Expected output count", + ); + promptBytes = safeAdd( + promptBytes, + invocation.promptBytes, + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + "Prompt bytes", + ); + referenceImageCount = safeAdd( + referenceImageCount, + invocation.referenceImageCount, + CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS * 64, + "Reference image count", + ); + referenceImageBytes = safeAdd( + referenceImageBytes, + invocation.referenceImageBytes, + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + "Reference image bytes", + ); + } + const initialProviderInputBytes = safeAdd( + promptBytes, + referenceImageBytes, + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + "Provider input bytes", + ); + const maximumAttempts = safeInteger( + input.maximumAttempts, + invocations.length, + CREATE_IMAGES_MAX_PROVIDER_ATTEMPTS, + "Maximum attempts", + ); + if (input.executionMode === "gemini" && maximumAttempts !== invocations.length) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Paid Gemini consent authorizes exactly one initial attempt per request and no automatic retry.", + ); + } + if (input.executionMode === "local-mock" && maximumAttempts > invocations.length * 3) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Local mock attempts exceed the bounded retry policy.", + ); + } + const accounting = deepFreeze({ + initialRequestCount: invocations.length, + expectedOutputCount, + maximumAttempts, + promptBytes, + referenceImageCount, + referenceImageBytes, + initialProviderInputBytes, + dataLeavesDevice: input.executionMode === "gemini", + retryPolicy: + input.executionMode === "gemini" + ? ("manual-new-consent" as const) + : ("bounded-local-automatic" as const), + }); + const estimate = validateEstimate(input.estimate); + if ( + (input.executionMode === "local-mock" && + (estimate.kind !== "mock" || estimate.amountMicros !== 0)) || + (input.executionMode === "gemini" && estimate.kind === "mock") + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Estimate kind does not match the execution mode.", + ); + } + const base = deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + authorizationId: input.authorizationId, + workflowId: input.workflowId, + workflowRevision: input.workflowRevision, + executionMode: input.executionMode, + capability, + ...(input.credentialBinding ? { credentialBinding: input.credentialBinding } : {}), + invocations: Object.freeze(invocations), + accounting, + estimate, + createdAt, + expiresAt, + }); + const consentFingerprint = fingerprint(base); + const mainPlan = deepFreeze({ ...base, consentFingerprint }); + const rendererPlan = deepFreeze({ + version: CREATE_IMAGES_PROVIDER_CONSENT_VERSION, + authorizationId: mainPlan.authorizationId, + workflowId: mainPlan.workflowId, + workflowRevision: mainPlan.workflowRevision, + executionMode: mainPlan.executionMode, + providerId: capability.providerId, + providerLabel: capability.providerId === "gemini" ? "Google Gemini" : "Aiden local mock", + modelId: capability.model.id, + modelLabel: capability.model.label, + accounting, + estimate, + createdAt, + expiresAt, + consentFingerprint, + ...(mainPlan.executionMode === "gemini" + ? { token: consentToken(authority, consentFingerprint) } + : {}), + }); + return deepFreeze({ mainPlan, rendererPlan }); +} + +export function parseCreateImagesProviderConsentClaim( + value: unknown, +): CreateImagesProviderConsentClaimV1 { + if ( + !isRecord(value) || + !exactKeys(value, ["version", "authorizationId", "consentFingerprint", "token", "reviewed"]) || + value.version !== CREATE_IMAGES_PROVIDER_CONSENT_VERSION || + typeof value.authorizationId !== "string" || + !OPAQUE_ID_PATTERN.test(value.authorizationId) || + typeof value.consentFingerprint !== "string" || + !FINGERPRINT_PATTERN.test(value.consentFingerprint) || + typeof value.token !== "string" || + !TOKEN_PATTERN.test(value.token) || + value.reviewed !== true + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-consent", + "Remote execution consent is malformed or contains unsupported fields.", + ); + } + return deepFreeze({ + version: value.version, + authorizationId: value.authorizationId, + consentFingerprint: value.consentFingerprint, + token: value.token, + reviewed: true, + }); +} + +function sameCredential( + left: CreateImagesMainCredentialBindingV1, + right: CreateImagesMainCredentialBindingV1, +): boolean { + return ( + left.providerId === right.providerId && + left.recordId === right.recordId && + left.revision === right.revision && + left.authKind === right.authKind + ); +} + +function assertPlanIntegrity(plan: CreateImagesProviderExecutionConsentPlanV1): void { + const { consentFingerprint: _consentFingerprint, ...withoutFingerprint } = plan; + const expected = fingerprint(executionPlanPayload(withoutFingerprint)); + if (expected !== plan.consentFingerprint) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "The main-owned consent plan fingerprint does not match its contents.", + ); + } +} + +export function admitCreateImagesProviderExecution(input: { + mainPlan: CreateImagesProviderExecutionConsentPlanV1; + claim?: unknown; + authority: CreateImagesProviderConsentAuthority; + currentCapability: CreateImagesProviderCapabilitySnapshotV1; + currentCredential?: CreateImagesMainCredentialBindingV1; + now: string; +}): CreateImagesProviderExecutionAuthorizationV1 { + assertPlanIntegrity(input.mainPlan); + const now = canonicalTimestamp(input.now, "Admission time"); + const nowMs = Date.parse(now); + if ( + nowMs < Date.parse(input.mainPlan.createdAt) || + nowMs > Date.parse(input.mainPlan.expiresAt) + ) { + throw new CreateImagesProviderAdmissionError( + "stale-consent", + "The provider consent is not currently valid.", + ); + } + if ( + input.currentCapability.fingerprint !== + fingerprint(capabilityPayload(input.currentCapability)) || + input.currentCapability.fingerprint !== input.mainPlan.capability.fingerprint || + input.currentCapability.catalogRevision !== input.mainPlan.capability.catalogRevision || + input.currentCapability.providerId !== input.mainPlan.capability.providerId || + input.currentCapability.model.id !== input.mainPlan.capability.model.id + ) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "Provider capabilities changed after the user reviewed the run.", + ); + } + if (input.mainPlan.executionMode === "gemini") { + const plannedCredential = input.mainPlan.credentialBinding; + if (!plannedCredential || !input.currentCredential) { + throw new CreateImagesProviderAdmissionError( + "credential-required", + "The reviewed Gemini credential is no longer connected.", + ); + } + if (!sameCredential(plannedCredential, input.currentCredential)) { + throw new CreateImagesProviderAdmissionError( + "credential-drift", + "The main-owned Gemini credential changed after review.", + ); + } + const claim = parseCreateImagesProviderConsentClaim(input.claim); + if ( + claim.authorizationId !== input.mainPlan.authorizationId || + claim.consentFingerprint !== input.mainPlan.consentFingerprint + ) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "Consent identity does not match the main-owned plan.", + ); + } + const expectedToken = Buffer.from( + consentToken(input.authority, input.mainPlan.consentFingerprint), + "hex", + ); + const actualToken = Buffer.from(claim.token, "hex"); + if ( + expectedToken.byteLength !== actualToken.byteLength || + !timingSafeEqual(expectedToken, actualToken) + ) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "Consent token was not minted by this main process.", + ); + } + } else if (input.claim !== undefined || input.currentCredential !== undefined) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Local mock admission cannot carry remote consent or credentials.", + ); + } + return deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + authorizationId: input.mainPlan.authorizationId, + workflowId: input.mainPlan.workflowId, + workflowRevision: input.mainPlan.workflowRevision, + executionMode: input.mainPlan.executionMode, + capability: input.mainPlan.capability, + ...(input.mainPlan.credentialBinding + ? { credentialBinding: input.mainPlan.credentialBinding } + : {}), + invocations: input.mainPlan.invocations, + accounting: input.mainPlan.accounting, + estimate: input.mainPlan.estimate, + consentFingerprint: input.mainPlan.consentFingerprint, + authorizedAt: now, + expiresAt: input.mainPlan.expiresAt, + }); +} + +export interface CreateImagesProviderGateConfig { + providerId: string; + maxConcurrency: number; + maxStartsPerWindow: number; + windowMs: number; + minimumStartIntervalMs: number; +} + +export interface CreateImagesProviderGateLease { + providerId: string; + leaseId: string; + acquiredAtMs: number; +} + +export type CreateImagesProviderGateDecision = + | { status: "acquired"; lease: CreateImagesProviderGateLease } + | { + status: "deferred"; + reason: "concurrency" | "rate"; + retryAfterMs: number; + }; + +interface ProviderGateState { + config: CreateImagesProviderGateConfig; + active: Map; + starts: number[]; + nextLease: number; +} + +export class CreateImagesProviderAdmissionGate { + readonly #states = new Map(); + + constructor(configs: readonly CreateImagesProviderGateConfig[]) { + if ( + configs.length < 1 || + new Set(configs.map((config) => config.providerId)).size !== configs.length + ) { + throw new Error("Provider gate configuration requires unique providers."); + } + for (const config of configs) { + if (!PROVIDER_ID_PATTERN.test(config.providerId)) + throw new Error("Invalid provider gate ID."); + for (const [value, minimum, maximum, label] of [ + [config.maxConcurrency, 1, 4, "concurrency"], + [config.maxStartsPerWindow, 1, 10_000, "window start count"], + [config.windowMs, 1, 60 * 60_000, "window"], + [config.minimumStartIntervalMs, 0, 60 * 60_000, "start interval"], + ] as const) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`Invalid provider gate ${label}.`); + } + } + this.#states.set(config.providerId, { + config: Object.freeze({ ...config }), + active: new Map(), + starts: [], + nextLease: 1, + }); + } + } + + tryAcquire(providerId: string, nowMs: number): CreateImagesProviderGateDecision { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) + throw new Error("Provider gate time is invalid."); + const state = this.#states.get(providerId); + if (!state) throw new Error("Provider gate is not configured for this provider."); + state.starts = state.starts.filter((startedAt) => startedAt > nowMs - state.config.windowMs); + if (state.active.size >= state.config.maxConcurrency) { + return { status: "deferred", reason: "concurrency", retryAfterMs: 0 }; + } + const lastStart = state.starts[state.starts.length - 1]; + if (lastStart !== undefined && nowMs - lastStart < state.config.minimumStartIntervalMs) { + return { + status: "deferred", + reason: "rate", + retryAfterMs: state.config.minimumStartIntervalMs - (nowMs - lastStart), + }; + } + if (state.starts.length >= state.config.maxStartsPerWindow) { + return { + status: "deferred", + reason: "rate", + retryAfterMs: Math.max(0, state.starts[0]! + state.config.windowMs - nowMs), + }; + } + const lease = Object.freeze({ + providerId, + leaseId: `${providerId}:${state.nextLease}`, + acquiredAtMs: nowMs, + }); + state.nextLease += 1; + state.active.set(lease.leaseId, lease); + state.starts.push(nowMs); + return { status: "acquired", lease }; + } + + release(lease: CreateImagesProviderGateLease): boolean { + const state = this.#states.get(lease.providerId); + if (!state) return false; + const current = state.active.get(lease.leaseId); + if (current !== lease) return false; + state.active.delete(lease.leaseId); + return true; + } + + snapshot(providerId: string): Readonly<{ active: number; startsInWindow: number }> { + const state = this.#states.get(providerId); + if (!state) throw new Error("Provider gate is not configured for this provider."); + return Object.freeze({ active: state.active.size, startsInWindow: state.starts.length }); + } +} + +export type CreateImagesProviderBillingStatus = + | "not-submitted" + | "possibly-billable" + | "provider-reported"; + +export interface CreateImagesProviderReportedUsageV1 { + inputUnits?: number; + outputUnits?: number; + totalUnits?: number; + billedRequestCount?: number; + costMicros?: number; + currency?: string; +} + +export interface CreateImagesProviderUsageMetadataV1 { + providerId: string; + modelId: string; + requestCount: 0 | 1; + outputCount: number; + billingStatus: CreateImagesProviderBillingStatus; + reported?: CreateImagesProviderReportedUsageV1; +} + +export type CreateImagesProviderAttemptStatus = + | "ready" + | "prepared" + | "accepted" + | "succeeded" + | "failed" + | "needs_attention" + | "cancel_requested" + | "cancelled"; + +export interface CreateImagesProviderAttemptProjectionV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + authorization: CreateImagesProviderExecutionAuthorizationV1; + runId: string; + nodeId: string; + attempt: number; + idempotencyKey: string; + status: CreateImagesProviderAttemptStatus; + submission: "not-prepared" | "prepared" | "accepted" | "confirmed-not-sent" | "unknown"; + providerJobId?: string; + lastSequence: number; + outputAssetIds: readonly string[]; + lateOutputAssetIds: readonly string[]; + usage: CreateImagesProviderUsageMetadataV1; + errorCode?: string; + cancellationReason?: "user" | "renderer-disconnected" | "app-quit"; +} + +interface CreateImagesProviderAttemptEventBase { + authorizationId: string; + runId: string; + nodeId: string; + attempt: number; + sequence: number; +} + +export type CreateImagesProviderAttemptEventV1 = + | (CreateImagesProviderAttemptEventBase & { kind: "submission-prepared" }) + | (CreateImagesProviderAttemptEventBase & { + kind: "submission-accepted"; + providerJobId: string; + usage?: CreateImagesProviderReportedUsageV1; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "submission-confirmed-not-sent"; + errorCode: string; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "submission-unknown"; + errorCode: string; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "provider-failed"; + errorCode: string; + usage?: CreateImagesProviderReportedUsageV1; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "output-published"; + outputAssetIds: readonly string[]; + usage?: CreateImagesProviderReportedUsageV1; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "cancellation-requested"; + reason: "user" | "renderer-disconnected" | "app-quit"; + }) + | (CreateImagesProviderAttemptEventBase & { kind: "cancelled" }) + | (CreateImagesProviderAttemptEventBase & { + kind: "late-output-published"; + outputAssetIds: readonly string[]; + usage?: CreateImagesProviderReportedUsageV1; + }); + +type CreateImagesProviderAttemptEventPayload = + CreateImagesProviderAttemptEventV1 extends infer Event + ? Event extends CreateImagesProviderAttemptEventV1 + ? Omit + : never + : never; + +export type CreateImagesProviderAttemptReduction = + | { accepted: true; projection: CreateImagesProviderAttemptProjectionV1 } + | { + accepted: false; + projection: CreateImagesProviderAttemptProjectionV1; + reason: + | "wrong-attempt" + | "duplicate-or-stale" + | "out-of-order" + | "invalid-transition" + | "output-mismatch" + | "invalid-event"; + }; + +function invocationFor( + authorization: CreateImagesProviderExecutionAuthorizationV1, + nodeId: string, +): CreateImagesProviderInvocationFactsV1 | undefined { + return authorization.invocations.find((invocation) => invocation.nodeId === nodeId); +} + +function emptyUsage( + authorization: CreateImagesProviderExecutionAuthorizationV1, +): CreateImagesProviderUsageMetadataV1 { + return Object.freeze({ + providerId: authorization.capability.providerId, + modelId: authorization.capability.model.id, + requestCount: 0, + outputCount: 0, + billingStatus: "not-submitted", + }); +} + +export function createCreateImagesProviderAttemptProjection( + authorization: CreateImagesProviderExecutionAuthorizationV1, + input: { runId: string; nodeId: string; attempt: number }, +): CreateImagesProviderAttemptProjectionV1 { + opaqueId(input.runId, "Run ID"); + opaqueId(input.nodeId, "Node ID"); + if (!invocationFor(authorization, input.nodeId)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The node was not included in the reviewed provider plan.", + ); + } + const maxAttempt = authorization.executionMode === "gemini" ? 1 : 3; + safeInteger(input.attempt, 1, maxAttempt, "Provider attempt"); + const idempotencyKey = `aiden-ci-${createHash("sha256") + .update(authorization.consentFingerprint) + .update("\0") + .update(input.runId) + .update("\0") + .update(input.nodeId) + .update("\0") + .update(String(input.attempt)) + .digest("hex")}`; + return deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + authorization, + runId: input.runId, + nodeId: input.nodeId, + attempt: input.attempt, + idempotencyKey, + status: "ready", + submission: "not-prepared", + lastSequence: 0, + outputAssetIds: Object.freeze([]), + lateOutputAssetIds: Object.freeze([]), + usage: emptyUsage(authorization), + }); +} + +function eventBase( + projection: CreateImagesProviderAttemptProjectionV1, +): CreateImagesProviderAttemptEventBase { + return { + authorizationId: projection.authorization.authorizationId, + runId: projection.runId, + nodeId: projection.nodeId, + attempt: projection.attempt, + sequence: projection.lastSequence + 1, + }; +} + +export function createCreateImagesProviderAttemptEvent< + Event extends CreateImagesProviderAttemptEventPayload, +>( + projection: CreateImagesProviderAttemptProjectionV1, + event: Event, +): CreateImagesProviderAttemptEventV1 { + return deepFreeze({ ...eventBase(projection), ...event } as CreateImagesProviderAttemptEventV1); +} + +function validUsage( + value: CreateImagesProviderReportedUsageV1 | undefined, +): CreateImagesProviderReportedUsageV1 | undefined { + if (!value) return undefined; + const output: CreateImagesProviderReportedUsageV1 = {}; + for (const key of [ + "inputUnits", + "outputUnits", + "totalUnits", + "billedRequestCount", + "costMicros", + ] as const) { + const candidate = value[key]; + if (candidate !== undefined) { + const maximum = key === "costMicros" ? Number.MAX_SAFE_INTEGER : MAX_PROVIDER_USAGE_UNITS; + if (!Number.isSafeInteger(candidate) || candidate < 0 || candidate > maximum) { + return undefined; + } + output[key] = candidate; + } + } + if (output.billedRequestCount !== undefined && output.billedRequestCount > 1) return undefined; + if ( + output.totalUnits !== undefined && + ((output.inputUnits !== undefined && output.totalUnits < output.inputUnits) || + (output.outputUnits !== undefined && output.totalUnits < output.outputUnits)) + ) { + return undefined; + } + if (value.currency !== undefined) { + if (!CURRENCY_PATTERN.test(value.currency)) return undefined; + output.currency = value.currency; + } + if ((output.costMicros === undefined) !== (output.currency === undefined)) return undefined; + return deepFreeze(output); +} + +function usageMetadata( + projection: CreateImagesProviderAttemptProjectionV1, + requestCount: 0 | 1, + outputCount: number, + billingStatus: CreateImagesProviderBillingStatus, + reported?: CreateImagesProviderReportedUsageV1, +): CreateImagesProviderUsageMetadataV1 | undefined { + const validated = validUsage(reported); + if (reported && !validated) return undefined; + return deepFreeze({ + providerId: projection.authorization.capability.providerId, + modelId: projection.authorization.capability.model.id, + requestCount, + outputCount, + billingStatus: validated ? "provider-reported" : billingStatus, + ...(validated ? { reported: validated } : {}), + }); +} + +function validOutputAssetIds( + projection: CreateImagesProviderAttemptProjectionV1, + assetIds: readonly string[], +): boolean { + const invocation = invocationFor(projection.authorization, projection.nodeId); + return ( + invocation !== undefined && + Array.isArray(assetIds) && + assetIds.length === invocation.requestedOutputs && + assetIds.every((assetId) => CREATE_IMAGES_ASSET_ID_PATTERN.test(assetId)) + ); +} + +function terminalAttempt(status: CreateImagesProviderAttemptStatus): boolean { + return ["succeeded", "failed", "needs_attention", "cancelled"].includes(status); +} + +function rejected( + projection: CreateImagesProviderAttemptProjectionV1, + reason: Extract["reason"], +): CreateImagesProviderAttemptReduction { + return { accepted: false, projection, reason }; +} + +export function reduceCreateImagesProviderAttemptEvent( + projection: CreateImagesProviderAttemptProjectionV1, + event: CreateImagesProviderAttemptEventV1, +): CreateImagesProviderAttemptReduction { + if ( + event.authorizationId !== projection.authorization.authorizationId || + event.runId !== projection.runId || + event.nodeId !== projection.nodeId || + event.attempt !== projection.attempt + ) { + return rejected(projection, "wrong-attempt"); + } + if (event.sequence <= projection.lastSequence) return rejected(projection, "duplicate-or-stale"); + if (event.sequence !== projection.lastSequence + 1) return rejected(projection, "out-of-order"); + const nextBase = { ...projection, lastSequence: event.sequence }; + if ( + terminalAttempt(projection.status) && + event.kind !== "late-output-published" && + !(projection.status === "needs_attention" && event.kind === "cancellation-requested") + ) { + return rejected(projection, "invalid-transition"); + } + if (event.kind === "submission-prepared") { + if (projection.status !== "ready" || projection.submission !== "not-prepared") { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ ...nextBase, status: "prepared", submission: "prepared" }), + }; + } + if (event.kind === "submission-accepted") { + if ( + projection.status !== "prepared" || + projection.authorization.capability.transport.kind !== "asynchronous" || + !PROVIDER_JOB_ID_PATTERN.test(event.providerJobId) + ) { + return rejected(projection, "invalid-transition"); + } + const usage = usageMetadata(projection, 1, 0, "possibly-billable", event.usage); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "accepted", + submission: "accepted", + providerJobId: event.providerJobId, + usage, + }), + }; + } + if (event.kind === "submission-confirmed-not-sent") { + if (projection.status !== "prepared" || !SAFE_ERROR_CODE_PATTERN.test(event.errorCode)) { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "failed", + submission: "confirmed-not-sent", + usage: emptyUsage(projection.authorization), + errorCode: event.errorCode, + }), + }; + } + if (event.kind === "submission-unknown") { + if ( + !["prepared", "accepted"].includes(projection.status) || + !SAFE_ERROR_CODE_PATTERN.test(event.errorCode) + ) { + return rejected(projection, "invalid-transition"); + } + const usage = usageMetadata(projection, 1, 0, "possibly-billable"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "needs_attention", + submission: "unknown", + usage: usage!, + errorCode: event.errorCode, + }), + }; + } + if (event.kind === "provider-failed") { + if ( + !["prepared", "accepted"].includes(projection.status) || + !SAFE_ERROR_CODE_PATTERN.test(event.errorCode) + ) { + return rejected(projection, "invalid-transition"); + } + const usage = usageMetadata(projection, 1, 0, "possibly-billable", event.usage); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "failed", + usage, + errorCode: event.errorCode, + }), + }; + } + if (event.kind === "output-published") { + if ( + !["prepared", "accepted"].includes(projection.status) || + !validOutputAssetIds(projection, event.outputAssetIds) + ) { + return rejected(projection, "output-mismatch"); + } + const usage = usageMetadata( + projection, + 1, + event.outputAssetIds.length, + "possibly-billable", + event.usage, + ); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "succeeded", + outputAssetIds: Object.freeze([...event.outputAssetIds]), + usage, + }), + }; + } + if (event.kind === "cancellation-requested") { + if ( + !["ready", "prepared", "accepted", "needs_attention"].includes(projection.status) || + !(["user", "renderer-disconnected", "app-quit"] as const).includes(event.reason) + ) { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "cancel_requested", + cancellationReason: event.reason, + }), + }; + } + if (event.kind === "cancelled") { + if (projection.status !== "cancel_requested") { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ ...nextBase, status: "cancelled" }), + }; + } + if ( + !["cancel_requested", "cancelled", "needs_attention"].includes(projection.status) || + !validOutputAssetIds(projection, event.outputAssetIds) + ) { + return rejected(projection, "output-mismatch"); + } + const usage = usageMetadata( + projection, + 1, + event.outputAssetIds.length, + "possibly-billable", + event.usage, + ); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: projection.status === "needs_attention" ? "needs_attention" : "cancelled", + lateOutputAssetIds: Object.freeze([...event.outputAssetIds]), + usage, + }), + }; +} + +export type CreateImagesProviderRecoveryDecision = + | { action: "resume-before-prepare" } + | { action: "reconcile-only"; providerJobId?: string } + | { action: "cancel-only"; providerJobId: string } + | { action: "finalize-cancel" } + | { action: "needs-attention"; reason: "prepared-or-unknown" | "accepted-unreconcilable" } + | { action: "none" }; + +/** + * A prepared remote attempt is never a submission permit after restart. The + * caller may resume only before the durable prepared boundary. Synchronous + * Gemini has no job ID to reconcile, so prepared/unknown always needs review. + */ +export function decideCreateImagesProviderAttemptRecovery( + projection: CreateImagesProviderAttemptProjectionV1, +): CreateImagesProviderRecoveryDecision { + if (projection.status === "ready") return { action: "resume-before-prepare" }; + if (["succeeded", "failed", "cancelled"].includes(projection.status)) return { action: "none" }; + const transport = projection.authorization.capability.transport; + if (projection.status === "cancel_requested") { + if ( + projection.providerJobId && + projection.authorization.capability.model.supportsCancellation + ) { + return { action: "cancel-only", providerJobId: projection.providerJobId }; + } + if ( + projection.submission === "not-prepared" || + projection.submission === "confirmed-not-sent" + ) { + return { action: "finalize-cancel" }; + } + if (projection.providerJobId && transport.supportsReconciliation) { + return { action: "reconcile-only", providerJobId: projection.providerJobId }; + } + return { action: "needs-attention", reason: "prepared-or-unknown" }; + } + if (projection.status === "accepted") { + return projection.providerJobId && transport.supportsReconciliation + ? { action: "reconcile-only", providerJobId: projection.providerJobId } + : { action: "needs-attention", reason: "accepted-unreconcilable" }; + } + if (projection.status === "prepared") { + return transport.supportsIdempotency && transport.supportsReconciliation + ? { action: "reconcile-only" } + : { action: "needs-attention", reason: "prepared-or-unknown" }; + } + return { action: "needs-attention", reason: "prepared-or-unknown" }; +} + +export interface CreateImagesResolvedMainCredential { + binding: CreateImagesMainCredentialBindingV1; + credential: TCredential; +} + +export type CreateImagesProviderSubmitResult = + | { + kind: "completed"; + output: TOutput; + outputCount: number; + usage?: CreateImagesProviderReportedUsageV1; + } + | { + kind: "accepted"; + providerJobId: string; + usage?: CreateImagesProviderReportedUsageV1; + } + | { + kind: "failed" | "rate-limited"; + errorCode: string; + usage?: CreateImagesProviderReportedUsageV1; + } + | { kind: "confirmed-not-sent"; errorCode: string } + | { kind: "unknown"; errorCode: string }; + +export type CreateImagesProviderSubmissionOutcome = + | { kind: "deferred"; reason: "concurrency" | "rate"; retryAfterMs: number } + | { kind: "not-admitted"; reason: "consent-expired" } + | { kind: "recovery"; decision: CreateImagesProviderRecoveryDecision } + | { + kind: "cancelled-before-submit"; + events: readonly [CreateImagesProviderAttemptEventV1, CreateImagesProviderAttemptEventV1]; + } + | { + kind: "completed"; + output: TOutput; + outputCount: number; + usage?: CreateImagesProviderReportedUsageV1; + } + | { + kind: "event"; + event: CreateImagesProviderAttemptEventV1; + retry: "none" | "new-consent-required"; + }; + +export interface ExecuteCreateImagesProviderSubmissionOptions { + projection: CreateImagesProviderAttemptProjectionV1; + gate: CreateImagesProviderAdmissionGate; + nowMs: number; + signal?: AbortSignal; + persistPrepared( + event: CreateImagesProviderAttemptEventV1, + ): Promise; + resolveCredential?( + binding: CreateImagesMainCredentialBindingV1, + ): Promise>; + submit(input: { + credential?: TCredential; + authorization: CreateImagesProviderExecutionAuthorizationV1; + runId: string; + nodeId: string; + attempt: number; + idempotencyKey: string; + signal?: AbortSignal; + }): Promise>; +} + +function preparedProjectionMatches( + before: CreateImagesProviderAttemptProjectionV1, + after: CreateImagesProviderAttemptProjectionV1, +): boolean { + return ( + after.authorization.authorizationId === before.authorization.authorizationId && + after.runId === before.runId && + after.nodeId === before.nodeId && + after.attempt === before.attempt && + after.idempotencyKey === before.idempotencyKey && + after.lastSequence === before.lastSequence + 1 && + after.status === "prepared" && + after.submission === "prepared" + ); +} + +function cancellationEvents( + prepared: CreateImagesProviderAttemptProjectionV1, +): readonly [CreateImagesProviderAttemptEventV1, CreateImagesProviderAttemptEventV1] { + const requested = createCreateImagesProviderAttemptEvent(prepared, { + kind: "cancellation-requested", + reason: "user", + }); + const requestedProjection = reduceCreateImagesProviderAttemptEvent(prepared, requested); + if (!requestedProjection.accepted) throw new Error("Cancellation event could not be projected."); + return Object.freeze([ + requested, + createCreateImagesProviderAttemptEvent(requestedProjection.projection, { kind: "cancelled" }), + ]); +} + +/** + * Executes at most one fresh provider submission. It journals `prepared` + * before resolving the credential/entering the adapter, never retries paid + * work, and treats every thrown post-call failure as an unknown submission. + * Callers must ingest `completed.output` before creating output-published. + */ +export async function executeCreateImagesProviderSubmission( + options: ExecuteCreateImagesProviderSubmissionOptions, +): Promise> { + const recovery = decideCreateImagesProviderAttemptRecovery(options.projection); + if (recovery.action !== "resume-before-prepare") { + return { kind: "recovery", decision: recovery }; + } + if (Date.parse(options.projection.authorization.expiresAt) < options.nowMs) { + return { kind: "not-admitted", reason: "consent-expired" }; + } + if (options.signal?.aborted) { + const requested = createCreateImagesProviderAttemptEvent(options.projection, { + kind: "cancellation-requested", + reason: "user", + }); + const reduced = reduceCreateImagesProviderAttemptEvent(options.projection, requested); + if (!reduced.accepted) throw new Error("Cancellation event could not be projected."); + return { + kind: "cancelled-before-submit", + events: Object.freeze([ + requested, + createCreateImagesProviderAttemptEvent(reduced.projection, { kind: "cancelled" }), + ]), + }; + } + const gateDecision = options.gate.tryAcquire( + options.projection.authorization.capability.providerId, + options.nowMs, + ); + if (gateDecision.status === "deferred") return { kind: "deferred", ...gateDecision }; + try { + const preparedEvent = createCreateImagesProviderAttemptEvent(options.projection, { + kind: "submission-prepared", + }); + const prepared = await options.persistPrepared(preparedEvent); + if (!preparedProjectionMatches(options.projection, prepared)) { + throw new Error("The durable prepared projection does not match the authorized attempt."); + } + if (options.signal?.aborted) { + return { kind: "cancelled-before-submit", events: cancellationEvents(prepared) }; + } + let credential: TCredential | undefined; + if (prepared.authorization.executionMode === "gemini") { + const binding = prepared.authorization.credentialBinding; + if (!binding || !options.resolveCredential) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: "credential-unavailable", + }), + retry: "new-consent-required", + }; + } + let resolved: CreateImagesResolvedMainCredential; + try { + resolved = await options.resolveCredential(binding); + } catch { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: "credential-unavailable", + }), + retry: "new-consent-required", + }; + } + if (!sameCredential(binding, resolved.binding)) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: "credential-drift", + }), + retry: "new-consent-required", + }; + } + credential = resolved.credential; + } + if (options.signal?.aborted) { + return { kind: "cancelled-before-submit", events: cancellationEvents(prepared) }; + } + let result: CreateImagesProviderSubmitResult; + try { + result = await options.submit({ + ...(credential === undefined ? {} : { credential }), + authorization: prepared.authorization, + runId: prepared.runId, + nodeId: prepared.nodeId, + attempt: prepared.attempt, + idempotencyKey: prepared.idempotencyKey, + ...(options.signal ? { signal: options.signal } : {}), + }); + } catch { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "transport-unknown", + }), + retry: "none", + }; + } + if (result.kind === "completed") { + const invocation = invocationFor(prepared.authorization, prepared.nodeId)!; + if ( + result.outputCount !== invocation.requestedOutputs || + (result.usage !== undefined && !validUsage(result.usage)) + ) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "provider-output-mismatch", + }), + retry: "none", + }; + } + if (options.signal?.aborted) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "cancelled-after-send", + }), + retry: "none", + }; + } + return { + kind: "completed", + output: result.output, + outputCount: result.outputCount, + ...(result.usage ? { usage: validUsage(result.usage)! } : {}), + }; + } + if (result.kind === "accepted") { + if ( + prepared.authorization.capability.transport.kind !== "asynchronous" || + !PROVIDER_JOB_ID_PATTERN.test(result.providerJobId) || + (result.usage !== undefined && !validUsage(result.usage)) + ) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "provider-contract-mismatch", + }), + retry: "none", + }; + } + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-accepted", + providerJobId: result.providerJobId, + ...(result.usage ? { usage: validUsage(result.usage)! } : {}), + }), + retry: "none", + }; + } + if (result.kind === "confirmed-not-sent") { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: SAFE_ERROR_CODE_PATTERN.test(result.errorCode) + ? result.errorCode + : "provider-error", + }), + retry: "new-consent-required", + }; + } + if (result.kind === "unknown") { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: SAFE_ERROR_CODE_PATTERN.test(result.errorCode) + ? result.errorCode + : "provider-error", + }), + retry: "none", + }; + } + if (result.usage !== undefined && !validUsage(result.usage)) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "provider-contract-mismatch", + }), + retry: "none", + }; + } + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "provider-failed", + errorCode: SAFE_ERROR_CODE_PATTERN.test(result.errorCode) + ? result.errorCode + : "provider-error", + ...(result.usage ? { usage: validUsage(result.usage)! } : {}), + }), + retry: "new-consent-required", + }; + } finally { + options.gate.release(gateDecision.lease); + } +} diff --git a/main/services/create-images/macos-image-normalizer.ts b/main/services/create-images/macos-image-normalizer.ts new file mode 100644 index 00000000..8d8cb916 --- /dev/null +++ b/main/services/create-images/macos-image-normalizer.ts @@ -0,0 +1,87 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { readRegularFile } from "../regular-file-read.js"; +import { validateImageBytes } from "./asset-image-validation-core.js"; + +const IMAGE_IO_TIMEOUT_MS = 20_000; +const MAX_DIAGNOSTIC_BYTES = 4 * 1024; + +export interface MacosImageNormalizerLimits { + maxInputBytes: number; + maxOutputBytes: number; + maxWidth: number; + maxHeight: number; + maxPixels: number; +} + +async function runSips(inputPath: string, outputPath: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn("/usr/bin/sips", ["-s", "format", "png", inputPath, "--out", outputPath], { + shell: false, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + }); + let settled = false; + let diagnostic = ""; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(); + }; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + if (diagnostic.length < MAX_DIAGNOSTIC_BYTES) { + diagnostic += chunk.slice(0, MAX_DIAGNOSTIC_BYTES - diagnostic.length); + } + }); + child.once("error", (error) => finish(error)); + child.once("close", (code, signal) => { + if (code === 0 && signal === null) finish(); + else { + finish( + new Error( + diagnostic.trim() || + `The macOS image converter stopped with ${signal ?? `exit code ${String(code)}`}.`, + ), + ); + } + }); + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + finish(new Error("The macOS image converter exceeded its time limit.")); + }, IMAGE_IO_TIMEOUT_MS); + }); +} + +/** + * Convert a static raster with macOS ImageIO after Chromium declines it. + * The selected file is copied into a private directory first, the converter + * receives fixed arguments without a shell, and its PNG is fully revalidated. + */ +export async function normalizeImageWithMacosImageIo( + selectedPath: string, + limits: MacosImageNormalizerLimits, +): Promise<{ bytes: Uint8Array; width: number; height: number }> { + if (process.platform !== "darwin") { + throw new Error("The macOS image converter is unavailable on this platform."); + } + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-image-normalize-")); + const inputPath = path.join(temporary, "source.raster"); + const outputPath = path.join(temporary, "normalized.png"); + try { + const input = await readRegularFile(selectedPath, limits.maxInputBytes); + await fs.writeFile(inputPath, input, { flag: "wx", mode: 0o600 }); + await runSips(inputPath, outputPath); + const bytes = await readRegularFile(outputPath, limits.maxOutputBytes); + const descriptor = validateImageBytes(bytes, "image/png", "normalized.png", limits); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return { bytes: copy, width: descriptor.width, height: descriptor.height }; + } finally { + await fs.rm(temporary, { force: true, recursive: true }).catch(() => undefined); + } +} diff --git a/main/services/create-images/mock-image-provider-core.test.ts b/main/services/create-images/mock-image-provider-core.test.ts new file mode 100644 index 00000000..6390860b --- /dev/null +++ b/main/services/create-images/mock-image-provider-core.test.ts @@ -0,0 +1,541 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { validateImageBytes } from "./asset-image-validation-core.js"; +import { + DeterministicMockImageProvider, + MOCK_IMAGE_MAX_OUTPUT_BYTES, + MockProviderEventCoordinator, + MockProviderCrashError, + reduceMockProviderEvent, + type MockImageOutputBatch, + type MockProviderEvent, +} from "./mock-image-provider-core.js"; +import type { + CoordinatorClock, + CoordinatorNodeExecutionContext, +} from "./scheduler-core.js"; + +class ImmediateClock implements CoordinatorClock { + readonly delays: number[] = []; + + now(): number { + return 1; + } + + async sleep(delayMs: number, signal: AbortSignal): Promise { + this.delays.push(delayMs); + if (signal.aborted) throw signal.reason ?? new Error("cancelled"); + } +} + +function context( + overrides: Partial = {}, +): CoordinatorNodeExecutionContext { + return { + workflowId: "workflow-1", + workflowRevision: 1, + runId: "run-1", + node: { + id: "generate-1", + type: "generate-image", + position: { x: 0, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + lane: "remote", + attempt: 1, + signal: new AbortController().signal, + dependencyOutputs: new Map(), + async recordRemoteJobId() {}, + ...overrides, + }; +} + +function successfulOutput( + result: Awaited>, +): MockImageOutputBatch { + assert.equal(result.kind, "success"); + if (result.kind !== "success") throw new Error("Expected mock success."); + return result.output as MockImageOutputBatch; +} + +test("mock success is deterministic, bounded, static-PNG-valid, and asset-ingest-compatible", async () => { + const clock = new ImmediateClock(); + const script = { + nodes: { + "generate-1": [ + { + outcome: "success" as const, + delayMs: 25, + width: 37, + height: 19, + seed: 0x1234_5678, + outputByteLimit: 64 * 1024, + }, + ], + }, + }; + const first = successfulOutput( + await new DeterministicMockImageProvider({ clock, script }).execute( + context(), + ), + ); + const second = successfulOutput( + await new DeterministicMockImageProvider({ clock, script }).execute( + context(), + ), + ); + assert.deepEqual(first, second); + assert.equal(first.images.length, 1); + const image = first.images[0]!; + assert.equal(image.metadata.byteLength, image.bytes.byteLength); + assert.equal(first.metadata.totalByteLength, image.bytes.byteLength); + assert.ok(image.bytes.byteLength > 64); + assert.ok(image.bytes.byteLength <= 64 * 1024); + assert.ok(image.bytes.byteLength <= MOCK_IMAGE_MAX_OUTPUT_BYTES); + assert.deepEqual( + validateImageBytes(image.bytes, "image/png", "mock.png", { + maxWidth: 1_024, + maxHeight: 1_024, + maxPixels: 1_048_576, + }), + { + mediaType: "image/png", + extension: "png", + width: 37, + height: 19, + pixels: 703, + }, + ); + assert.deepEqual(clock.delays, [25, 25]); + const serializedMetadata = JSON.stringify({ + batch: first.metadata, + image: image.metadata, + }); + assert.doesNotMatch(serializedMetadata, /(?:file:|https?:|path|url)/iu); +}); + +test("mock returns exactly the requested count as distinct valid PNGs under one aggregate bound", async () => { + const base = context(); + assert.equal(base.node.type, "generate-image"); + if (base.node.type !== "generate-image") return; + const batch = successfulOutput( + await new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + width: 24, + height: 24, + seed: 99, + outputByteLimit: 128 * 1024, + }, + ], + }, + }, + }).execute({ + ...base, + node: { ...base.node, data: { ...base.node.data, count: 4 } }, + }), + ); + assert.equal(batch.images.length, 4); + assert.equal(batch.metadata.count, 4); + assert.equal( + batch.metadata.totalByteLength, + batch.images.reduce((total, image) => total + image.bytes.byteLength, 0), + ); + assert.ok(batch.metadata.totalByteLength <= 128 * 1024); + const digests = batch.images.map((image) => { + assert.deepEqual( + validateImageBytes(image.bytes, "image/png", "mock.png", { + maxWidth: 1_024, + maxHeight: 1_024, + maxPixels: 1_048_576, + }), + { + mediaType: "image/png", + extension: "png", + width: 24, + height: 24, + pixels: 576, + }, + ); + return createHash("sha256").update(image.bytes).digest("hex"); + }); + assert.equal(new Set(digests).size, 4); +}); + +test("mock exposes deterministic failure, rate limit, ambiguity, and exact crash boundaries", async () => { + const clock = new ImmediateClock(); + const provider = new DeterministicMockImageProvider({ + clock, + script: { + nodes: { + "generate-1": [ + { outcome: "failure", error: "refused", retrySafety: "never" }, + { + outcome: "rate-limit", + error: "limited", + retrySafety: "same-idempotency-key", + retryAfterMs: 321, + idempotencyKey: "idempotency-key-1", + durableRemoteJob: true, + }, + { outcome: "ambiguous-submit", error: "unknown acceptance" }, + { outcome: "crash-before-send", error: "before send" }, + { outcome: "accepted-before-response" }, + { outcome: "crash-after-send" }, + ], + }, + }, + }); + const recordedAttempts: number[] = []; + const executionContext = (attempt: number) => + context({ + attempt, + recordRemoteJobId: async () => { + recordedAttempts.push(attempt); + }, + }); + assert.deepEqual(await provider.execute(executionContext(1)), { + kind: "failure", + error: "refused", + retrySafety: "never", + }); + assert.deepEqual(await provider.execute(executionContext(2)), { + kind: "rate-limited", + error: "limited", + retrySafety: "same-idempotency-key", + retryAfterMs: 321, + idempotencyKey: "idempotency-key-1", + }); + assert.deepEqual(await provider.execute(executionContext(3)), { + kind: "ambiguous-submit", + error: "unknown acceptance", + }); + assert.deepEqual(await provider.execute(executionContext(4)), { + kind: "failure", + error: "before send", + retrySafety: "confirmed-not-submitted", + }); + await assert.rejects( + provider.execute(executionContext(5)), + (error: unknown) => { + return ( + error instanceof MockProviderCrashError && + error.boundary === "accepted-before-response" + ); + }, + ); + await assert.rejects( + provider.execute(executionContext(6)), + (error: unknown) => { + return ( + error instanceof MockProviderCrashError && + error.boundary === "after-send" + ); + }, + ); + assert.deepEqual(recordedAttempts, [2, 6]); +}); + +test("mock event stream can deterministically duplicate and reorder provider notifications", async () => { + const events: MockProviderEvent[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + duplicateSubmittedEvent: true, + outOfOrderCompletionEvent: true, + }, + ], + }, + }, + onProviderEvent: (event) => events.push(event), + }); + await provider.execute(context()); + assert.deepEqual( + events.map((event) => [event.kind, event.sequence]), + [ + ["submitted", 1], + ["submitted", 1], + ["completed", 3], + ["progress", 2], + ], + ); + let cursor = { + runId: "run-1", + nodeId: "generate-1", + remoteJobId: events[0]!.remoteJobId, + attempt: 1, + lastSequence: 0, + terminal: false, + }; + const reasons: string[] = []; + for (const event of events) { + const reduced = reduceMockProviderEvent(cursor, event); + if (reduced.accepted) cursor = reduced.cursor; + else reasons.push(reduced.reason); + } + assert.deepEqual(reasons, ["duplicate-or-stale", "out-of-order"]); + assert.equal(cursor.lastSequence, 2); + assert.equal(cursor.terminal, false); +}); + +test("product event coordinator accepts only ordered provider terminal notifications", async () => { + const coordinator = new MockProviderEventCoordinator(); + const events: MockProviderEvent[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + duplicateSubmittedEvent: true, + outOfOrderCompletionEvent: true, + }, + ], + }, + }, + onProviderEvent: (event) => { + events.push(event); + coordinator.observe(event); + }, + }); + await provider.execute(context()); + const identity = { runId: "run-1", nodeId: "generate-1", attempt: 1 }; + assert.equal(coordinator.acceptedTerminalKind(identity), undefined); + assert.deepEqual(coordinator.rejectionReasons(identity), [ + "duplicate-or-stale", + "out-of-order", + ]); + coordinator.observe({ + ...events[events.length - 1]!, + kind: "completed", + sequence: 3, + }); + assert.equal(coordinator.acceptedTerminalKind(identity), "completed"); +}); + +test("accepted mock jobs reconcile deterministically without a second submission", async () => { + const submitted: MockProviderEvent[] = []; + const recorded: string[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { outcome: "crash-after-send", width: 8, height: 8, seed: 91 }, + ], + }, + }, + onProviderEvent: (event) => submitted.push(event), + }); + const executionContext = context({ + idempotencyKey: + "aiden-ci-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + recordRemoteJobId: async (remoteJobId) => { + recorded.push(remoteJobId); + }, + }); + await assert.rejects( + provider.execute(executionContext), + MockProviderCrashError, + ); + assert.equal(recorded.length, 1); + const reconciled = provider.reconcileAccepted({ + runId: executionContext.runId, + node: executionContext.node, + attempt: executionContext.attempt, + idempotencyKey: executionContext.idempotencyKey!, + remoteJobId: recorded[0]!, + }); + assert.equal(reconciled.kind, "success"); + assert.deepEqual( + submitted.map((event) => event.kind), + ["submitted"], + ); +}); + +test("late mock completion after cancellation is emitted but rejected by the event reducer", async () => { + const events: MockProviderEvent[] = []; + const controller = new AbortController(); + controller.abort(new Error("cancel now")); + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [{ outcome: "success", lateCompletionAfterCancel: true }], + }, + }, + onProviderEvent: (event) => events.push(event), + }); + const result = await provider.execute(context({ signal: controller.signal })); + assert.equal(result.kind, "cancelled"); + assert.deepEqual( + events.map((event) => [event.kind, event.sequence]), + [ + ["submitted", 1], + ["cancelled", 2], + ["completed", 3], + ], + ); + let cursor = { + runId: "run-1", + nodeId: "generate-1", + remoteJobId: events[0]!.remoteJobId, + attempt: 1, + lastSequence: 0, + terminal: false, + }; + const first = reduceMockProviderEvent(cursor, events[0]!); + assert.equal(first.accepted, true); + if (first.accepted) cursor = first.cursor; + const cancelled = reduceMockProviderEvent(cursor, events[1]!); + assert.equal(cancelled.accepted, true); + if (cancelled.accepted) cursor = cancelled.cursor; + const late = reduceMockProviderEvent(cursor, events[2]!); + assert.equal(late.accepted, false); + if (!late.accepted) assert.equal(late.reason, "late-after-terminal"); +}); + +test("mock validates script size, output dimensions, output byte ceilings, and identifiers", () => { + assert.throws( + () => + new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { "generate-1": [{ outcome: "success", width: 1_025 }] }, + }, + }), + /width/u, + ); + assert.throws( + () => + new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { nodes: { "bad/id": [{ outcome: "success" }] } }, + }), + /opaque node IDs/u, + ); + assert.throws( + () => + new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [{ outcome: "success", outputByteLimit: 63 }], + }, + }, + }), + /byte limit/u, + ); +}); + +test("max-length node IDs derive bounded journal-compatible provider and idempotency IDs", async () => { + const nodeId = `n${"x".repeat(127)}`; + const recordedJobIds: string[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + [nodeId]: [ + { + outcome: "rate-limit", + retrySafety: "same-idempotency-key", + durableRemoteJob: true, + }, + ], + }, + }, + }); + const base = context(); + const result = await provider.execute({ + ...base, + node: { ...base.node, id: nodeId }, + recordRemoteJobId: async (remoteJobId) => { + recordedJobIds.push(remoteJobId); + }, + }); + assert.equal(recordedJobIds.length, 1); + assert.match(recordedJobIds[0]!, /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u); + assert.ok(recordedJobIds[0]!.length <= 256); + assert.equal(result.kind, "rate-limited"); + if (result.kind !== "rate-limited") return; + assert.match( + result.idempotencyKey ?? "", + /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u, + ); + assert.ok((result.idempotencyKey?.length ?? 0) <= 192); +}); + +test("same idempotency key derives the same mock job across retry attempts", async () => { + const recorded: string[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "failure", + retrySafety: "same-idempotency-key", + durableRemoteJob: true, + }, + { outcome: "success" }, + ], + }, + }, + }); + const stableKey = + "aiden-ci-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + for (const attempt of [1, 2]) { + await provider.execute( + context({ + attempt, + idempotencyKey: stableKey, + recordRemoteJobId: async (remoteJobId) => { + recorded.push(remoteJobId); + }, + }), + ); + } + assert.equal(recorded.length, 2); + assert.equal(recorded[0], recorded[1]); +}); + +test("ambiguous and confirmed-not-submitted outcomes never record a durable provider job", async () => { + for (const attemptScript of [ + { outcome: "ambiguous-submit" as const }, + { + outcome: "rate-limit" as const, + retrySafety: "confirmed-not-submitted" as const, + }, + ]) { + let durableJobs = 0; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { nodes: { "generate-1": [attemptScript] } }, + }); + await provider.execute( + context({ + recordRemoteJobId: async () => { + durableJobs += 1; + }, + }), + ); + assert.equal(durableJobs, 0); + } +}); diff --git a/main/services/create-images/mock-image-provider-core.ts b/main/services/create-images/mock-image-provider-core.ts new file mode 100644 index 00000000..fd6fd7dd --- /dev/null +++ b/main/services/create-images/mock-image-provider-core.ts @@ -0,0 +1,755 @@ +import { createHash } from "node:crypto"; +import { deflateSync } from "node:zlib"; +import type { + CoordinatorAttemptResult, + CoordinatorClock, + CoordinatorNodeExecutionContext, + CoordinatorRetrySafety, +} from "./scheduler-core.js"; + +export const MOCK_IMAGE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +export const MOCK_IMAGE_MAX_DIMENSION = 1_024; +export const MOCK_IMAGE_MAX_PIXELS = 1_048_576; +const MOCK_MAX_SCRIPTED_NODES = 500; +const MOCK_MAX_ATTEMPTS_PER_NODE = 6; +const MOCK_MAX_DELAY_MS = 5 * 60_000; +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; + +export interface MockImageOutput { + bytes: Uint8Array; + metadata: { + source: "deterministic-local-mock"; + seed: number; + mimeType: "image/png"; + width: number; + height: number; + byteLength: number; + }; +} + +export interface MockImageOutputBatch { + images: readonly MockImageOutput[]; + metadata: { + source: "deterministic-local-mock"; + count: 1 | 2 | 3 | 4; + totalByteLength: number; + }; +} + +export type MockProviderOutcome = + | "success" + | "failure" + | "rate-limit" + | "crash-before-send" + | "accepted-before-response" + | "crash-after-send" + /** @deprecated Use an explicit crash boundary. This aliases crash-after-send. */ + | "crash" + | "ambiguous-submit"; + +export interface MockProviderAttemptScript { + outcome: MockProviderOutcome; + delayMs?: number; + error?: string; + retrySafety?: CoordinatorRetrySafety; + retryAfterMs?: number; + idempotencyKey?: string; + remoteJobId?: string; + /** Whether the provider returned an accepted, durable job ID to Aiden. */ + durableRemoteJob?: boolean; + /** Strict ceiling for the generated PNG, not padding or claimed media length. */ + outputByteLimit?: number; + width?: number; + height?: number; + seed?: number; + duplicateSubmittedEvent?: boolean; + outOfOrderCompletionEvent?: boolean; + lateCompletionAfterCancel?: boolean; +} + +export interface MockImageProviderScript { + nodes: Readonly>; +} + +export type MockProviderEventKind = + "submitted" | "progress" | "completed" | "failed" | "cancelled"; + +export interface MockProviderEvent { + runId: string; + nodeId: string; + remoteJobId: string; + attempt: number; + sequence: number; + kind: MockProviderEventKind; + output?: MockImageOutputBatch; + error?: string; +} + +export interface MockProviderEventCursor { + runId: string; + nodeId: string; + remoteJobId: string; + attempt: number; + lastSequence: number; + terminal: boolean; +} + +export type MockProviderEventRejectionReason = + "wrong-job" | "duplicate-or-stale" | "out-of-order" | "late-after-terminal"; + +export type MockProviderEventReduction = + | { accepted: true; cursor: MockProviderEventCursor } + | { + accepted: false; + cursor: MockProviderEventCursor; + reason: MockProviderEventRejectionReason; + }; + +export function reduceMockProviderEvent( + cursor: MockProviderEventCursor, + event: MockProviderEvent, +): MockProviderEventReduction { + if ( + cursor.runId !== event.runId || + cursor.nodeId !== event.nodeId || + cursor.remoteJobId !== event.remoteJobId || + cursor.attempt !== event.attempt + ) { + return { accepted: false, cursor, reason: "wrong-job" }; + } + if (event.sequence <= cursor.lastSequence) { + return { accepted: false, cursor, reason: "duplicate-or-stale" }; + } + if (event.sequence !== cursor.lastSequence + 1) { + return { accepted: false, cursor, reason: "out-of-order" }; + } + if (cursor.terminal) + return { accepted: false, cursor, reason: "late-after-terminal" }; + return { + accepted: true, + cursor: Object.freeze({ + ...cursor, + lastSequence: event.sequence, + terminal: ["completed", "failed", "cancelled"].includes(event.kind), + }), + }; +} + +export interface MockProviderEventAttemptIdentity { + runId: string; + nodeId: string; + attempt: number; +} + +interface MockProviderEventState { + cursor: MockProviderEventCursor; + terminalKind?: Extract< + MockProviderEventKind, + "completed" | "failed" | "cancelled" + >; + rejectionReasons: MockProviderEventRejectionReason[]; +} + +function eventAttemptKey(identity: MockProviderEventAttemptIdentity): string { + return `${identity.runId}\0${identity.nodeId}\0${identity.attempt}`; +} + +/** Product-facing reducer for mock provider notifications; provider callback order is never trusted. */ +export class MockProviderEventCoordinator { + readonly #states = new Map(); + + observe(event: MockProviderEvent): MockProviderEventReduction { + const key = eventAttemptKey(event); + let state = this.#states.get(key); + if (!state) { + state = { + cursor: { + runId: event.runId, + nodeId: event.nodeId, + remoteJobId: event.remoteJobId, + attempt: event.attempt, + lastSequence: 0, + terminal: false, + }, + rejectionReasons: [], + }; + this.#states.set(key, state); + } + const reduction = reduceMockProviderEvent(state.cursor, event); + if (reduction.accepted) { + state.cursor = reduction.cursor; + if (["completed", "failed", "cancelled"].includes(event.kind)) { + state.terminalKind = + event.kind as MockProviderEventState["terminalKind"]; + } + } else { + state.rejectionReasons.push(reduction.reason); + } + return reduction; + } + + acceptedTerminalKind( + identity: MockProviderEventAttemptIdentity, + ): MockProviderEventState["terminalKind"] { + return this.#states.get(eventAttemptKey(identity))?.terminalKind; + } + + rejectionReasons( + identity: MockProviderEventAttemptIdentity, + ): readonly string[] { + return [ + ...(this.#states.get(eventAttemptKey(identity))?.rejectionReasons ?? []), + ]; + } +} + +export interface DeterministicMockImageProviderOptions { + clock: CoordinatorClock; + script: MockImageProviderScript; + onProviderEvent?(event: MockProviderEvent): void; +} + +export class MockProviderCrashError extends Error { + readonly code = "MOCK_PROVIDER_CRASH"; + + constructor( + readonly nodeId: string, + readonly attempt: number, + readonly boundary: "accepted-before-response" | "after-send", + ) { + super( + `The deterministic mock crashed at ${nodeId} attempt ${attempt} (${boundary}).`, + ); + this.name = "MockProviderCrashError"; + } +} + +function assertInteger( + value: number, + minimum: number, + maximum: number, + label: string, +): void { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error( + `${label} must be an integer between ${minimum} and ${maximum}.`, + ); + } +} + +function validateAttempt( + nodeId: string, + attempt: MockProviderAttemptScript, +): void { + const delayMs = attempt.delayMs ?? 0; + assertInteger(delayMs, 0, MOCK_MAX_DELAY_MS, "Mock delay"); + if ( + attempt.error !== undefined && + (attempt.error.length === 0 || attempt.error.length > 1_000) + ) { + throw new Error("Mock errors must contain between 1 and 1000 characters."); + } + if (attempt.retryAfterMs !== undefined) { + assertInteger( + attempt.retryAfterMs, + 0, + MOCK_MAX_DELAY_MS, + "Mock retry-after delay", + ); + } + if ( + attempt.remoteJobId !== undefined && + !PROVIDER_JOB_ID_PATTERN.test(attempt.remoteJobId) + ) { + throw new Error( + "Mock remote job ID must be a bounded provider identifier.", + ); + } + if ( + attempt.idempotencyKey !== undefined && + !IDEMPOTENCY_KEY_PATTERN.test(attempt.idempotencyKey) + ) { + throw new Error( + "Mock idempotency key must match the run journal contract.", + ); + } + const durableRemoteJob = + attempt.durableRemoteJob ?? + (attempt.remoteJobId !== undefined || + attempt.outcome === "success" || + attempt.outcome === "crash-after-send" || + attempt.outcome === "crash"); + if (attempt.remoteJobId !== undefined && durableRemoteJob !== true) { + throw new Error("A scripted remote job ID must be marked durable."); + } + if ( + durableRemoteJob && + (attempt.outcome === "ambiguous-submit" || + attempt.outcome === "crash-before-send" || + attempt.outcome === "accepted-before-response" || + attempt.retrySafety === "confirmed-not-submitted") + ) { + throw new Error( + "Ambiguous or confirmed-not-submitted outcomes cannot have a durable remote job.", + ); + } + if ( + (attempt.outcome === "crash-after-send" || attempt.outcome === "crash") && + !durableRemoteJob + ) { + throw new Error( + "A crash-after-send outcome requires a durable remote job.", + ); + } + const byteLimit = attempt.outputByteLimit ?? MOCK_IMAGE_MAX_OUTPUT_BYTES; + assertInteger( + byteLimit, + 64, + MOCK_IMAGE_MAX_OUTPUT_BYTES, + "Mock output byte limit", + ); + const width = attempt.width ?? 16; + const height = attempt.height ?? 16; + assertInteger(width, 1, MOCK_IMAGE_MAX_DIMENSION, "Mock output width"); + assertInteger(height, 1, MOCK_IMAGE_MAX_DIMENSION, "Mock output height"); + if (width * height > MOCK_IMAGE_MAX_PIXELS) + throw new Error("Mock output exceeds the pixel limit."); + const seed = attempt.seed ?? 1; + assertInteger(seed, 0, 0xffff_ffff, "Mock seed"); + if ( + (attempt.outcome === "rate-limit" || attempt.outcome === "failure") && + attempt.retrySafety === "local-safe" + ) { + throw new Error( + `Remote mock node "${nodeId}" cannot use local-safe retry classification.`, + ); + } +} + +function validateScript( + script: MockImageProviderScript, +): MockImageProviderScript { + const entries = Object.entries(script.nodes); + if (entries.length > MOCK_MAX_SCRIPTED_NODES) + throw new Error("The mock provider script has too many nodes."); + const copy: Record = + Object.create(null); + for (const [nodeId, attempts] of entries) { + if (!OPAQUE_ID_PATTERN.test(nodeId)) + throw new Error("Mock scripts require opaque node IDs."); + if ( + !Array.isArray(attempts) || + attempts.length === 0 || + attempts.length > MOCK_MAX_ATTEMPTS_PER_NODE + ) { + throw new Error( + `Mock node "${nodeId}" requires 1 through ${MOCK_MAX_ATTEMPTS_PER_NODE} attempts.`, + ); + } + copy[nodeId] = Object.freeze( + attempts.map((attempt) => { + validateAttempt(nodeId, attempt); + return Object.freeze({ ...attempt }); + }), + ); + } + return Object.freeze({ nodes: Object.freeze(copy) }); +} + +const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return crc >>> 0; +}); + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concatenate(parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array( + parts.reduce((total, part) => total + part.byteLength, 0), + ); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function pngChunk( + type: "IHDR" | "IDAT" | "IEND", + data: Uint8Array, +): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const crcInput = concatenate([typeBytes, data]); + let crc = 0xffff_ffff; + for (const byte of crcInput) + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 0xff]!; + return concatenate([ + u32(data.byteLength), + typeBytes, + data, + u32((crc ^ 0xffff_ffff) >>> 0), + ]); +} + +function deterministicPng( + width: number, + height: number, + seed: number, +): Uint8Array { + const rowBytes = width * 4 + 1; + const raw = new Uint8Array(rowBytes * height); + const red = seed & 0xff; + const green = (seed >>> 8) & 0xff; + const blue = (seed >>> 16) & 0xff; + for (let y = 0; y < height; y += 1) { + const row = y * rowBytes; + raw[row] = 0; + for (let x = 0; x < width; x += 1) { + const pixel = row + 1 + x * 4; + raw[pixel] = (red + x) & 0xff; + raw[pixel + 1] = (green + y) & 0xff; + raw[pixel + 2] = (blue + x + y) & 0xff; + raw[pixel + 3] = 0xff; + } + } + const header = concatenate([ + u32(width), + u32(height), + Uint8Array.from([8, 6, 0, 0, 0]), + ]); + const compressed = new Uint8Array(deflateSync(raw, { level: 9 })); + return concatenate([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", compressed), + pngChunk("IEND", new Uint8Array()), + ]); +} + +function outputFrom( + attempt: MockProviderAttemptScript, + seed: number, +): MockImageOutput { + const width = attempt.width ?? 16; + const height = attempt.height ?? 16; + const bytes = deterministicPng(width, height, seed); + return Object.freeze({ + bytes, + metadata: Object.freeze({ + source: "deterministic-local-mock" as const, + seed, + mimeType: "image/png" as const, + width, + height, + byteLength: bytes.byteLength, + }), + }); +} + +function outputBatchFrom( + attempt: MockProviderAttemptScript, + count: 1 | 2 | 3 | 4, +): MockImageOutputBatch { + const seed = attempt.seed ?? 1; + const images = Object.freeze( + Array.from({ length: count }, (_, index) => + outputFrom(attempt, (seed + index) >>> 0), + ), + ); + const totalByteLength = images.reduce( + (total, image) => total + image.bytes.byteLength, + 0, + ); + const byteLimit = attempt.outputByteLimit ?? MOCK_IMAGE_MAX_OUTPUT_BYTES; + if ( + totalByteLength > byteLimit || + totalByteLength > MOCK_IMAGE_MAX_OUTPUT_BYTES + ) { + throw new Error( + "The deterministic PNG batch exceeds the configured mock output byte limit.", + ); + } + return Object.freeze({ + images, + metadata: Object.freeze({ + source: "deterministic-local-mock" as const, + count, + totalByteLength, + }), + }); +} + +function derivedIdentifier( + prefix: "mock-job" | "mock-idempotency", + context: Pick< + CoordinatorNodeExecutionContext, + "runId" | "attempt" | "idempotencyKey" + > & { + node: Pick; + }, +): string { + const digest = createHash("sha256") + .update(context.runId) + .update("\0") + .update(context.node.id) + .update("\0") + .update(context.idempotencyKey ?? String(context.attempt)) + .digest("hex"); + return `${prefix}-${digest}`; +} + +export interface MockAcceptedJobReconciliationContext { + runId: string; + node: CoordinatorNodeExecutionContext["node"]; + attempt: number; + idempotencyKey: string; + remoteJobId: string; +} + +export class DeterministicMockImageProvider { + readonly providerId = "local-mock"; + readonly #clock: CoordinatorClock; + readonly #script: MockImageProviderScript; + readonly #onProviderEvent?: (event: MockProviderEvent) => void; + + constructor(options: DeterministicMockImageProviderOptions) { + this.#clock = options.clock; + this.#script = validateScript(options.script); + this.#onProviderEvent = options.onProviderEvent; + } + + reconcileAccepted( + context: MockAcceptedJobReconciliationContext, + ): CoordinatorAttemptResult { + if (context.node.type !== "generate-image") { + return { + kind: "failure", + error: "Only Generate Image mock jobs can be reconciled.", + retrySafety: "never", + }; + } + const script = this.#script.nodes[context.node.id]?.[context.attempt - 1]; + if (!script) { + return { + kind: "ambiguous-submit", + error: + "The accepted mock job has no deterministic reconciliation outcome.", + }; + } + const expectedJobId = + script.remoteJobId ?? derivedIdentifier("mock-job", context); + if (context.remoteJobId !== expectedJobId) { + return { + kind: "ambiguous-submit", + error: + "The durable mock job ID does not match its deterministic reconciliation record.", + }; + } + const durableRemoteJob = + script.durableRemoteJob ?? + (script.remoteJobId !== undefined || + script.outcome === "success" || + script.outcome === "crash-after-send" || + script.outcome === "crash"); + if (!durableRemoteJob) { + return { + kind: "ambiguous-submit", + error: "The mock outcome does not prove a durable accepted job.", + }; + } + if ( + script.outcome === "success" || + script.outcome === "crash-after-send" || + script.outcome === "crash" + ) { + return { + kind: "success", + output: outputBatchFrom(script, context.node.data.count), + }; + } + if (script.outcome === "rate-limit") { + return { + kind: "rate-limited", + error: script.error ?? "Mock rate limit.", + retrySafety: "never", + }; + } + return { + kind: "failure", + error: script.error ?? "The accepted mock job failed.", + retrySafety: "never", + }; + } + + async execute( + context: CoordinatorNodeExecutionContext, + ): Promise { + if (context.lane !== "remote" || context.node.type !== "generate-image") { + return { + kind: "failure", + error: + "The local image mock only executes remote Generate Image nodes.", + retrySafety: "never", + }; + } + const attempts = this.#script.nodes[context.node.id]; + const script = attempts?.[context.attempt - 1]; + if (!script) { + return { + kind: "failure", + error: `No deterministic mock outcome exists for attempt ${context.attempt}.`, + retrySafety: "never", + }; + } + const remoteJobId = + script.remoteJobId ?? derivedIdentifier("mock-job", context); + const durableRemoteJob = + script.durableRemoteJob ?? + (script.remoteJobId !== undefined || + script.outcome === "success" || + script.outcome === "crash-after-send" || + script.outcome === "crash"); + if (script.outcome === "crash-before-send") { + return { + kind: "failure", + error: script.error ?? "Mock crashed before submission.", + retrySafety: "confirmed-not-submitted", + }; + } + if (durableRemoteJob) await context.recordRemoteJobId(remoteJobId); + let providerSequence = 1; + const emit = ( + event: Omit< + MockProviderEvent, + "runId" | "nodeId" | "remoteJobId" | "attempt" + >, + ): void => { + this.#onProviderEvent?.({ + runId: context.runId, + nodeId: context.node.id, + remoteJobId, + attempt: context.attempt, + ...event, + }); + }; + emit({ kind: "submitted", sequence: providerSequence }); + if (script.duplicateSubmittedEvent) + emit({ kind: "submitted", sequence: providerSequence }); + + if (script.outcome === "accepted-before-response") { + throw new MockProviderCrashError( + context.node.id, + context.attempt, + "accepted-before-response", + ); + } + + try { + await this.#clock.sleep(script.delayMs ?? 0, context.signal); + } catch (error) { + if (!context.signal.aborted) throw error; + providerSequence += 1; + emit({ + kind: "cancelled", + sequence: providerSequence, + error: "Mock execution was cancelled.", + }); + if (script.lateCompletionAfterCancel) { + providerSequence += 1; + emit({ + kind: "completed", + sequence: providerSequence, + output: outputBatchFrom(script, context.node.data.count), + }); + } + return { kind: "cancelled", error: "Mock execution was cancelled." }; + } + + if (script.outcome === "crash" || script.outcome === "crash-after-send") { + throw new MockProviderCrashError( + context.node.id, + context.attempt, + "after-send", + ); + } + if (script.outcome === "ambiguous-submit") { + return { + kind: "ambiguous-submit", + error: script.error ?? "Mock submission outcome is ambiguous.", + }; + } + if (script.outcome === "rate-limit") { + providerSequence += 1; + emit({ + kind: "failed", + sequence: providerSequence, + error: script.error ?? "Mock rate limit.", + }); + return { + kind: "rate-limited", + error: script.error ?? "Mock rate limit.", + retrySafety: script.retrySafety ?? "confirmed-not-submitted", + ...(script.retryAfterMs === undefined + ? {} + : { retryAfterMs: script.retryAfterMs }), + ...(script.retrySafety === "same-idempotency-key" + ? { + idempotencyKey: + script.idempotencyKey ?? + context.idempotencyKey ?? + derivedIdentifier("mock-idempotency", context), + } + : script.idempotencyKey === undefined + ? {} + : { idempotencyKey: script.idempotencyKey }), + }; + } + if (script.outcome === "failure") { + providerSequence += 1; + emit({ + kind: "failed", + sequence: providerSequence, + error: script.error ?? "Mock provider failure.", + }); + return { + kind: "failure", + error: script.error ?? "Mock provider failure.", + retrySafety: script.retrySafety ?? "never", + ...(script.retrySafety === "same-idempotency-key" + ? { + idempotencyKey: + script.idempotencyKey ?? + context.idempotencyKey ?? + derivedIdentifier("mock-idempotency", context), + } + : script.idempotencyKey === undefined + ? {} + : { idempotencyKey: script.idempotencyKey }), + }; + } + const output = outputBatchFrom(script, context.node.data.count); + if (script.outOfOrderCompletionEvent) { + emit({ kind: "completed", sequence: providerSequence + 2, output }); + providerSequence += 1; + emit({ kind: "progress", sequence: providerSequence }); + } else { + providerSequence += 1; + emit({ kind: "completed", sequence: providerSequence, output }); + } + return { kind: "success", output }; + } +} diff --git a/main/services/create-images/mutation-rate-limit-core.test.ts b/main/services/create-images/mutation-rate-limit-core.test.ts new file mode 100644 index 00000000..0b18d23c --- /dev/null +++ b/main/services/create-images/mutation-rate-limit-core.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CreateImagesMutationRateLimiter } from "./mutation-rate-limit-core.js"; + +test("bounds renderer document mutations and recovers after the window", () => { + let now = 1_000; + const limiter = new CreateImagesMutationRateLimiter(() => now, 3, 1_000); + assert.equal(limiter.consume("owner:document"), true); + assert.equal(limiter.consume("owner:document"), true); + assert.equal(limiter.consume("owner:document"), true); + assert.equal(limiter.consume("owner:document"), false); + assert.equal(limiter.consume("other:document"), true); + + now += 1_001; + assert.equal(limiter.consume("owner:document"), true); +}); + +test("fails closed for malformed owner keys and invalid bounds", () => { + const limiter = new CreateImagesMutationRateLimiter(); + assert.equal(limiter.consume(""), false); + assert.equal(limiter.consume("x".repeat(769)), false); + assert.throws( + () => new CreateImagesMutationRateLimiter(Date.now, 0), + /capacity/u, + ); + assert.throws( + () => new CreateImagesMutationRateLimiter(Date.now, 1, 999), + /window/u, + ); + assert.throws( + () => new CreateImagesMutationRateLimiter(Date.now, 1, 1_000, 0), + /owner capacity/u, + ); +}); + +test("charges weighted operations and never grows past the owner bound", () => { + let now = 1_000; + const limiter = new CreateImagesMutationRateLimiter(() => now, 10, 1_000, 2); + assert.equal(limiter.consume("webcontents:1", 8), true); + assert.equal(limiter.consume("webcontents:1", 3), false); + assert.equal(limiter.retryAfterMs("webcontents:1"), 0); + assert.equal(limiter.consume("webcontents:1", 2), true); + assert.equal(limiter.retryAfterMs("webcontents:1"), 1_000); + assert.equal(limiter.consume("webcontents:2"), true); + assert.equal(limiter.consume("webcontents:3"), false); + assert.equal(limiter.ownerCountForTests(), 2); + + now += 1_001; + assert.equal(limiter.consume("webcontents:3"), true); + assert.equal(limiter.ownerCountForTests(), 1); +}); diff --git a/main/services/create-images/mutation-rate-limit-core.ts b/main/services/create-images/mutation-rate-limit-core.ts new file mode 100644 index 00000000..0e379e99 --- /dev/null +++ b/main/services/create-images/mutation-rate-limit-core.ts @@ -0,0 +1,80 @@ +export class CreateImagesMutationRateLimiter { + private readonly events = new Map(); + + constructor( + private readonly now: () => number = Date.now, + private readonly maxMutations = 120, + private readonly windowMs = 60_000, + private readonly maxOwners = 64, + ) { + if ( + !Number.isSafeInteger(maxMutations) || + maxMutations < 1 || + maxMutations > 10_000 + ) { + throw new Error("Create Images mutation capacity is invalid."); + } + if ( + !Number.isSafeInteger(windowMs) || + windowMs < 1_000 || + windowMs > 60 * 60_000 + ) { + throw new Error("Create Images mutation window is invalid."); + } + if ( + !Number.isSafeInteger(maxOwners) || + maxOwners < 1 || + maxOwners > 1_024 + ) { + throw new Error("Create Images owner capacity is invalid."); + } + } + + private pruneExpired(now: number): void { + const cutoff = now - this.windowMs; + for (const [key, timestamps] of this.events) { + const active = timestamps.filter((timestamp) => timestamp > cutoff); + if (active.length === 0) this.events.delete(key); + else this.events.set(key, active); + } + } + + consume(ownerKey: string, cost = 1): boolean { + if (!ownerKey || ownerKey.length > 768) return false; + if (!Number.isSafeInteger(cost) || cost < 1 || cost > this.maxMutations) + return false; + const now = this.now(); + const cutoff = now - this.windowMs; + if (!this.events.has(ownerKey) && this.events.size >= this.maxOwners) { + this.pruneExpired(now); + if (this.events.size >= this.maxOwners) return false; + } + const recent = (this.events.get(ownerKey) ?? []).filter( + (timestamp) => timestamp > cutoff, + ); + if (recent.length + cost > this.maxMutations) { + this.events.set(ownerKey, recent); + return false; + } + for (let index = 0; index < cost; index += 1) recent.push(now); + this.events.set(ownerKey, recent); + + if (this.events.size > Math.min(32, this.maxOwners)) this.pruneExpired(now); + return true; + } + + retryAfterMs(ownerKey: string): number { + if (!ownerKey || ownerKey.length > 768) return this.windowMs; + const now = this.now(); + const cutoff = now - this.windowMs; + const recent = (this.events.get(ownerKey) ?? []).filter( + (timestamp) => timestamp > cutoff, + ); + if (recent.length < this.maxMutations) return 0; + return Math.max(1, recent[0]! + this.windowMs - now); + } + + ownerCountForTests(): number { + return this.events.size; + } +} diff --git a/main/services/create-images/native-archive-service.test.ts b/main/services/create-images/native-archive-service.test.ts new file mode 100644 index 00000000..99adda0c --- /dev/null +++ b/main/services/create-images/native-archive-service.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import test from "node:test"; +import * as yazl from "yazl"; +import { CREATE_IMAGES_ARCHIVE_MANIFEST_PATH } from "../../../renderer/shared/create-images/archive.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { CreateImagesService } from "./create-images-service.js"; +import { CreateImagesNativeArchiveError } from "./native-archive-service.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + return concat(u32(data.byteLength), typeBytes, data, u32(crc32(concat(typeBytes, data)))); +} + +function makePng(): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +async function* chunks(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function service(root: string): CreateImagesService { + return new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { bytes: makePng(), width: 1, height: 1, mediaType: "image/png" as const }; + }, + }, + now: () => Date.parse("2026-08-19T12:00:00.000Z"), + }, + }); +} + +async function writeDuplicateManifestArchive(filePath: string): Promise { + const zip = new yazl.ZipFile(); + zip.addBuffer(Buffer.from("{}"), CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, { compress: false }); + zip.addBuffer(Buffer.from("{}"), CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, { compress: false }); + const writing = pipeline(zip.outputStream, createWriteStream(filePath, { mode: 0o600 })); + zip.end(); + await writing; +} + +test("native archive export/import round-trips a workflow and referenced image without paths", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-native-archive-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const image = await createImages.assets.ingest(chunks(makePng()), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "reference.png", + }); + const workflow = createStarterWorkflow({ + workflowId: "archive-source", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-19T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 0, y: 320 }, + data: { assetId: image.asset.assetId, label: "Reference" }, + }); + workflow.assetRefs = [image.asset.assetId]; + await createImages.mutateWorkflow(workflow.id, workflow.assetRefs, () => + createImages.workflows.create(workflow), + ); + + const archivePath = path.join(root, "exported.aiden-images"); + const exported = await createImages.archives.exportToFile({ + workflowId: workflow.id, + expectedRevision: 1, + destination: archivePath, + }); + assert.deepEqual(exported, { + workflowId: workflow.id, + revision: 1, + fileName: "exported.aiden-images", + assetCount: 1, + }); + assert.equal(Object.prototype.hasOwnProperty.call(exported, "filePath"), false); + assert.ok((await fs.stat(archivePath)).size > image.asset.byteLength); + + const imported = await createImages.archives.importFromFile(archivePath); + assert.notEqual(imported.workflow.id, workflow.id); + assert.equal(imported.workflow.revision, 1); + assert.deepEqual(imported.workflow.assetRefs, [image.asset.assetId]); + assert.equal(imported.importedAssetCount, 1); + assert.equal(imported.sourceFileName, "exported.aiden-images"); + assert.equal(Object.prototype.hasOwnProperty.call(imported, "filePath"), false); + assert.ok(await createImages.workflows.get(imported.workflow.id)); + assert.equal( + (await createImages.assets.getAvailable(image.asset.assetId))?.assetId, + image.asset.assetId, + ); +}); + +test("native archive import rejects invalid bytes without publishing a workflow", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-native-archive-invalid-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const archivePath = path.join(root, "hostile.aiden-images"); + await fs.writeFile(archivePath, "not a zip", { mode: 0o600 }); + const before = await createImages.workflows.list(); + await assert.rejects( + createImages.archives.importFromFile(archivePath), + (error: unknown) => + error instanceof CreateImagesNativeArchiveError && error.code === "archive_invalid", + ); + assert.deepEqual(await createImages.workflows.list(), before); +}); + +test("native archive import rejects a duplicate manifest before publication", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-native-archive-duplicate-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const archivePath = path.join(root, "duplicate-manifest.aiden-images"); + await writeDuplicateManifestArchive(archivePath); + const before = await createImages.workflows.list(); + + await assert.rejects( + createImages.archives.importFromFile(archivePath), + (error: unknown) => + error instanceof CreateImagesNativeArchiveError && error.code === "archive_invalid", + ); + assert.deepEqual(await createImages.workflows.list(), before); +}); diff --git a/main/services/create-images/native-archive-service.ts b/main/services/create-images/native-archive-service.ts new file mode 100644 index 00000000..9806071a --- /dev/null +++ b/main/services/create-images/native-archive-service.ts @@ -0,0 +1,567 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants, createReadStream, createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import * as yauzl from "yauzl"; +import * as yazl from "yazl"; +import { + CREATE_IMAGES_ARCHIVE_EXTENSION, + CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + CREATE_IMAGES_ARCHIVE_MAX_ENTRIES, + CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES, + CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES, + CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + CREATE_IMAGES_ARCHIVE_FORMAT, + CREATE_IMAGES_ARCHIVE_VERSION, + parseCreateImagesArchiveManifestBytes, + validateCreateImagesArchiveBootstrap, + validateCreateImagesArchiveExtractedEntries, + validateCreateImagesArchiveInventory, + validateCreateImagesArchiveWorkflowAssets, + type CreateImagesArchiveExtractedEntry, + type CreateImagesArchiveInventoryEntry, + type CreateImagesArchiveManifestV1, + type CreateImagesArchiveValidatedAsset, +} from "../../../renderer/shared/create-images/archive.js"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "../../../renderer/shared/create-images/schema.js"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import type { WorkflowManifestStore } from "./workflow-manifest-store.js"; + +const ARCHIVE_QUARANTINE_DIRECTORY = "archive-quarantine"; +const UNIX_FILE_TYPE_MASK = 0o170000; +const UNIX_DIRECTORY = 0o040000; +const UNIX_SYMLINK = 0o120000; + +export type CreateImagesNativeArchiveErrorCode = + | "archive_invalid" + | "archive_io" + | "archive_revision_conflict" + | "archive_workflow_missing"; + +export class CreateImagesNativeArchiveError extends Error { + constructor( + readonly code: CreateImagesNativeArchiveErrorCode, + message: string, + ) { + super(message); + this.name = "CreateImagesNativeArchiveError"; + } +} + +export interface CreateImagesNativeArchiveDependencies { + rootDirectory: string; + workflows: WorkflowManifestStore; + assets: ContentAddressedAssetStore; + publishImportedWorkflow( + workflow: WorkflowDocumentV1, + isCurrent: () => boolean, + ): Promise; + now?: () => number; + randomId?: () => string; +} + +export interface CreateImagesNativeArchiveExportResult { + workflowId: string; + revision: number; + fileName: string; + assetCount: number; +} + +export interface CreateImagesNativeArchiveImportResult { + workflow: WorkflowDocumentV1; + sourceFileName: string; + importedAssetCount: number; +} + +interface QuarantinedEntry { + path: string; + filePath: string; + extracted: CreateImagesArchiveExtractedEntry; +} + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = (value >>> 1) ^ (value & 1 ? 0xedb8_8320 : 0); + } + table[index] = value >>> 0; + } + return table; +})(); + +class Crc32Accumulator { + private value = 0xffff_ffff; + + update(bytes: Uint8Array): void { + for (const byte of bytes) { + this.value = (this.value >>> 8) ^ CRC32_TABLE[(this.value ^ byte) & 0xff]!; + } + } + + digest(): number { + return (this.value ^ 0xffff_ffff) >>> 0; + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function ensurePrivateDirectory(directory: string): Promise { + const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new CreateImagesNativeArchiveError("archive_io", "Archive storage is unavailable."); + } + await fs.chmod(directory, 0o700); + if (created !== undefined) await syncDirectory(path.dirname(directory)); +} + +function serializeWorkflow(workflow: WorkflowDocumentV1): Buffer { + const bytes = Buffer.from(`${JSON.stringify(workflow, null, 2)}\n`, "utf8"); + if (bytes.byteLength < 1 || bytes.byteLength > CREATE_IMAGES_MAX_WORKFLOW_BYTES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The workflow exceeds the native archive limit.", + ); + } + return bytes; +} + +function inventoryKind(entry: yauzl.Entry): CreateImagesArchiveInventoryEntry["kind"] { + const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff; + const fileType = unixMode & UNIX_FILE_TYPE_MASK; + if (fileType === UNIX_SYMLINK) return "symlink"; + if (entry.fileName.endsWith("/") || fileType === UNIX_DIRECTORY) return "directory"; + return "file"; +} + +function inventoryEntry(entry: yauzl.Entry): CreateImagesArchiveInventoryEntry { + return { + path: entry.fileName, + kind: inventoryKind(entry), + encrypted: entry.isEncrypted(), + compressionMethod: entry.compressionMethod, + compressedBytes: entry.compressedSize, + uncompressedBytes: entry.uncompressedSize, + crc32: entry.crc32 >>> 0, + }; +} + +async function boundedEntryBytes( + zip: yauzl.ZipFile, + entry: yauzl.Entry, + maximumBytes: number, +): Promise { + const stream = await zip.openReadStreamPromise(entry); + const chunks: Buffer[] = []; + let total = 0; + for await (const raw of stream) { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array); + total += chunk.byteLength; + if (total > maximumBytes) { + stream.destroy(); + throw new CreateImagesNativeArchiveError("archive_invalid", "Archive entry is too large."); + } + chunks.push(chunk); + } + return Buffer.concat(chunks, total); +} + +async function extractEntry( + zip: yauzl.ZipFile, + entry: yauzl.Entry, + inventory: CreateImagesArchiveInventoryEntry, + filePath: string, +): Promise { + const stream = await zip.openReadStreamPromise(entry); + const handle = await fs.open(filePath, "wx", 0o600); + const digest = createHash("sha256"); + const crc = new Crc32Accumulator(); + let byteLength = 0; + try { + for await (const raw of stream) { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array); + byteLength += chunk.byteLength; + if ( + byteLength > inventory.uncompressedBytes || + byteLength > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES + ) { + stream.destroy(); + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "Archive entry exceeded its declared size.", + ); + } + digest.update(chunk); + crc.update(chunk); + let offset = 0; + while (offset < chunk.byteLength) { + const result = await handle.write(chunk, offset, chunk.byteLength - offset, null); + if (result.bytesWritten < 1) throw new Error("Archive extraction made no progress."); + offset += result.bytesWritten; + } + } + await handle.sync(); + } finally { + await handle.close(); + } + return { + path: inventory.path, + byteLength, + crc32: crc.digest(), + sha256: digest.digest("hex"), + }; +} + +async function copyAssetToStage(source: string, destination: string): Promise { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const sourceHandle = await fs.open(source, constants.O_RDONLY | noFollow); + const destinationHandle = await fs.open(destination, "wx", 0o600); + try { + const info = await sourceHandle.stat(); + if (!info.isFile()) throw new Error("The asset is not a regular file."); + await pipeline( + sourceHandle.createReadStream({ autoClose: true }), + destinationHandle.createWriteStream({ autoClose: true }), + ); + } finally { + await Promise.allSettled([sourceHandle.close(), destinationHandle.close()]); + } + const durable = await fs.open(destination, "r"); + try { + await durable.sync(); + } finally { + await durable.close(); + } +} + +async function writeZipAtomically( + zip: yazl.ZipFile, + destination: string, +): Promise { + const directory = path.dirname(destination); + const temp = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`); + try { + const output = createWriteStream(temp, { flags: "wx", mode: 0o600 }); + zip.end(); + await pipeline(zip.outputStream as Readable, output); + const handle = await fs.open(temp, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined); + throw error; + } + try { + await fs.rename(temp, destination); + await fs.chmod(destination, 0o600); + await syncDirectory(directory); + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined); + throw error; + } +} + +function safeArchiveDestination(destination: string): string { + if (!path.isAbsolute(destination) || destination.includes("\0")) { + throw new CreateImagesNativeArchiveError("archive_io", "The archive destination is invalid."); + } + return destination.endsWith(CREATE_IMAGES_ARCHIVE_EXTENSION) + ? destination + : `${destination}${CREATE_IMAGES_ARCHIVE_EXTENSION}`; +} + +export class CreateImagesNativeArchiveService { + private readonly now: () => number; + private readonly randomId: () => string; + + constructor(private readonly dependencies: CreateImagesNativeArchiveDependencies) { + this.now = dependencies.now ?? Date.now; + this.randomId = dependencies.randomId ?? randomUUID; + } + + private async operationDirectory(): Promise { + const root = path.join(this.dependencies.rootDirectory, ARCHIVE_QUARANTINE_DIRECTORY); + await ensurePrivateDirectory(root); + return fs.mkdtemp(path.join(root, "operation-")); + } + + async exportToFile(input: { + workflowId: string; + expectedRevision: number; + destination: string; + }): Promise { + const destination = safeArchiveDestination(input.destination); + const workflow = await this.dependencies.workflows.get(input.workflowId); + if (!workflow) { + throw new CreateImagesNativeArchiveError( + "archive_workflow_missing", + "The workflow no longer exists.", + ); + } + if (workflow.revision !== input.expectedRevision) { + throw new CreateImagesNativeArchiveError( + "archive_revision_conflict", + "The workflow changed before export.", + ); + } + const operationDirectory = await this.operationDirectory(); + try { + const workflowBytes = serializeWorkflow(workflow); + const exportedAt = new Date(this.now()).toISOString(); + const assets: CreateImagesArchiveManifestV1["assets"] = []; + const stagedAssets: Array<{ path: string; filePath: string }> = []; + for (const [index, assetId] of workflow.assetRefs.entries()) { + await this.dependencies.assets.withAssetFile(assetId, async ({ filePath, asset }) => { + const extension = asset.mediaType === "image/png" ? "png" : "jpg"; + const archivePath = `assets/${assetId}.${extension}`; + const stagedPath = path.join(operationDirectory, `asset-${index}.${extension}`); + await copyAssetToStage(filePath, stagedPath); + const validated = await this.dependencies.assets.validateQuarantinedAssetFile(stagedPath, { + declaredMimeType: asset.mediaType, + displayName: `asset.${extension}`, + }); + if ( + validated.sha256 !== assetId || + validated.mediaType !== asset.mediaType || + validated.byteLength !== asset.byteLength || + validated.width !== asset.width || + validated.height !== asset.height + ) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "A referenced image changed before export.", + ); + } + assets.push({ + assetId, + sha256: assetId, + path: archivePath, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + width: asset.width, + height: asset.height, + }); + stagedAssets.push({ path: archivePath, filePath: stagedPath }); + }); + } + const manifest: CreateImagesArchiveManifestV1 = { + format: CREATE_IMAGES_ARCHIVE_FORMAT, + version: CREATE_IMAGES_ARCHIVE_VERSION, + exportedAt, + workflow: { + path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + sha256: createHash("sha256").update(workflowBytes).digest("hex"), + byteLength: workflowBytes.byteLength, + }, + assets, + }; + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + if (manifestBytes.byteLength > CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The native archive manifest is too large.", + ); + } + const zip = new yazl.ZipFile(); + const zipOptions = { compress: false, mode: 0o100600, mtime: new Date(exportedAt) }; + zip.addBuffer(manifestBytes, CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, zipOptions); + zip.addBuffer(workflowBytes, CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, zipOptions); + for (const asset of stagedAssets) zip.addFile(asset.filePath, asset.path, zipOptions); + await writeZipAtomically(zip, destination); + return { + workflowId: workflow.id, + revision: workflow.revision, + fileName: path.basename(destination), + assetCount: assets.length, + }; + } catch (error) { + if (error instanceof CreateImagesNativeArchiveError) throw error; + throw new CreateImagesNativeArchiveError("archive_io", "The workflow could not be exported."); + } finally { + await fs.rm(operationDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } + + async importFromFile( + source: string, + isCurrent: () => boolean = () => true, + ): Promise { + if (!path.isAbsolute(source) || !source.endsWith(CREATE_IMAGES_ARCHIVE_EXTENSION)) { + throw new CreateImagesNativeArchiveError("archive_invalid", "Choose an .aiden-images file."); + } + const operationDirectory = await this.operationDirectory(); + let zip: yauzl.ZipFile | undefined; + try { + zip = await yauzl.openPromise(source, { + autoClose: false, + lazyEntries: true, + strictFileNames: true, + validateEntrySizes: true, + }); + if (zip.entryCount > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The archive contains too many entries.", + ); + } + const entries: yauzl.Entry[] = []; + for await (const entry of zip.eachEntry()) { + entries.push(entry); + if (entries.length > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The archive contains too many entries.", + ); + } + } + const inventory = entries.map(inventoryEntry); + if (validateCreateImagesArchiveBootstrap(inventory).length > 0) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive is unsafe."); + } + const manifestIndex = inventory.findIndex( + (entry) => entry.path === CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + ); + const manifestBytes = await boundedEntryBytes( + zip, + entries[manifestIndex]!, + CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES, + ); + const manifestResult = parseCreateImagesArchiveManifestBytes( + manifestBytes, + inventory[manifestIndex]!, + ); + if (!manifestResult.success) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive manifest is invalid."); + } + const manifest = manifestResult.value; + if (validateCreateImagesArchiveInventory(manifest, inventory).length > 0) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive inventory is invalid."); + } + + const quarantined: QuarantinedEntry[] = []; + let extractedTotal = 0; + for (const [index, entry] of entries.entries()) { + const filePath = path.join(operationDirectory, `entry-${index}.bin`); + const extracted = await extractEntry(zip, entry, inventory[index]!, filePath); + extractedTotal += extracted.byteLength; + if (extractedTotal > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive is too large."); + } + quarantined.push({ path: entry.fileName, filePath, extracted }); + } + if ( + validateCreateImagesArchiveExtractedEntries( + manifest, + inventory, + quarantined.map((entry) => entry.extracted), + ).length > 0 + ) { + throw new CreateImagesNativeArchiveError("archive_invalid", "Archive contents are invalid."); + } + + const workflowEntry = quarantined.find( + (entry) => entry.path === CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + ); + if (!workflowEntry) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The workflow entry is missing."); + } + const workflowBytes = await fs.readFile(workflowEntry.filePath); + let workflowValue: unknown; + try { + workflowValue = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(workflowBytes)); + } catch { + throw new CreateImagesNativeArchiveError("archive_invalid", "The workflow JSON is invalid."); + } + const parsedWorkflow = parseWorkflowDocument(workflowValue); + if (!parsedWorkflow.success) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The workflow schema is invalid."); + } + + const validatedAssets: CreateImagesArchiveValidatedAsset[] = []; + for (const expected of manifest.assets) { + const archived = quarantined.find((entry) => entry.path === expected.path); + if (!archived) { + throw new CreateImagesNativeArchiveError("archive_invalid", "An image entry is missing."); + } + const actual = await this.dependencies.assets.validateQuarantinedAssetFile( + archived.filePath, + { declaredMimeType: expected.mediaType, displayName: path.basename(expected.path) }, + ); + if (actual.sha256 !== expected.assetId) { + throw new CreateImagesNativeArchiveError("archive_invalid", "An image digest is invalid."); + } + validatedAssets.push({ + assetId: expected.assetId, + mediaType: actual.mediaType, + byteLength: actual.byteLength, + width: actual.width, + height: actual.height, + }); + } + if ( + validateCreateImagesArchiveWorkflowAssets( + manifest, + parsedWorkflow.value, + validatedAssets, + ).length > 0 + ) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "Workflow image references are invalid.", + ); + } + + for (const expected of manifest.assets) { + const archived = quarantined.find((entry) => entry.path === expected.path)!; + const result = await this.dependencies.assets.ingest(createReadStream(archived.filePath), { + origin: { kind: "import" }, + declaredMimeType: expected.mediaType, + displayName: path.basename(expected.path), + validationDisplayName: path.basename(expected.path), + }); + if (result.asset.assetId !== expected.assetId) { + throw new CreateImagesNativeArchiveError("archive_invalid", "An imported image changed."); + } + } + + const now = new Date(this.now()).toISOString(); + const imported: WorkflowDocumentV1 = { + ...structuredClone(parsedWorkflow.value), + id: this.randomId(), + revision: 1, + createdAt: now, + updatedAt: now, + }; + const published = await this.dependencies.publishImportedWorkflow(imported, isCurrent); + return { + workflow: published, + sourceFileName: path.basename(source), + importedAssetCount: manifest.assets.length, + }; + } catch (error) { + if (error instanceof CreateImagesNativeArchiveError) throw error; + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive could not be imported."); + } finally { + zip?.close(); + await fs.rm(operationDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } +} diff --git a/main/services/create-images/native-output-zip.test.ts b/main/services/create-images/native-output-zip.test.ts new file mode 100644 index 00000000..cec055a3 --- /dev/null +++ b/main/services/create-images/native-output-zip.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import * as yauzl from "yauzl"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import { + CREATE_IMAGES_MAX_OUTPUT_ZIP_BYTES, + writeCreateImagesOutputZip, +} from "./native-output-zip.js"; + +const FIRST = "a".repeat(64); +const SECOND = "b".repeat(64); + +function fakeAssets( + records: Readonly>, +): ContentAddressedAssetStore { + return { + async getAvailable(assetId: string) { + const record = records[assetId]; + return record + ? { + assetId, + mediaType: record.mediaType, + byteLength: record.bytes.byteLength, + width: 1, + height: 1, + createdAt: "2026-08-21T12:00:00.000Z", + origin: { kind: "import" as const }, + referenceCount: 1, + thumbnailSizes: [], + } + : undefined; + }, + async exportAssetToFile(assetId: string, destination: string) { + const record = records[assetId]; + if (!record) throw new Error("missing"); + await fs.writeFile(destination, record.bytes, { flag: "wx", mode: 0o600 }); + }, + } as unknown as ContentAddressedAssetStore; +} + +test("native output ZIP uses collision-safe inert names and preserves selected order", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-output-zip-test-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const destination = path.join(directory, "selected.zip"); + await writeCreateImagesOutputZip( + fakeAssets({ + [FIRST]: { mediaType: "image/png", bytes: Uint8Array.of(1, 2, 3) }, + [SECOND]: { mediaType: "image/jpeg", bytes: Uint8Array.of(4, 5) }, + }), + [FIRST, FIRST, SECOND], + destination, + ); + + const zip = await yauzl.openPromise(destination, { lazyEntries: true }); + t.after(() => zip.close()); + const names: string[] = []; + for await (const entry of zip.eachEntry()) names.push(entry.fileName); + assert.deepEqual(names, [ + "Aiden image 01-aaaaaaaa.png", + "Aiden image 02-aaaaaaaa.png", + "Aiden image 03-bbbbbbbb.jpg", + ]); + assert.equal(names.every((name) => !name.includes("/") && !name.includes("..")), true); +}); + +test("native output ZIP fails closed for missing, empty, and oversized selections", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-output-zip-bounds-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const destination = path.join(directory, "blocked.zip"); + await assert.rejects( + writeCreateImagesOutputZip(fakeAssets({}), [FIRST], destination), + /unavailable/u, + ); + await assert.rejects(writeCreateImagesOutputZip(fakeAssets({}), [], destination), /512 MB/u); + + const store = { + async getAvailable(assetId: string) { + return { + assetId, + mediaType: "image/png" as const, + byteLength: CREATE_IMAGES_MAX_OUTPUT_ZIP_BYTES + 1, + }; + }, + } as unknown as ContentAddressedAssetStore; + await assert.rejects(writeCreateImagesOutputZip(store, [FIRST], destination), /512 MB/u); + await assert.rejects(fs.access(destination)); +}); diff --git a/main/services/create-images/native-output-zip.ts b/main/services/create-images/native-output-zip.ts new file mode 100644 index 00000000..37defd33 --- /dev/null +++ b/main/services/create-images/native-output-zip.ts @@ -0,0 +1,51 @@ +import { createWriteStream } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rename, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import * as yazl from "yazl"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; + +export const CREATE_IMAGES_MAX_OUTPUT_ZIP_BYTES = 512 * 1024 * 1024; + +function collisionSafeName(index: number, assetId: string, mediaType: string): string { + const extension = mediaType === "image/png" ? "png" : "jpg"; + return `Aiden image ${String(index + 1).padStart(2, "0")}-${assetId.slice(0, 8)}.${extension}`; +} + +/** Main-owned, bounded ZIP export. No source or destination path crosses IPC. */ +export async function writeCreateImagesOutputZip( + assets: ContentAddressedAssetStore, + assetIds: readonly string[], + destination: string, +): Promise { + const metadata = await Promise.all(assetIds.map((assetId) => assets.getAvailable(assetId))); + if (metadata.some((asset) => asset === undefined)) { + throw new Error("One or more retained images are unavailable."); + } + const totalBytes = metadata.reduce((total, asset) => total + (asset?.byteLength ?? 0), 0); + if (totalBytes < 1 || totalBytes > CREATE_IMAGES_MAX_OUTPUT_ZIP_BYTES) { + throw new Error("The selected images exceed the 512 MB ZIP export limit."); + } + const stage = await mkdtemp(path.join(tmpdir(), "aiden-create-images-output-")); + const temporaryDestination = `${destination}.${randomUUID()}.tmp`; + try { + const zip = new yazl.ZipFile(); + for (const [index, assetId] of assetIds.entries()) { + const asset = metadata[index]!; + const name = collisionSafeName(index, assetId, asset.mediaType); + const stagedPath = path.join(stage, name); + await assets.exportAssetToFile(assetId, stagedPath); + zip.addFile(stagedPath, name, { compress: false, mode: 0o100600, mtime: new Date(0) }); + } + const output = createWriteStream(temporaryDestination, { flags: "wx", mode: 0o600 }); + const writing = pipeline(zip.outputStream, output); + zip.end(); + await writing; + await rename(temporaryDestination, destination); + } finally { + await rm(temporaryDestination, { force: true }); + await rm(stage, { recursive: true, force: true }); + } +} diff --git a/main/services/create-images/node-banana-import-service.test.ts b/main/services/create-images/node-banana-import-service.test.ts new file mode 100644 index 00000000..07302b89 --- /dev/null +++ b/main/services/create-images/node-banana-import-service.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { CreateImagesService } from "./create-images-service.js"; +import { CreateImagesNodeBananaServiceError } from "./node-banana-import-service.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + return concat(u32(data.byteLength), typeBytes, data, u32(crc32(concat(typeBytes, data)))); +} + +function makePng(): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +function service(root: string): CreateImagesService { + return new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { bytes: makePng(), width: 1, height: 1, mediaType: "image/png" as const }; + }, + }, + now: () => Date.parse("2026-08-19T12:00:00.000Z"), + }, + }); +} + +test("Node Banana file import externalizes validated images and reports every rewritten node", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-node-banana-import-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const source = path.join(root, "node-banana.json"); + const png = Buffer.from(makePng()).toString("base64"); + await fs.writeFile( + source, + JSON.stringify({ + version: 1, + name: "Imported edit", + directoryPath: "/private/source", + providers: { gemini: { apiKey: "do-not-import" } }, + nodes: [ + { + id: "image-1", + type: "imageInput", + position: { x: 0, y: 100 }, + data: { + filename: "reference.png", + image: `data:image/png;base64,${png}`, + imageRef: "/private/source/reference.png", + }, + }, + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 300 }, + data: { prompt: "Make it yellow" }, + }, + { + id: "generate-1", + type: "nanoBanana", + position: { x: 360, y: 180 }, + data: { + aspectRatio: "1:1", + resolution: "1K", + model: "gemini-3.1-flash-image-preview", + apiKey: "do-not-import", + }, + }, + { + id: "output-1", + type: "output", + position: { x: 720, y: 180 }, + data: { image: `data:image/png;base64,${png}` }, + }, + { id: "video-1", type: "generateVideo", position: {}, data: {} }, + ], + edges: [ + { source: "image-1", target: "generate-1", targetHandle: "image" }, + { source: "prompt-1", target: "generate-1", targetHandle: "text" }, + { source: "generate-1", target: "output-1", targetHandle: "image" }, + ], + }), + { mode: 0o600 }, + ); + + const result = await createImages.nodeBananaImports.importFromFile(source); + assert.equal(result.sourceFileName, "node-banana.json"); + assert.equal(result.importedAssetCount, 1); + assert.equal(result.report.importedEmbeddedImageCount, 1); + assert.equal(result.report.skippedNodeCount, 1); + assert.equal(result.report.entries.length, 5); + assert.equal(Object.prototype.hasOwnProperty.call(result, "filePath"), false); + const imageNode = result.workflow.nodes.find((node) => node.type === "image-input"); + assert.equal(imageNode?.type, "image-input"); + assert.equal(result.workflow.assetRefs.length, 1); + assert.equal(imageNode?.data.assetId, result.workflow.assetRefs[0]); + assert.ok(await createImages.assets.getAvailable(result.workflow.assetRefs[0]!)); + assert.ok(await createImages.workflows.get(result.workflow.id)); + const serialized = JSON.stringify(result.workflow); + assert.equal(serialized.includes("do-not-import"), false); + assert.equal(serialized.includes("/private/source"), false); + assert.equal(serialized.includes("data:image"), false); +}); + +test("Node Banana file import rejects invalid JSON without publishing a workflow", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-node-banana-invalid-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const source = path.join(root, "invalid.json"); + await fs.writeFile(source, "{broken", { mode: 0o600 }); + const before = await createImages.workflows.list(); + await assert.rejects( + createImages.nodeBananaImports.importFromFile(source), + (error: unknown) => error instanceof CreateImagesNodeBananaServiceError, + ); + assert.deepEqual(await createImages.workflows.list(), before); +}); diff --git a/main/services/create-images/node-banana-import-service.ts b/main/services/create-images/node-banana-import-service.ts new file mode 100644 index 00000000..b72b958b --- /dev/null +++ b/main/services/create-images/node-banana-import-service.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + convertNodeBananaWorkflow, + CreateImagesNodeBananaImportError, + type CreateImagesNodeBananaImportReport, +} from "../../../renderer/shared/create-images/node-banana-import.js"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "../../../renderer/shared/create-images/schema.js"; +import { decodeUtf8, readRegularFile } from "../regular-file-read.js"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import { ingestCreateImagesImageFile } from "./electron-asset-import.js"; + +const COMPATIBILITY_QUARANTINE_DIRECTORY = "compatibility-import-quarantine"; + +export type CreateImagesNodeBananaServiceErrorCode = "invalid" | "io"; + +export class CreateImagesNodeBananaServiceError extends Error { + constructor( + readonly code: CreateImagesNodeBananaServiceErrorCode, + message: string, + ) { + super(message); + this.name = "CreateImagesNodeBananaServiceError"; + } +} + +export interface CreateImagesNodeBananaImportDependencies { + rootDirectory: string; + assets: ContentAddressedAssetStore; + publishImportedWorkflow( + workflow: WorkflowDocumentV1, + isCurrent: () => boolean, + ): Promise; + now?: () => number; + randomId?: () => string; +} + +export interface CreateImagesNodeBananaImportResult { + workflow: WorkflowDocumentV1; + sourceFileName: string; + importedAssetCount: number; + report: CreateImagesNodeBananaImportReport; +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function ensurePrivateDirectory(directory: string): Promise { + const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new CreateImagesNodeBananaServiceError("io", "Compatibility import is unavailable."); + } + await fs.chmod(directory, 0o700); + if (created !== undefined) await syncDirectory(path.dirname(directory)); +} + +function extensionFor(mediaType: string): string { + const subtype = mediaType.slice("image/".length).toLowerCase(); + if (subtype === "jpeg" || subtype === "pjpeg") return "jpg"; + if (subtype === "svg+xml") return "svg"; + const safe = subtype.replace(/[^a-z0-9]/gu, "").slice(0, 12); + return safe || "image"; +} + +function decodeCanonicalBase64(value: string): Buffer { + const bytes = Buffer.from(value, "base64"); + if (bytes.byteLength < 1 || bytes.toString("base64") !== value) { + throw new CreateImagesNodeBananaServiceError("invalid", "An embedded image is malformed."); + } + return bytes; +} + +function updateImageEntry( + report: CreateImagesNodeBananaImportReport, + sourceNodeIndex: number, + suffix: string, +): void { + const entry = report.entries.find((candidate) => candidate.sourceNodeIndex === sourceNodeIndex); + if (entry) entry.message = `${entry.message} ${suffix}`; +} + +export class CreateImagesNodeBananaImportService { + private readonly now: () => number; + private readonly randomId: () => string; + + constructor(private readonly dependencies: CreateImagesNodeBananaImportDependencies) { + this.now = dependencies.now ?? Date.now; + this.randomId = dependencies.randomId ?? randomUUID; + } + + private async operationDirectory(): Promise { + const root = path.join(this.dependencies.rootDirectory, COMPATIBILITY_QUARANTINE_DIRECTORY); + await ensurePrivateDirectory(root); + return fs.mkdtemp(path.join(root, "operation-")); + } + + async importFromFile( + source: string, + isCurrent: () => boolean = () => true, + ): Promise { + if (!path.isAbsolute(source) || path.extname(source).toLowerCase() !== ".json") { + throw new CreateImagesNodeBananaServiceError("invalid", "Choose a Node Banana JSON file."); + } + const operationDirectory = await this.operationDirectory(); + try { + let value: unknown; + try { + const bytes = await readRegularFile(source, CREATE_IMAGES_MAX_WORKFLOW_BYTES); + value = JSON.parse(decodeUtf8(bytes)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EFBIG") { + throw new CreateImagesNodeBananaServiceError( + "invalid", + "The Node Banana workflow exceeds Aiden's import limit.", + ); + } + throw new CreateImagesNodeBananaServiceError("invalid", "The Node Banana JSON is invalid."); + } + + const now = new Date(this.now()).toISOString(); + const converted = convertNodeBananaWorkflow(value, { + workflowId: this.randomId(), + now, + nextId: this.randomId, + }); + const assetIdsByNode = new Map(); + for (const [index, image] of converted.inlineImages.entries()) { + const extension = extensionFor(image.mediaType); + const filePath = path.join(operationDirectory, `image-${index}.${extension}`); + try { + const bytes = decodeCanonicalBase64(image.base64); + const handle = await fs.open(filePath, "wx", 0o600); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + const imported = await ingestCreateImagesImageFile(this.dependencies.assets, filePath); + assetIdsByNode.set(image.targetNodeId, imported.asset.assetId); + converted.report.importedEmbeddedImageCount += 1; + updateImageEntry( + converted.report, + image.sourceNodeIndex, + imported.deduplicated + ? "The validated image matched an existing device-local asset." + : "The validated image was stored as a device-local asset.", + ); + } catch { + converted.report.skippedEmbeddedImageCount += 1; + updateImageEntry( + converted.report, + image.sourceNodeIndex, + "Its embedded image failed safe decoding and was left empty.", + ); + } + } + + const assetRefs: string[] = []; + const seenAssetIds = new Set(); + const nodes = converted.workflow.nodes.map((node) => { + if (node.type !== "image-input") return node; + const assetId = assetIdsByNode.get(node.id); + if (!assetId) return node; + if (!seenAssetIds.has(assetId)) { + seenAssetIds.add(assetId); + assetRefs.push(assetId); + } + return { ...node, data: { ...node.data, assetId } }; + }); + const finalized = parseWorkflowDocument({ + ...converted.workflow, + nodes, + assetRefs, + }); + if (!finalized.success) { + throw new CreateImagesNodeBananaServiceError( + "invalid", + "The converted workflow failed Aiden's schema.", + ); + } + const workflow = await this.dependencies.publishImportedWorkflow(finalized.value, isCurrent); + return { + workflow, + sourceFileName: path.basename(source), + importedAssetCount: assetRefs.length, + report: converted.report, + }; + } catch (error) { + if ( + error instanceof CreateImagesNodeBananaServiceError || + error instanceof CreateImagesNodeBananaImportError + ) { + throw error; + } + throw new CreateImagesNodeBananaServiceError( + "invalid", + "The Node Banana workflow could not be imported safely.", + ); + } finally { + await fs.rm(operationDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } +} diff --git a/main/services/create-images/packaged-canvas-acceptance-core.test.ts b/main/services/create-images/packaged-canvas-acceptance-core.test.ts new file mode 100644 index 00000000..52f36227 --- /dev/null +++ b/main/services/create-images/packaged-canvas-acceptance-core.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { + CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME, + CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV, + CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX, + CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH, + countCreateImagesProductFileMutations, + createImagesPhaseTwoProductFileEvidence, + isCreateImagesDurableWorkflowPublication, + loadCreateImagesPackagedAcceptanceSession, + snapshotCreateImagesProductFiles, +} from "./packaged-canvas-acceptance-core.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; + +async function fixture(): Promise<{ root: string; controlPath: string; nonce: string }> { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX), + ); + await fs.chmod(root, 0o700); + const controlPath = path.join(root, CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME); + const nonce = randomBytes(32).toString("base64url"); + await fs.writeFile(controlPath, JSON.stringify({ version: 1, nonce }), { + mode: 0o600, + flag: "wx", + }); + await fs.chmod(controlPath, 0o600); + return { root, controlPath, nonce }; +} + +test("durable publication accepts serialized autosaves without hard-coding one revision", () => { + const workflow = createStarterWorkflow({ + workflowId: "workflow-1", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "prompt-edge", + outputEdgeId: "output-edge", + now: "2026-08-11T12:00:00.000Z", + }); + const prompt = workflow.nodes.find((node) => node.type === "prompt"); + assert.ok(prompt?.type === "prompt"); + prompt.data.text = "Durable edit"; + workflow.revision = 3; + + assert.equal(isCreateImagesDurableWorkflowPublication(workflow, 1, "Durable edit"), true); + assert.equal(isCreateImagesDurableWorkflowPublication(workflow, 3, "Durable edit"), false); + assert.equal(isCreateImagesDurableWorkflowPublication(workflow, 1, "Other edit"), false); +}); + +test("packaged canvas acceptance is one-shot, private, and opt-in", async (context) => { + const value = await fixture(); + context.after(() => fs.rm(value.root, { recursive: true, force: true })); + assert.equal( + await loadCreateImagesPackagedAcceptanceSession({ + isPackaged: true, + argv: [`${CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH}=${value.controlPath}`], + environment: {}, + }), + undefined, + ); + const session = await loadCreateImagesPackagedAcceptanceSession({ + isPackaged: true, + argv: [`${CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH}=${value.controlPath}`], + environment: { [CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV]: "1" }, + }); + assert.equal(session?.control.nonce, value.nonce); + assert.equal(session?.root, await fs.realpath(value.root)); +}); + +test("Phase 2 product evidence is exact, content-addressed, and rejects recovery debris", () => { + const assetId = "a".repeat(64); + const workflowId = "packaged-phase-two"; + const workflowDigest = "b".repeat(64); + const base = "user-data/create-images"; + const firstPredecessorDigest = "1".repeat(64); + const secondPredecessorDigest = "2".repeat(64); + const thirdPredecessorDigest = "3".repeat(64); + const workspacePredecessorDigest = "4".repeat(64); + const workspacePredecessorPath = `${base}/.workspace.json.${workspacePredecessorDigest}.44444444-4444-4444-8444-444444444444.previous`; + const baseline = [ + { path: `${base}/asset-index.json`, bytes: 80, digest: firstPredecessorDigest }, + { path: `${base}/index.json`, bytes: 80, digest: "7".repeat(64) }, + { path: `${base}/run-index.json`, bytes: 72, digest: "9".repeat(64) }, + { path: `${base}/workspace.json`, bytes: 240, digest: workspacePredecessorDigest }, + ]; + const files = [ + { + path: `${base}/.asset-index.json.${firstPredecessorDigest}.11111111-1111-4111-8111-111111111111.previous`, + bytes: 440, + digest: firstPredecessorDigest, + }, + { + path: `${base}/.asset-index.json.${secondPredecessorDigest}.22222222-2222-4222-8222-222222222222.previous`, + bytes: 520, + digest: secondPredecessorDigest, + }, + { + path: `${base}/.asset-index.json.${thirdPredecessorDigest}.33333333-3333-4333-8333-333333333333.previous`, + bytes: 560, + digest: thirdPredecessorDigest, + }, + { path: workspacePredecessorPath, bytes: 240, digest: workspacePredecessorDigest }, + { path: `${base}/asset-index.json`, bytes: 600, digest: "c".repeat(64) }, + { + path: `${base}/assets/sha256/aa/${assetId}.png`, + bytes: 4096, + digest: assetId, + }, + { path: `${base}/index.json`, bytes: 240, digest: "d".repeat(64) }, + { path: `${base}/run-index.json`, bytes: 72, digest: "9".repeat(64) }, + { path: `${base}/thumbnails/${assetId}/512.png`, bytes: 1200, digest: "e".repeat(64) }, + { path: `${base}/workspace.json`, bytes: 620, digest: "8".repeat(64) }, + { + path: `${base}/workflows/${workflowId}/workflow.json`, + bytes: 900, + digest: workflowDigest, + }, + { + path: `${base}/workflows/${workflowId}/workflow.last-known-good.json`, + bytes: 900, + digest: workflowDigest, + }, + ]; + assert.deepEqual( + createImagesPhaseTwoProductFileEvidence(baseline, files, { + workflowId, + assetId, + assetExtension: "png", + }), + [...files].sort((left, right) => left.path.localeCompare(right.path)), + ); + assert.throws( + () => + createImagesPhaseTwoProductFileEvidence( + baseline, + [ + ...files, + { + path: `${base}/workflows/${workflowId}/workflow.autosave.json`, + bytes: 20, + digest: "f".repeat(64), + }, + ], + { workflowId, assetId, assetExtension: "png" }, + ), + /unexpected (?:durable files|file mutations)/u, + ); + assert.throws( + () => + createImagesPhaseTwoProductFileEvidence( + baseline, + files.map((entry) => + entry.path.endsWith("workflow.last-known-good.json") + ? { ...entry, digest: "f".repeat(64) } + : entry, + ), + { workflowId, assetId, assetExtension: "png" }, + ), + /content-addressed relationships/u, + ); +}); + +test("product-file snapshots detect durable writes but ignore Chromium-only files", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-image-snapshot-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + const configDir = path.join(root, "portable"); + const userDataDir = path.join(root, "user-data"); + await fs.mkdir(path.join(userDataDir, "Cache"), { recursive: true }); + await fs.mkdir(path.join(userDataDir, "logs"), { recursive: true }); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(path.join(configDir, "config.json"), "{}", "utf8"); + await fs.writeFile(path.join(userDataDir, "config.json"), '{"user":true}', "utf8"); + await fs.writeFile(path.join(userDataDir, "usage.json"), '{"usage":[]}', "utf8"); + const before = await snapshotCreateImagesProductFiles({ configDir, userDataDir }); + assert.deepEqual( + before.map((entry) => entry.path), + ["config/config.json", "user-data/config.json", "user-data/usage.json"], + ); + await fs.writeFile(path.join(userDataDir, "Cache", "entry"), "ignored", "utf8"); + await fs.writeFile(path.join(userDataDir, "logs", "aiden.log"), "ignored", "utf8"); + assert.equal( + countCreateImagesProductFileMutations( + before, + await snapshotCreateImagesProductFiles({ configDir, userDataDir }), + ), + 0, + ); + await fs.writeFile(path.join(configDir, "config.json"), '{"changed":true}', "utf8"); + assert.equal( + countCreateImagesProductFileMutations( + before, + await snapshotCreateImagesProductFiles({ configDir, userDataDir }), + ), + 1, + ); + await fs.writeFile(path.join(userDataDir, "provider-keys.json"), '{"secret":"changed"}', "utf8"); + assert.equal( + countCreateImagesProductFileMutations( + before, + await snapshotCreateImagesProductFiles({ configDir, userDataDir }), + ), + 2, + ); +}); diff --git a/main/services/create-images/packaged-canvas-acceptance-core.ts b/main/services/create-images/packaged-canvas-acceptance-core.ts new file mode 100644 index 00000000..426a8e3e --- /dev/null +++ b/main/services/create-images/packaged-canvas-acceptance-core.ts @@ -0,0 +1,541 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; + +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV = "AIDEN_CREATE_IMAGES_PACKAGED_ACCEPTANCE"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH = "--aiden-create-images-acceptance-control"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX = "aiden-create-images-acceptance-"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME = "control.json"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_RECEIPT_FILENAME = "receipt.json"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION = 1 as const; + +const NONCE_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const PRIVATE_DIRECTORY_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; +const MAX_CONTROL_BYTES = 4_096; +const VOLATILE_RUNTIME_USER_DATA_PATHS = new Set([ + "Cache", + "Code Cache", + "Cookies", + "Cookies-journal", + "Crashpad", + "DIPS", + "DIPS-wal", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUCache", + "Local State", + "Local Storage", + "Network", + "Network Persistent State", + "Preferences", + "QuotaManager", + "QuotaManager-journal", + "Service Worker", + "Session Storage", + "Shared Dictionary", + "SharedStorage", + "SharedStorage-wal", + "SingletonCookie", + "SingletonLock", + "SingletonSocket", + "TransportSecurity", + "Trust Tokens", + "Trust Tokens-journal", + "WebStorage", + "blob_storage", + "logs", +]); + +export interface CreateImagesPackagedAcceptanceControl { + version: typeof CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION; + nonce: string; +} + +export interface CreateImagesPackagedAcceptanceReceipt { + version: typeof CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION; + nonce: string; + route: "/create-images/stress-100"; + initialNodeCount: 100; + addedNodeCount: 101; + duplicatedNodeCount: 102; + undoNodeCount: 101; + redoNodeCount: 102; + deletedNodeCount: 101; + nativeDeleteUndoNodeCount: 102; + nativeDeleteRedoNodeCount: 101; + spatialConnectionPassed: boolean; + spatialInvalidDropPassed: boolean; + nativeEdgeDeletePassed: boolean; + keyboardConnectionPassed: boolean; + keyboardMoveUndoPassed: boolean; + repeatedAnnouncementPassed: boolean; + uniqueAccessibleNodeLabels: boolean; + narrowValidationPassed: boolean; + narrowAddPlacementPassed: boolean; + focusRestoredAfterPalette: boolean; + focusRestoredAfterNativeDelete: boolean; + nativeNodeDeleteGraphPassed: boolean; + reducedMotionPassed: boolean; + liveRegionMutations: number; + keyboardActions: number; + rendererErrors: number; + networkRequests: number; + rendererEgressProbePassed: boolean; + rendererEgressProbeRequests: number; + rendererEgressProbeBlocked: number; + productFileMutations: number; + durableWorkflowPassed: boolean; + assetProtocolPreviewPassed: boolean; + assetProtocolGrantCount: number; + assetProtocolRequests: number; + assetProtocolAuthorizations: number; + assetProtocolLastRequest: CreateImagesPackagedAssetRequestEvidence; + rendererReloadPersistencePassed: boolean; + noGraphBase64Passed: boolean; + phaseTwoProductFileMutations: number; + phaseTwoProductFiles: ProductFileSnapshotEntry[]; + phaseTwoStorageRelationshipsPassed: boolean; + phaseTwoWorkflowRevision: number; + phaseTwoAssetBytes: number; + phaseTwoAssetWidth: number; + phaseTwoAssetHeight: number; + responsiveWidthsPassed: boolean; + sandboxed: boolean; + contextIsolation: boolean; + nodeIntegration: boolean; + durationMs: number; +} + +export interface CreateImagesPackagedAcceptanceSession { + control: CreateImagesPackagedAcceptanceControl; + root: string; + controlPath: string; + receiptPath: string; +} + +export interface LoadCreateImagesPackagedAcceptanceInput { + isPackaged: boolean; + argv?: readonly string[]; + environment?: Readonly>; + temporaryDirectory?: string; + userId?: number; +} + +export function isCreateImagesDurableWorkflowPublication( + workflow: WorkflowDocumentV1 | undefined, + initialRevision: number, + expectedPrompt: string, +): workflow is WorkflowDocumentV1 { + return ( + workflow !== undefined && + Number.isSafeInteger(workflow.revision) && + workflow.revision > initialRevision && + workflow.nodes.some((node) => node.type === "prompt" && node.data.text === expectedPrompt) + ); +} + +export interface ProductFileSnapshotEntry { + path: string; + bytes: number; + digest: string; +} + +export interface CreateImagesPackagedAssetRequestEvidence { + method: "GET"; + resourceType: "image"; + webContentsIdPresent: true; + framePresent: true; + frameIsMain: true; + frameDetached: false; +} + +const PRODUCT_DIGEST_PATTERN = /^[a-f0-9]{64}$/u; +const CREATE_IMAGES_PRODUCT_PREFIX = "user-data/create-images/"; +const ASSET_INDEX_PREDECESSOR_PATTERN = + /^user-data\/create-images\/\.asset-index\.json\.([a-f0-9]{64})\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.previous$/u; +const WORKSPACE_PREDECESSOR_PATTERN = + /^user-data\/create-images\/\.workspace\.json\.([a-f0-9]{64})\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.previous$/u; + +/** + * Produce strict, sanitized Create Images storage evidence for a fresh configured + * acceptance profile. The baseline may contain only the empty asset index and the + * explicit workspace selection. Every later durable file must be an expected + * publication or a protected predecessor retained until restart; journals, quarantine + * records, unexpected thumbnails, orphan assets, and non-empty run indexes fail closed. + */ +export function createImagesPhaseTwoProductFileEvidence( + before: readonly ProductFileSnapshotEntry[], + after: readonly ProductFileSnapshotEntry[], + identity: { workflowId: string; assetId: string; assetExtension: "jpg" | "png" }, +): ProductFileSnapshotEntry[] { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(identity.workflowId)) { + throw new Error("Packaged Create Images storage evidence has an invalid workflow ID."); + } + if (!PRODUCT_DIGEST_PATTERN.test(identity.assetId)) { + throw new Error("Packaged Create Images storage evidence has an invalid asset ID."); + } + const baselinePaths = before + .filter((entry) => entry.path.startsWith(CREATE_IMAGES_PRODUCT_PREFIX)) + .map((entry) => entry.path) + .sort((left, right) => left.localeCompare(right)); + const baselineWorkspacePredecessors = before.filter((entry) => + WORKSPACE_PREDECESSOR_PATTERN.test(entry.path), + ); + if ( + baselineWorkspacePredecessors.length > 1 || + baselineWorkspacePredecessors.some( + (entry) => WORKSPACE_PREDECESSOR_PATTERN.exec(entry.path)?.[1] !== entry.digest, + ) + ) { + throw new Error("Packaged Create Images storage evidence has an invalid workspace baseline."); + } + const allowedBaselinePaths = new Set([ + `${CREATE_IMAGES_PRODUCT_PREFIX}asset-index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}run-index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}workspace.json`, + ...baselineWorkspacePredecessors.map((entry) => entry.path), + ]); + if ( + !baselinePaths.includes(`${CREATE_IMAGES_PRODUCT_PREFIX}workspace.json`) || + baselinePaths.some((filePath) => !allowedBaselinePaths.has(filePath)) + ) { + throw new Error("Packaged Create Images storage evidence did not start from a fresh profile."); + } + const workflowRoot = `${CREATE_IMAGES_PRODUCT_PREFIX}workflows/${identity.workflowId}`; + const fixedExpectedPaths = [ + `${CREATE_IMAGES_PRODUCT_PREFIX}asset-index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}assets/sha256/${identity.assetId.slice(0, 2)}/${identity.assetId}.${identity.assetExtension}`, + `${CREATE_IMAGES_PRODUCT_PREFIX}index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}run-index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}thumbnails/${identity.assetId}/512.png`, + `${CREATE_IMAGES_PRODUCT_PREFIX}workspace.json`, + `${workflowRoot}/workflow.json`, + `${workflowRoot}/workflow.last-known-good.json`, + ]; + const predecessorPaths = after + .filter((entry) => ASSET_INDEX_PREDECESSOR_PATTERN.test(entry.path)) + .map((entry) => entry.path); + if (predecessorPaths.length !== 3) { + throw new Error( + "Packaged Create Images storage evidence did not retain its three protected index predecessors.", + ); + } + for (const filePath of predecessorPaths) { + const encodedDigest = ASSET_INDEX_PREDECESSOR_PATTERN.exec(filePath)?.[1]; + if (after.find((entry) => entry.path === filePath)?.digest !== encodedDigest) { + throw new Error("Packaged Create Images storage evidence has an invalid index predecessor."); + } + } + const workspacePredecessorPaths = after + .filter((entry) => WORKSPACE_PREDECESSOR_PATTERN.test(entry.path)) + .map((entry) => entry.path); + if (workspacePredecessorPaths.length !== 1) { + throw new Error( + "Packaged Create Images storage evidence did not retain its protected workspace predecessor.", + ); + } + const workspacePredecessorPath = workspacePredecessorPaths[0]!; + const workspacePredecessorDigest = WORKSPACE_PREDECESSOR_PATTERN.exec( + workspacePredecessorPath, + )?.[1]; + if (after.find((entry) => entry.path === workspacePredecessorPath)?.digest !== workspacePredecessorDigest) { + throw new Error("Packaged Create Images storage evidence has an invalid workspace predecessor."); + } + const expectedPaths = [ + ...fixedExpectedPaths, + ...predecessorPaths, + workspacePredecessorPath, + ].sort((left, right) => left.localeCompare(right)); + const previous = new Map(before.map((entry) => [entry.path, entry])); + const currentSnapshot = new Map(after.map((entry) => [entry.path, entry])); + const mutatedPaths = [...new Set([...previous.keys(), ...currentSnapshot.keys()])] + .filter((filePath) => { + const left = previous.get(filePath); + const right = currentSnapshot.get(filePath); + return !left || !right || left.bytes !== right.bytes || left.digest !== right.digest; + }) + .sort((left, right) => left.localeCompare(right)); + const allowedMutationPaths = new Set([ + ...expectedPaths, + ...baselineWorkspacePredecessors + .map((entry) => entry.path) + .filter((filePath) => !currentSnapshot.has(filePath)), + ]); + if (mutatedPaths.some((filePath) => !allowedMutationPaths.has(filePath))) { + throw new Error("Packaged Create Images storage evidence found unexpected file mutations."); + } + const productFiles = after + .filter((entry) => entry.path.startsWith(CREATE_IMAGES_PRODUCT_PREFIX)) + .map((entry) => ({ ...entry })) + .sort((left, right) => left.path.localeCompare(right.path)); + if ( + productFiles.length !== expectedPaths.length || + productFiles.some((entry, index) => entry.path !== expectedPaths[index]) + ) { + throw new Error("Packaged Create Images storage evidence found unexpected durable files."); + } + if ( + productFiles.some( + (entry) => + !Number.isSafeInteger(entry.bytes) || + entry.bytes < 1 || + !PRODUCT_DIGEST_PATTERN.test(entry.digest), + ) + ) { + throw new Error("Packaged Create Images storage evidence contains invalid file metadata."); + } + const byPath = new Map(productFiles.map((entry) => [entry.path, entry])); + const asset = byPath.get( + `${CREATE_IMAGES_PRODUCT_PREFIX}assets/sha256/${identity.assetId.slice(0, 2)}/${identity.assetId}.${identity.assetExtension}`, + ); + const current = byPath.get(`${workflowRoot}/workflow.json`); + const lastKnownGood = byPath.get(`${workflowRoot}/workflow.last-known-good.json`); + if ( + asset?.digest !== identity.assetId || + !current || + !lastKnownGood || + current.bytes !== lastKnownGood.bytes || + current.digest !== lastKnownGood.digest + ) { + throw new Error( + "Packaged Create Images durable files do not satisfy content-addressed relationships.", + ); + } + return productFiles; +} + +function invalidControl(): Error { + return new Error("Invalid packaged Create Images acceptance control."); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function privateMode(actual: number, expected: number): boolean { + return (actual & 0o777) === expected; +} + +function ownedBy(stat: { uid: number }, userId: number | undefined): boolean { + return userId === undefined || stat.uid === userId; +} + +function sameFileIdentity( + left: { dev: number; ino: number; uid: number; nlink: number; size: number; mode: number }, + right: { dev: number; ino: number; uid: number; nlink: number; size: number; mode: number }, +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.uid === right.uid && + left.nlink === right.nlink && + left.size === right.size && + left.mode === right.mode + ); +} + +function controlArgument(argv: readonly string[]): string | undefined { + const prefix = `${CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH}=`; + const values = argv + .filter((argument) => argument.startsWith(prefix)) + .map((argument) => argument.slice(prefix.length)); + return values.length === 1 && values[0] ? values[0] : undefined; +} + +export function parseCreateImagesPackagedAcceptanceControl( + value: unknown, +): CreateImagesPackagedAcceptanceControl { + if (!isRecord(value) || Object.keys(value).length !== 2) throw invalidControl(); + if ( + value.version !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION || + typeof value.nonce !== "string" || + !NONCE_PATTERN.test(value.nonce) + ) { + throw invalidControl(); + } + return { version: CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION, nonce: value.nonce }; +} + +async function readPrivateControl( + controlPath: string, + userId: number | undefined, +): Promise { + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(controlPath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = await handle.stat(); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1 || + !ownedBy(before, userId) || + !privateMode(before.mode, PRIVATE_FILE_MODE) || + before.size < 1 || + before.size > MAX_CONTROL_BYTES + ) { + throw invalidControl(); + } + const bytes = Buffer.alloc(before.size); + const read = await handle.read(bytes, 0, bytes.length, 0); + const after = await handle.stat(); + if (read.bytesRead !== bytes.length || !sameFileIdentity(before, after)) { + throw invalidControl(); + } + return parseCreateImagesPackagedAcceptanceControl(JSON.parse(bytes.toString("utf8"))); + } catch (error) { + if (error instanceof SyntaxError) throw invalidControl(); + throw error; + } finally { + await handle?.close(); + } +} + +export async function loadCreateImagesPackagedAcceptanceSession( + input: LoadCreateImagesPackagedAcceptanceInput, +): Promise { + const environment = input.environment ?? process.env; + const argv = input.argv ?? process.argv; + if (!input.isPackaged || environment[CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV] !== "1") { + return undefined; + } + const suppliedControlPath = controlArgument(argv); + if (!suppliedControlPath || !path.isAbsolute(suppliedControlPath)) throw invalidControl(); + const temporaryDirectory = await fs.realpath(input.temporaryDirectory ?? os.tmpdir()); + const controlPath = await fs.realpath(suppliedControlPath); + const root = await fs.realpath(path.dirname(controlPath)); + if ( + path.dirname(root) !== temporaryDirectory || + !path.basename(root).startsWith(CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX) || + path.basename(controlPath) !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME + ) { + throw invalidControl(); + } + const rootStat = await fs.lstat(root); + const userId = + input.userId ?? (typeof process.getuid === "function" ? process.getuid() : undefined); + if ( + !rootStat.isDirectory() || + rootStat.isSymbolicLink() || + !ownedBy(rootStat, userId) || + !privateMode(rootStat.mode, PRIVATE_DIRECTORY_MODE) + ) { + throw invalidControl(); + } + const receiptPath = path.join(root, CREATE_IMAGES_PACKAGED_ACCEPTANCE_RECEIPT_FILENAME); + try { + await fs.lstat(receiptPath); + throw invalidControl(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return { + control: await readPrivateControl(controlPath, userId), + root, + controlPath, + receiptPath, + }; +} + +async function snapshotTarget( + target: string, + displayRoot: string, + displayPrefix: "config" | "user-data", + output: ProductFileSnapshotEntry[], +): Promise { + let stat; + try { + stat = await fs.lstat(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (stat.isSymbolicLink()) throw new Error("Product persistence snapshot rejected a symlink."); + if (stat.isDirectory()) { + const entries = await fs.readdir(target); + entries.sort((left, right) => left.localeCompare(right)); + for (const entry of entries) { + await snapshotTarget(path.join(target, entry), displayRoot, displayPrefix, output); + } + return; + } + if (!stat.isFile()) throw new Error("Product persistence snapshot rejected a special file."); + const bytes = await fs.readFile(target); + output.push({ + path: path.posix.join( + displayPrefix, + path.relative(displayRoot, target).split(path.sep).join("/"), + ), + bytes: bytes.length, + digest: createHash("sha256").update(bytes).digest("hex"), + }); +} + +/** Snapshot every Aiden-owned durable record, excluding explicit Chromium/runtime-only paths. */ +export async function snapshotCreateImagesProductFiles(input: { + configDir: string; + userDataDir: string; +}): Promise { + const output: ProductFileSnapshotEntry[] = []; + await snapshotTarget(input.configDir, input.configDir, "config", output); + let userDataEntries: string[] = []; + try { + userDataEntries = await fs.readdir(input.userDataDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + userDataEntries.sort((left, right) => left.localeCompare(right)); + for (const name of userDataEntries) { + if (VOLATILE_RUNTIME_USER_DATA_PATHS.has(name)) continue; + await snapshotTarget( + path.join(input.userDataDir, name), + input.userDataDir, + "user-data", + output, + ); + } + return output.sort((left, right) => left.path.localeCompare(right.path)); +} + +export function countCreateImagesProductFileMutations( + before: readonly ProductFileSnapshotEntry[], + after: readonly ProductFileSnapshotEntry[], +): number { + const previous = new Map(before.map((entry) => [entry.path, entry])); + const current = new Map(after.map((entry) => [entry.path, entry])); + const paths = new Set([...previous.keys(), ...current.keys()]); + let mutations = 0; + for (const filePath of paths) { + const left = previous.get(filePath); + const right = current.get(filePath); + if (!left || !right || left.bytes !== right.bytes || left.digest !== right.digest) { + mutations += 1; + } + } + return mutations; +} + +export async function writeCreateImagesPackagedAcceptanceReceipt( + session: CreateImagesPackagedAcceptanceSession, + receipt: CreateImagesPackagedAcceptanceReceipt, +): Promise { + if (receipt.nonce !== session.control.nonce) throw invalidControl(); + const handle = await fs.open(session.receiptPath, "wx", PRIVATE_FILE_MODE); + try { + await handle.writeFile(`${JSON.stringify(receipt, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.chmod(session.receiptPath, PRIVATE_FILE_MODE); + const directory = await fs.open(session.root, "r"); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} diff --git a/main/services/create-images/packaged-canvas-acceptance-runner.ts b/main/services/create-images/packaged-canvas-acceptance-runner.ts new file mode 100644 index 00000000..800710bd --- /dev/null +++ b/main/services/create-images/packaged-canvas-acceptance-runner.ts @@ -0,0 +1,1486 @@ +import { app } from "../../platform.js"; +import type { BrowserWindow } from "electron"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { deflateSync } from "node:zlib"; +import { ONBOARDING_COMPLETE_STORAGE_KEY } from "../../../renderer/shared/onboarding.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { + countCreateImagesProductFileMutations, + createImagesPhaseTwoProductFileEvidence, + isCreateImagesDurableWorkflowPublication, + snapshotCreateImagesProductFiles, + writeCreateImagesPackagedAcceptanceReceipt, + type CreateImagesPackagedAssetRequestEvidence, + type CreateImagesPackagedAcceptanceSession, +} from "./packaged-canvas-acceptance-core.js"; +import { createImagesService } from "./create-images-service.js"; +import { observeCreateImagesRequestPolicy } from "./asset-protocol.js"; + +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROUTE = "/create-images/stress-100" as const; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_WAIT_MS = 30_000; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_POLL_MS = 25; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_BASELINE_STABLE_SAMPLES = 80; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH = 4_000; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT = 4_000; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_METADATA_BYTES = 20 * 1024 * 1024; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_EGRESS_PROBE = + "https://create-images-acceptance.invalid/blocked"; + +function createImagesAcceptanceCrc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function createImagesAcceptanceU32(value: number): Buffer { + const bytes = Buffer.alloc(4); + bytes.writeUInt32BE(value); + return bytes; +} + +function createImagesAcceptancePngChunk(type: string, payload: Buffer): Buffer { + const typeBytes = Buffer.from(type, "ascii"); + const checksum = Buffer.concat([typeBytes, payload]); + return Buffer.concat([ + createImagesAcceptanceU32(payload.byteLength), + checksum, + createImagesAcceptanceU32(createImagesAcceptanceCrc32(checksum)), + ]); +} + +/** Same deterministic near-limit static PNG exercised by the decoder memory canary. */ +function createImagesAcceptanceLargePng(): Uint8Array { + const header = Buffer.alloc(13); + header.writeUInt32BE(CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH, 0); + header.writeUInt32BE(CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT, 4); + header[8] = 8; + header[9] = 6; + const rowBytes = CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH * 4 + 1; + const pixels = Buffer.alloc(rowBytes * CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + createImagesAcceptancePngChunk("IHDR", header), + createImagesAcceptancePngChunk( + "tEXt", + Buffer.alloc(CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_METADATA_BYTES), + ), + createImagesAcceptancePngChunk("IDAT", deflateSync(pixels, { level: 9 })), + createImagesAcceptancePngChunk("IEND", Buffer.alloc(0)), + ]); +} + +// Packaged acceptance uses only fixed, build-time scripts and native key events. +// The control file cannot provide routes, selectors, JavaScript, or workflow data. +const CREATE_IMAGES_ACCEPTANCE_COMPLETE_ONBOARDING_SCRIPT = `(() => { + localStorage.setItem("${ONBOARDING_COMPLETE_STORAGE_KEY}", "true"); + localStorage.setItem("aiden-agent.sidebar-collapsed", "0"); + localStorage.setItem("aiden-agent.sidebar-width", "340"); + return true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_READY_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + return workbench instanceof HTMLElement && workbench.dataset.nodeCount === "100"; +})()`; +const CREATE_IMAGES_ACCEPTANCE_INSTALL_ERROR_COUNTER_SCRIPT = `(() => { + globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ = 0; + window.addEventListener("error", () => { globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ += 1; }); + window.addEventListener("unhandledrejection", () => { globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ += 1; }); + window.addEventListener("securitypolicyviolation", () => { globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ += 1; }); + return true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_NODE_COUNT_SCRIPT = `(() => { + const value = document.querySelector(".create-images-workbench")?.getAttribute("data-node-count"); + return value && /^\\d+$/u.test(value) ? Number(value) : -1; +})()`; +const CREATE_IMAGES_PHASE_TWO_READY_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + const preview = document.querySelector('.create-images-node img[src^="aiden-asset://asset/"]'); + const prompt = document.querySelector('textarea[aria-label^="Prompt text · "]'); + return { + workbenchPresent: workbench instanceof HTMLElement, + nodeCount: workbench instanceof HTMLElement ? workbench.dataset.nodeCount ?? null : null, + previewPresent: preview instanceof HTMLImageElement, + previewComplete: preview instanceof HTMLImageElement ? preview.complete : false, + previewWidth: preview instanceof HTMLImageElement ? preview.naturalWidth : 0, + promptPresent: prompt instanceof HTMLTextAreaElement, + }; +})()`; +const CREATE_IMAGES_PHASE_TWO_EDIT_SCRIPT = `(() => { + const prompt = document.querySelector('textarea[aria-label^="Prompt text · "]'); + if (!(prompt instanceof HTMLTextAreaElement)) return false; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!setter) return false; + setter.call(prompt, "Packaged durable prompt edit"); + prompt.dispatchEvent(new Event("input", { bubbles: true })); + prompt.focus(); + prompt.blur(); + return true; +})()`; +const CREATE_IMAGES_PHASE_TWO_REOPENED_SCRIPT = `(() => { + const preview = document.querySelector('.create-images-node img[src^="aiden-asset://asset/"]'); + const prompt = document.querySelector('textarea[aria-label^="Prompt text · "]'); + return preview instanceof HTMLImageElement && preview.complete && preview.naturalWidth > 0 && + prompt instanceof HTMLTextAreaElement && prompt.value === "Packaged durable prompt edit"; +})()`; +const CREATE_IMAGES_ACCEPTANCE_EDGE_COUNT_SCRIPT = `(() => { + const value = document.querySelector(".create-images-workbench")?.getAttribute("data-edge-count"); + return value && /^\\d+$/u.test(value) ? Number(value) : -1; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_INSPECTOR_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Toggle node inspector"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_INSPECTOR_OPEN_SCRIPT = + 'document.querySelector(".create-images-inspector") instanceof HTMLElement'; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_ZOOM_IN_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Zoom In"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_FIT_WORKFLOW_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Fit workflow"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_NODE_LABELS_SCRIPT = `(() => { + const buttons = Array.from(document.querySelectorAll('ul[aria-label="Workflow nodes"] button')); + return buttons.map((button) => button.textContent?.replace(/\\s+/gu, " ").trim() ?? ""); +})()`; +const CREATE_IMAGES_ACCEPTANCE_NO_REDUNDANT_NODE_SEMANTICS_SCRIPT = `(() => { + const promptLabels = Array.from( + document.querySelectorAll('textarea[aria-label^="Prompt text · "]'), + (element) => element.getAttribute("aria-label") ?? "", + ); + return document.querySelectorAll("article.create-images-node, .create-images-node[aria-label]").length === 0 && + promptLabels.length > 0 && new Set(promptLabels).size === promptLabels.length; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PREPARE_SPATIAL_EDGE_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + const inspector = document.querySelector(".create-images-inspector"); + if (!(workbench instanceof HTMLElement)) return false; + const workbenchBounds = workbench.getBoundingClientRect(); + const inspectorBounds = inspector instanceof HTMLElement ? inspector.getBoundingClientRect() : null; + const sources = Array.from(document.querySelectorAll('.react-flow__handle.source[data-handleid="text"]')); + for (const source of sources) { + if (!(source instanceof HTMLElement)) continue; + const sourceNodeId = source.dataset.nodeid ?? ""; + const match = /^stress-prompt-(\\d+)$/u.exec(sourceNodeId); + if (!match) continue; + const targetNodeId = "stress-generate-" + match[1]; + const target = Array.from(document.querySelectorAll('.react-flow__handle.target[data-handleid="prompt"]')).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.nodeid === targetNodeId, + ); + if (!(target instanceof HTMLElement)) continue; + const sourceBounds = source.getBoundingClientRect(); + const targetBounds = target.getBoundingClientRect(); + const inside = [sourceBounds, targetBounds].every((bounds) => + bounds.width > 0 && bounds.height > 0 && bounds.left >= workbenchBounds.left && + bounds.right <= workbenchBounds.right && bounds.top >= workbenchBounds.top && + bounds.bottom <= workbenchBounds.bottom && (!inspectorBounds || bounds.right < inspectorBounds.left) + ); + if (!inside) continue; + globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__ = { + sourceNodeId, + targetNodeId, + edgeId: "stress-edge-prompt-" + match[1], + }; + return true; + } + return false; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_MANAGE_CONNECTIONS_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes("Manage connections"), + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_SPATIAL_DISCONNECT_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge || typeof edge.edgeId !== "string") return false; + const button = Array.from(document.querySelectorAll("button[data-disconnect-edge]")).find( + (candidate) => candidate instanceof HTMLButtonElement && candidate.dataset.disconnectEdge === edge.edgeId, + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_KEYBOARD_DISCONNECT_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button[data-disconnect-edge]")).find( + (candidate) => { + const text = candidate.closest("li")?.textContent ?? ""; + return text.includes("stress-prompt-0") && text.includes("stress-generate-0"); + }, + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECT_NODES_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === "Connect nodes", + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SPATIAL_POINTS_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge) return null; + const source = Array.from(document.querySelectorAll('.react-flow__handle.source[data-handleid="text"]')).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.nodeid === edge.sourceNodeId, + ); + const target = Array.from(document.querySelectorAll('.react-flow__handle.target[data-handleid="prompt"]')).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.nodeid === edge.targetNodeId, + ); + if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) return null; + const from = source.getBoundingClientRect(); + const to = target.getBoundingClientRect(); + return { + fromX: Math.round(from.left + from.width / 2), + fromY: Math.round(from.top + from.height / 2), + toX: Math.round(to.left + to.width / 2), + toY: Math.round(to.top + to.height / 2), + }; +})()`; +const CREATE_IMAGES_ACCEPTANCE_INSTALL_LIVE_MUTATION_COUNTER_SCRIPT = `(() => { + const liveRegion = document.querySelector("[data-create-images-action-status]"); + if (!(liveRegion instanceof HTMLElement)) return false; + globalThis.__AIDEN_CREATE_IMAGES_LIVE_MUTATIONS__ = 0; + const previous = globalThis.__AIDEN_CREATE_IMAGES_LIVE_OBSERVER__; + if (previous instanceof MutationObserver) previous.disconnect(); + const observer = new MutationObserver((records) => { + globalThis.__AIDEN_CREATE_IMAGES_LIVE_MUTATIONS__ += records.length; + }); + observer.observe(liveRegion, { childList: true, characterData: true, subtree: true }); + globalThis.__AIDEN_CREATE_IMAGES_LIVE_OBSERVER__ = observer; + return true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT = + "Number(globalThis.__AIDEN_CREATE_IMAGES_LIVE_MUTATIONS__ ?? 0)"; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_ADD_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Add node"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_OUTPUT_GALLERY_SCRIPT = `(() => { + const dialog = document.querySelector("[data-create-images-node-palette]"); + if (!(dialog instanceof HTMLElement)) return false; + const button = Array.from(dialog.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("Output Gallery") + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_INSIDE_SCRIPT = `(() => { + const dialog = document.querySelector("[data-create-images-node-palette]"); + const search = dialog?.querySelector("input"); + return dialog instanceof HTMLElement && dialog.contains(document.activeElement) && document.activeElement !== search; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_SEARCH_SCRIPT = `(() => { + const dialog = document.querySelector("[data-create-images-node-palette]"); + const search = dialog?.querySelector("input"); + return search instanceof HTMLInputElement && document.activeElement === search; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PALETTE_CLOSED_SCRIPT = `(() => { + const add = document.querySelector('button[aria-label="Add node"]'); + return !document.querySelector("[data-create-images-node-palette]") && document.activeElement === add; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_CANVAS_NODE_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge || typeof edge.sourceNodeId !== "string") return false; + const node = Array.from(document.querySelectorAll(".react-flow__node[data-id]")).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.id === edge.sourceNodeId, + ); + if (!(node instanceof HTMLElement)) return false; + node.focus(); + return document.activeElement === node; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_INSPECTOR_NODE_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge || typeof edge.sourceNodeId !== "string") return false; + const button = Array.from(document.querySelectorAll("button[data-workflow-node-id]")).find( + (candidate) => candidate instanceof HTMLButtonElement && + candidate.dataset.workflowNodeId === edge.sourceNodeId, + ); + if (!(button instanceof HTMLButtonElement)) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_BACK_TO_NODES_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === "Back to nodes", + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_CONNECTION_TOOLS_OPEN_SCRIPT = `(() => + Array.from(document.querySelectorAll("button")).some( + (candidate) => candidate.textContent?.trim() === "Back to nodes", + ) +)()`; +const CREATE_IMAGES_ACCEPTANCE_CONNECTED_CANVAS_NODE_SELECTED_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + const node = document.querySelector(".react-flow__node.selected"); + return node instanceof HTMLElement && node.dataset.id === edge?.sourceNodeId; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_CANVAS_NODE_FOCUSED_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + return node instanceof HTMLElement && document.activeElement === node; +})()`; +const CREATE_IMAGES_ACCEPTANCE_CAPTURE_SELECTED_NODE_X_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + if (!(node instanceof HTMLElement) || document.activeElement !== node) return null; + const transform = node.style.transform; + globalThis.__AIDEN_CREATE_IMAGES_SELECTED_NODE_TRANSFORM__ = transform; + return transform; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_MOVED_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + const before = globalThis.__AIDEN_CREATE_IMAGES_SELECTED_NODE_TRANSFORM__; + return node instanceof HTMLElement && typeof before === "string" && + node.style.transform !== before; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_POSITION_RESTORED_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + const before = globalThis.__AIDEN_CREATE_IMAGES_SELECTED_NODE_TRANSFORM__; + return node instanceof HTMLElement && typeof before === "string" && + node.style.transform === before; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_EDGE_SCRIPT = `(() => { + const edge = Array.from(document.querySelectorAll(".react-flow__edge")).find( + (candidate) => candidate instanceof SVGElement && candidate.getAttribute("tabindex") === "0", + ); + if (!(edge instanceof SVGElement)) return false; + edge.focus(); + return document.activeElement === edge; +})()`; +const CREATE_IMAGES_ACCEPTANCE_EDGE_SELECTED_SCRIPT = + "Boolean(document.querySelector('.react-flow__edge.selected'))"; +const CREATE_IMAGES_ACCEPTANCE_INSPECTOR_FOCUSED_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Toggle node inspector"]'); + return button instanceof HTMLButtonElement && document.activeElement === button; +})()`; +const createImagesAcceptanceFocusButtonScript = ( + label: "Undo" | "Redo" | "Delete selected nodes", +) => `(() => { + const button = document.querySelector('button[aria-label="${label}"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_ANNOUNCEMENT_SCRIPT = `(() => + document.querySelector("[data-create-images-action-status]")?.textContent?.trim() ?? "" +)()`; +const CREATE_IMAGES_ACCEPTANCE_RENDERER_ERRORS_SCRIPT = + "Number(globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ ?? 0)"; +const CREATE_IMAGES_ACCEPTANCE_ASSISTANT_HIDDEN_SCRIPT = `(() => { + const dock = document.querySelector('[data-environment-modal-background="assistant"]'); + return dock instanceof HTMLElement && dock.inert && dock.getAttribute("aria-hidden") === "true" && getComputedStyle(dock).visibility === "hidden"; +})()`; +const CREATE_IMAGES_ACCEPTANCE_ENABLE_REDUCED_MOTION_SCRIPT = `(() => { + document.documentElement.dataset.reduceMotion = "true"; + const button = document.querySelector('button[aria-label="Fit workflow"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_REDUCED_MOTION_SCRIPT = `(() => { + const node = document.querySelector(".create-images-node"); + const handle = document.querySelector(".create-images-handle"); + if (!(node instanceof HTMLElement) || !(handle instanceof HTMLElement)) return false; + const nodeStyle = getComputedStyle(node); + const handleStyle = getComputedStyle(handle); + const durations = [ + nodeStyle.animationDuration, + nodeStyle.transitionDuration, + handleStyle.animationDuration, + handleStyle.transitionDuration, + ].flatMap((value) => value.split(",")).map((value) => Number.parseFloat(value)); + return document.documentElement.dataset.reduceMotion === "true" && + durations.length > 0 && durations.every((value) => Number.isFinite(value) && value <= 0.001); +})()`; +const CREATE_IMAGES_ACCEPTANCE_RESPONSIVE_SCRIPT = `(() => ({ + width: window.innerWidth, + workbenchWidth: document.querySelector(".create-images-workbench")?.getBoundingClientRect().width ?? -1, + sidebarWidth: Number(document.querySelector('[role="separator"][aria-label="Resize sidebar"]')?.getAttribute("aria-valuenow") ?? -1), + overflowFree: document.documentElement.scrollWidth <= window.innerWidth + 1 && document.body.scrollWidth <= window.innerWidth + 1, + minimapVisible: Boolean(document.querySelector(".react-flow__minimap")), + minimapToggleVisible: Boolean(document.querySelector('button[aria-label="Toggle minimap"]')), + validationIssueTriggerVisible: (() => { + const trigger = document.querySelector('button[aria-controls="create-images-validation-issues"]'); + return trigger instanceof HTMLButtonElement && getComputedStyle(trigger).display !== "none"; + })(), +}))()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_VALIDATION_TRIGGER_SCRIPT = `(() => { + const trigger = document.querySelector('button[aria-controls="create-images-validation-issues"]'); + if (!(trigger instanceof HTMLButtonElement) || getComputedStyle(trigger).display === "none") return false; + trigger.focus(); + return document.activeElement === trigger; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_FIRST_VALIDATION_ISSUE_SCRIPT = `(() => { + const button = document.querySelector('#create-images-validation-issues li button:not(:disabled)'); + if (!(button instanceof HTMLButtonElement)) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_VALIDATION_ISSUE_FOCUSED_SCRIPT = `(() => { + const panel = document.querySelector("#create-images-validation-issues"); + return panel instanceof HTMLElement && panel.contains(document.activeElement) && + document.activeElement?.matches("li button:not(:disabled)") === true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_VALIDATION_TARGET_FOCUSED_SCRIPT = `(() => { + const active = document.activeElement; + return active instanceof Element && ( + active.matches(".react-flow__node, .react-flow__edge, button[data-workflow-node-id]") || + active.matches('.create-images-inspector[aria-label="Workflow node inspector"]') + ); +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_VISIBLE_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + const node = document.querySelector(".react-flow__node.selected"); + if (!(workbench instanceof HTMLElement) || !(node instanceof HTMLElement)) return false; + const outer = workbench.getBoundingClientRect(); + const inner = node.getBoundingClientRect(); + return inner.width > 0 && inner.height > 0 && + inner.left >= outer.left - 1 && inner.right <= outer.right + 1 && + inner.top >= outer.top + 51 && inner.bottom <= outer.bottom + 1 && + !document.querySelector(".create-images-inspector"); +})()`; + +export interface RunPackagedCreateImagesAcceptanceOptions { + window: BrowserWindow; + reloadRenderer(): Promise; + navigate(path: string): Promise; + runtimeProfile: { configDir: string; userDataPath: string }; +} + +interface PhaseTwoReadyObservation { + workbenchPresent: boolean; + nodeCount: string | null; + previewPresent: boolean; + previewComplete: boolean; + previewWidth: number; + promptPresent: boolean; + grantCount: number; + assetProtocolRequests: number; + assetProtocolAuthorizations: number; + lastAssetRequest: { + method: string; + resourceType: string; + webContentsIdPresent: boolean; + framePresent: boolean; + frameIsMain: boolean; + frameDetached: boolean; + } | null; +} + +function isAcceptedAssetRequestEvidence( + value: PhaseTwoReadyObservation["lastAssetRequest"], +): value is CreateImagesPackagedAssetRequestEvidence { + return ( + value?.method === "GET" && + value.resourceType === "image" && + value.webContentsIdPresent && + value.framePresent && + value.frameIsMain && + !value.frameDetached + ); +} + +let mainWindow: BrowserWindow | null = null; +let createImagesAcceptanceKeyboardActions = 0; + +function pauseForCreateImagesPackagedAcceptance(): Promise { + return new Promise((resolve) => setTimeout(resolve, CREATE_IMAGES_PACKAGED_ACCEPTANCE_POLL_MS)); +} + +async function waitForCreateImagesPackagedAcceptance( + step: string, + read: () => Promise, + accept: (value: T) => boolean, +): Promise { + const deadline = Date.now() + CREATE_IMAGES_PACKAGED_ACCEPTANCE_WAIT_MS; + let lastValue: T | undefined; + while (Date.now() < deadline) { + const value = await read(); + lastValue = value; + if (accept(value)) return value; + await pauseForCreateImagesPackagedAcceptance(); + } + let observation = "unavailable"; + try { + observation = JSON.stringify(lastValue); + } catch { + observation = "unserializable"; + } + throw new Error( + `Packaged Create Images acceptance did not reach ${step}. Last observation: ${observation}.`, + ); +} + +async function readCreateImagesAcceptanceScript(script: string): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + return (await window.webContents.executeJavaScript(script, true)) as T; +} + +async function focusCreateImagesAcceptanceWindow(window: BrowserWindow): Promise { + app.focus({ steal: true }); + window.show(); + window.focus(); + window.webContents.focus(); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function activateCreateImagesAcceptanceControl(focusScript: string): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + await focusCreateImagesAcceptanceWindow(window); + const focused = await readCreateImagesAcceptanceScript(focusScript); + if (!focused) throw new Error("Packaged Create Images acceptance could not focus a control."); + createImagesAcceptanceKeyboardActions += 1; + window.webContents.sendInputEvent({ type: "keyDown", keyCode: "Space" }); + await pauseForCreateImagesPackagedAcceptance(); + window.webContents.sendInputEvent({ type: "keyUp", keyCode: "Space" }); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function sendCreateImagesAcceptanceDuplicateShortcut(): Promise { + await sendCreateImagesAcceptanceKey("D", ["meta"]); +} + +async function sendCreateImagesAcceptanceKey( + keyCode: string, + modifiers: Electron.KeyboardInputEvent["modifiers"] = [], +): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + await focusCreateImagesAcceptanceWindow(window); + createImagesAcceptanceKeyboardActions += 1; + window.webContents.sendInputEvent({ type: "keyDown", keyCode, modifiers }); + await pauseForCreateImagesPackagedAcceptance(); + window.webContents.sendInputEvent({ type: "keyUp", keyCode, modifiers }); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function sendCreateImagesAcceptanceTab(shift = false): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + await focusCreateImagesAcceptanceWindow(window); + createImagesAcceptanceKeyboardActions += 1; + const modifiers: Electron.KeyboardInputEvent["modifiers"] = shift ? ["shift"] : []; + window.webContents.sendInputEvent({ type: "keyDown", keyCode: "Tab", modifiers }); + await pauseForCreateImagesPackagedAcceptance(); + window.webContents.sendInputEvent({ type: "keyUp", keyCode: "Tab", modifiers }); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function waitForCreateImagesNodeCount(expected: number): Promise { + return waitForCreateImagesPackagedAcceptance( + `${expected} nodes`, + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_NODE_COUNT_SCRIPT), + (count) => count === expected, + ); +} + +async function waitForCreateImagesEdgeCount(expected: number): Promise { + return waitForCreateImagesPackagedAcceptance( + `${expected} edges`, + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_EDGE_COUNT_SCRIPT), + (count) => count === expected, + ); +} + +async function waitForCreateImagesProductFilesToSettle(input: { + configDir: string; + userDataDir: string; +}): Promise>> { + const deadline = Date.now() + CREATE_IMAGES_PACKAGED_ACCEPTANCE_WAIT_MS; + let previous = await snapshotCreateImagesProductFiles(input); + let stableSamples = 0; + while (Date.now() < deadline) { + await pauseForCreateImagesPackagedAcceptance(); + const current = await snapshotCreateImagesProductFiles(input); + if (countCreateImagesProductFileMutations(previous, current) === 0) { + stableSamples += 1; + if (stableSamples >= CREATE_IMAGES_PACKAGED_ACCEPTANCE_BASELINE_STABLE_SAMPLES) + return current; + } else { + stableSamples = 0; + } + previous = current; + } + throw new Error("Packaged Create Images product files did not reach a stable baseline."); +} + +async function dragCreateImagesAcceptanceConnection(): Promise { + const points = await readCreateImagesAcceptanceScript<{ + fromX: number; + fromY: number; + toX: number; + toY: number; + } | null>(CREATE_IMAGES_ACCEPTANCE_SPATIAL_POINTS_SCRIPT); + const window = mainWindow; + if (!points || !window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its spatial connection handles."); + } + await focusCreateImagesAcceptanceWindow(window); + window.webContents.sendInputEvent({ + type: "mouseMove", + x: points.fromX, + y: points.fromY, + }); + window.webContents.sendInputEvent({ + type: "mouseDown", + x: points.fromX, + y: points.fromY, + button: "left", + clickCount: 1, + }); + for (let step = 1; step <= 6; step += 1) { + const progress = step / 6; + window.webContents.sendInputEvent({ + type: "mouseMove", + x: Math.round(points.fromX + (points.toX - points.fromX) * progress), + y: Math.round(points.fromY + (points.toY - points.fromY) * progress), + button: "left", + }); + await pauseForCreateImagesPackagedAcceptance(); + } + window.webContents.sendInputEvent({ + type: "mouseUp", + x: points.toX, + y: points.toY, + button: "left", + clickCount: 1, + }); +} + +async function observeCreateImagesAnnouncement(expected: RegExp): Promise { + await waitForCreateImagesPackagedAcceptance( + `announcement ${expected.source}`, + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_ANNOUNCEMENT_SCRIPT), + (announcement) => expected.test(announcement), + ); +} + +export async function runPackagedCreateImagesAcceptance( + acceptance: CreateImagesPackagedAcceptanceSession, + options: RunPackagedCreateImagesAcceptanceOptions, +): Promise { + mainWindow = options.window; + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance requires a live main window."); + } + const startedAt = performance.now(); + createImagesAcceptanceKeyboardActions = 0; + await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_COMPLETE_ONBOARDING_SCRIPT, + ); + await options.reloadRenderer(); + + const runtimeProfile = options.runtimeProfile; + const persistenceInput = { + configDir: runtimeProfile.configDir, + userDataDir: runtimeProfile.userDataPath, + }; + // Production requires an explicitly selected, writable Finder-facing workspace. The + // one-shot packaged acceptance profile has no onboarding choice to persist, so give it + // a private directory inside the disposable acceptance root and exercise the same + // main-owned configuration path used by the real picker. Keep it outside config/userData + // so product-persistence evidence does not mistake user-selected workspace mirrors for + // app-owned records. + const service = createImagesService(); + const acceptanceWorkspace = path.join( + path.dirname(runtimeProfile.userDataPath), + "create-images-workspace", + ); + await fs.mkdir(acceptanceWorkspace, { recursive: false, mode: 0o700 }); + await fs.chmod(acceptanceWorkspace, 0o700); + const workspaceStatus = await service.workspace.configureChosenDirectory(acceptanceWorkspace); + if (!workspaceStatus.configured || workspaceStatus.state !== "ready") { + throw new Error("Packaged Create Images acceptance could not configure its private workspace."); + } + // Materialize the empty authoritative indexes before the mutation baseline. Opening the + // stress canvas may read them, but must not be credited with product writes of its own. + await service.initialize(); + // A fresh acceptance profile creates ordinary app bootstrap records asynchronously. Establish + // the baseline only after those writes settle, before navigating to the side-effect-free canvas. + const filesBefore = await waitForCreateImagesProductFilesToSettle(persistenceInput); + let networkRequests = 0; + let rendererEgressProbeRequests = 0; + let rendererEgressProbeBlocked = 0; + let rendererEgressProbeWebContentsPresent = false; + let assetProtocolRequests = 0; + let assetProtocolAuthorizations = 0; + let lastAssetRequest: PhaseTwoReadyObservation["lastAssetRequest"] = null; + let mainObservedRendererErrors = 0; + const onConsoleMessage = ( + event: Electron.Event, + legacyLevel: number, + legacyMessage: string, + ) => { + const level = event.level ?? legacyLevel; + const message = event.message ?? legacyMessage ?? ""; + if ( + level === "error" || + (typeof level === "number" && level >= 3) || + /content security policy|securitypolicyviolation/iu.test(message) + ) { + mainObservedRendererErrors += 1; + } + }; + const onRenderProcessGone = () => { + mainObservedRendererErrors += 1; + }; + const onDidFailLoad = ( + _event: Electron.Event, + _errorCode: number, + _errorDescription: string, + _validatedURL: string, + isMainFrame: boolean, + ) => { + if (isMainFrame) mainObservedRendererErrors += 1; + }; + window.webContents.on("console-message", onConsoleMessage); + window.webContents.on("render-process-gone", onRenderProcessGone); + window.webContents.on("did-fail-load", onDidFailLoad); + const stopRequestPolicyObservation = observeCreateImagesRequestPolicy((observation) => { + if (observation.kind === "renderer-egress") { + if (observation.url === CREATE_IMAGES_PACKAGED_ACCEPTANCE_EGRESS_PROBE) { + rendererEgressProbeRequests += 1; + if (!observation.allowed) rendererEgressProbeBlocked += 1; + rendererEgressProbeWebContentsPresent ||= observation.webContentsIdPresent; + return; + } + networkRequests += 1; + return; + } + assetProtocolRequests += 1; + if (observation.allowed) assetProtocolAuthorizations += 1; + lastAssetRequest = { + method: observation.method, + resourceType: observation.resourceType, + webContentsIdPresent: observation.webContentsIdPresent, + framePresent: observation.framePresent, + frameIsMain: observation.frameIsMain, + frameDetached: observation.frameDetached, + }; + }); + try { + // Use Electron's download entry point so the production webRequest policy + // sees a request owned by the real main WebContents. A renderer fetch/image + // is correctly stopped by CSP before webRequest and cannot prove this layer. + window.webContents.downloadURL(CREATE_IMAGES_PACKAGED_ACCEPTANCE_EGRESS_PROBE); + const rendererEgressProbePassed = await waitForCreateImagesPackagedAcceptance( + "the production renderer-egress denial", + async () => ({ + requests: rendererEgressProbeRequests, + blocked: rendererEgressProbeBlocked, + webContentsPresent: rendererEgressProbeWebContentsPresent, + }), + (value) => + value.requests >= 1 && value.blocked === value.requests && value.webContentsPresent, + ).then(() => true); + await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSTALL_ERROR_COUNTER_SCRIPT, + ); + await options.navigate(CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROUTE); + await waitForCreateImagesPackagedAcceptance( + "the production canvas route", + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_READY_SCRIPT), + Boolean, + ); + const initialNodeCount = await waitForCreateImagesNodeCount(100); + const assistantHidden = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_ASSISTANT_HIDDEN_SCRIPT, + ); + if (!assistantHidden) { + throw new Error("Packaged Create Images acceptance found the assistant dock interactive."); + } + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_INSPECTOR_SCRIPT); + const labels = await waitForCreateImagesPackagedAcceptance( + "the non-spatial node list", + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_NODE_LABELS_SCRIPT), + (value) => value.length === 100, + ); + const uniqueAccessibleNodeLabels = new Set(labels).size === labels.length; + const noRedundantNodeSemantics = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_NO_REDUNDANT_NODE_SEMANTICS_SCRIPT, + ); + if (!uniqueAccessibleNodeLabels || !noRedundantNodeSemantics) { + throw new Error("Packaged Create Images acceptance found duplicate accessible node labels."); + } + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_FIT_WORKFLOW_SCRIPT); + for (let index = 0; index < 16; index += 1) { + await pauseForCreateImagesPackagedAcceptance(); + } + for (let index = 0; index < 6; index += 1) { + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_ZOOM_IN_SCRIPT); + } + const spatialEdgePrepared = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PREPARE_SPATIAL_EDGE_SCRIPT, + ); + if (!spatialEdgePrepared) { + throw new Error("Packaged Create Images acceptance found no visible spatial edge pair."); + } + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_MANAGE_CONNECTIONS_SCRIPT, + ); + await waitForCreateImagesPackagedAcceptance( + "the spatial edge disconnect control", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_SPATIAL_DISCONNECT_SCRIPT, + ), + Boolean, + ); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_SPATIAL_DISCONNECT_SCRIPT, + ); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Nodes disconnected\./u); + await dragCreateImagesAcceptanceConnection(); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Nodes connected\./u); + const spatialConnectionPassed = true; + const liveMutationCounterInstalled = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSTALL_LIVE_MUTATION_COUNTER_SCRIPT, + ); + if (!liveMutationCounterInstalled) { + throw new Error("Packaged Create Images acceptance could not observe its live region."); + } + await dragCreateImagesAcceptanceConnection(); + const firstInvalidMutationCount = await waitForCreateImagesPackagedAcceptance( + "the first invalid spatial-drop announcement", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT, + ), + (count) => count > 0, + ); + await observeCreateImagesAnnouncement(/This connection already exists\./u); + await dragCreateImagesAcceptanceConnection(); + await waitForCreateImagesPackagedAcceptance( + "the repeated invalid spatial-drop announcement", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT, + ), + (count) => count > firstInvalidMutationCount, + ); + const repeatedAnnouncementPassed = true; + const spatialInvalidDropPassed = (await waitForCreateImagesEdgeCount(75)) === 75; + + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_KEYBOARD_DISCONNECT_SCRIPT, + ); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Nodes disconnected\./u); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECT_NODES_SCRIPT, + ); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Nodes connected\./u); + const keyboardConnectionPassed = true; + + const focusedEdge = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_EDGE_SCRIPT, + ); + if (!focusedEdge) { + throw new Error("Packaged Create Images acceptance could not focus a spatial edge."); + } + await sendCreateImagesAcceptanceKey("Enter"); + await waitForCreateImagesPackagedAcceptance( + "keyboard selection of the spatial edge", + () => + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_EDGE_SELECTED_SCRIPT), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Delete"); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/1 connection deleted\./u); + await waitForCreateImagesPackagedAcceptance( + "focus restoration after native edge deletion", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSPECTOR_FOCUSED_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta", "shift"]); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Redid the graph edit\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + const nativeEdgeDeletePassed = true; + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_ADD_SCRIPT); + await waitForCreateImagesPackagedAcceptance( + "the node palette", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_SEARCH_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceTab(true); + await waitForCreateImagesPackagedAcceptance( + "focus to remain trapped in the node palette", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_INSIDE_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceTab(); + await waitForCreateImagesPackagedAcceptance( + "focus to wrap to the node search", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_SEARCH_SCRIPT, + ), + Boolean, + ); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_OUTPUT_GALLERY_SCRIPT, + ); + const addedNodeCount = await waitForCreateImagesNodeCount(101); + await observeCreateImagesAnnouncement(/Output Gallery added\./u); + const focusRestoredAfterPalette = await waitForCreateImagesPackagedAcceptance( + "palette focus restoration", + () => + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_PALETTE_CLOSED_SCRIPT), + Boolean, + ); + + await sendCreateImagesAcceptanceDuplicateShortcut(); + const duplicatedNodeCount = await waitForCreateImagesNodeCount(102); + await observeCreateImagesAnnouncement(/duplicated\./u); + await waitForCreateImagesPackagedAcceptance( + "focus on the duplicated canvas node", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_CANVAS_NODE_FOCUSED_SCRIPT, + ), + Boolean, + ); + const selectedNodeX = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_CAPTURE_SELECTED_NODE_X_SCRIPT, + ); + if (selectedNodeX === null) { + throw new Error("Packaged Create Images acceptance could not capture duplicate position."); + } + await sendCreateImagesAcceptanceKey("Right"); + await waitForCreateImagesPackagedAcceptance( + "the duplicated node keyboard move", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_MOVED_SCRIPT, + ), + Boolean, + ); + await observeCreateImagesAnnouncement(/Node moved\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesPackagedAcceptance( + "the keyboard-move undo", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_POSITION_RESTORED_SCRIPT, + ), + Boolean, + ); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + const keyboardMoveUndoPassed = true; + await activateCreateImagesAcceptanceControl(createImagesAcceptanceFocusButtonScript("Undo")); + const undoNodeCount = await waitForCreateImagesNodeCount(101); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + await activateCreateImagesAcceptanceControl(createImagesAcceptanceFocusButtonScript("Redo")); + const redoNodeCount = await waitForCreateImagesNodeCount(102); + await observeCreateImagesAnnouncement(/Redid the graph edit\./u); + const inspectorOpen = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSPECTOR_OPEN_SCRIPT, + ); + if (!inspectorOpen) { + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_INSPECTOR_SCRIPT); + } + const connectionToolsOpen = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_CONNECTION_TOOLS_OPEN_SCRIPT, + ); + if (connectionToolsOpen) { + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_BACK_TO_NODES_SCRIPT, + ); + } + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_INSPECTOR_NODE_SCRIPT, + ); + await waitForCreateImagesPackagedAcceptance( + "the selected connected canvas node", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_CANVAS_NODE_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Enter"); + await waitForCreateImagesPackagedAcceptance( + "keyboard selection of a connected canvas node", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_CONNECTED_CANVAS_NODE_SELECTED_SCRIPT, + ), + Boolean, + ); + await waitForCreateImagesEdgeCount(75); + await sendCreateImagesAcceptanceKey("Delete"); + const deletedNodeCount = await waitForCreateImagesNodeCount(101); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/deleted\./u); + const focusRestoredAfterNativeDelete = await waitForCreateImagesPackagedAcceptance( + "focus restoration after native node deletion", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSPECTOR_FOCUSED_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + const nativeDeleteUndoNodeCount = await waitForCreateImagesNodeCount(102); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta", "shift"]); + const nativeDeleteRedoNodeCount = await waitForCreateImagesNodeCount(101); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Redid the graph edit\./u); + const nativeNodeDeleteGraphPassed = true; + + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_ENABLE_REDUCED_MOTION_SCRIPT, + ); + const reducedMotionPassed = await waitForCreateImagesPackagedAcceptance( + "the reduced-motion canvas state", + () => + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_REDUCED_MOTION_SCRIPT), + Boolean, + ); + + let responsiveWidthsPassed = true; + let narrowValidationPassed = false; + let narrowAddPlacementPassed = false; + for (const width of [1280, 1000, 700, 390]) { + window.setContentSize(width, 650, false); + const narrowExpected = width !== 1280; + const responsive = await waitForCreateImagesPackagedAcceptance( + `the ${width}px canvas layout`, + () => + readCreateImagesAcceptanceScript<{ + width: number; + workbenchWidth: number; + sidebarWidth: number; + overflowFree: boolean; + minimapVisible: boolean; + minimapToggleVisible: boolean; + validationIssueTriggerVisible: boolean; + }>(CREATE_IMAGES_ACCEPTANCE_RESPONSIVE_SCRIPT), + (value) => + Math.abs(value.width - width) <= 1 && + value.workbenchWidth > 0 && + (narrowExpected ? value.workbenchWidth <= 760 : value.workbenchWidth > 760) && + value.sidebarWidth === 340 && + value.overflowFree && + value.minimapVisible === !narrowExpected && + value.minimapToggleVisible === !narrowExpected && + value.validationIssueTriggerVisible, + ); + responsiveWidthsPassed &&= + responsive.overflowFree && + responsive.minimapVisible === !narrowExpected && + responsive.minimapToggleVisible === !narrowExpected && + responsive.validationIssueTriggerVisible; + + if (width === 390) { + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_VALIDATION_TRIGGER_SCRIPT, + ); + const validationIssueFocused = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_FIRST_VALIDATION_ISSUE_SCRIPT, + ); + if (!validationIssueFocused) { + throw new Error("Packaged Create Images could not focus a narrow validation issue."); + } + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesNodeCount(101); + await waitForCreateImagesPackagedAcceptance( + "validation-panel shortcut isolation", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_VALIDATION_ISSUE_FOCUSED_SCRIPT, + ), + Boolean, + ); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_FIRST_VALIDATION_ISSUE_SCRIPT, + ); + narrowValidationPassed = await waitForCreateImagesPackagedAcceptance( + "validation issue focus at 390px", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_VALIDATION_TARGET_FOCUSED_SCRIPT, + ), + Boolean, + ); + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_ADD_SCRIPT); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_OUTPUT_GALLERY_SCRIPT, + ); + await waitForCreateImagesNodeCount(102); + narrowAddPlacementPassed = await waitForCreateImagesPackagedAcceptance( + "a fully visible newly added node at 390px", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_VISIBLE_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesNodeCount(101); + } + } + window.setContentSize(1000, 700, false); + + const [rendererEventErrors, liveRegionMutations, filesAfter] = await Promise.all([ + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_RENDERER_ERRORS_SCRIPT), + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT), + snapshotCreateImagesProductFiles(persistenceInput), + ]); + const runtimePreferences = ( + window.webContents as unknown as { + getLastWebPreferences(): { + sandbox?: boolean; + contextIsolation?: boolean; + nodeIntegration?: boolean; + }; + } + ).getLastWebPreferences(); + const productFileMutations = countCreateImagesProductFileMutations(filesBefore, filesAfter); + + const sourceBytes = createImagesAcceptanceLargePng(); + async function* sourceChunks(): AsyncGenerator { + const chunkSize = 64 * 1024; + for (let offset = 0; offset < sourceBytes.byteLength; offset += chunkSize) { + yield sourceBytes.subarray(offset, Math.min(offset + chunkSize, sourceBytes.byteLength)); + } + } + const imported = await service.assets.ingest(sourceChunks(), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "packaged-large-reference.png", + }); + if ( + imported.asset.byteLength <= CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_METADATA_BYTES || + imported.asset.width !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH || + imported.asset.height !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT + ) { + throw new Error("Packaged Create Images did not import the bounded large-image fixture."); + } + const now = new Date().toISOString(); + const workflow = createStarterWorkflow({ + workflowId: "packaged-phase-two", + promptNodeId: "packaged-prompt", + generationNodeId: "packaged-generate", + outputNodeId: "packaged-output", + promptEdgeId: "packaged-edge-prompt", + outputEdgeId: "packaged-edge-output", + now, + }); + workflow.nodes.push({ + id: "packaged-image", + type: "image-input", + position: { x: 40, y: 360 }, + data: { assetId: imported.asset.assetId, label: "Packaged reference" }, + }); + workflow.assetRefs = [imported.asset.assetId]; + await service.mutateWorkflow(workflow.id, workflow.assetRefs, () => + service.workflows.create(workflow), + ); + await options.navigate(`/create-images/${workflow.id}`); + const phaseTwoReady = await waitForCreateImagesPackagedAcceptance( + "the durable workflow and protocol image preview", + async () => ({ + ...(await readCreateImagesAcceptanceScript< + Omit< + PhaseTwoReadyObservation, + | "grantCount" + | "assetProtocolRequests" + | "assetProtocolAuthorizations" + | "lastAssetRequest" + > + >(CREATE_IMAGES_PHASE_TWO_READY_SCRIPT)), + grantCount: service.grants.size(), + assetProtocolRequests, + assetProtocolAuthorizations, + lastAssetRequest, + }), + (value) => + value.workbenchPresent && + value.nodeCount === "4" && + value.previewPresent && + value.previewComplete && + value.previewWidth > 0 && + value.promptPresent && + value.grantCount >= 1 && + value.assetProtocolRequests >= 1 && + value.assetProtocolAuthorizations >= 1 && + value.assetProtocolAuthorizations === value.assetProtocolRequests && + isAcceptedAssetRequestEvidence(value.lastAssetRequest), + ); + const assetProtocolPreviewPassed = + phaseTwoReady.previewWidth > 0 && + phaseTwoReady.grantCount >= 1 && + phaseTwoReady.assetProtocolRequests >= 1 && + phaseTwoReady.assetProtocolAuthorizations >= 1 && + phaseTwoReady.assetProtocolAuthorizations === phaseTwoReady.assetProtocolRequests && + isAcceptedAssetRequestEvidence(phaseTwoReady.lastAssetRequest); + const preEditWorkflow = await service.workflows.get(workflow.id); + if (!preEditWorkflow) { + throw new Error("Packaged Create Images lost its durable workflow before editing."); + } + const editDispatched = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_PHASE_TWO_EDIT_SCRIPT, + ); + if (!editDispatched) + throw new Error("Packaged Create Images could not edit the durable prompt."); + const savedWorkflow = await waitForCreateImagesPackagedAcceptance( + "the durable autosave publication", + () => service.workflows.get(workflow.id), + (value) => + isCreateImagesDurableWorkflowPublication( + value, + preEditWorkflow.revision, + "Packaged durable prompt edit", + ), + ); + if (!savedWorkflow) { + throw new Error("Packaged Create Images lost its workflow after the durable edit."); + } + const durableWorkflowPassed = isCreateImagesDurableWorkflowPublication( + savedWorkflow, + preEditWorkflow.revision, + "Packaged durable prompt edit", + ); + await options.reloadRenderer(); + await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSTALL_ERROR_COUNTER_SCRIPT, + ); + await options.navigate(`/create-images/${workflow.id}`); + const rendererReloadPersistencePassed = await waitForCreateImagesPackagedAcceptance( + "the durable workflow after a renderer restart", + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_PHASE_TWO_REOPENED_SCRIPT), + Boolean, + ); + const graphText = await fs.readFile( + path.join( + app.getPath("userData"), + "create-images", + "workflows", + workflow.id, + "workflow.json", + ), + "utf8", + ); + const noGraphBase64Passed = + Buffer.byteLength(graphText) < 4 * 1024 * 1024 && + !/data:image|;base64,/u.test(graphText) && + graphText.includes(imported.asset.assetId); + const [phaseTwoRendererEventErrors, filesAfterPhaseTwo] = await Promise.all([ + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_RENDERER_ERRORS_SCRIPT), + snapshotCreateImagesProductFiles(persistenceInput), + ]); + const phaseTwoProductFileMutations = countCreateImagesProductFileMutations( + filesAfter, + filesAfterPhaseTwo, + ); + const phaseTwoProductFiles = createImagesPhaseTwoProductFileEvidence( + filesAfter, + filesAfterPhaseTwo, + { + workflowId: workflow.id, + assetId: imported.asset.assetId, + assetExtension: imported.asset.mediaType === "image/png" ? "png" : "jpg", + }, + ); + const createImagesRoot = path.join(app.getPath("userData"), "create-images"); + const workflowRoot = path.join(createImagesRoot, "workflows", workflow.id); + const [lastKnownGoodText, workflowIndexText, assetIndexText, runIndexText] = await Promise.all([ + fs.readFile(path.join(workflowRoot, "workflow.last-known-good.json"), "utf8"), + fs.readFile(path.join(createImagesRoot, "index.json"), "utf8"), + fs.readFile(path.join(createImagesRoot, "asset-index.json"), "utf8"), + fs.readFile(path.join(createImagesRoot, "run-index.json"), "utf8"), + ]); + const workflowRecord = JSON.parse(graphText) as { + id?: unknown; + revision?: unknown; + assetRefs?: unknown; + }; + const workflowIndexRecord = JSON.parse(workflowIndexText) as { + workflows?: Array<{ id?: unknown; revision?: unknown; assetCount?: unknown }>; + }; + const assetIndexRecord = JSON.parse(assetIndexText) as { + assets?: Record< + string, + { + assetId?: unknown; + byteLength?: unknown; + width?: unknown; + height?: unknown; + referenceOwners?: unknown; + thumbnails?: Record; + } + >; + }; + const runIndexRecord = JSON.parse(runIndexText) as { + version?: unknown; + revision?: unknown; + entries?: unknown; + degraded?: unknown; + }; + const storedAsset = assetIndexRecord.assets?.[imported.asset.assetId]; + const assetFile = phaseTwoProductFiles.find((entry) => + entry.path.includes(`/assets/sha256/${imported.asset.assetId.slice(0, 2)}/`), + ); + const thumbnailFile = phaseTwoProductFiles.find((entry) => + entry.path.includes(`/thumbnails/${imported.asset.assetId}/512.png`), + ); + const phaseTwoStorageRelationshipsPassed = + graphText === lastKnownGoodText && + workflowRecord.id === workflow.id && + workflowRecord.revision === savedWorkflow.revision && + Array.isArray(workflowRecord.assetRefs) && + workflowRecord.assetRefs.length === 1 && + workflowRecord.assetRefs[0] === imported.asset.assetId && + Array.isArray(workflowIndexRecord.workflows) && + workflowIndexRecord.workflows.length === 1 && + workflowIndexRecord.workflows[0]?.id === workflow.id && + workflowIndexRecord.workflows[0]?.revision === savedWorkflow.revision && + workflowIndexRecord.workflows[0]?.assetCount === 1 && + Object.keys(assetIndexRecord.assets ?? {}).length === 1 && + storedAsset?.assetId === imported.asset.assetId && + storedAsset.byteLength === assetFile?.bytes && + storedAsset.width === CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH && + storedAsset.height === CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT && + runIndexRecord.version === 1 && + Number.isSafeInteger(runIndexRecord.revision) && + (runIndexRecord.revision as number) >= 1 && + Array.isArray(runIndexRecord.entries) && + runIndexRecord.entries.length === 0 && + Array.isArray(runIndexRecord.degraded) && + runIndexRecord.degraded.length === 0 && + Array.isArray(storedAsset.referenceOwners) && + storedAsset.referenceOwners.length === 1 && + storedAsset.referenceOwners[0] === `workflow:${workflow.id}` && + storedAsset.thumbnails?.["512"]?.byteLength === thumbnailFile?.bytes; + if (!phaseTwoStorageRelationshipsPassed) { + throw new Error( + "Packaged Create Images durable workflow, index, and asset relationships are inconsistent.", + ); + } + if (!isAcceptedAssetRequestEvidence(lastAssetRequest)) { + throw new Error( + "Packaged Create Images did not observe an accepted production asset request.", + ); + } + const rendererErrors = + rendererEventErrors + phaseTwoRendererEventErrors + mainObservedRendererErrors; + await writeCreateImagesPackagedAcceptanceReceipt(acceptance, { + version: 1, + nonce: acceptance.control.nonce, + route: CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROUTE, + initialNodeCount: initialNodeCount as 100, + addedNodeCount: addedNodeCount as 101, + duplicatedNodeCount: duplicatedNodeCount as 102, + undoNodeCount: undoNodeCount as 101, + redoNodeCount: redoNodeCount as 102, + deletedNodeCount: deletedNodeCount as 101, + nativeDeleteUndoNodeCount: nativeDeleteUndoNodeCount as 102, + nativeDeleteRedoNodeCount: nativeDeleteRedoNodeCount as 101, + spatialConnectionPassed, + spatialInvalidDropPassed, + nativeEdgeDeletePassed, + keyboardConnectionPassed, + keyboardMoveUndoPassed, + repeatedAnnouncementPassed, + uniqueAccessibleNodeLabels, + narrowValidationPassed, + narrowAddPlacementPassed, + focusRestoredAfterPalette, + focusRestoredAfterNativeDelete, + nativeNodeDeleteGraphPassed, + reducedMotionPassed, + liveRegionMutations, + keyboardActions: createImagesAcceptanceKeyboardActions, + rendererErrors, + networkRequests, + rendererEgressProbePassed, + rendererEgressProbeRequests, + rendererEgressProbeBlocked, + productFileMutations, + durableWorkflowPassed, + assetProtocolPreviewPassed, + assetProtocolGrantCount: phaseTwoReady.grantCount, + assetProtocolRequests, + assetProtocolAuthorizations, + assetProtocolLastRequest: lastAssetRequest, + rendererReloadPersistencePassed, + noGraphBase64Passed, + phaseTwoProductFileMutations, + phaseTwoProductFiles, + phaseTwoStorageRelationshipsPassed, + phaseTwoWorkflowRevision: savedWorkflow.revision, + phaseTwoAssetBytes: imported.asset.byteLength, + phaseTwoAssetWidth: imported.asset.width, + phaseTwoAssetHeight: imported.asset.height, + responsiveWidthsPassed, + sandboxed: runtimePreferences.sandbox === true, + contextIsolation: runtimePreferences.contextIsolation === true, + nodeIntegration: runtimePreferences.nodeIntegration === true, + durationMs: performance.now() - startedAt, + }); + } finally { + stopRequestPolicyObservation(); + window.webContents.off("console-message", onConsoleMessage); + window.webContents.off("render-process-gone", onRenderProcessGone); + window.webContents.off("did-fail-load", onDidFailLoad); + } +} diff --git a/main/services/create-images/phase-three-integration.test.ts b/main/services/create-images/phase-three-integration.test.ts new file mode 100644 index 00000000..b9e868ba --- /dev/null +++ b/main/services/create-images/phase-three-integration.test.ts @@ -0,0 +1,777 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import { projectCreateImagesRun } from "../../../renderer/shared/create-images/run-contract.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { CreateImagesService } from "./create-images-service.js"; +import { shouldReleaseCreateImagesRunOwner } from "./run-publication-binding-core.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const RUN_TIMEOUT_MS = 10_000; + +interface StoredAssetIndex { + schemaVersion: 1; + revision: number; + assets: Record; +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concatenate(...parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const checksum = concatenate(typeBytes, data); + return concatenate(u32(data.byteLength), checksum, u32(crc32(checksum))); +} + +function staticPng(): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concatenate( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-phase-three-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} + +function serviceOptions(now: () => number) { + const thumbnail = staticPng(); + return { + assetStore: { + now, + deepValidator: { + async validate({ descriptor }: { descriptor: { width: number; height: number } }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: thumbnail, + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }; +} + +async function waitUntil( + description: string, + inspect: () => Promise, +): Promise { + const deadline = Date.now() + RUN_TIMEOUT_MS; + while (Date.now() < deadline) { + const result = await inspect(); + if (result !== undefined) return result; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${description}.`); +} + +test("a future run index leaves the workflow library readable while run admission stays closed", async (t) => { + const root = await temporaryRoot(t); + let clock = Date.parse(NOW); + const now = () => clock++; + const first = new CreateImagesService(root, serviceOptions(now)); + await first.initialize(); + const workflow = createStarterWorkflow({ + workflowId: "workflow-future-run-index", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + await first.mutateWorkflow(workflow.id, [], () => first.workflows.create(workflow)); + + const futureIndex = '{"version":2,"revision":1,"entries":[]}\n'; + const indexPath = path.join(root, "run-index.json"); + await fs.writeFile(indexPath, futureIndex, "utf8"); + const restarted = new CreateImagesService(root, serviceOptions(now)); + + await assert.rejects(restarted.initialize()); + await restarted.initializeReadOnlyLibrary(); + assert.deepEqual( + (await restarted.workflows.list()).map(({ id, title }) => ({ id, title })), + [{ id: workflow.id, title: workflow.title }], + ); + assert.deepEqual(await restarted.runs.journals.indexHealth(), { status: "unsafe" }); + assert.equal(await fs.readFile(indexPath, "utf8"), futureIndex); + await assert.rejects( + restarted.runs.start( + { + workflowId: workflow.id, + expectedRevision: workflow.revision, + scope: { kind: "all" }, + }, + () => true, + ), + ); + assert.equal(await fs.readFile(indexPath, "utf8"), futureIndex); +}); + +test("an authoritative run-free workflow can still be deleted through the lifecycle fence", async (t) => { + const root = await temporaryRoot(t); + const service = new CreateImagesService( + root, + serviceOptions(() => Date.parse(NOW)), + ); + const workflow = createStarterWorkflow({ + workflowId: "workflow-without-runs", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + await service.mutateWorkflow(workflow.id, [], () => service.workflows.create(workflow)); + + assert.deepEqual(await service.deleteWorkflow(workflow.id, workflow.revision, () => true), { + status: "deleted", + }); + assert.equal(await service.workflows.get(workflow.id), undefined); + assert.deepEqual(await service.runs.list(workflow.id), { status: "not-found" }); +}); + +test("same-process unassociated corruption is discovered authoritatively before deletion", async (t) => { + const root = await temporaryRoot(t); + const service = new CreateImagesService( + root, + serviceOptions(() => Date.parse(NOW)), + ); + const workflow = createStarterWorkflow({ + workflowId: "workflow-post-init-unassociated-run", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + await service.mutateWorkflow(workflow.id, [], () => service.workflows.create(workflow)); + assert.equal(await service.runs.journals.hasUnassociatedDegradedRuns(), false); + + const injectedRunRoot = path.join(root, "runs", "post-init-unassociated-run"); + await fs.mkdir(injectedRunRoot); + await Promise.all([ + fs.writeFile(path.join(injectedRunRoot, "run.json"), "{broken-current", "utf8"), + fs.writeFile( + path.join(injectedRunRoot, "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + assert.equal(await service.runs.journals.hasUnassociatedDegradedRuns(), false); + + const deletion = await service.deleteWorkflow(workflow.id, workflow.revision, () => true); + assert.equal(deletion.status, "unavailable"); + if (deletion.status === "unavailable") { + assert.match(deletion.message, /unassociated run recovery authority/u); + } + assert.deepEqual(await service.workflows.get(workflow.id), workflow); + assert.equal(await service.runs.journals.hasUnassociatedDegradedRuns(), true); +}); + +test("unassociated degraded run authority blocks workflow deletion after restart", async (t) => { + const root = await temporaryRoot(t); + let clock = Date.parse(NOW); + const now = () => clock++; + const first = new CreateImagesService(root, serviceOptions(now)); + const workflow = createStarterWorkflow({ + workflowId: "workflow-unassociated-degraded-run", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + const prompt = workflow.nodes.find((node) => node.id === "prompt-1"); + const generation = workflow.nodes.find((node) => node.id === "generate-1"); + assert.equal(prompt?.type, "prompt"); + assert.equal(generation?.type, "generate-image"); + if (!prompt || prompt.type !== "prompt" || !generation || generation.type !== "generate-image") { + return; + } + prompt.data.text = "Preserve this unassociated degraded run"; + generation.data.providerId = "gemini"; + generation.data.modelId = "gemini-3.1-flash-image"; + await first.mutateWorkflow(workflow.id, [], () => first.workflows.create(workflow)); + const started = await first.runs.start( + { + workflowId: workflow.id, + expectedRevision: workflow.revision, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitUntil("the run to become terminal", async () => { + const journal = await first.runs.journals.get(started.run.runId); + return journal && projectCreateImagesRun(journal).terminal ? journal : undefined; + }); + + const runRoot = path.join(root, "runs", started.run.runId); + await Promise.all([ + fs.writeFile(path.join(runRoot, "run.json"), "{broken-current", "utf8"), + fs.writeFile(path.join(runRoot, "run.last-known-good.json"), "{broken-recovery", "utf8"), + fs.rm(path.join(root, "run-index.json")), + ]); + const restarted = new CreateImagesService(root, serviceOptions(now)); + await restarted.initialize(); + assert.equal(await restarted.runs.journals.hasUnassociatedDegradedRuns(), true); + + const deletion = await restarted.deleteWorkflow(workflow.id, workflow.revision, () => true); + assert.equal(deletion.status, "unavailable"); + if (deletion.status === "unavailable") { + assert.match(deletion.message, /unassociated run recovery authority/u); + } + assert.deepEqual(await restarted.workflows.get(workflow.id), workflow); + assert.equal(await restarted.runs.journals.hasUnassociatedDegradedRuns(), true); +}); + +test("production services preserve a multi-output local run, ownership, and GC protection across restart", async (t) => { + const root = await temporaryRoot(t); + let clock = Date.parse(NOW); + const now = () => clock++; + const first = new CreateImagesService(root, serviceOptions(now)); + await first.initialize(); + + const workflow = createStarterWorkflow({ + workflowId: "phase-three-production-join", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + const generation = workflow.nodes.find((node) => node.id === "generate-1"); + const prompt = workflow.nodes.find((node) => node.id === "prompt-1"); + assert.equal(generation?.type, "generate-image"); + assert.equal(prompt?.type, "prompt"); + if (!generation || generation.type !== "generate-image" || !prompt || prompt.type !== "prompt") { + return; + } + prompt.data.text = "A deterministic three-image production join"; + generation.data.providerId = "gemini"; + generation.data.modelId = "gemini-3.1-flash-image"; + generation.data.count = 3; + await first.mutateWorkflow(workflow.id, [], () => first.workflows.create(workflow)); + assert.deepEqual(await first.workflows.get(workflow.id), workflow); + + const started = await first.runs.start( + { + workflowId: workflow.id, + expectedRevision: workflow.revision, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const runId = started.run.runId; + + const terminalJournal = await waitUntil("the durable local run to finish", async () => { + const journal = await first.runs.journals.get(runId); + return journal && projectCreateImagesRun(journal).terminal ? journal : undefined; + }); + const terminalList = await waitUntil("the completed run to leave active state", async () => { + const snapshot = await first.runs.list(workflow.id); + return snapshot.status === "ready" && + snapshot.activeRun === undefined && + snapshot.history.some((entry) => entry.runId === runId) + ? snapshot + : undefined; + }); + const projection = projectCreateImagesRun(terminalJournal); + assert.equal(projection.status, "succeeded"); + const generatedAssetIds = projection.nodes["generate-1"]?.outputAssetIds ?? []; + assert.equal(generatedAssetIds.length, 3); + assert.equal(new Set(generatedAssetIds).size, 3); + assert.equal( + terminalList.history.find((entry) => entry.runId === runId)?.outputCount, + generatedAssetIds.length, + ); + assert.equal(terminalList.history.find((entry) => entry.runId === runId)?.requestCount, 1); + assert.deepEqual( + terminalList.latestTerminalRun?.nodes.find((node) => node.nodeId === "generate-1") + ?.outputAssetIds, + generatedAssetIds, + ); + const acceptedIndex = terminalJournal.events.findIndex( + (event) => event.type === "node-submission-accepted" && event.nodeId === "generate-1", + ); + const succeededIndex = terminalJournal.events.findIndex( + (event) => event.type === "node-succeeded" && event.nodeId === "generate-1", + ); + assert.ok(acceptedIndex >= 0 && succeededIndex > acceptedIndex); + + for (const assetId of generatedAssetIds) { + const asset = await first.assets.getAvailable(assetId); + assert.ok(asset); + assert.deepEqual(asset.origin, { + kind: "provider", + providerId: "local-mock", + modelId: "deterministic-v1", + runId, + }); + assert.equal(first.references.isRunAssetReferenced(runId, assetId), true); + assert.equal(await first.runs.isRunAssetReferenced(workflow.id, runId, assetId), true); + } + const firstGc = await first.assets.planGarbageCollection(0); + assert.deepEqual( + firstGc.candidateAssetIds.filter((assetId) => generatedAssetIds.includes(assetId)), + [], + ); + + const firstIndex = JSON.parse( + await fs.readFile(path.join(root, "asset-index.json"), "utf8"), + ) as StoredAssetIndex; + assert.equal(firstIndex.schemaVersion, 1); + for (const assetId of generatedAssetIds) { + assert.deepEqual(firstIndex.assets[assetId]?.referenceOwners, [`run:${runId}`]); + } + + clock += 1_000; + const restarted = new CreateImagesService(root, serviceOptions(now)); + await restarted.initialize(); + assert.deepEqual(await restarted.workflows.get(workflow.id), workflow); + const restartedJournal = await restarted.runs.journals.get(runId); + assert.ok(restartedJournal); + assert.equal(restartedJournal && projectCreateImagesRun(restartedJournal).status, "succeeded"); + assert.deepEqual( + restartedJournal && + projectCreateImagesRun(restartedJournal).nodes["generate-1"]?.outputAssetIds, + generatedAssetIds, + ); + const restartedList = await restarted.runs.list(workflow.id); + assert.equal(restartedList.status, "ready"); + if (restartedList.status !== "ready") return; + assert.equal(restartedList.activeRun, undefined); + assert.equal(restartedList.history.find((entry) => entry.runId === runId)?.status, "succeeded"); + assert.equal( + restartedList.history.find((entry) => entry.runId === runId)?.outputCount, + generatedAssetIds.length, + ); + assert.deepEqual( + restartedList.latestTerminalRun?.nodes.find((node) => node.nodeId === "generate-1") + ?.outputAssetIds, + generatedAssetIds, + ); + const deletion = await restarted.deleteWorkflow(workflow.id, workflow.revision, () => true); + assert.equal(deletion.status, "unavailable"); + if (deletion.status === "unavailable") assert.match(deletion.message, /retained run history/u); + assert.deepEqual(await restarted.workflows.get(workflow.id), workflow); + const retainedList = await restarted.runs.list(workflow.id); + assert.equal(retainedList.status, "ready"); + assert.equal( + retainedList.status === "ready" + ? retainedList.history.some((entry) => entry.runId === runId) + : false, + true, + ); + const retainedDetail = await restarted.runs.get(workflow.id, runId); + assert.equal(retainedDetail.status, "ready"); + assert.equal(retainedDetail.status === "ready" ? retainedDetail.run.runId : undefined, runId); + for (const assetId of generatedAssetIds) { + assert.ok(await restarted.assets.getAvailable(assetId)); + assert.equal(restarted.references.isRunAssetReferenced(runId, assetId), true); + assert.equal(await restarted.runs.isRunAssetReferenced(workflow.id, runId, assetId), true); + } + const restartedGc = await restarted.assets.planGarbageCollection(0); + assert.deepEqual( + restartedGc.candidateAssetIds.filter((assetId) => generatedAssetIds.includes(assetId)), + [], + ); + const restartedIndex = JSON.parse( + await fs.readFile(path.join(root, "asset-index.json"), "utf8"), + ) as StoredAssetIndex; + for (const assetId of generatedAssetIds) { + assert.deepEqual(restartedIndex.assets[assetId]?.referenceOwners, [`run:${runId}`]); + } +}); + +test("transient publication contention retains every renderer-disconnect run owner", () => { + const runIds = ["run-1", "run-2", "run-3", "run-4"]; + for (const status of ["unavailable", "busy", "not-found"]) { + assert.deepEqual( + runIds.map((runId) => shouldReleaseCreateImagesRunOwner(runId, { status })), + [false, false, false, false], + ); + } + assert.deepEqual( + runIds.map((runId) => + shouldReleaseCreateImagesRunOwner(runId, { + status: "ready", + activeRun: { runId: "run-1" }, + }), + ), + [false, true, true, true], + ); + assert.deepEqual( + runIds.map((runId) => shouldReleaseCreateImagesRunOwner(runId, { status: "ready" })), + [true, true, true, true], + ); +}); + +test("durable workflow recovery and asset-picking handlers initialize fully before side effects", async () => { + const handlers = (await fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8")) + .replace(/\s+/gu, " ") + .replace(/\( /gu, "("); + const handlerSlice = (channel: string, nextChannel: string): string => { + const start = handlers.indexOf(`ipcMain.handle("${channel}"`); + const end = handlers.indexOf(`ipcMain.handle("${nextChannel}"`, start + 1); + assert.ok(start >= 0, `${channel} must be registered`); + assert.ok(end > start, `${channel} must precede ${nextChannel}`); + return handlers.slice(start, end); + }; + const assertInitializedBefore = ( + channel: string, + nextChannel: string, + sideEffects: readonly string[], + ): void => { + const source = handlerSlice(channel, nextChannel); + const initializedAt = source.indexOf("await service.initialize()"); + assert.ok(initializedAt >= 0, `${channel} must perform full service initialization`); + for (const sideEffect of sideEffects) { + const sideEffectAt = source.indexOf(sideEffect); + assert.ok(sideEffectAt >= 0, `${channel} must retain ${sideEffect}`); + assert.ok(initializedAt < sideEffectAt, `${channel} must initialize before ${sideEffect}`); + } + }; + + assertInitializedBefore("imageWorkflows:recover", "imageWorkflows:repairRecoveryMetadata", [ + "service.workflows.recover(", + ]); + assertInitializedBefore( + "imageWorkflows:repairRecoveryMetadata", + "imageWorkflows:discardAutosave", + ["service.workflows.repairRecoveryMetadata("], + ); + assertInitializedBefore("imageWorkflows:discardAutosave", "imageWorkflows:pickAsset", [ + "service.workflows.discardAutosave(", + ]); + assertInitializedBefore("imageWorkflows:pickAsset", "imageWorkflows:grantAsset", [ + "dialog.showOpenDialog(", + "ingestSelectedImage(service", + ]); +}); + +test("workflow deletion uses the admission-fenced run lifecycle guard and honest UI copy", async () => { + const [handlers, service, runService, view] = await Promise.all([ + fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8"), + fs.readFile(path.resolve("main/services/create-images/create-images-service.ts"), "utf8"), + fs.readFile(path.resolve("main/services/create-images/run-service.ts"), "utf8"), + fs.readFile(path.resolve("renderer/create-images/create-images-view.tsx"), "utf8"), + ]); + const deleteHandler = handlers.slice( + handlers.indexOf('ipcMain.handle("imageWorkflows:delete"'), + handlers.indexOf('ipcMain.handle("imageWorkflows:recover"'), + ); + assert.match(deleteHandler, /service\.deleteWorkflow\(/u); + assert.doesNotMatch(deleteHandler, /service\.runs\.list|service\.workflows\.delete/u); + assert.match(service, /this\.runs\.deleteWorkflowIfRunLifecycleEmpty\(/u); + const deletionGuard = runService.slice( + runService.indexOf("async deleteWorkflowIfRunLifecycleEmpty"), + runService.indexOf( + "async stop(", + runService.indexOf("async deleteWorkflowIfRunLifecycleEmpty"), + ), + ); + const fenceAt = deletionGuard.indexOf("const previous = this.startAdmissionTail"); + const auditAt = deletionGuard.indexOf("await this.journals.auditWorkflowAdmission(workflowId)"); + const listAt = deletionGuard.indexOf("await this.list(workflowId)"); + const deleteAt = deletionGuard.indexOf("value: await deleteWorkflow()"); + assert.ok(fenceAt >= 0 && fenceAt < auditAt); + assert.ok(auditAt < listAt && listAt < deleteAt); + assert.match(deletionGuard, /evaluateCreateImagesWorkflowDeletion\(snapshot\)/u); + assert.match(runService, /snapshot\.latestTerminalRun \|\| snapshot\.history\.length > 0/u); + assert.match(runService, /snapshot\.recoveries\.length > 0/u); + assert.match(view, /can be deleted only when it has no active run, retained run history/u); + assert.match(view, /mutationMessage\(result, "Aiden could not delete the workflow\."\)/u); +}); + +test("main, preload, and renderer sources keep the exact run lifecycle and authorization contract", async () => { + const [handlers, preloadChannels, preload, rendererIpc, main] = await Promise.all([ + fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8"), + fs.readFile(path.resolve("renderer/preload-channels.ts"), "utf8"), + fs.readFile(path.resolve("renderer/preload.ts"), "utf8"), + fs.readFile(path.resolve("renderer/lib/ipc.ts"), "utf8"), + fs.readFile(path.resolve("main/index.ts"), "utf8"), + ]); + + const runHandlerChannels = [ + ...handlers.matchAll(/ipcMain\.handle\(\s*"(imageWorkflows:[^"]+)"/gu), + ] + .map((match) => match[1]!) + .filter((channel) => /Run|Runs/u.test(channel)) + .sort(); + assert.deepEqual(runHandlerChannels, [ + "imageWorkflows:discardDegradedRun", + "imageWorkflows:downloadRunAsset", + "imageWorkflows:downloadRunAssetsZip", + "imageWorkflows:getRun", + "imageWorkflows:grantRunAsset", + "imageWorkflows:listRuns", + "imageWorkflows:planDegradedRunDiscard", + "imageWorkflows:planRunHistoryPrune", + "imageWorkflows:prepareRun", + "imageWorkflows:pruneRunHistory", + "imageWorkflows:recoverRun", + "imageWorkflows:resolveRunAmbiguity", + "imageWorkflows:resumeRun", + "imageWorkflows:startRun", + "imageWorkflows:stopRun", + "imageWorkflows:subscribeRuns", + "imageWorkflows:unsubscribeRuns", + ]); + + const ownerBinding = handlers.slice( + handlers.indexOf("const bindRunToOwner"), + handlers.indexOf('ipcMain.handle("imageWorkflows:list"'), + ); + assert.match(ownerBinding, /owner\.onInvalidated\(invalidate\)/u); + assert.match( + ownerBinding, + /runs\.stop\(\s*workflowId,\s*runId,\s*"renderer-disconnected",?\s*\)/u, + ); + const normalizedHandlers = handlers.replace(/\s+/gu, " ").replace(/\( /gu, "("); + assert.match( + normalizedHandlers, + /runs\.start\(\{ workflowId: input\.workflowId, expectedRevision: input\.expectedRevision, scope: input\.scope, executionMode: input\.consent\.executionMode,/u, + ); + assert.match( + normalizedHandlers, + /if \(result\.status === "started"\) \{ bindRunToOwner\(owner, input\.workflowId, result\.run\.runId\); \}/u, + ); + + const grantRunAsset = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:grantRunAsset"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:storageHealth"'), + ); + assert.match( + grantRunAsset, + /service\.runs\.isRunAssetReferenced\(input\.workflowId, input\.runId, input\.assetId, \)/u, + ); + assert.match( + grantRunAsset, + /service\.references\.isRunAssetReferenced\(input\.runId, input\.assetId\)/u, + ); + assert.match( + grantRunAsset, + /service\.grantAsset\(owner, input\.assetId, \(assetId\) => service\.references\.isRunAssetReferenced\(input\.runId, assetId\), \)/u, + ); + + assert.match(preloadChannels, /"imageWorkflows:run-changed"/u); + assert.match(preload, /NOTIFICATION_CHANNELS\.has\(channel\)/u); + const expectedRendererChannels = [ + "imageWorkflows:prepareRun", + "imageWorkflows:startRun", + "imageWorkflows:stopRun", + "imageWorkflows:listRuns", + "imageWorkflows:planRunHistoryPrune", + "imageWorkflows:pruneRunHistory", + "imageWorkflows:getRun", + "imageWorkflows:recoverRun", + "imageWorkflows:subscribeRuns", + "imageWorkflows:unsubscribeRuns", + "imageWorkflows:grantRunAsset", + "imageWorkflows:downloadRunAsset", + "imageWorkflows:run-changed", + ]; + for (const channel of expectedRendererChannels) { + assert.ok(rendererIpc.includes(`"${channel}"`), `${channel} must be wired by renderer IPC`); + } + + assert.match(handlers, /The subscription is live before this initial read begins/u); + const workflowListHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:list"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:get"'), + ); + assert.match(workflowListHandler, /service\.initializeReadOnlyLibrary\(\)/u); + const startRunHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:startRun"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:stopRun"'), + ); + assert.match( + startRunHandler, + /await runBounded\(owner\.id, \(\) => createImagesService\(\)\.runs\.start/u, + ); + assert.match(startRunHandler, /if \(bounded\.status === "busy"\) return runRateFailure\(\)/u); + const subscriptionHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:subscribeRuns"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:getRun"'), + ); + assert.match(subscriptionHandler, /const subscriptionId = randomUUID\(\)/u); + assert.doesNotMatch(subscriptionHandler, /runSubscriptions\.entries\(\)|const existing/u); + const recoverRunHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:recoverRun"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:planRunHistoryPrune"'), + ); + assert.match(recoverRunHandler, /await runBounded\(owner\.id, async \(\) =>/u); + assert.match( + recoverRunHandler, + /return bounded\.status === "completed" \? bounded\.value : runRateFailure\(\)/u, + ); + assert.match(handlers, /streamSequence: subscription\.streamSequence/u); + assert.match(handlers, /readRateLimiter/u); + assert.match(handlers, /readOwnerKey/u); + assert.doesNotMatch(handlers, /document:\$\{owner\.documentId\}:run-read/u); + assert.match(handlers, /runBounded/u); + assert.match(handlers, /readAllowed\(owner, 12\)/u); + const storageHealthHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:storageHealth"'), + ); + assert.match(storageHealthHandler, /if \(!readAllowed\(owner, 12\)\)/u); + assert.match(storageHealthHandler, /await runBounded\(owner\.id, async \(\) =>/u); + assert.match(storageHealthHandler, /service\.initializeReadOnlyLibrary\(\)/u); + assert.match(storageHealthHandler, /if \(bounded\.status === "busy"\)/u); + assert.match(handlers, /activeRunOperations >= 8 \|\| ownerOperations >= 2/u); + assert.match(handlers, /runSubscriptions\.size >= 128/u); + assert.match(handlers, /runPublicationStates\.size >= 256/u); + assert.match(handlers, /attempt < 3 && !snapshot/u); + assert.match(handlers, /shouldReleaseCreateImagesRunOwner\(runId, snapshot\)/u); + assert.doesNotMatch(handlers, /snapshot\.status !== "ready" \|\|\s*snapshot\.activeRun/u); + assert.match(handlers, /service\.runs\.journals\.indexHealth\(\)/u); + assert.match(handlers, /runIndex\.diagnostic === "rebuilt-corrupt-index"/u); + assert.match(handlers, /service\.workflows\.get\(workflowId\)/u); + assert.match(handlers, /parseCreateImagesResolveRunAmbiguityRequest/u); + assert.match(handlers, /runs\.resolveRunAmbiguity\(input\)/u); + assert.match(main, /activeImageRunsWithinQuitDeadline/u); + assert.match(main, /stopped\.status === "blocked"/u); + assert.match(main, /confirmActiveImageRunsBeforeQuit/u); + assert.match(main, /"Keep Aiden Open", stopLabel/u); + assert.match(main, /showQuitMessageBox\(window/u); + assert.match(main, /dialog\.showMessageBoxSync\(options\)/u); + assert.match(main, /confirmActiveImageRunsBeforeQuit\(\)/u); + assert.match( + main, + /function resumeCreateImagesAfterCancelledShutdown\(\): void \{[\s\S]*?resumeRunAdmissionsAfterCancelledShutdown\(\);[\s\S]*?\}/u, + ); + const applicationQuit = main.slice( + main.indexOf("async function requestApplicationQuit"), + main.indexOf("async function clearRendererOnboardingCompletion"), + ); + assert.match( + applicationQuit, + /finally \{[\s\S]*?if \(!shutdownStarted\) resumeCreateImagesAfterCancelledShutdown\(\);/u, + ); + const shutdownAndQuit = main.slice( + main.indexOf("async function shutdownAndQuit"), + main.indexOf("async function refreshCloseGuardFromRenderer"), + ); + assert.match( + shutdownAndQuit, + /computerUseSettings\.resumeAfterCancelledShutdown\(\);\s*resumeCreateImagesAfterCancelledShutdown\(\);/u, + ); + const beforeQuit = main.slice( + main.indexOf('app.on("before-quit"'), + main.indexOf('app.on("will-quit"'), + ); + assert.ok( + beforeQuit.indexOf("confirmActiveImageRunsBeforeQuit()") < + beforeQuit.indexOf("shutdownAndQuit()"), + "windowless quit must confirm active image runs before shutdown can stop them", + ); + + assert.equal(main.match(/createImagesService\(\)\.runs\.stopAll\("app-quit"\)/gu)?.length, 2); +}); + +test("Phase 4 provider status is a bounded main-owned API-key capability read", async () => { + const [handlers, statusCore, providerRegistry, rendererIpc, queries, preloadChannels] = + await Promise.all([ + fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8"), + fs.readFile( + path.resolve("main/services/create-images/gemini-provider-status-core.ts"), + "utf8", + ), + fs.readFile(path.resolve("main/services/provider-registry.ts"), "utf8"), + fs.readFile(path.resolve("renderer/lib/ipc.ts"), "utf8"), + fs.readFile(path.resolve("renderer/lib/queries.ts"), "utf8"), + fs.readFile(path.resolve("renderer/preload-channels.ts"), "utf8"), + ]); + + const normalizedHandlers = handlers.replace(/\s+/gu, " ").replace(/\( /gu, "("); + const providerStatusHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:providerStatus"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:list"'), + ); + assert.match(providerStatusHandler, /rendererDocumentOwner\(event/u); + assert.match(providerStatusHandler, /if \(!readAllowed\(owner, 2\)\)/u); + assert.match(providerStatusHandler, /await runBounded\(owner\.id/u); + assert.match( + providerStatusHandler, + /providerRegistry\.getBuiltinCredentialKind\(CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID/u, + ); + assert.match( + providerStatusHandler, + /providerRegistry\.getBuiltinRequestAuth\(CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID\)/u, + ); + assert.match(providerStatusHandler, /bounded\.status === "busy" \|\| owner\.isDestroyed\(\)/u); + assert.doesNotMatch(providerStatusHandler, /console\.|onNotification|apiKey/u); + + assert.match(statusCore, /CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID = "google"/u); + assert.match(statusCore, /kind !== "api_key"/u); + assert.match(statusCore, /usableApiKey\(auth\)/u); + assert.match(providerRegistry, /async getBuiltinCredentialKind\(/u); + assert.match(providerRegistry, /await this\.credentials\.list\(\)/u); + assert.match(providerRegistry, /this\.models\.getAuth\(providerId\)/u); + + assert.match( + rendererIpc, + /providerStatus:\s*\(\) =>\s*invoke\("imageWorkflows:providerStatus"\)/u, + ); + assert.match(queries, /createImagesProviderStatus: \["createImagesProviderStatus", "gemini"\]/u); + assert.match(queries, /export function useCreateImagesProviderStatus\(enabled = true\)/u); + assert.match(queries, /queryFn: createImagesApi\.providerStatus/u); + assert.match(queries, /retry: false/u); + assert.match(queries, /refetchOnWindowFocus: true/u); + assert.match(preloadChannels, /"imageWorkflows:"/u); + assert.doesNotMatch(preloadChannels, /imageWorkflows:provider-status-changed/u); +}); diff --git a/main/services/create-images/phase-two-integration.test.ts b/main/services/create-images/phase-two-integration.test.ts new file mode 100644 index 00000000..7c0cac01 --- /dev/null +++ b/main/services/create-images/phase-two-integration.test.ts @@ -0,0 +1,461 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { + ContentAddressedAssetStore, + type AssetReferenceAuthority, + type AssetReferenceSnapshot, +} from "./asset-store-core.js"; +import { CreateImagesService } from "./create-images-service.js"; +import { WorkflowManifestStore } from "./workflow-manifest-store.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const checksum = concat(typeBytes, data); + return concat(u32(data.byteLength), checksum, u32(crc32(checksum))); +} + +function largeStaticPng(payloadBytes = 20 * 1024 * 1024): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("tEXt", new Uint8Array(payloadBytes)), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +async function* imageChunks(bytes: Uint8Array): AsyncGenerator { + const chunkSize = 256 * 1024; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + yield bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)); + } +} + +class IntegrationReferenceAuthority implements AssetReferenceAuthority { + snapshot: AssetReferenceSnapshot = { + epoch: "0", + completeKinds: ["workflow", "run", "export"], + records: [], + }; + + async withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise { + return callback(structuredClone(this.snapshot)); + } +} + +test("large content-addressed images and workflows survive restart and recovery without graph bytes", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-phase-two-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const authority = new IntegrationReferenceAuthority(); + const thumbnail = largeStaticPng(0); + const options = { + deepValidator: { + async validate({ descriptor }: { descriptor: { width: number; height: number } }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: thumbnail, + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }; + + const largeImage = largeStaticPng(); + const firstAssets = new ContentAddressedAssetStore(root, authority, options); + const imported = await firstAssets.ingest(imageChunks(largeImage), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "twenty-megabyte-reference.png", + }); + assert.ok(imported.asset.byteLength > 20 * 1024 * 1024); + assert.equal(Object.prototype.hasOwnProperty.call(imported.asset, "filePath"), false); + + const now = "2026-08-11T12:00:00.000Z"; + const workflow = createStarterWorkflow({ + workflowId: "durable-large-image", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now, + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 20, y: 340 }, + data: { assetId: imported.asset.assetId, label: "Large local reference" }, + }); + workflow.assetRefs = [imported.asset.assetId]; + const firstWorkflows = new WorkflowManifestStore(() => root); + await firstWorkflows.create(workflow); + authority.snapshot = { + epoch: "1", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "workflow", id: workflow.id, assetIds: [imported.asset.assetId] }], + }; + await firstAssets.rebuildReferenceAccounting(); + + const workflowPath = path.join(root, "workflows", workflow.id, "workflow.json"); + const graphText = await fs.readFile(workflowPath, "utf8"); + assert.ok(Buffer.byteLength(graphText) < 64 * 1024); + assert.doesNotMatch(graphText, /data:image|;base64,/u); + assert.notEqual(path.join(root, "index.json"), path.join(root, "asset-index.json")); + + const restartedWorkflows = new WorkflowManifestStore(() => root); + const restartedAssets = new ContentAddressedAssetStore(root, authority, options); + const reopenedWorkflow = await restartedWorkflows.get(workflow.id); + const reopenedAsset = await restartedAssets.get(imported.asset.assetId); + assert.equal(reopenedWorkflow?.assetRefs[0], imported.asset.assetId); + assert.equal(reopenedAsset?.byteLength, largeImage.byteLength); + const generatedThumbnail = await restartedAssets.getThumbnail(imported.asset.assetId, 512); + assert.ok(generatedThumbnail.byteLength < 4 * 1024 * 1024); + assert.ok(restartedAssets.thumbnailCacheStatus().byteLength < 64 * 1024 * 1024); + assert.deepEqual((await restartedAssets.planGarbageCollection(0)).candidateAssetIds, []); + + await fs.writeFile(workflowPath, "{corrupt", "utf8"); + const recoveryStore = new WorkflowManifestStore(() => root); + const recovery = await recoveryStore.inspect(workflow.id); + assert.equal(recovery.status, "recovery-required"); + const restored = await recoveryStore.recover( + workflow.id, + "last-known-good", + workflow.revision, + "2026-08-11T12:01:00.000Z", + ); + assert.deepEqual(restored.assetRefs, [imported.asset.assetId]); + assert.equal( + (await restartedAssets.get(imported.asset.assetId))?.assetId, + imported.asset.assetId, + ); +}); + +test("asset protocol falls back to the validated source when thumbnail generation is unavailable", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-preview-fallback-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const source = largeStaticPng(0); + const service = new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: Uint8Array.from([0]), + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }); + const imported = await service.assets.ingest(imageChunks(source), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "fallback-reference.png", + }); + + const response = await service.assetResponse(imported.asset.assetId); + assert.equal(response?.status, 200); + assert.equal(response?.headers.get("content-type"), "image/png"); + assert.deepEqual(new Uint8Array(await response!.arrayBuffer()), source); + + const originalResponse = await service.assetResponse(imported.asset.assetId, "original"); + assert.equal(originalResponse?.status, 200); + assert.equal(originalResponse?.headers.get("content-type"), "image/png"); + assert.deepEqual(new Uint8Array(await originalResponse!.arrayBuffer()), source); +}); + +test("durable journal references survive renderer loss, lease expiry, GC, and recovery", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-journal-gc-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + let now = Date.parse("2026-08-11T12:00:00.000Z"); + let rendererAlive = true; + let failAfterJournal = false; + const thumbnail = largeStaticPng(0); + const service = new CreateImagesService(root, { + workflowDurability: { + async afterJournalPublished() { + if (failAfterJournal) rendererAlive = false; + }, + }, + assetStore: { + now: () => now, + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: thumbnail, + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }); + await service.initialize(); + const imported = await service.assets.ingest(imageChunks(thumbnail), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "pending-journal-reference.png", + }); + const workflow = createStarterWorkflow({ + workflowId: "journal-reference-recovery", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + await service.mutateWorkflow(workflow.id, [], () => service.workflows.create(workflow)); + const lease = await service.assets.acquirePreviewLease( + imported.asset.assetId, + "journal-regression", + 1_000, + ); + const pending = structuredClone(workflow); + pending.revision = 2; + pending.updatedAt = "2026-08-11T12:01:00.000Z"; + pending.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 20, y: 340 }, + data: { assetId: imported.asset.assetId, label: "Pending durable reference" }, + }); + pending.assetRefs = [imported.asset.assetId]; + failAfterJournal = true; + + await assert.rejects( + () => + service.mutateWorkflow(workflow.id, pending.assetRefs, () => + service.workflows.save(pending, 1, () => rendererAlive), + ), + /renderer document is no longer active/u, + ); + assert.equal((await service.workflows.autosaveStatus(workflow.id)).state, "pending"); + + now = lease.expiresAt + 1; + const pendingPlan = await service.assets.planGarbageCollection(0); + assert.deepEqual(pendingPlan.candidateAssetIds, []); + assert.deepEqual( + (await service.assets.applyGarbageCollection(pendingPlan.planId)).deletedAssetIds, + [], + ); + assert.equal((await service.assets.get(imported.asset.assetId))?.assetId, imported.asset.assetId); + + const recovered = await service.workflows.recover( + workflow.id, + "autosave", + pending.revision, + "2026-08-11T12:02:00.000Z", + ); + await service.refreshReferenceAuthority(); + assert.deepEqual(recovered.assetRefs, [imported.asset.assetId]); + const recoveredPlan = await service.assets.planGarbageCollection(0); + assert.deepEqual(recoveredPlan.candidateAssetIds, []); + assert.deepEqual( + (await service.assets.applyGarbageCollection(recoveredPlan.planId)).deletedAssetIds, + [], + ); + assert.equal((await service.assets.get(imported.asset.assetId))?.assetId, imported.asset.assetId); +}); + +test("missing workflow assets remain editable but surface deterministic integrity diagnostics", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-missing-asset-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const missingAssetId = "a".repeat(64); + const workflow = createStarterWorkflow({ + workflowId: "missing-asset-diagnostic", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "missing-image-1", + type: "image-input", + position: { x: 20, y: 340 }, + data: { assetId: missingAssetId, label: "Missing local reference" }, + }); + workflow.assetRefs = [missingAssetId]; + + const service = new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: largeStaticPng(0), + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }); + await service.workflows.create(workflow); + await service.initialize(); + + assert.equal((await service.workflows.list())[0]?.health, "healthy"); + assert.equal((await service.assets.status()).healthy, true); + assert.deepEqual(service.missingAssetIdsForWorkflow(workflow.id), [missingAssetId]); + assert.equal(service.missingAssetCount(), 1); + + const retained = structuredClone(workflow); + retained.revision = 2; + retained.updatedAt = "2026-08-11T12:01:00.000Z"; + retained.title = "Editable despite a diagnosed missing image"; + await service.mutateWorkflow(workflow.id, retained.assetRefs, () => + service.workflows.save(retained, 1), + ); + assert.deepEqual(service.missingAssetIdsForWorkflow(workflow.id), [missingAssetId]); + assert.equal(service.missingAssetCount(), 1); + + let introducedMissingAssetPublished = false; + await assert.rejects( + () => + service.mutateWorkflow(workflow.id, [missingAssetId, "b".repeat(64)], async () => { + introducedMissingAssetPublished = true; + }), + /does not exist/u, + ); + assert.equal(introducedMissingAssetPublished, false); + + const repaired = structuredClone(retained); + repaired.revision = 3; + repaired.updatedAt = "2026-08-11T12:02:00.000Z"; + repaired.nodes = repaired.nodes.filter((node) => node.id !== "missing-image-1"); + repaired.assetRefs = []; + await service.mutateWorkflow(workflow.id, repaired.assetRefs, () => + service.workflows.save(repaired, 2), + ); + assert.deepEqual(service.missingAssetIdsForWorkflow(workflow.id), []); + assert.equal(service.missingAssetCount(), 0); +}); + +test("an indexed asset whose source disappears is diagnosed after restart", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-missing-source-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const options = { + assetStore: { + deepValidator: { + async validate({ descriptor }: { descriptor: { width: number; height: number } }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: largeStaticPng(0), + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }; + const service = new CreateImagesService(root, options); + const imported = await service.assets.ingest(imageChunks(largeStaticPng(0)), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + }); + const workflow = createStarterWorkflow({ + workflowId: "missing-published-source", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 0, y: 340 }, + data: { assetId: imported.asset.assetId }, + }); + workflow.assetRefs = [imported.asset.assetId]; + await service.mutateWorkflow(workflow.id, workflow.assetRefs, () => + service.workflows.create(workflow), + ); + await fs.rm( + path.join( + root, + "assets", + "sha256", + imported.asset.assetId.slice(0, 2), + `${imported.asset.assetId}.png`, + ), + ); + + const restarted = new CreateImagesService(root, options); + await restarted.initialize(); + assert.deepEqual(restarted.missingAssetIdsForWorkflow(workflow.id), [imported.asset.assetId]); + assert.equal(restarted.missingAssetCount(), 1); + assert.equal(await restarted.assets.getAvailable(imported.asset.assetId), undefined); +}); diff --git a/main/services/create-images/phase-zero-contracts.test.ts b/main/services/create-images/phase-zero-contracts.test.ts new file mode 100644 index 00000000..ffa9c697 --- /dev/null +++ b/main/services/create-images/phase-zero-contracts.test.ts @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AssetDeliveryGrantRegistry } from "./asset-delivery-core.js"; +import { + CREATE_IMAGES_FEATURE_FLAG, + createImagesEnabled, + createWhenImagesEnabled, +} from "./feature-flag.js"; +import { + buildGeminiInteractionsRequest, + GEMINI_INTERACTIONS_ENDPOINT, + GEMINI_IMAGE_MODELS, + validateGeminiImageRequest, +} from "./providers/gemini-interactions-core.js"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; + +function fakeOwner( + documentId: string, + id = 1, +): { + owner: RendererDocumentOwner; + invalidate(): void; +} { + let destroyed = false; + const listeners = new Set<() => void>(); + return { + owner: { + id, + documentId, + isDestroyed: () => destroyed, + send: () => undefined, + onInvalidated: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + invalidate: () => { + destroyed = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +function fakeLease(expiresAt = Number.MAX_SAFE_INTEGER, released?: () => void) { + return { expiresAt, release: released ?? (() => undefined) }; +} + +test("Create Images feature flag is fail-closed and does not construct services while disabled", () => { + assert.equal(createImagesEnabled({}), false); + assert.equal(createImagesEnabled({ [CREATE_IMAGES_FEATURE_FLAG]: "0" }), false); + assert.equal(createImagesEnabled({ [CREATE_IMAGES_FEATURE_FLAG]: "true" }), false); + assert.equal(createImagesEnabled({ [CREATE_IMAGES_FEATURE_FLAG]: " 1 " }), true); + let constructed = 0; + assert.equal( + createWhenImagesEnabled(() => { + constructed += 1; + return "service"; + }, {}), + undefined, + ); + assert.equal(constructed, 0); +}); + +test("asset delivery grants are opaque, document-bound, expiring, and revocable", () => { + let now = 1_000; + const registry = new AssetDeliveryGrantRegistry(() => now, 1_000, 2); + const firstOwner = fakeOwner("123:45:frame-token", 123); + const otherOwner = fakeOwner("124:46:other-frame", 124); + const authorized = new Set(["asset-1", "asset-2"]); + const first = registry.mint( + firstOwner.owner, + "asset-1", + (assetId) => authorized.has(assetId), + fakeLease(), + ); + assert.doesNotMatch(first.token, /asset-1|frame-token/u); + assert.equal(registry.resolve(first.token, otherOwner.owner), undefined); + assert.equal(registry.resolve(first.token, firstOwner.owner), "asset-1"); + assert.equal(registry.revoke(first.token, otherOwner.owner), false); + assert.equal(registry.revoke(first.token, firstOwner.owner), true); + + const second = registry.mint( + firstOwner.owner, + "asset-2", + (assetId) => authorized.has(assetId), + fakeLease(), + ); + now += 1_000; + assert.equal(registry.resolve(second.token, firstOwner.owner), undefined); + assert.equal(registry.size(), 0); +}); + +test("asset delivery grants enforce authorization and revoke on document invalidation", () => { + const registry = new AssetDeliveryGrantRegistry(); + const current = fakeOwner("123:45:frame-token", 123); + const allowed = new Set(["asset-1"]); + assert.throws( + () => + registry.mint(current.owner, "asset-denied", (assetId) => allowed.has(assetId), fakeLease()), + /not authorized/u, + ); + const grant = registry.mint( + current.owner, + "asset-1", + (assetId) => allowed.has(assetId), + fakeLease(), + ); + allowed.clear(); + assert.equal(registry.resolve(grant.token, current.owner), undefined); + const next = registry.mint(current.owner, "asset-1", () => true, fakeLease()); + current.invalidate(); + assert.equal(registry.resolve(next.token, current.owner), undefined); + assert.equal(registry.size(), 0); +}); + +test("asset delivery grants enforce a bounded registry", () => { + let now = 1_000; + const registry = new AssetDeliveryGrantRegistry(() => now, 60_000, 2); + const current = fakeOwner("123:45:frame-token", 123); + const first = registry.mint(current.owner, "asset-1", () => true, fakeLease()); + now += 1; + const second = registry.mint(current.owner, "asset-2", () => true, fakeLease()); + now += 1; + registry.mint(current.owner, "asset-3", () => true, fakeLease()); + assert.equal(registry.resolve(first.token, current.owner), undefined); + assert.equal(registry.resolve(second.token, current.owner), "asset-2"); + assert.equal(registry.revokeDocument(current.owner), 2); +}); + +test("every grant deletion path releases its resource exactly once", () => { + let now = 1_000; + const released: string[] = []; + const registry = new AssetDeliveryGrantRegistry(() => now, 1_000, 1); + const firstOwner = fakeOwner("123:45:first", 123); + const secondOwner = fakeOwner("124:46:second", 124); + const first = registry.mint( + firstOwner.owner, + "asset-1", + () => true, + fakeLease(2_000, () => released.push("first")), + ); + registry.mint( + firstOwner.owner, + "asset-2", + () => true, + fakeLease(2_000, () => released.push("evicted")), + ); + assert.equal(registry.resolve(first.token, firstOwner.owner), undefined); + assert.deepEqual(released, ["first"]); + firstOwner.invalidate(); + assert.deepEqual(released, ["first", "evicted"]); + + const expiring = registry.mint( + secondOwner.owner, + "asset-3", + () => true, + fakeLease(2_000, () => released.push("expired")), + ); + now = 2_000; + assert.equal(registry.resolve(expiring.token, secondOwner.owner), undefined); + assert.deepEqual(released, ["first", "evicted", "expired"]); +}); + +test("asset protocol delivery requires an exact-frame one-time authorization ticket", () => { + const registry = new AssetDeliveryGrantRegistry(); + const current = fakeOwner("123:45:frame-token", 123); + const grant = registry.mint(current.owner, "asset-1", () => true, fakeLease()); + assert.equal(registry.consumeProtocolRequest(grant.token), undefined); + assert.equal( + registry.authorizeProtocolRequest(grant.token, 123, "123:45:different-frame"), + false, + ); + assert.equal(registry.authorizeProtocolRequest(grant.token, 123, "123:45:frame-token"), true); + assert.equal(registry.consumeProtocolRequest(grant.token), "asset-1"); + assert.equal(registry.consumeProtocolRequest(grant.token), undefined); +}); + +test("Gemini contract uses the fixed Interactions origin and contains no credential fields", () => { + assert.equal( + GEMINI_INTERACTIONS_ENDPOINT, + "https://generativelanguage.googleapis.com/v1beta/interactions", + ); + assert.deepEqual( + GEMINI_IMAGE_MODELS.map((model) => model.id), + ["gemini-3.1-flash-lite-image", "gemini-3.1-flash-image", "gemini-3-pro-image"], + ); + const request = buildGeminiInteractionsRequest({ + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: " Draw a quiet harbor at dawn. ", + aspectRatio: "16:9", + imageSize: "2K", + outputMime: "image/png", + count: 1, + references: [ + { + assetId: "asset-1", + bytes: new Uint8Array([0, 1, 2, 3]), + mimeType: "image/png", + }, + ], + }); + assert.deepEqual(request, { + model: "gemini-3.1-flash-image", + input: [ + { type: "text", text: "Draw a quiet harbor at dawn." }, + { type: "image", mime_type: "image/png", data: "AAECAw==" }, + ], + response_format: { + type: "image", + aspect_ratio: "16:9", + image_size: "2K", + }, + store: false, + background: false, + }); + assert.doesNotMatch(JSON.stringify(request), /api.?key|authorization|credential/iu); + + const jpegRequest = buildGeminiInteractionsRequest({ + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: "Draw a quiet harbor at dawn.", + aspectRatio: "16:9", + imageSize: "2K", + outputMime: "image/jpeg", + count: 1, + references: [], + }); + assert.equal(jpegRequest.response_format.mime_type, "image/jpeg"); +}); + +test("Gemini contract rejects arbitrary models, excess output count, and empty media", () => { + const base = { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: "prompt", + aspectRatio: "1:1" as const, + imageSize: "1K" as const, + outputMime: "image/png" as const, + count: 1, + references: [], + }; + assert.throws( + () => validateGeminiImageRequest({ ...base, modelId: "attacker/model" }), + /not supported/u, + ); + assert.throws(() => validateGeminiImageRequest({ ...base, count: 2 }), /one output/u); + assert.throws( + () => + validateGeminiImageRequest({ + ...base, + references: [{ assetId: "asset-1", bytes: new Uint8Array(), mimeType: "image/png" }], + }), + /between 1 byte/u, + ); +}); diff --git a/main/services/create-images/presentation-store.test.ts b/main/services/create-images/presentation-store.test.ts new file mode 100644 index 00000000..b417a6f4 --- /dev/null +++ b/main/services/create-images/presentation-store.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { CreateImagesPresentationStore } from "./presentation-store.js"; + +const firstAsset = "a".repeat(64); +const secondAsset = "b".repeat(64); + +test("gallery presentation hiding survives restart and never stores image data", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-presentation-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const store = new CreateImagesPresentationStore(root); + assert.deepEqual(await store.setAssetHidden("workflow-1", secondAsset, true), [secondAsset]); + assert.deepEqual(await store.setAssetHidden("workflow-1", firstAsset, true), [firstAsset, secondAsset]); + assert.deepEqual(await new CreateImagesPresentationStore(root).hiddenAssetIds("workflow-1"), [ + firstAsset, + secondAsset, + ]); + const persisted = await fs.readFile(path.join(root, "presentation.json"), "utf8"); + assert.doesNotMatch(persisted, /data:image|prompt|path|credential/u); + assert.deepEqual(await store.setAssetHidden("workflow-1", firstAsset, false), [secondAsset]); +}); + +test("corrupt presentation data fails closed without touching the source file", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-presentation-corrupt-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const file = path.join(root, "presentation.json"); + await fs.writeFile(file, "not-json", "utf8"); + const store = new CreateImagesPresentationStore(root); + assert.deepEqual(await store.hiddenAssetIds("workflow-1"), []); + assert.equal(await fs.readFile(file, "utf8"), "not-json"); +}); diff --git a/main/services/create-images/presentation-store.ts b/main/services/create-images/presentation-store.ts new file mode 100644 index 00000000..b4047c2e --- /dev/null +++ b/main/services/create-images/presentation-store.ts @@ -0,0 +1,130 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const PRESENTATION_FILE = "presentation.json"; +const PRESENTATION_VERSION = 1; +const MAX_WORKFLOWS = 500; +const MAX_HIDDEN_ASSETS_PER_WORKFLOW = 50; +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const ASSET_ID_PATTERN = /^[a-f0-9]{64}$/u; + +interface PresentationDocumentV1 { + version: 1; + workflows: Record; +} + +function emptyDocument(): PresentationDocumentV1 { + return { version: PRESENTATION_VERSION, workflows: {} }; +} + +function parseDocument(value: unknown): PresentationDocumentV1 { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid"); + const record = value as Record; + if ( + Object.keys(record).some((key) => key !== "version" && key !== "workflows") || + record.version !== PRESENTATION_VERSION || + !record.workflows || + typeof record.workflows !== "object" || + Array.isArray(record.workflows) + ) { + throw new Error("invalid"); + } + const entries = Object.entries(record.workflows as Record); + if (entries.length > MAX_WORKFLOWS) throw new Error("invalid"); + const workflows: PresentationDocumentV1["workflows"] = {}; + for (const [workflowId, raw] of entries) { + if (!OPAQUE_ID_PATTERN.test(workflowId) || !raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("invalid"); + } + const entry = raw as Record; + if ( + Object.keys(entry).some((key) => key !== "hiddenAssetIds") || + !Array.isArray(entry.hiddenAssetIds) || + entry.hiddenAssetIds.length > MAX_HIDDEN_ASSETS_PER_WORKFLOW || + !entry.hiddenAssetIds.every( + (assetId) => typeof assetId === "string" && ASSET_ID_PATTERN.test(assetId), + ) || + new Set(entry.hiddenAssetIds).size !== entry.hiddenAssetIds.length + ) { + throw new Error("invalid"); + } + workflows[workflowId] = { hiddenAssetIds: [...entry.hiddenAssetIds] }; + } + return { version: PRESENTATION_VERSION, workflows }; +} + +/** Device-local presentation state. This never owns or retains an asset. */ +export class CreateImagesPresentationStore { + private readonly filePath: string; + private tail: Promise = Promise.resolve(); + + constructor(rootDirectory: string) { + this.filePath = path.join(rootDirectory, PRESENTATION_FILE); + } + + private serialized(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async read(): Promise { + try { + const bytes = await fs.readFile(this.filePath, "utf8"); + if (Buffer.byteLength(bytes, "utf8") > 256 * 1024) throw new Error("invalid"); + return parseDocument(JSON.parse(bytes) as unknown); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyDocument(); + // Presentation is disposable and never authoritative. Corrupt/future data + // fails closed to an empty view without touching run journals or assets. + return emptyDocument(); + } + } + + private async write(document: PresentationDocumentV1): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const temporary = `${this.filePath}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temporary, `${JSON.stringify(document)}\n`, { encoding: "utf8", mode: 0o600 }); + await fs.rename(temporary, this.filePath); + } finally { + await fs.rm(temporary, { force: true }).catch(() => undefined); + } + } + + hiddenAssetIds(workflowId: string): Promise { + return this.serialized(async () => { + if (!OPAQUE_ID_PATTERN.test(workflowId)) throw new Error("invalid"); + return [...(await this.read()).workflows[workflowId]?.hiddenAssetIds ?? []]; + }); + } + + setAssetHidden(workflowId: string, assetId: string, hidden: boolean): Promise { + return this.serialized(async () => { + if (!OPAQUE_ID_PATTERN.test(workflowId) || !ASSET_ID_PATTERN.test(assetId)) { + throw new Error("invalid"); + } + const document = await this.read(); + const hiddenAssetIds = new Set(document.workflows[workflowId]?.hiddenAssetIds ?? []); + if (hidden) hiddenAssetIds.add(assetId); + else hiddenAssetIds.delete(assetId); + if (hiddenAssetIds.size > MAX_HIDDEN_ASSETS_PER_WORKFLOW) { + throw new Error("limit"); + } + if (hiddenAssetIds.size > 0) { + if (!document.workflows[workflowId] && Object.keys(document.workflows).length >= MAX_WORKFLOWS) { + throw new Error("limit"); + } + document.workflows[workflowId] = { hiddenAssetIds: [...hiddenAssetIds].sort() }; + } else { + delete document.workflows[workflowId]; + } + await this.write(document); + return [...hiddenAssetIds].sort(); + }); + } +} diff --git a/main/services/create-images/provider-contract.ts b/main/services/create-images/provider-contract.ts new file mode 100644 index 00000000..88025eeb --- /dev/null +++ b/main/services/create-images/provider-contract.ts @@ -0,0 +1,57 @@ +import type { + CreateImagesAspectRatio, + CreateImagesImageSize, + CreateImagesOutputMime, +} from "../../../renderer/shared/create-images/schema.js"; + +export interface ImageProviderModelCapabilities { + id: string; + label: string; + providerId: string; + aspectRatios: readonly CreateImagesAspectRatio[]; + imageSizes: readonly CreateImagesImageSize[]; + outputMimes: readonly CreateImagesOutputMime[]; + maxReferenceImages: number; + maxOutputs: number; + supportsEditing: boolean; + supportsCancellation: boolean; +} + +export interface ImageGenerationReference { + assetId: string; + bytes: Uint8Array; + mimeType: "image/png" | "image/jpeg" | "image/webp"; +} + +export interface ValidatedImageGenerationRequest { + providerId: string; + modelId: string; + prompt: string; + aspectRatio: CreateImagesAspectRatio; + imageSize: CreateImagesImageSize; + outputMime: CreateImagesOutputMime; + count: number; + references: readonly ImageGenerationReference[]; +} + +export interface ImageProviderJob { + providerId: string; + kind: "synchronous" | "asynchronous"; + remoteId?: string; +} + +export interface ImageProviderAdapter { + readonly providerId: string; + listModels(): readonly ImageProviderModelCapabilities[]; + validate(request: ValidatedImageGenerationRequest): ValidatedImageGenerationRequest; + submit( + credential: TCredential, + request: ValidatedImageGenerationRequest, + context: { runId: string; nodeId: string; signal: AbortSignal }, + ): Promise; + cancel?( + credential: TCredential, + job: ImageProviderJob, + context: { signal: AbortSignal }, + ): Promise; +} diff --git a/main/services/create-images/providers/gemini-image-provider-core.test.ts b/main/services/create-images/providers/gemini-image-provider-core.test.ts new file mode 100644 index 00000000..14e3f61c --- /dev/null +++ b/main/services/create-images/providers/gemini-image-provider-core.test.ts @@ -0,0 +1,608 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AuthResult } from "@earendil-works/pi-ai"; +import type { ValidatedImageGenerationRequest } from "../provider-contract.js"; +import { + GEMINI_IMAGE_MAX_RESPONSE_BYTES, + GEMINI_IMAGE_MAX_RETRY_AFTER_MS, + GeminiImageProvider, +} from "./gemini-image-provider-core.js"; +import { GEMINI_INTERACTIONS_ENDPOINT } from "./gemini-interactions-core.js"; + +const SECRET_KEY = "AIzaSy_TEST_GEMINI_KEY_NEVER_LEAK"; +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; +const JPEG_BASE64 = Buffer.from( + Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xc0, 0, 11, 8, 0, 1, 0, 1, 1, 1, 0x11, 0, 0xff, 0xda, 0, 8, 1, 1, 0, 0, 63, + 0, 1, 2, 3, 0xff, 0xd9, + ]), +).toString("base64"); + +function auth(overrides: Partial = {}): AuthResult { + return { + auth: { + apiKey: SECRET_KEY, + ...overrides, + }, + source: "configured API key", + }; +} + +function request( + overrides: Partial = {}, +): ValidatedImageGenerationRequest { + return { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: "Draw a quiet harbor at dawn.", + aspectRatio: "16:9", + imageSize: "2K", + outputMime: "image/png", + count: 1, + references: [], + ...overrides, + }; +} + +function context(signal = new AbortController().signal) { + return { runId: "run-1", nodeId: "generate-1", signal }; +} + +function interaction(overrides: Record = {}): Record { + return { + id: "interactions/interaction-1", + status: "completed", + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: PNG_BASE64 }], + }, + ], + usage: { + total_input_tokens: 10, + total_output_tokens: 20, + total_thought_tokens: 5, + total_tokens: 35, + }, + ...overrides, + }; +} + +function jsonResponse(value: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(value), { + status: init.status ?? 200, + statusText: init.statusText, + headers: { "content-type": "application/json; charset=utf-8", ...init.headers }, + }); +} + +function injectedFetch( + implementation: (input: string | URL | Request, init?: RequestInit) => Promise, +): typeof globalThis.fetch { + return implementation as typeof globalThis.fetch; +} + +test("uses only the fixed endpoint and main-owned API-key header, with bounded inline stateless input", async () => { + let capturedUrl: string | URL | Request | undefined; + let capturedInit: RequestInit | undefined; + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async (url, init) => { + capturedUrl = url; + capturedInit = init; + return jsonResponse(interaction()); + }), + }); + const result = await provider.execute( + auth({ + baseUrl: "http://127.0.0.1:9999/private", + headers: { Authorization: "Bearer inherited-secret" }, + }), + request({ + references: [ + { + assetId: "reference-1", + mimeType: "image/png", + bytes: Uint8Array.from([1, 2, 3]), + }, + ], + }), + context(), + ); + + assert.equal(result.kind, "success"); + assert.equal(capturedUrl, GEMINI_INTERACTIONS_ENDPOINT); + assert.equal(capturedInit?.method, "POST"); + assert.equal(capturedInit?.redirect, "error"); + assert.equal(capturedInit?.headers?.["x-goog-api-key" as never], SECRET_KEY); + assert.deepEqual(capturedInit?.headers, { + "content-type": "application/json", + "x-goog-api-key": SECRET_KEY, + }); + const serialized = String(capturedInit?.body); + assert.doesNotMatch(serialized, /(?:127\.0\.0\.1|Authorization|inherited-secret|api.?key)/iu); + assert.deepEqual(JSON.parse(serialized).response_format, { + type: "image", + aspect_ratio: "16:9", + image_size: "2K", + }); + assert.equal(JSON.parse(serialized).store, false); + assert.equal(JSON.parse(serialized).background, false); +}); + +test("returns validated path-free image and aggregate usage metadata", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => jsonResponse(interaction())), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "success"); + if (result.kind !== "success") return; + assert.equal(result.output.images.length, 1); + assert.equal(Buffer.from(result.output.images[0].bytes).toString("base64"), PNG_BASE64); + assert.deepEqual(result.output.images[0].metadata, { + source: "gemini-interactions", + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + mimeType: "image/png", + width: 1, + height: 1, + byteLength: 68, + outputIndex: 0, + }); + assert.deepEqual(result.output.metadata, { + source: "gemini-interactions", + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + count: 1, + totalByteLength: 68, + interactionId: "interactions/interaction-1", + usage: { + totalInputTokens: 10, + totalOutputTokens: 20, + totalThoughtTokens: 5, + totalTokens: 35, + }, + }); + assert.doesNotMatch( + JSON.stringify(result), + /(?:AIza|quiet harbor|file:|https?:\/\/|absolute|path)/iu, + ); +}); + +test("never follows redirects or accepts an unexpected/private response URL", async () => { + const calls: string[] = []; + const redirectProvider = new GeminiImageProvider({ + fetch: injectedFetch(async (url) => { + calls.push(String(url)); + return jsonResponse( + {}, + { + status: 302, + headers: { location: "http://127.0.0.1/internal" }, + }, + ); + }), + }); + const redirect = await redirectProvider.execute(auth(), request(), context()); + assert.equal(redirect.kind, "failure"); + assert.equal(redirect.providerErrorCode, "redirect-rejected"); + assert.deepEqual(calls, [GEMINI_INTERACTIONS_ENDPOINT]); + + const unexpectedResponse = jsonResponse(interaction()); + Object.defineProperty(unexpectedResponse, "url", { value: "http://[::1]/internal" }); + const originProvider = new GeminiImageProvider({ + fetch: injectedFetch(async () => unexpectedResponse), + }); + const origin = await originProvider.execute(auth(), request(), context()); + assert.equal(origin.kind, "failure"); + assert.equal(origin.providerErrorCode, "redirect-rejected"); +}); + +test("redacts credentials and raw network errors while preserving post-send ambiguity", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => { + throw new Error(`socket failed for ${SECRET_KEY}: raw provider response`); + }), + }); + const result = await provider.execute(auth(), request(), context()); + assert.deepEqual(result, { + kind: "ambiguous-submit", + providerErrorCode: "offline", + error: "A network error left the Gemini request's submission state unknown.", + }); + assert.doesNotMatch(JSON.stringify(result), new RegExp(SECRET_KEY, "u")); + assert.doesNotMatch(JSON.stringify(result), /raw provider response/u); +}); + +test("rejects missing credentials and invalid requests before calling fetch", async () => { + let calls = 0; + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => { + calls += 1; + return jsonResponse(interaction()); + }), + }); + assert.deepEqual(await provider.execute(undefined, request(), context()), { + kind: "failure", + providerErrorCode: "authentication-required", + error: "Connect a Google Gemini API key before creating remote images.", + retrySafety: "confirmed-not-submitted", + }); + const invalid = await provider.execute( + auth(), + request({ modelId: "http://127.0.0.1/private-model" }), + context(), + ); + assert.equal(invalid.kind, "failure"); + assert.equal(invalid.providerErrorCode, "invalid-request"); + assert.equal(invalid.retrySafety, "confirmed-not-submitted"); + assert.equal(calls, 0); +}); + +test("normalizes auth, permission, rate limit, provider, and request status without body leakage", async () => { + const cases = [ + [401, "authentication-required"], + [403, "permission-denied"], + [500, "provider-unavailable"], + [400, "request-rejected"], + ] as const; + for (const [status, code] of cases) { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse({ error: { message: `secret ${SECRET_KEY}` } }, { status }), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "failure"); + assert.equal(result.providerErrorCode, code); + assert.equal(result.retrySafety, "never"); + assert.doesNotMatch(JSON.stringify(result), new RegExp(SECRET_KEY, "u")); + } + + const invalidKey = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + { + error: { + status: "INVALID_ARGUMENT", + message: `credential ${SECRET_KEY}`, + details: [{ reason: "API_KEY_INVALID", metadata: { key: SECRET_KEY } }], + }, + }, + { status: 400 }, + ), + ), + }); + const invalidKeyResult = await invalidKey.execute(auth(), request(), context()); + assert.equal(invalidKeyResult.kind, "failure"); + assert.equal(invalidKeyResult.providerErrorCode, "authentication-required"); + assert.doesNotMatch(JSON.stringify(invalidKeyResult), new RegExp(SECRET_KEY, "u")); + + const limited = new GeminiImageProvider({ + now: () => 1_000, + fetch: injectedFetch(async () => + jsonResponse({}, { status: 429, headers: { "retry-after": "999999" } }), + ), + }); + assert.deepEqual(await limited.execute(auth(), request(), context()), { + kind: "rate-limited", + providerErrorCode: "rate-limited", + error: "Gemini is rate limiting image requests.", + retrySafety: "never", + retryAfterMs: GEMINI_IMAGE_MAX_RETRY_AFTER_MS, + }); +}); + +test("requires an exact JSON response type and bounded complete body", async () => { + const wrongType = new GeminiImageProvider({ + fetch: injectedFetch( + async () => + new Response("not json", { status: 200, headers: { "content-type": "text/plain" } }), + ), + }); + const wrongTypeResult = await wrongType.execute(auth(), request(), context()); + assert.equal(wrongTypeResult.kind, "failure"); + assert.equal(wrongTypeResult.providerErrorCode, "response-malformed"); + + const huge = new GeminiImageProvider({ + maxResponseBytes: 1_024, + fetch: injectedFetch( + async () => + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(GEMINI_IMAGE_MAX_RESPONSE_BYTES), + }, + }), + ), + }); + const hugeResult = await huge.execute(auth(), request(), context()); + assert.equal(hugeResult.kind, "failure"); + assert.equal(hugeResult.providerErrorCode, "response-too-large"); + + const truncated = new GeminiImageProvider({ + fetch: injectedFetch( + async () => + new Response('{"status":"completed"', { + status: 200, + headers: { "content-type": "application/json", "content-length": "999" }, + }), + ), + }); + const truncatedResult = await truncated.execute(auth(), request(), context()); + assert.equal(truncatedResult.kind, "failure"); + assert.equal(truncatedResult.providerErrorCode, "response-malformed"); + assert.equal(truncatedResult.retrySafety, "never"); +}); + +test("rejects malformed, truncated, non-canonical, and oversized base64", async () => { + const values = ["%%%%", PNG_BASE64.slice(0, -1), "AA=A"]; + for (const data of values) { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + interaction({ + steps: [ + { type: "model_output", content: [{ type: "image", mime_type: "image/png", data }] }, + ], + }), + ), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "failure"); + assert.equal(result.providerErrorCode, "response-malformed"); + } + + const oversized = new GeminiImageProvider({ + maxOutputBytes: 32, + fetch: injectedFetch(async () => jsonResponse(interaction())), + }); + const oversizedResult = await oversized.execute(auth(), request(), context()); + assert.equal(oversizedResult.kind, "failure"); + assert.equal(oversizedResult.providerErrorCode, "response-malformed"); +}); + +test("uses the last generated image and accepts its validated PNG or JPEG media type", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: PNG_BASE64 }], + }, + { + type: "model_output", + content: [ + { type: "text", text: "Final image follows." }, + { type: "image", mime_type: "image/jpeg", data: JPEG_BASE64 }, + ], + }, + ], + }), + ), + ), + }); + const result = await provider.execute(auth(), request({ outputMime: "image/png" }), context()); + assert.equal(result.kind, "success"); + if (result.kind !== "success") return; + assert.equal(result.output.images.length, 1); + assert.equal(result.output.images[0].metadata.mimeType, "image/jpeg"); + assert.equal(Buffer.from(result.output.images[0].bytes).toString("base64"), JPEG_BASE64); +}); + +test("infers a missing final MIME from validated image bytes", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", data: PNG_BASE64 }], + }, + ], + }), + ), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "success"); + if (result.kind !== "success") return; + assert.equal(result.output.images[0].metadata.mimeType, "image/png"); +}); + +test("rejects missing, remote-final, unsupported, and declared-MIME-mismatched images", async () => { + const cases: Array<[Record, string]> = [ + [ + interaction({ steps: [{ type: "model_output", content: [{ type: "text", text: "none" }] }] }), + "response-malformed", + ], + [ + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", uri: "http://127.0.0.1/x" }], + }, + ], + }), + "response-malformed", + ], + [ + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/webp", data: PNG_BASE64 }], + }, + ], + }), + "response-mime-mismatch", + ], + [ + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/jpeg", data: PNG_BASE64 }], + }, + ], + }), + "response-mime-mismatch", + ], + ]; + for (const [response, code] of cases) { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => jsonResponse(response)), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "failure"); + assert.equal(result.providerErrorCode, code); + assert.equal(result.retrySafety, "never"); + } +}); + +test("normalizes content-policy refusal without exposing the provider message", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + interaction({ + status: "failed", + steps: [ + { + type: "model_output", + error: { status: "SAFETY", message: `blocked ${SECRET_KEY}` }, + content: [], + }, + ], + }), + ), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.deepEqual(result, { + kind: "failure", + providerErrorCode: "refused", + error: "Gemini declined this image request under its content policy.", + retrySafety: "never", + }); +}); + +test("pre-send abort is definitely cancelled without touching the transport", async () => { + const controller = new AbortController(); + controller.abort(new Error("stop")); + let called = false; + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => { + called = true; + return jsonResponse(interaction()); + }), + }); + assert.deepEqual(await provider.execute(auth(), request(), context(controller.signal)), { + kind: "cancelled", + providerErrorCode: "cancelled-before-send", + error: "The Gemini image request was cancelled before it was sent.", + }); + assert.equal(called, false); +}); + +test("abort after transport invocation is ambiguous even before headers arrive", async () => { + const controller = new AbortController(); + let invoked!: () => void; + const invokedPromise = new Promise((resolve) => { + invoked = resolve; + }); + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async (_url, init) => { + invoked(); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }), + }); + const pending = provider.execute(auth(), request(), context(controller.signal)); + await invokedPromise; + controller.abort(new Error("user cancelled")); + assert.deepEqual(await pending, { + kind: "ambiguous-submit", + providerErrorCode: "cancelled-after-send", + error: "The Gemini request was cancelled after submission; completion is unknown.", + }); +}); + +test("timeout remains active while waiting for headers", async () => { + const provider = new GeminiImageProvider({ + timeoutMs: 10, + fetch: injectedFetch( + async (_url, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("timeout")), { + once: true, + }); + }), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "ambiguous-submit"); + assert.equal(result.providerErrorCode, "timeout"); +}); + +test("timeout remains active through a stalled response body", async () => { + const provider = new GeminiImageProvider({ + timeoutMs: 10, + fetch: injectedFetch( + async () => + new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "ambiguous-submit"); + assert.equal(result.providerErrorCode, "timeout"); +}); + +test("user abort during a stalled response body remains post-send ambiguous", async () => { + const controller = new AbortController(); + let bodyStarted!: () => void; + const bodyStartedPromise = new Promise((resolve) => { + bodyStarted = resolve; + }); + const provider = new GeminiImageProvider({ + fetch: injectedFetch( + async () => + new Response( + new ReadableStream({ + start() { + bodyStarted(); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + }); + const pending = provider.execute(auth(), request(), context(controller.signal)); + await bodyStartedPromise; + controller.abort(new Error("stop")); + const result = await pending; + assert.equal(result.kind, "ambiguous-submit"); + assert.equal(result.providerErrorCode, "cancelled-after-send"); +}); + +test("nonterminal synchronous response is never silently resubmitted", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse(interaction({ status: "in_progress", steps: [] })), + ), + }); + assert.deepEqual(await provider.execute(auth(), request(), context()), { + kind: "ambiguous-submit", + providerErrorCode: "submission-ambiguous", + error: "Gemini accepted the request but did not return a terminal response.", + }); +}); diff --git a/main/services/create-images/providers/gemini-image-provider-core.ts b/main/services/create-images/providers/gemini-image-provider-core.ts new file mode 100644 index 00000000..fb3b219c --- /dev/null +++ b/main/services/create-images/providers/gemini-image-provider-core.ts @@ -0,0 +1,922 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import { writeDevLog } from "../../dev-log.js"; +import { AssetImageValidationError, validateImageBytes } from "../asset-image-validation-core.js"; +import type { CoordinatorRetrySafety } from "../scheduler-core.js"; +import type { ValidatedImageGenerationRequest } from "../provider-contract.js"; +import { + buildGeminiInteractionsRequest, + GEMINI_IMAGE_MODELS, + GEMINI_INTERACTIONS_ENDPOINT, + validateGeminiImageRequest, +} from "./gemini-interactions-core.js"; + +export const GEMINI_IMAGE_REQUEST_TIMEOUT_MS = 180_000; +export const GEMINI_IMAGE_MAX_REQUEST_BYTES = 96 * 1024 * 1024; +export const GEMINI_IMAGE_MAX_RESPONSE_BYTES = 96 * 1024 * 1024; +export const GEMINI_IMAGE_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +export const GEMINI_IMAGE_MAX_RETRY_AFTER_MS = 5 * 60_000; + +const MAX_RESPONSE_STEPS = 128; +const MAX_CONTENT_BLOCKS_PER_STEP = 32; +const MAX_INTERACTION_ID_LENGTH = 256; +const MAX_TOKEN_COUNT = 1_000_000_000; +const MAX_PROVIDER_ERROR_BYTES = 64 * 1024; +const API_KEY_PATTERN = /^[\x21-\x7e]{1,512}$/u; +const INTERACTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u; +const JSON_CONTENT_TYPE = /^application\/json(?:\s*;|$)/iu; +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +function logGemini( + level: "info" | "warn", + message: string, + metadata: Readonly>, +): void { + const method = level === "info" ? console.info : console.warn; + method(`[create-images] ${message}`, metadata); + writeDevLog(level, "create-images", [message, metadata]); +} + +export type GeminiImageProviderErrorCode = + | "authentication-required" + | "permission-denied" + | "rate-limited" + | "provider-unavailable" + | "request-rejected" + | "refused" + | "provider-failed" + | "provider-cancelled" + | "incomplete" + | "invalid-request" + | "response-too-large" + | "response-malformed" + | "response-mime-mismatch" + | "redirect-rejected" + | "offline" + | "timeout" + | "cancelled-before-send" + | "cancelled-after-send" + | "submission-ambiguous"; + +export interface GeminiImageUsageMetadata { + totalInputTokens?: number; + totalOutputTokens?: number; + totalThoughtTokens?: number; + totalTokens?: number; +} + +export interface GeminiImageProviderOutput { + images: readonly [ + { + bytes: Uint8Array; + metadata: { + source: "gemini-interactions"; + providerId: "gemini"; + modelId: string; + mimeType: "image/png" | "image/jpeg"; + width: number; + height: number; + byteLength: number; + outputIndex: 0; + }; + }, + ]; + metadata: { + source: "gemini-interactions"; + providerId: "gemini"; + modelId: string; + count: 1; + totalByteLength: number; + interactionId?: string; + usage?: GeminiImageUsageMetadata; + }; +} + +interface GeminiAttemptBase { + providerErrorCode: GeminiImageProviderErrorCode; +} + +export type GeminiImageProviderAttemptResult = + | { kind: "success"; output: GeminiImageProviderOutput } + | (GeminiAttemptBase & { + kind: "failure"; + error: string; + retrySafety: CoordinatorRetrySafety; + }) + | (GeminiAttemptBase & { + kind: "rate-limited"; + error: string; + retrySafety: "never"; + retryAfterMs?: number; + }) + | (GeminiAttemptBase & { kind: "cancelled"; error: string }) + | (GeminiAttemptBase & { kind: "ambiguous-submit"; error: string }); + +export interface GeminiImageProviderExecutionContext { + runId: string; + nodeId: string; + signal: AbortSignal; +} + +export interface GeminiImageProviderOptions { + fetch?: typeof globalThis.fetch; + timeoutMs?: number; + maxResponseBytes?: number; + maxOutputBytes?: number; + now?: () => number; +} + +function failure( + providerErrorCode: GeminiImageProviderErrorCode, + error: string, + retrySafety: CoordinatorRetrySafety = "never", +): Extract { + return { kind: "failure", providerErrorCode, error, retrySafety }; +} + +function ambiguous( + providerErrorCode: Extract< + GeminiImageProviderErrorCode, + "offline" | "timeout" | "cancelled-after-send" | "submission-ambiguous" + >, + error: string, +): GeminiImageProviderAttemptResult { + return { kind: "ambiguous-submit", providerErrorCode, error }; +} + +function safeApiKey(auth: AuthResult | undefined): string | undefined { + const key = auth?.auth.apiKey; + if (typeof key !== "string" || !API_KEY_PATTERN.test(key)) return undefined; + return key; +} + +function timeoutValue(value: number | undefined): number { + const timeoutMs = value ?? GEMINI_IMAGE_REQUEST_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 60_000) { + throw new Error("Gemini image timeout must be between 1 ms and 10 minutes."); + } + return timeoutMs; +} + +function boundedLimit(value: number | undefined, maximum: number, label: string): number { + const limit = value ?? maximum; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) { + throw new Error(`${label} must be a positive integer within Aiden's hard limit.`); + } + return limit; +} + +function createCombinedSignal( + external: AbortSignal, + timeoutMs: number, +): { + signal: AbortSignal; + didTimeout(): boolean; + dispose(): void; +} { + const controller = new AbortController(); + let timedOut = false; + const onExternalAbort = () => controller.abort(external.reason); + external.addEventListener("abort", onExternalAbort, { once: true }); + if (external.aborted) onExternalAbort(); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new Error("Gemini image request timed out.")); + }, timeoutMs); + return { + signal: controller.signal, + didTimeout: () => timedOut, + dispose: () => { + clearTimeout(timer); + external.removeEventListener("abort", onExternalAbort); + }, + }; +} + +function contentLength(response: Response): number | undefined { + const header = response.headers.get("content-length"); + if (header === null) return undefined; + if (!/^(?:0|[1-9][0-9]*)$/u.test(header)) return Number.NaN; + const value = Number(header); + return Number.isSafeInteger(value) ? value : Number.NaN; +} + +async function readBoundedResponse( + response: Response, + signal: AbortSignal, + maxResponseBytes: number, +): Promise { + const declared = contentLength(response); + if ( + declared !== undefined && + (!Number.isSafeInteger(declared) || declared < 1 || declared > maxResponseBytes) + ) { + throw new BoundedResponseError("too-large"); + } + if (!response.body) throw new BoundedResponseError("malformed"); + const reader = response.body.getReader(); + const onAbort = () => { + void reader.cancel().catch(() => undefined); + }; + signal.addEventListener("abort", onAbort, { once: true }); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + if (signal.aborted) throw signal.reason ?? new Error("Request aborted."); + const next = await reader.read(); + if (next.done) break; + if (!(next.value instanceof Uint8Array) || next.value.byteLength === 0) continue; + total += next.value.byteLength; + if (total > maxResponseBytes) { + throw new BoundedResponseError("too-large"); + } + chunks.push(next.value); + } + } finally { + signal.removeEventListener("abort", onAbort); + reader.releaseLock(); + } + if (total === 0 || (declared !== undefined && declared !== total)) { + throw new BoundedResponseError("malformed"); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +class BoundedResponseError extends Error { + constructor(readonly reason: "too-large" | "malformed") { + super("The Gemini response did not satisfy Aiden's response bounds."); + this.name = "BoundedResponseError"; + } +} + +function plainRecord(value: unknown): Record | undefined { + return value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ? (value as Record) + : undefined; +} + +function parseTokenCount(value: unknown): number | undefined { + return Number.isSafeInteger(value) && + (value as number) >= 0 && + (value as number) <= MAX_TOKEN_COUNT + ? (value as number) + : undefined; +} + +function parseUsage(value: unknown): GeminiImageUsageMetadata | undefined { + if (value === undefined) return undefined; + const record = plainRecord(value); + if (!record) throw new Error("usage"); + const fieldMap = [ + ["total_input_tokens", "totalInputTokens"], + ["total_output_tokens", "totalOutputTokens"], + ["total_thought_tokens", "totalThoughtTokens"], + ["total_tokens", "totalTokens"], + ] as const; + const parsed: GeminiImageUsageMetadata = {}; + for (const [wireName, resultName] of fieldMap) { + if (record[wireName] === undefined) continue; + const count = parseTokenCount(record[wireName]); + if (count === undefined) throw new Error("usage"); + parsed[resultName] = count; + } + return Object.keys(parsed).length > 0 ? Object.freeze(parsed) : undefined; +} + +function strictBase64(value: unknown, maxOutputBytes: number): Uint8Array { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > Math.ceil(maxOutputBytes / 3) * 4 || + value.length % 4 !== 0 || + !BASE64_PATTERN.test(value) + ) { + throw new Error("base64"); + } + const bytes = Buffer.from(value, "base64"); + if ( + bytes.byteLength === 0 || + bytes.byteLength > maxOutputBytes || + bytes.toString("base64") !== value + ) { + throw new Error("base64"); + } + // Do not expose a view onto Node's pooled Buffer backing store. + return Uint8Array.from(bytes); +} + +function safeInteractionId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "string" || + value.length > MAX_INTERACTION_ID_LENGTH || + !INTERACTION_ID_PATTERN.test(value) + ) { + throw new Error("interaction-id"); + } + return value; +} + +function containsRefusalMarker(steps: readonly unknown[]): boolean { + for (const stepValue of steps) { + const step = plainRecord(stepValue); + const error = plainRecord(step?.error); + const marker = error?.status; + if ( + typeof marker === "string" && + /^(?:SAFETY|BLOCKED|CONTENT_FILTERED|PERMISSION_DENIED)$/u.test(marker) + ) { + return true; + } + } + return false; +} + +type ParsedCompletedResponse = + | { kind: "success"; output: GeminiImageProviderOutput } + | { + kind: "failure"; + code: GeminiImageProviderErrorCode; + message: string; + diagnostic: string; + } + | { kind: "ambiguous" }; + +function completedFailure( + code: GeminiImageProviderErrorCode, + message: string, + diagnostic: string, +): Extract { + return { kind: "failure", code, message, diagnostic }; +} + +function safeResponseShape(value: unknown): Readonly> { + const response = plainRecord(value); + const steps = Array.isArray(response?.steps) ? response.steps : []; + let modelOutputStepCount = 0; + let imageBlockCount = 0; + let finalImageMime: "image/png" | "image/jpeg" | "missing" | "unsupported" = "missing"; + for (const stepValue of steps.slice(0, MAX_RESPONSE_STEPS)) { + const step = plainRecord(stepValue); + if (step?.type !== "model_output" || !Array.isArray(step.content)) continue; + modelOutputStepCount += 1; + for (const blockValue of step.content.slice(0, MAX_CONTENT_BLOCKS_PER_STEP)) { + const block = plainRecord(blockValue); + if (block?.type !== "image") continue; + imageBlockCount += 1; + finalImageMime = + block.mime_type === "image/png" || block.mime_type === "image/jpeg" + ? block.mime_type + : block.mime_type === undefined + ? "missing" + : "unsupported"; + } + } + const status = response?.status; + return Object.freeze({ + responseStatus: + typeof status === "string" && + [ + "completed", + "failed", + "cancelled", + "incomplete", + "in_progress", + "queued", + "requires_action", + ].includes(status) + ? status + : "invalid", + stepCount: steps.length, + modelOutputStepCount, + imageBlockCount, + finalImageMime, + }); +} + +function parseCompletedResponse( + value: unknown, + request: ValidatedImageGenerationRequest, + maxOutputBytes: number, +): ParsedCompletedResponse { + const response = plainRecord(value); + if (!response) { + return completedFailure( + "response-malformed", + "Gemini returned an invalid response.", + "invalid-response-root", + ); + } + const steps = response.steps; + if (!Array.isArray(steps) || steps.length > MAX_RESPONSE_STEPS) { + return completedFailure( + "response-malformed", + "Gemini returned an invalid response timeline.", + "invalid-response-timeline", + ); + } + const status = response.status; + if (typeof status !== "string") { + return completedFailure( + "response-malformed", + "Gemini returned an invalid response status.", + "invalid-response-status", + ); + } + if (["in_progress", "queued", "requires_action"].includes(status)) { + return { kind: "ambiguous" }; + } + if (status !== "completed") { + if (containsRefusalMarker(steps)) { + return completedFailure( + "refused", + "Gemini declined this image request under its content policy.", + "provider-refusal", + ); + } + const statusError: Record = { + failed: ["provider-failed", "Gemini could not complete this image request."], + cancelled: ["provider-cancelled", "Gemini cancelled this image request."], + incomplete: ["incomplete", "Gemini returned an incomplete image response."], + }; + const normalized = statusError[status]; + return normalized + ? completedFailure(normalized[0], normalized[1], `provider-status-${status}`) + : completedFailure( + "response-malformed", + "Gemini returned an unsupported response status.", + "unsupported-response-status", + ); + } + + let finalImage: { data: unknown; mimeType: unknown; remote: boolean } | undefined; + for (const stepValue of steps) { + const step = plainRecord(stepValue); + if (!step || typeof step.type !== "string") { + return completedFailure( + "response-malformed", + "Gemini returned an invalid response step.", + "invalid-response-step", + ); + } + if (step.type !== "model_output") continue; + if (!Array.isArray(step.content) || step.content.length > MAX_CONTENT_BLOCKS_PER_STEP) { + return completedFailure( + "response-malformed", + "Gemini returned invalid model output.", + "invalid-model-output", + ); + } + for (const blockValue of step.content) { + const block = plainRecord(blockValue); + if (!block || typeof block.type !== "string") { + return completedFailure( + "response-malformed", + "Gemini returned an invalid output block.", + "invalid-output-block", + ); + } + if (block.type === "image") { + // Google's `interaction.output_image` convenience property selects the + // last generated image block. Gemini 3 may expose earlier thought + // images in the timeline, so mirror that exact final-output behavior. + finalImage = { + data: block.data, + mimeType: block.mime_type, + remote: block.uri !== undefined, + }; + } + } + } + if (!finalImage) { + return containsRefusalMarker(steps) + ? completedFailure( + "refused", + "Gemini declined this image request under its content policy.", + "provider-refusal", + ) + : completedFailure( + "response-malformed", + "Gemini returned no final image.", + "missing-final-image", + ); + } + if (finalImage.remote) { + return completedFailure( + "response-malformed", + "Gemini returned a remote image instead of bounded inline bytes.", + "remote-final-image", + ); + } + if ( + finalImage.mimeType !== undefined && + finalImage.mimeType !== "image/png" && + finalImage.mimeType !== "image/jpeg" + ) { + return completedFailure( + "response-mime-mismatch", + "Gemini returned an unsupported image media type.", + "unsupported-final-image-mime", + ); + } + try { + const bytes = strictBase64(finalImage.data, maxOutputBytes); + const descriptor = validateImageBytes(bytes, finalImage.mimeType, undefined, { + maxWidth: 32_768, + maxHeight: 32_768, + maxPixels: 16_000_000, + }); + const interactionId = safeInteractionId(response.id); + const usage = parseUsage(response.usage); + const metadata = Object.freeze({ + source: "gemini-interactions" as const, + providerId: "gemini" as const, + modelId: request.modelId, + mimeType: descriptor.mediaType, + width: descriptor.width, + height: descriptor.height, + byteLength: bytes.byteLength, + outputIndex: 0 as const, + }); + const output: GeminiImageProviderOutput = Object.freeze({ + images: Object.freeze([{ bytes, metadata }]) as GeminiImageProviderOutput["images"], + metadata: Object.freeze({ + source: "gemini-interactions" as const, + providerId: "gemini" as const, + modelId: request.modelId, + count: 1 as const, + totalByteLength: bytes.byteLength, + ...(interactionId ? { interactionId } : {}), + ...(usage ? { usage } : {}), + }), + }); + return { kind: "success", output }; + } catch (error) { + const code = + error instanceof AssetImageValidationError && error.code === "mime_mismatch" + ? "response-mime-mismatch" + : "response-malformed"; + const diagnostic = + error instanceof AssetImageValidationError + ? error.code === "mime_mismatch" + ? "declared-mime-does-not-match-bytes" + : `invalid-image-${error.code}` + : error instanceof Error && ["base64", "interaction-id", "usage"].includes(error.message) + ? `invalid-${error.message}` + : "invalid-image-data"; + return completedFailure( + code, + code === "response-mime-mismatch" + ? "Gemini returned bytes that do not match the declared media type." + : "Gemini returned invalid image data.", + diagnostic, + ); + } +} + +function retryAfterMs(value: string | null, now: number): number | undefined { + if (value === null || value.length > 128) return undefined; + let delay: number; + if (/^(?:0|[1-9][0-9]*)$/u.test(value)) { + delay = Number(value) * 1_000; + } else { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return undefined; + delay = Math.max(0, timestamp - now); + } + if (!Number.isSafeInteger(delay) || delay < 0) return undefined; + return Math.min(delay, GEMINI_IMAGE_MAX_RETRY_AFTER_MS); +} + +function statusResult( + response: Response, + now: number, + metadata: SafeProviderFailureMetadata = {}, +): Extract | undefined { + if (response.status >= 200 && response.status < 300) return undefined; + if ( + response.status === 401 || + metadata.providerStatus === "UNAUTHENTICATED" || + metadata.providerReason === "API_KEY_INVALID" + ) { + return failure("authentication-required", "Gemini rejected the configured API key."); + } + if (response.status === 403 || metadata.providerStatus === "PERMISSION_DENIED") { + return failure( + "permission-denied", + "The configured Gemini account cannot use this image model.", + ); + } + if (response.status === 429) { + const delay = retryAfterMs(response.headers.get("retry-after"), now); + return { + kind: "rate-limited", + providerErrorCode: "rate-limited", + error: "Gemini is rate limiting image requests.", + retrySafety: "never", + ...(delay === undefined ? {} : { retryAfterMs: delay }), + }; + } + if (response.status >= 500 && response.status <= 599) { + return failure("provider-unavailable", "Gemini is temporarily unavailable."); + } + if (response.status >= 300 && response.status <= 399) { + return failure("redirect-rejected", "Gemini returned a redirect that Aiden will not follow."); + } + return failure("request-rejected", "Gemini rejected this image request."); +} + +const SAFE_PROVIDER_MARKER = /^[A-Z][A-Z0-9_]{0,95}$/u; +const SAFE_PROVIDER_FIELD = /^[A-Za-z][A-Za-z0-9_.[\]-]{0,127}$/u; + +interface SafeProviderFailureMetadata { + providerStatus?: string; + providerReason?: string; + providerFields?: readonly string[]; +} + +async function readSafeProviderFailureMetadata( + response: Response, + signal: AbortSignal, +): Promise { + try { + const bytes = await readBoundedResponse(response, signal, MAX_PROVIDER_ERROR_BYTES); + const root = plainRecord(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + const error = plainRecord(root?.error); + const providerStatus = + typeof error?.status === "string" && SAFE_PROVIDER_MARKER.test(error.status) + ? error.status + : undefined; + const details = Array.isArray(error?.details) ? error.details : []; + const providerReason = details + .map((detail) => plainRecord(detail)?.reason) + .find( + (reason): reason is string => + typeof reason === "string" && SAFE_PROVIDER_MARKER.test(reason), + ); + const providerFields = details + .flatMap((detail) => { + const fieldViolations = plainRecord(detail)?.fieldViolations; + return Array.isArray(fieldViolations) ? fieldViolations : []; + }) + .map((violation) => plainRecord(violation)?.field) + .filter( + (field): field is string => typeof field === "string" && SAFE_PROVIDER_FIELD.test(field), + ) + .slice(0, 16); + return { + ...(providerStatus ? { providerStatus } : {}), + ...(providerReason ? { providerReason } : {}), + ...(providerFields.length > 0 ? { providerFields: Object.freeze(providerFields) } : {}), + }; + } catch { + response.body?.cancel().catch(() => undefined); + return {}; + } +} + +/** + * Main-process-only stateless Gemini Interactions adapter. It accepts Pi's + * resolved request auth, but deliberately uses only `auth.apiKey`; alternate + * endpoints, inherited headers, OAuth bearer tokens, and provider URLs never + * cross this fixed transport boundary. + */ +export class GeminiImageProvider { + readonly providerId = "gemini" as const; + readonly #fetch: typeof globalThis.fetch; + readonly #timeoutMs: number; + readonly #maxResponseBytes: number; + readonly #maxOutputBytes: number; + readonly #now: () => number; + + constructor(options: GeminiImageProviderOptions = {}) { + this.#fetch = options.fetch ?? globalThis.fetch; + this.#timeoutMs = timeoutValue(options.timeoutMs); + this.#maxResponseBytes = boundedLimit( + options.maxResponseBytes, + GEMINI_IMAGE_MAX_RESPONSE_BYTES, + "Gemini response byte limit", + ); + this.#maxOutputBytes = boundedLimit( + options.maxOutputBytes, + GEMINI_IMAGE_MAX_OUTPUT_BYTES, + "Gemini output byte limit", + ); + this.#now = options.now ?? Date.now; + } + + listModels() { + return GEMINI_IMAGE_MODELS; + } + + validate(request: ValidatedImageGenerationRequest): ValidatedImageGenerationRequest { + return validateGeminiImageRequest(request); + } + + async execute( + auth: AuthResult | undefined, + request: ValidatedImageGenerationRequest, + context: GeminiImageProviderExecutionContext, + ): Promise { + if (context.signal.aborted) { + return { + kind: "cancelled", + providerErrorCode: "cancelled-before-send", + error: "The Gemini image request was cancelled before it was sent.", + }; + } + const key = safeApiKey(auth); + if (!key) { + return failure( + "authentication-required", + "Connect a Google Gemini API key before creating remote images.", + "confirmed-not-submitted", + ); + } + let validated: ValidatedImageGenerationRequest; + let body: string; + try { + validated = this.validate(request); + const serialized = buildGeminiInteractionsRequest(validated); + body = JSON.stringify(serialized); + if (Buffer.byteLength(body, "utf8") > GEMINI_IMAGE_MAX_REQUEST_BYTES) { + return failure( + "invalid-request", + "The Gemini image request exceeds Aiden's request-size limit.", + "confirmed-not-submitted", + ); + } + } catch { + return failure( + "invalid-request", + "The Gemini image request is invalid.", + "confirmed-not-submitted", + ); + } + if (context.signal.aborted) { + return { + kind: "cancelled", + providerErrorCode: "cancelled-before-send", + error: "The Gemini image request was cancelled before it was sent.", + }; + } + + const requestStartedAt = this.#now(); + logGemini("info", "Sending Gemini image request.", { + runId: context.runId, + nodeId: context.nodeId, + modelId: validated.modelId, + referenceCount: validated.references.length, + outputCount: validated.count, + aspectRatio: validated.aspectRatio, + imageSize: validated.imageSize, + outputMime: validated.outputMime, + requestBytes: Buffer.byteLength(body, "utf8"), + }); + const combined = createCombinedSignal(context.signal, this.#timeoutMs); + const done = (result: Result): Result => { + combined.dispose(); + return result; + }; + let response: Response; + try { + response = await this.#fetch(GEMINI_INTERACTIONS_ENDPOINT, { + method: "POST", + headers: { + "content-type": "application/json", + "x-goog-api-key": key, + }, + body, + redirect: "error", + signal: combined.signal, + }); + } catch { + if (combined.didTimeout()) { + return done( + ambiguous("timeout", "The Gemini request timed out after it may have been submitted."), + ); + } + if (context.signal.aborted) { + return done( + ambiguous( + "cancelled-after-send", + "The Gemini request was cancelled after submission; completion is unknown.", + ), + ); + } + return done( + ambiguous("offline", "A network error left the Gemini request's submission state unknown."), + ); + } + + if ( + response.redirected || + (response.url !== "" && response.url !== GEMINI_INTERACTIONS_ENDPOINT) + ) { + response.body?.cancel().catch(() => undefined); + return done( + failure( + "redirect-rejected", + "Gemini returned a redirect or unexpected response origin that Aiden rejected.", + ), + ); + } + const contentType = response.headers.get("content-type") ?? ""; + if (!JSON_CONTENT_TYPE.test(contentType)) { + response.body?.cancel().catch(() => undefined); + return done(failure("response-malformed", "Gemini returned an unexpected response type.")); + } + const providerFailureMetadata = + response.status >= 200 && response.status < 300 + ? {} + : await readSafeProviderFailureMetadata(response, combined.signal); + const normalizedStatus = statusResult(response, this.#now(), providerFailureMetadata); + if (normalizedStatus) { + logGemini("warn", "Gemini request ended with a safe provider failure.", { + status: response.status, + providerErrorCode: normalizedStatus.providerErrorCode, + ...providerFailureMetadata, + }); + return done(normalizedStatus); + } + if (response.status !== 200) { + response.body?.cancel().catch(() => undefined); + return done(failure("response-malformed", "Gemini returned an unsupported success status.")); + } + + let bytes: Uint8Array; + try { + bytes = await readBoundedResponse(response, combined.signal, this.#maxResponseBytes); + } catch (error) { + if (combined.didTimeout()) { + return done(ambiguous("timeout", "The Gemini response timed out after submission.")); + } + if (context.signal.aborted) { + return done( + ambiguous( + "cancelled-after-send", + "The Gemini request was cancelled after submission; completion is unknown.", + ), + ); + } + return done( + failure( + error instanceof BoundedResponseError && error.reason === "too-large" + ? "response-too-large" + : "response-malformed", + error instanceof BoundedResponseError && error.reason === "too-large" + ? "Gemini returned a response larger than Aiden's safe limit." + : "Gemini returned a truncated or malformed response.", + ), + ); + } + let decoded: unknown; + try { + decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + return done(failure("response-malformed", "Gemini returned invalid JSON.")); + } + const parsed = parseCompletedResponse(decoded, validated, this.#maxOutputBytes); + if (parsed.kind === "success") { + logGemini("info", "Gemini image request completed.", { + runId: context.runId, + nodeId: context.nodeId, + modelId: validated.modelId, + durationMs: Math.max(0, this.#now() - requestStartedAt), + outputCount: parsed.output.images.length, + outputBytes: parsed.output.metadata.totalByteLength, + outputMime: parsed.output.images[0].metadata.mimeType, + width: parsed.output.images[0].metadata.width, + height: parsed.output.images[0].metadata.height, + }); + return done(parsed); + } + if (parsed.kind === "ambiguous") { + return done( + ambiguous( + "submission-ambiguous", + "Gemini accepted the request but did not return a terminal response.", + ), + ); + } + logGemini("warn", "Gemini image response failed safe validation.", { + runId: context.runId, + nodeId: context.nodeId, + modelId: validated.modelId, + durationMs: Math.max(0, this.#now() - requestStartedAt), + providerErrorCode: parsed.code, + validationReason: parsed.diagnostic, + ...safeResponseShape(decoded), + }); + return done(failure(parsed.code, parsed.message)); + } +} diff --git a/main/services/create-images/providers/gemini-interactions-core.ts b/main/services/create-images/providers/gemini-interactions-core.ts new file mode 100644 index 00000000..8b5d259d --- /dev/null +++ b/main/services/create-images/providers/gemini-interactions-core.ts @@ -0,0 +1,163 @@ +import type { + ImageProviderModelCapabilities, + ValidatedImageGenerationRequest, +} from "../provider-contract.js"; + +export const GEMINI_INTERACTIONS_ENDPOINT = + "https://generativelanguage.googleapis.com/v1beta/interactions"; + +const COMMON_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const; + +/** + * Release-pinned image catalog verified against Google's Interactions API on + * 2026-08-20. Runtime execution accepts no arbitrary renderer model ID. + */ +export const GEMINI_IMAGE_MODELS: readonly ImageProviderModelCapabilities[] = [ + { + id: "gemini-3.1-flash-lite-image", + label: "Nano Banana 2 Lite", + providerId: "gemini", + aspectRatios: COMMON_ASPECT_RATIOS, + imageSizes: ["1K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }, + { + id: "gemini-3.1-flash-image", + label: "Nano Banana 2", + providerId: "gemini", + aspectRatios: COMMON_ASPECT_RATIOS, + imageSizes: ["1K", "2K", "4K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }, + { + id: "gemini-3-pro-image", + label: "Nano Banana Pro", + providerId: "gemini", + aspectRatios: COMMON_ASPECT_RATIOS, + imageSizes: ["1K", "2K", "4K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }, +]; + +export interface GeminiInteractionsRequestBody { + model: string; + input: Array< + | { type: "text"; text: string } + | { type: "image"; mime_type: "image/png" | "image/jpeg" | "image/webp"; data: string } + >; + response_format: { + type: "image"; + mime_type?: "image/jpeg"; + aspect_ratio: string; + image_size: string; + }; + store: false; + background: false; +} + +function selectedModel(modelId: string): ImageProviderModelCapabilities { + const model = GEMINI_IMAGE_MODELS.find((candidate) => candidate.id === modelId); + if (!model) throw new Error("This Gemini image model is not supported by this Aiden release."); + return model; +} + +export function validateGeminiImageRequest( + request: ValidatedImageGenerationRequest, +): ValidatedImageGenerationRequest { + if (request.providerId !== "gemini") throw new Error("Expected a Gemini image request."); + const model = selectedModel(request.modelId); + const prompt = request.prompt.trim(); + if (!prompt) throw new Error("Gemini image generation requires a prompt."); + if (prompt.length > 32_000) throw new Error("The image prompt exceeds Aiden's safe limit."); + if (!model.aspectRatios.includes(request.aspectRatio)) { + throw new Error("The selected Gemini model does not support this aspect ratio."); + } + if (!model.imageSizes.includes(request.imageSize)) { + throw new Error("The selected Gemini model does not support this image size."); + } + if (!model.outputMimes.includes(request.outputMime)) { + throw new Error("The selected Gemini model does not support this output format."); + } + if (request.count !== 1) { + throw new Error( + "The Gemini Interactions image adapter currently supports one output per call.", + ); + } + const references = request.references; + if (references.length > model.maxReferenceImages) { + throw new Error( + `The selected Gemini model accepts at most ${model.maxReferenceImages} references.`, + ); + } + let totalBytes = 0; + for (const reference of references) { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(reference.assetId)) { + throw new Error("Gemini references require opaque Aiden asset IDs."); + } + const byteLength = reference.bytes.byteLength; + if (byteLength === 0 || byteLength > 20 * 1024 * 1024) { + throw new Error("Each Gemini reference must be between 1 byte and 20 MB."); + } + totalBytes += byteLength; + } + if (totalBytes > 64 * 1024 * 1024) { + throw new Error("Gemini reference images exceed Aiden's 64 MB request limit."); + } + return { ...request, prompt }; +} + +export function buildGeminiInteractionsRequest( + request: ValidatedImageGenerationRequest, +): GeminiInteractionsRequestBody { + const validated = validateGeminiImageRequest(request); + return { + model: validated.modelId, + input: [ + { type: "text", text: validated.prompt }, + ...validated.references.map((reference) => ({ + type: "image" as const, + mime_type: reference.mimeType, + data: Buffer.from( + reference.bytes.buffer, + reference.bytes.byteOffset, + reference.bytes.byteLength, + ).toString("base64"), + })), + ], + response_format: { + type: "image", + // Google's current Interactions schema only enumerates JPEG here. When + // PNG is selected we omit the field and let Gemini return its default + // inline image format, matching Google's image-generation examples and + // Node Banana's working transport behavior. + ...(validated.outputMime === "image/jpeg" ? { mime_type: "image/jpeg" as const } : {}), + aspect_ratio: validated.aspectRatio, + image_size: validated.imageSize, + }, + store: false, + background: false, + }; +} diff --git a/main/services/create-images/renderer-egress-core.test.ts b/main/services/create-images/renderer-egress-core.test.ts new file mode 100644 index 00000000..3a53784a --- /dev/null +++ b/main/services/create-images/renderer-egress-core.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { isAidenMainRendererUrl, shouldBlockAidenRendererEgress } from "./renderer-egress-core.js"; + +test("packaged main renderer egress is denied while non-Aiden windows stay independent", () => { + const rendererUrl = + "file:///Applications/Aiden.app/Contents/Resources/app.asar/build/renderer/main-window.html"; + assert.equal(isAidenMainRendererUrl(rendererUrl), true); + for (const requestUrl of [ + "https://attacker.example/collect", + "http://attacker.example/pixel", + "wss://attacker.example/socket", + ]) { + assert.equal(shouldBlockAidenRendererEgress({ requestUrl, rendererUrl, packaged: true }), true); + } + assert.equal( + shouldBlockAidenRendererEgress({ + requestUrl: "https://accounts.example/login", + rendererUrl: "https://accounts.example/login", + packaged: true, + }), + false, + ); +}); + +test("development permits only loopback renderer transport", () => { + const rendererUrl = "http://127.0.0.1:4143/main-window.html"; + assert.equal( + shouldBlockAidenRendererEgress({ + requestUrl: "ws://127.0.0.1:4143/hmr", + rendererUrl, + packaged: false, + }), + false, + ); + assert.equal( + shouldBlockAidenRendererEgress({ + requestUrl: "https://attacker.example/collect", + rendererUrl, + packaged: false, + }), + true, + ); +}); + +test("main renderer CSP has no broad remote image, media, or connection source", () => { + const html = readFileSync(new URL("../../../main-window.html", import.meta.url), "utf8"); + const policy = html.match(/http-equiv="Content-Security-Policy"\s+content="([^"]+)"/u)?.[1] ?? ""; + const directive = (name: string) => + policy + .split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(`${name} `)) ?? ""; + for (const name of ["connect-src", "img-src", "media-src"]) { + const value = directive(name); + assert.ok(value, `${name} must be present`); + const sources = new Set(value.split(/\s+/u).slice(1)); + assert.equal(sources.has("http:"), false); + assert.equal(sources.has("https:"), false); + assert.equal(sources.has("file:"), false); + } +}); + +test("the installed request policy honors the branded development runtime profile", () => { + const source = readFileSync(new URL("./asset-protocol.ts", import.meta.url), "utf8"); + assert.match(source, /packaged: isPackagedRuntime\(\)/u); + assert.doesNotMatch(source, /packaged: app\.isPackaged/u); +}); diff --git a/main/services/create-images/renderer-egress-core.ts b/main/services/create-images/renderer-egress-core.ts new file mode 100644 index 00000000..f85cfcba --- /dev/null +++ b/main/services/create-images/renderer-egress-core.ts @@ -0,0 +1,33 @@ +const REMOTE_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); + +function parsed(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +export function isAidenMainRendererUrl(value: string): boolean { + const url = parsed(value); + if (!url) return false; + if (url.protocol === "file:") return url.pathname.endsWith("/main-window.html"); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname.endsWith("/main-window.html") + ); +} + +/** Main-window web content has no direct production network capability. */ +export function shouldBlockAidenRendererEgress(input: { + requestUrl: string; + rendererUrl: string | undefined; + packaged: boolean; +}): boolean { + const request = parsed(input.requestUrl); + if (!request || !REMOTE_PROTOCOLS.has(request.protocol) || !input.rendererUrl) return false; + if (!isAidenMainRendererUrl(input.rendererUrl)) return false; + if (input.packaged) return true; + return request.hostname !== "127.0.0.1" && request.hostname !== "localhost"; +} diff --git a/main/services/create-images/run-journal-performance.test.ts b/main/services/create-images/run-journal-performance.test.ts new file mode 100644 index 00000000..b8eb7138 --- /dev/null +++ b/main/services/create-images/run-journal-performance.test.ts @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import test, { type TestContext } from "node:test"; +import { + appendCreateImagesRunEvent, + createCreateImagesRunJournal, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + CreateImagesRunJournalStore, + createImagesWorkflowSnapshotFingerprint, +} from "./run-journal-store.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-run-journal-performance-")); + t.after(() => fs.rm(root, { force: true, recursive: true })); + return root; +} + +function workload(nodeCount: number): { + snapshot: WorkflowDocumentV1; + orderedNodeIds: string[]; +} { + const orderedNodeIds = Array.from({ length: nodeCount }, (_, index) => `prompt-${index + 1}`); + return { + snapshot: { + schemaVersion: 1, + id: "workflow-performance", + title: "Journal performance gate", + revision: 1, + createdAt: NOW, + updatedAt: NOW, + nodes: orderedNodeIds.map((id, index) => ({ + id, + type: "prompt" as const, + position: { x: (index % 25) * 100, y: Math.floor(index / 25) * 100 }, + data: { text: `Prompt ${index + 1}` }, + })), + edges: [], + assetRefs: [], + settings: { concurrency: 4 }, + }, + orderedNodeIds, + }; +} + +function event( + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): Extract { + return { + type, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: NOW, + ...fields, + } as Extract; +} + +test("100/250/500-node successful journals append and replay within bounded linear-storage gates", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const { snapshot, orderedNodeIds } = workload(500); + let journal = await store.start( + { + runId: "run-performance", + workflowSnapshot: snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds, + dependencies: Object.fromEntries(orderedNodeIds.map((nodeId) => [nodeId, []])), + }, + createdAt: NOW, + }, + () => true, + ); + const runDirectory = path.join(root, "runs", journal.runId); + const checkpointBytes = (await fs.lstat(path.join(runDirectory, "run.json"))).size; + const startedAt = performance.now(); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "run-started", {}), + ); + const indexBeforeProgress = await fs.readFile(path.join(root, "run-index.json"), "utf8"); + const elapsedByNode = new Map(); + for (const [index, nodeId] of orderedNodeIds.entries()) { + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "node-started", { nodeId }), + ); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "node-output-published", { nodeId, outputAssetIds: [] }), + ); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "node-succeeded", { nodeId, outputAssetIds: [] }), + ); + const completedNodes = index + 1; + if ([100, 250, 500].includes(completedNodes)) { + elapsedByNode.set(completedNodes, performance.now() - startedAt); + } + } + + assert.ok((elapsedByNode.get(100) ?? Infinity) < 45_000, "100-node append gate exceeded 45s"); + assert.ok((elapsedByNode.get(250) ?? Infinity) < 105_000, "250-node append gate exceeded 105s"); + assert.ok((elapsedByNode.get(500) ?? Infinity) < 240_000, "500-node append gate exceeded 240s"); + assert.equal(await fs.readFile(path.join(root, "run-index.json"), "utf8"), indexBeforeProgress); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "run-terminal", { status: "succeeded" }), + ); + assert.equal((await fs.lstat(path.join(runDirectory, "run.json"))).size, checkpointBytes); + assert.equal( + (await fs.lstat(path.join(runDirectory, "run.last-known-good.json"))).size, + checkpointBytes, + ); + await assert.rejects(fs.lstat(path.join(runDirectory, "run.pending.json")), { + code: "ENOENT", + }); + + const replayStartedAt = performance.now(); + const replayed = await new CreateImagesRunJournalStore(() => root).get(journal.runId); + const replayMs = performance.now() - replayStartedAt; + assert.equal(replayed?.journalRevision, journal.journalRevision); + assert.ok(replayMs < 3_500, "500-node replay gate exceeded 3.5s"); + + t.diagnostic( + JSON.stringify({ + appendMs: Object.fromEntries(elapsedByNode), + replayMs: Math.round(replayMs), + eventCount: journal.events.length, + currentLogBytes: (await fs.lstat(path.join(runDirectory, "run.events.jsonl"))).size, + }), + ); +}); + +test("1,000 output-rich terminal journals restart, reconcile, and inventory within bounded storage gates", async (t) => { + const root = await temporaryRoot(t); + const runsPath = path.join(root, "runs"); + await fs.mkdir(runsPath, { recursive: true }); + const { snapshot, orderedNodeIds } = workload(1); + const plan = { + scope: { kind: "all" } as const, + orderedNodeIds, + dependencies: { [orderedNodeIds[0] as string]: [] }, + }; + const entries: Array> = []; + const writes: Array> = []; + for (let index = 1; index <= 1_000; index += 1) { + const runId = `retained-${String(index).padStart(4, "0")}`; + let journal = createCreateImagesRunJournal({ + runId, + workflowSnapshot: snapshot, + workflowFingerprint: createImagesWorkflowSnapshotFingerprint(snapshot), + plan, + createdAt: NOW, + }); + journal = appendCreateImagesRunEvent(journal, event(journal, "run-started", {})); + journal = appendCreateImagesRunEvent( + journal, + event(journal, "node-started", { nodeId: orderedNodeIds[0] as string }), + ); + journal = appendCreateImagesRunEvent( + journal, + event(journal, "node-output-published", { + nodeId: orderedNodeIds[0] as string, + outputAssetIds: Array.from({ length: 250 }, (_, assetIndex) => + (index * 1_000 + assetIndex).toString(16).padStart(64, "0"), + ), + }), + ); + const outputAssetIds = ( + journal.events[journal.events.length - 1] as Extract< + CreateImagesRunEventV1, + { type: "node-output-published" } + > + ).outputAssetIds; + journal = appendCreateImagesRunEvent( + journal, + event(journal, "node-succeeded", { + nodeId: orderedNodeIds[0] as string, + outputAssetIds, + }), + ); + journal = appendCreateImagesRunEvent( + journal, + event(journal, "run-terminal", { status: "succeeded" }), + ); + const directory = path.join(runsPath, runId); + const serialized = `${JSON.stringify(journal, null, 2)}\n`; + writes.push( + (async () => { + await fs.mkdir(directory, { recursive: true }); + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), serialized, "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), serialized, "utf8"), + ]); + })(), + ); + entries.push({ + runId, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + journalRevision: journal.journalRevision, + status: "succeeded", + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + terminal: true, + health: "healthy", + }); + if (writes.length === 50) { + await Promise.all(writes.splice(0)); + } + } + await Promise.all(writes); + await fs.writeFile( + path.join(root, "run-index.json"), + `${JSON.stringify({ version: 1, revision: 1, entries }, null, 2)}\n`, + "utf8", + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + const productPathStartedAt = performance.now(); + await restarted.initialize(); + const initializedAt = performance.now(); + const admissionStartedAt = performance.now(); + const admission = await restarted.auditWorkflowAdmission("workflow-performance"); + const admissionMs = performance.now() - admissionStartedAt; + const firstReferences = await restarted.referenceInventory(); + const secondReferences = await restarted.referenceInventory(); + const reconciliation = await restarted.reconciliationCandidates(); + const thirdReferences = await restarted.referenceInventory(); + const productPathMs = performance.now() - productPathStartedAt; + const restartMs = initializedAt - productPathStartedAt; + assert.ok(restartMs < 20_000, "1,000-journal restart gate exceeded 20s"); + assert.ok(admissionMs < 20_000, "1,000-journal admission audit exceeded 20s"); + assert.deepEqual(admission, { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); + assert.ok( + productPathMs < 60_000, + "1,000-journal product reference/reconciliation gate exceeded 60s", + ); + assert.equal(firstReferences.complete, true); + assert.equal(secondReferences.complete, true); + assert.equal(thirdReferences.complete, true); + assert.equal(firstReferences.records.length, 1_000); + assert.equal( + firstReferences.records.reduce((total, record) => total + record.assetIds.length, 0), + 250_000, + ); + assert.deepEqual(reconciliation, []); + const cache = restarted.cacheStats(); + assert.ok(cache.journalCount <= 32); + assert.ok(cache.journalBytes <= 32 * 1024 * 1024); + assert.ok(cache.tailCount <= 128); + assert.ok(cache.tailBytes <= 64 * 1024); + assert.equal((await restarted.terminalHistory()).length, 1_000); + assert.ok( + (await fs.lstat(path.join(root, "run-index.json"))).size < 1024 * 1024, + "metadata-only run index exceeded 1 MiB", + ); + const retentionStartedAt = performance.now(); + const retention = await restarted.terminalRetentionCandidates({ + keepLatest: 900, + limit: 100, + }); + const retentionMs = performance.now() - retentionStartedAt; + assert.equal(retention.length, 40); + assert.ok(retentionMs < 5_000, "1,000-journal high-reference retention lookup exceeded 5s"); + t.diagnostic( + JSON.stringify({ + terminalJournalCount: 1_000, + outputAssetIdsPerRun: 250, + restartMs: Math.round(restartMs), + admissionMs: Math.round(admissionMs), + productPathMs: Math.round(productPathMs), + retentionMs: Math.round(retentionMs), + indexBytes: (await fs.lstat(path.join(root, "run-index.json"))).size, + cache, + }), + ); +}); diff --git a/main/services/create-images/run-journal-store.test.ts b/main/services/create-images/run-journal-store.test.ts new file mode 100644 index 00000000..12222e81 --- /dev/null +++ b/main/services/create-images/run-journal-store.test.ts @@ -0,0 +1,1572 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import { + appendCreateImagesRunEvent, + createCreateImagesRunJournal, + projectCreateImagesRun, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + CreateImagesRunJournalLoadError, + CreateImagesRunJournalRevisionConflictError, + CreateImagesRunJournalStore, + createImagesWorkflowSnapshotFingerprint, +} from "./run-journal-store.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const LATER = "2026-08-11T12:00:01.000Z"; +const ASSET_ID = "c".repeat(64); +const INPUT_ASSET_ID = "a".repeat(64); + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-run-journal-")); + t.after(() => fs.rm(root, { force: true, recursive: true })); + return root; +} + +function workflow(): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Durable run", + revision: 3, + createdAt: NOW, + updatedAt: NOW, + nodes: [ + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "A durable prompt" }, + }, + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-prompt", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-output", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ], + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function startInput(runId = "run-1") { + return { + runId, + workflowSnapshot: workflow(), + plan: { + scope: { kind: "all" } as const, + orderedNodeIds: ["prompt-1", "generate-1", "output-1"], + dependencies: { + "prompt-1": [], + "generate-1": ["prompt-1"], + "output-1": ["generate-1"], + }, + }, + createdAt: NOW, + }; +} + +function emptyStartInput(runId: string) { + const snapshot: WorkflowDocumentV1 = { + ...workflow(), + id: `workflow-${runId}`, + nodes: [ + { + id: "prompt-only", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "retire me" }, + }, + ], + edges: [], + assetRefs: [], + }; + return { + runId, + workflowSnapshot: snapshot, + plan: { + scope: { kind: "all" } as const, + orderedNodeIds: ["prompt-only"], + dependencies: { "prompt-only": [] }, + }, + createdAt: NOW, + }; +} + +function event( + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): Extract { + return { + type, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: LATER, + ...fields, + } as Extract; +} + +async function append( + store: CreateImagesRunJournalStore, + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): Promise { + return store.append(journal.runId, journal.journalRevision, event(journal, type, fields)); +} + +interface PendingAppendFixture { + kind: "append"; + event: CreateImagesRunEventV1; + targetJournalDigest: string; +} + +async function expectedPendingEventRecord( + root: string, + runId: string, + checkpointFile: "run.json" | "run.last-known-good.json", + eventLogFile: "run.events.jsonl" | "run.last-known-good.events.jsonl", +): Promise<{ pending: PendingAppendFixture; bytes: Buffer }> { + const directory = path.join(root, "runs", runId); + const pending = JSON.parse( + await fs.readFile(path.join(directory, "run.pending.json"), "utf8"), + ) as PendingAppendFixture; + assert.equal(pending.kind, "append"); + const checkpoint = JSON.parse( + await fs.readFile(path.join(directory, checkpointFile), "utf8"), + ) as CreateImagesRunJournalV1; + let previousDigest = createHash("sha256") + .update(JSON.stringify(checkpoint), "utf8") + .digest("hex"); + const log = await fs.readFile(path.join(directory, eventLogFile), "utf8").catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + }); + const lines = log.trimEnd().split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine) previousDigest = (JSON.parse(lastLine) as { digest: string }).digest; + const journalRevision = pending.event.sequence + 1; + const digest = createHash("sha256") + .update( + JSON.stringify({ + runId, + journalRevision, + previousDigest, + event: pending.event, + }), + "utf8", + ) + .digest("hex"); + return { + pending, + bytes: Buffer.from( + `${JSON.stringify({ + version: 1, + runId, + journalRevision, + previousDigest, + digest, + event: pending.event, + })}\n`, + "utf8", + ), + }; +} + +async function startGenerateNode( + store: CreateImagesRunJournalStore, + journal: CreateImagesRunJournalV1, +): Promise { + let next = await append(store, journal, "node-started", { + nodeId: "prompt-1", + }); + next = await append(store, next, "node-output-published", { + nodeId: "prompt-1", + outputAssetIds: [], + }); + next = await append(store, next, "node-succeeded", { + nodeId: "prompt-1", + outputAssetIds: [], + }); + return append(store, next, "node-started", { nodeId: "generate-1" }); +} + +async function terminalFailedRun( + store: CreateImagesRunJournalStore, + runId: string, + workflowId = `workflow-${runId}`, +): Promise { + const input = emptyStartInput(runId); + input.workflowSnapshot.id = workflowId; + let journal = await store.start(input, () => true); + journal = await append(store, journal, "run-started", {}); + journal = await append(store, journal, "node-started", { + nodeId: "prompt-only", + }); + journal = await append(store, journal, "node-failed", { + nodeId: "prompt-only", + errorCode: "test-failure", + }); + return append(store, journal, "run-terminal", { status: "failed" }); +} + +async function terminalAmbiguousRun( + store: CreateImagesRunJournalStore, + runId = "run-ambiguous", + beforeTerminal?: () => void, +): Promise { + let journal = await store.start(startInput(runId), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: `idem-${runId}-0001`, + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = await append(store, journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-blocked", { + nodeId: "output-1", + upstreamNodeIds: ["generate-1"], + }); + beforeTerminal?.(); + return append(store, journal, "run-terminal", { status: "needs_attention" }); +} + +test("start publishes fingerprinted current and recovery journals atomically", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + assert.equal(journal.workflowFingerprint, createImagesWorkflowSnapshotFingerprint(workflow())); + assert.equal((await store.health("run-1")).status, "healthy"); + assert.equal((await store.get("run-1"))?.journalRevision, 1); + assert.equal( + await fs.readFile(path.join(root, "runs", "run-1", "run.json"), "utf8"), + await fs.readFile(path.join(root, "runs", "run-1", "run.last-known-good.json"), "utf8"), + ); + await assert.rejects( + store.start(startInput(), () => true), + CreateImagesRunJournalRevisionConflictError, + ); +}); + +test("stale renderer ownership blocks only pre-intent start publication", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await assert.rejects( + store.start(startInput(), () => false), + /no longer active/u, + ); + assert.equal((await store.health("run-1")).status, "missing"); + + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + assert.equal(projectCreateImagesRun(journal).status, "running"); +}); + +test("restart completes a start that crashed after durable intent without renderer liveness", async (t) => { + const root = await temporaryRoot(t); + let crash = true; + const crashing = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crash) throw new Error("simulated process loss"); + }, + }); + await assert.rejects( + crashing.start(startInput(), () => true), + /simulated process loss/u, + ); + crash = false; + const restarted = new CreateImagesRunJournalStore(() => root); + const health = await restarted.initialize(); + assert.equal(health[0]?.status, "healthy"); + assert.equal((await restarted.get("run-1"))?.journalRevision, 1); +}); + +for (const boundary of ["pending", "current", "last-known-good"] as const) { + test(`restart completes append crashed after ${boundary} publication`, async (t) => { + const root = await temporaryRoot(t); + let crash = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crash && boundary === "pending") throw new Error("crash-pending"); + }, + afterCurrentPublished: async () => { + if (crash && boundary === "current") throw new Error("crash-current"); + }, + afterLastKnownGoodPublished: async () => { + if (crash && boundary === "last-known-good") throw new Error("crash-last-known-good"); + }, + }); + const initial = await store.start(startInput(), () => true); + crash = true; + await assert.rejects( + append(store, initial, "run-started", {}), + new RegExp(`crash-${boundary}`, "u"), + ); + const restarted = new CreateImagesRunJournalStore(() => root); + const recovered = await restarted.get("run-1"); + assert.equal(recovered?.journalRevision, 2); + assert.equal(recovered && projectCreateImagesRun(recovered).status, "running"); + await assert.rejects( + restarted.append("run-1", 1, event(initial, "run-started", {})), + CreateImagesRunJournalRevisionConflictError, + ); + }); +} + +for (const boundary of ["current", "last-known-good"] as const) { + for (const fragment of ["partial-json", "valid-json-without-newline"] as const) { + test(`restart atomically repairs a ${fragment} torn ${boundary} event append`, async (t) => { + const root = await temporaryRoot(t); + let tear = false; + const tearEventLog = async (runId: string): Promise => { + if (!tear) return; + tear = false; + const checkpointFile = boundary === "current" ? "run.json" : "run.last-known-good.json"; + const eventLogFile = + boundary === "current" ? "run.events.jsonl" : "run.last-known-good.events.jsonl"; + const expected = await expectedPendingEventRecord( + root, + runId, + checkpointFile, + eventLogFile, + ); + const tornBytes = + fragment === "partial-json" + ? expected.bytes.subarray(0, Math.floor(expected.bytes.length / 2)) + : expected.bytes.subarray(0, expected.bytes.length - 1); + await fs.appendFile(path.join(root, "runs", runId, eventLogFile), tornBytes); + throw new Error(`crash-torn-${boundary}`); + }; + const store = new CreateImagesRunJournalStore(() => root, { + ...(boundary === "current" + ? { afterPendingPublished: tearEventLog } + : { afterCurrentPublished: tearEventLog }), + }); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + tear = true; + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + new RegExp(`crash-torn-${boundary}`, "u"), + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + const recovered = await restarted.get(started.runId); + assert.equal(recovered?.journalRevision, started.journalRevision + 1); + assert.equal(recovered?.events[recovered.events.length - 1]?.type, "node-started"); + assert.equal((await restarted.health(started.runId)).status, "healthy"); + await assert.rejects(fs.lstat(path.join(root, "runs", started.runId, "run.pending.json")), { + code: "ENOENT", + }); + assert.equal( + (await new CreateImagesRunJournalStore(() => root).get(started.runId))?.journalRevision, + started.journalRevision + 1, + ); + }); + } +} + +test("torn append recovery refuses a pending target digest mismatch", async (t) => { + const root = await temporaryRoot(t); + let tear = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async (runId) => { + if (!tear) return; + tear = false; + const expected = await expectedPendingEventRecord( + root, + runId, + "run.json", + "run.events.jsonl", + ); + const directory = path.join(root, "runs", runId); + await fs.appendFile( + path.join(directory, "run.events.jsonl"), + expected.bytes.subarray(0, Math.floor(expected.bytes.length / 2)), + ); + await fs.writeFile( + path.join(directory, "run.pending.json"), + `${JSON.stringify({ ...expected.pending, targetJournalDigest: "0".repeat(64) }, null, 2)}\n`, + "utf8", + ); + throw new Error("crash-with-wrong-target-digest"); + }, + }); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + tear = true; + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + /crash-with-wrong-target-digest/u, + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + await assert.rejects(restarted.get(started.runId), CreateImagesRunJournalLoadError); + const health = await restarted.health(started.runId); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") assert.equal(health.reason, "pending-conflict"); + await fs.access(path.join(root, "runs", started.runId, "run.pending.json")); +}); + +test("CAS and monotonic event identity reject stale, duplicate, and out-of-order writes", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + const started = await append(store, journal, "run-started", {}); + await assert.rejects( + store.append("run-1", 1, event(journal, "run-started", {})), + CreateImagesRunJournalRevisionConflictError, + ); + await assert.rejects( + store.append("run-1", started.journalRevision, { + ...event(started, "node-started", { nodeId: "generate-1" }), + sequence: 99, + }), + /monotonic/u, + ); + await assert.rejects( + store.append("run-1", started.journalRevision, { + ...event(started, "node-started", { nodeId: "generate-1" }), + runId: "run-stale", + }), + /identity/u, + ); + assert.equal((await store.get("run-1"))?.journalRevision, 2); +}); + +for (const authoritativeFile of [ + "run.json", + "run.last-known-good.json", + "run.events.jsonl", + "run.last-known-good.events.jsonl", +] as const) { + test(`cached authority detects same-size ${authoritativeFile} tampering before append`, async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + assert.equal((await store.get(started.runId))?.journalRevision, started.journalRevision); + + const target = path.join(root, "runs", started.runId, authoritativeFile); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + assert.ok(bytes.length > 0); + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] === 0x5b ? 0x7b : bytes[0] ^ 1; + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + assert.equal((await fs.stat(target)).size, before.size); + + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + CreateImagesRunJournalLoadError, + ); + }); +} + +for (const authoritativeFile of [ + "run.json", + "run.last-known-good.json", + "run.events.jsonl", + "run.last-known-good.events.jsonl", +] as const) { + test(`durable append intent detects post-pending ${authoritativeFile} replacement`, async (t) => { + const root = await temporaryRoot(t); + let tamperAfterPending = false; + let tampered = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async (runId) => { + if (!tamperAfterPending) return; + tamperAfterPending = false; + const target = path.join(root, "runs", runId, authoritativeFile); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + assert.ok(bytes.length > 0); + if (authoritativeFile.endsWith(".jsonl")) { + const digestOffset = bytes.indexOf(Buffer.from('"digest":"', "utf8")); + assert.notEqual(digestOffset, -1); + const firstDigestByte = digestOffset + Buffer.byteLength('"digest":"', "utf8"); + bytes[firstDigestByte] = bytes[firstDigestByte] === 0x61 ? 0x62 : 0x61; + } else { + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] === 0x5b ? 0x7b : bytes[0] ^ 1; + } + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + const after = await fs.stat(target); + assert.equal(after.size, before.size); + assert.ok(Math.abs(after.mtimeMs - before.mtimeMs) < 1); + tampered = true; + }, + }); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + tamperAfterPending = true; + + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + CreateImagesRunJournalLoadError, + ); + assert.equal(tampered, true); + const pendingPath = path.join(root, "runs", started.runId, "run.pending.json"); + const pendingBytes = await fs.readFile(pendingPath, "utf8"); + assert.match(pendingBytes, /"authority"/u); + const health = await store.health(started.runId); + assert.equal(health.status, "recovery-required"); + if (health.status !== "recovery-required") return; + assert.equal(health.reason, "pending-conflict"); + assert.equal(health.canRecover, false); + assert.equal(health.workflowId, started.workflowId); + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + CreateImagesRunJournalLoadError, + ); + assert.equal(await fs.readFile(pendingPath, "utf8"), pendingBytes); + }); +} + +test("safe retry state survives a crash before the next attempt", async (t) => { + const root = await temporaryRoot(t); + let crashOnRetry = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crashOnRetry) throw new Error("crash-after-retry-intent"); + }, + }); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + crashOnRetry = true; + await assert.rejects( + append(store, journal, "node-retry-scheduled", { + nodeId: "generate-1", + attempt: 1, + errorCode: "rate-limited", + delayMs: 2_000, + retrySafety: "confirmed-not-submitted", + }), + /crash-after-retry-intent/u, + ); + const restarted = new CreateImagesRunJournalStore(() => root); + journal = (await restarted.get("run-1")) as CreateImagesRunJournalV1; + assert.equal( + projectCreateImagesRun(journal).nodes["generate-1"]?.attempts[0]?.submission, + "retry-scheduled", + ); + journal = await append(restarted, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0002", + providerId: "mock", + modelId: "mock-image-v1", + }); + assert.equal(projectCreateImagesRun(journal).nodes["generate-1"]?.attempts.length, 2); +}); + +test("unresolved ambiguity is durable terminal history and never looks runnable after restart", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = await append(store, journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-blocked", { + nodeId: "output-1", + upstreamNodeIds: ["generate-1"], + }); + await append(store, journal, "run-terminal", { status: "needs_attention" }); + + const restarted = new CreateImagesRunJournalStore(() => root); + const loaded = (await restarted.get("run-1")) as CreateImagesRunJournalV1; + assert.equal(projectCreateImagesRun(loaded).status, "needs_attention"); + assert.equal((await restarted.terminalHistory())[0]?.status, "needs_attention"); + await assert.rejects( + append(restarted, loaded, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }), + /Terminal runs/u, + ); +}); + +test("unresolved ambiguity cannot be retired until its CAS acknowledgement is durable", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await terminalAmbiguousRun(store); + assert.deepEqual(await store.terminalRetentionCandidates({ keepLatest: 0, limit: 100 }), []); + await assert.rejects( + store.planTerminalPrune([{ runId: journal.runId, journalRevision: journal.journalRevision }]), + /must be acknowledged/u, + ); + + journal = await append(store, journal, "run-ambiguity-acknowledged", { + expectedNeedsAttentionJournalRevision: journal.journalRevision, + }); + assert.equal( + (await store.terminalRetentionCandidates({ keepLatest: 0, limit: 100 }))[0]?.runId, + journal.runId, + ); +}); + +test("an index write failure after terminal ambiguity dirties admission until rebuild", async (t) => { + const root = await temporaryRoot(t); + let failIndex = false; + const store = new CreateImagesRunJournalStore(() => root, { + beforeIndexPublished: async () => { + if (failIndex) throw new Error("simulated-index-write-failure"); + }, + }); + await assert.rejects( + terminalAmbiguousRun(store, "run-index-dirty", () => { + failIndex = true; + }), + /simulated-index-write-failure/u, + ); + const authoritative = await store.get("run-index-dirty"); + assert.equal(projectCreateImagesRun(authoritative!).status, "needs_attention"); + const health = await store.indexHealth(); + assert.equal(health.status, "degraded"); + if (health.status === "degraded") assert.equal(health.diagnostic, "stale-derived-index"); + await assert.rejects( + store.hasUnresolvedAmbiguity("workflow-1"), + /simulated-index-write-failure/u, + ); + + failIndex = false; + assert.equal(await store.hasUnresolvedAmbiguity("workflow-1"), true); + assert.equal((await store.indexHealth()).status, "healthy"); +}); + +test("the workflow admission audit exposes authoritative nonterminal runs", async (t) => { + const queuedRoot = await temporaryRoot(t); + const queuedStore = new CreateImagesRunJournalStore(() => queuedRoot); + await queuedStore.start(startInput(), () => true); + assert.equal(await queuedStore.hasNonterminalRun("workflow-1"), true); + assert.deepEqual(await queuedStore.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: true, + hasUnresolvedAmbiguity: false, + }); + + const terminalRoot = await temporaryRoot(t); + const terminalStore = new CreateImagesRunJournalStore(() => terminalRoot); + await terminalFailedRun(terminalStore, "terminal-run", "workflow-1"); + assert.equal(await terminalStore.hasNonterminalRun("workflow-1"), false); + assert.deepEqual(await terminalStore.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); +}); + +test("same-process checkpoint corruption cannot be hidden by journal or index caches", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await terminalFailedRun(store, "cached-terminal", "workflow-1"); + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); + const directory = path.join(root, "runs", "cached-terminal"); + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), "{broken-current", "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), "{broken-recovery", "utf8"), + ]); + + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: true, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); + assert.equal( + (await store.auditWorkflowAdmission("unrelated-workflow")).hasDegradedAuthority, + true, + ); + await assert.rejects(store.get("cached-terminal"), CreateImagesRunJournalLoadError); + assert.deepEqual( + (await store.workflowDegradedCandidates("workflow-1")).map((candidate) => candidate.runId), + ["cached-terminal"], + ); +}); + +test("the admission audit completes a valid crash-pending mutation before deciding", async (t) => { + const root = await temporaryRoot(t); + let crash = true; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crash) throw new Error("simulated crash after pending authority"); + }, + }); + await assert.rejects( + store.start(startInput("pending-run"), () => true), + /simulated crash/u, + ); + crash = false; + + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: true, + hasUnresolvedAmbiguity: false, + }); + assert.equal((await store.health("pending-run")).status, "healthy"); +}); + +test("durable cancellation intent survives restart before node cancellation", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + const cancelled = await store.requestCancellation("run-1", journal.journalRevision, { + at: LATER, + reason: "renderer-disconnected", + }); + assert.equal(projectCreateImagesRun(cancelled).status, "cancel_requested"); + const restarted = new CreateImagesRunJournalStore(() => root); + const projection = projectCreateImagesRun( + (await restarted.get("run-1")) as CreateImagesRunJournalV1, + ); + assert.equal(projection.status, "cancel_requested"); + assert.equal(projection.cancellation?.reason, "renderer-disconnected"); + assert.deepEqual( + (await restarted.reconciliationCandidates()).map((candidate) => candidate.runId), + ["run-1"], + ); +}); + +test("restart removes only strictly named orphan atomic staging files", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + const runDirectory = path.join(root, "runs", "run-1"); + const staged = path.join(runDirectory, ".run.json.12345678-1234-4123-8123-123456789abc.tmp"); + await fs.writeFile(staged, "partial", "utf8"); + assert.equal((await store.initialize())[0]?.status, "healthy"); + await assert.rejects(fs.lstat(staged), { code: "ENOENT" }); + + await fs.writeFile(path.join(runDirectory, ".unexpected.tmp"), "untrusted", "utf8"); + await assert.rejects(store.initialize(), CreateImagesRunJournalLoadError); +}); + +test("corrupt current is distinguishable and explicit last-known-good recovery is CAS guarded", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + const currentPath = path.join(root, "runs", "run-1", "run.json"); + await fs.writeFile(currentPath, "{broken", "utf8"); + const health = await store.health("run-1"); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") assert.equal(health.reason, "current-corrupt"); + await assert.rejects(store.get("run-1"), CreateImagesRunJournalLoadError); + await assert.rejects( + store.recoverFromLastKnownGood("run-1", 99), + CreateImagesRunJournalRevisionConflictError, + ); + const recovered = await store.recoverFromLastKnownGood("run-1", 1); + assert.equal(recovered.journalRevision, 1); + assert.equal((await store.health("run-1")).status, "healthy"); +}); + +test("degraded discard refuses healthy and recoverable records and binds corrupt state", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("healthy-run"), () => true); + assert.deepEqual(await store.planDegradedRunDiscard("healthy-run"), { + status: "not-degraded", + }); + + await store.start(startInput("recoverable-run"), () => true); + await fs.writeFile( + path.join(root, "runs", "recoverable-run", "run.json"), + "{broken-current", + "utf8", + ); + assert.deepEqual(await store.planDegradedRunDiscard("recoverable-run"), { + status: "recoverable", + }); + + await store.start(startInput("discard-run"), () => true); + await Promise.all([ + fs.writeFile(path.join(root, "runs", "discard-run", "run.json"), "{broken", "utf8"), + fs.writeFile( + path.join(root, "runs", "discard-run", "run.last-known-good.json"), + "{broken", + "utf8", + ), + ]); + const planned = await store.planDegradedRunDiscard("discard-run"); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") return; + assert.equal(planned.plan.association, "workflow"); + assert.equal(planned.plan.workflowId, "workflow-1"); + await fs.writeFile( + path.join(root, "runs", "discard-run", "run.json"), + "{changed-corruption", + "utf8", + ); + assert.deepEqual( + await store.discardDegradedRun({ + runId: planned.plan.runId, + authorizationToken: planned.plan.authorizationToken, + }), + { status: "conflict" }, + ); + assert.notEqual((await store.health("discard-run")).status, "missing"); +}); + +test("unassociated degraded discard is crash-resumable and fail-closed for references", async (t) => { + const root = await temporaryRoot(t); + const seeded = new CreateImagesRunJournalStore(() => root); + await seeded.start(startInput("unassociated-run"), () => true); + await Promise.all([ + fs.writeFile( + path.join(root, "runs", "unassociated-run", "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(root, "runs", "unassociated-run", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + fs.rm(path.join(root, "run-index.json")), + ]); + const rebuilt = new CreateImagesRunJournalStore(() => root); + await rebuilt.initialize(); + const planned = await rebuilt.planDegradedRunDiscard("unassociated-run"); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") return; + assert.equal(planned.plan.association, "unassociated"); + + const crashing = new CreateImagesRunJournalStore(() => root, { + afterDiscardManifestPublished: async () => { + throw new Error("discard-manifest-crash"); + }, + }); + await assert.rejects( + crashing.discardDegradedRun({ + runId: planned.plan.runId, + authorizationToken: planned.plan.authorizationToken, + }), + /discard-manifest-crash/u, + ); + assert.equal((await crashing.referenceInventory()).complete, false); + assert.equal(await crashing.get("unassociated-run"), undefined); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + assert.equal(await restarted.get("unassociated-run"), undefined); + assert.equal(await restarted.degradedRunCount(), 0); + assert.equal((await restarted.referenceInventory()).complete, true); + await assert.rejects(fs.lstat(path.join(root, "run-discard.pending.json")), { + code: "ENOENT", + }); +}); + +test("degraded discard resumes after atomic retirement and post-delete crash boundaries", async (t) => { + for (const boundary of ["afterDegradedRunRetired", "afterDiscardedRunDeleted"] as const) { + const root = await temporaryRoot(t); + const seeded = new CreateImagesRunJournalStore(() => root); + await seeded.start(startInput(`discard-${boundary}`), () => true); + await Promise.all([ + fs.writeFile( + path.join(root, "runs", `discard-${boundary}`, "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(root, "runs", `discard-${boundary}`, "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + const planned = await seeded.planDegradedRunDiscard(`discard-${boundary}`); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") continue; + const crashing = new CreateImagesRunJournalStore(() => root, { + [boundary]: async () => { + throw new Error(`crash-${boundary}`); + }, + }); + await assert.rejects( + crashing.discardDegradedRun({ + runId: planned.plan.runId, + authorizationToken: planned.plan.authorizationToken, + }), + new RegExp(`crash-${boundary}`, "u"), + ); + assert.equal((await crashing.referenceInventory()).complete, false); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + assert.equal(await restarted.get(planned.plan.runId), undefined); + assert.equal(await restarted.degradedRunCount(), 0); + assert.equal((await restarted.referenceInventory()).complete, true); + } +}); + +test("a forged discard manifest cannot retire a healthy journal", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("healthy-authority"), () => true); + const forgedPlan = { + version: 1 as const, + runId: "healthy-authority", + reason: "current-corrupt" as const, + association: "workflow" as const, + workflowId: "workflow-1", + expectedCurrentJournalRevision: 1, + expectedLastKnownGoodJournalRevision: 1, + recordFingerprint: "d".repeat(64), + }; + await fs.writeFile( + path.join(root, "run-discard.pending.json"), + `${JSON.stringify({ + ...forgedPlan, + authorizationToken: createHash("sha256") + .update(JSON.stringify(forgedPlan), "utf8") + .digest("hex"), + createdAt: NOW, + })}\n`, + "utf8", + ); + const restarted = new CreateImagesRunJournalStore(() => root); + await assert.rejects(restarted.initialize(), CreateImagesRunJournalRevisionConflictError); + const persisted = JSON.parse( + await fs.readFile(path.join(root, "runs", "healthy-authority", "run.json"), "utf8"), + ) as { runId?: string }; + assert.equal(persisted.runId, "healthy-authority"); +}); + +test("future schema and corrupt pending metadata fail closed without overwrite", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + const runDirectory = path.join(root, "runs", "run-1"); + const currentPath = path.join(runDirectory, "run.json"); + const future = { ...journal, version: 2 }; + await fs.writeFile(currentPath, `${JSON.stringify(future)}\n`, "utf8"); + const unsafe = await store.health("run-1"); + assert.equal(unsafe.status, "unsafe"); + await assert.rejects( + store.append("run-1", 1, event(journal, "run-started", {})), + CreateImagesRunJournalLoadError, + ); + assert.equal(JSON.parse(await fs.readFile(currentPath, "utf8")).version, 2); + + const root2 = await temporaryRoot(t); + const second = new CreateImagesRunJournalStore(() => root2); + await second.start(startInput(), () => true); + await fs.writeFile(path.join(root2, "runs", "run-1", "run.pending.json"), "{broken", "utf8"); + const corrupt = await second.health("run-1"); + assert.equal(corrupt.status, "recovery-required"); + if (corrupt.status === "recovery-required") assert.equal(corrupt.reason, "pending-corrupt"); +}); + +test("reference inventory retains durable outputs and fails closed around corruption", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = await append(store, journal, "node-submission-accepted", { + nodeId: "generate-1", + attempt: 1, + providerJobId: "mock-job-1", + }); + const indexBeforeOutput = await fs.readFile(path.join(root, "run-index.json"), "utf8"); + journal = await append(store, journal, "node-output-published", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID], + }); + assert.equal(await fs.readFile(path.join(root, "run-index.json"), "utf8"), indexBeforeOutput); + await append(store, journal, "node-succeeded", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID], + }); + let inventory = await store.referenceInventory(); + assert.deepEqual(inventory, { + complete: true, + records: [{ runId: "run-1", assetIds: [ASSET_ID] }], + }); + await fs.writeFile(path.join(root, "runs", "run-1", "run.json"), "{broken", "utf8"); + inventory = await store.referenceInventory(); + assert.equal(inventory.complete, false); + assert.deepEqual(inventory.records[0]?.assetIds, [ASSET_ID]); +}); + +test("reference inventory unions immutable snapshot inputs before any run event", async (t) => { + const root = await temporaryRoot(t); + const snapshot: WorkflowDocumentV1 = { + ...workflow(), + nodes: [ + { + id: "input-1", + type: "image-input", + position: { x: -100, y: 0 }, + data: { assetId: INPUT_ASSET_ID }, + }, + ...workflow().nodes, + ], + assetRefs: [INPUT_ASSET_ID], + }; + const store = new CreateImagesRunJournalStore(() => root); + await store.start( + { + ...startInput(), + workflowSnapshot: snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: ["input-1", "prompt-1", "generate-1", "output-1"], + dependencies: { + "input-1": [], + "prompt-1": [], + "generate-1": ["prompt-1"], + "output-1": ["generate-1"], + }, + }, + }, + () => true, + ); + assert.deepEqual(await store.referenceInventory(), { + complete: true, + records: [{ runId: "run-1", assetIds: [INPUT_ASSET_ID] }], + }); +}); + +test("corrupt recovery copies remain listable with trusted identity and explicit repair direction", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + await fs.writeFile( + path.join(root, "runs", "run-1", "run.last-known-good.json"), + "{broken", + "utf8", + ); + const health = await store.health("run-1"); + assert.deepEqual(health, { + status: "recovery-required", + runId: "run-1", + reason: "last-known-good-corrupt", + canRecover: "from-current", + workflowId: "workflow-1", + workflowRevision: 3, + currentJournalRevision: 1, + }); + assert.deepEqual(await store.recoveryCandidates(), [ + { + runId: "run-1", + workflowId: "workflow-1", + workflowRevision: 3, + reason: "last-known-good-corrupt", + canRecover: "from-current", + expectedJournalRevision: 1, + }, + ]); + await assert.rejects( + store.recoverLastKnownGoodFromCurrent("run-1", 2), + CreateImagesRunJournalRevisionConflictError, + ); + await store.recoverLastKnownGoodFromCurrent("run-1", 1); + assert.equal((await store.health("run-1")).status, "healthy"); +}); + +test("future-schema event logs and hostile durable indexes fail closed without overwrite", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + await append(store, journal, "run-started", {}); + const logPath = path.join(root, "runs", "run-1", "run.events.jsonl"); + const record = JSON.parse((await fs.readFile(logPath, "utf8")).trim()) as Record; + record.version = 2; + await fs.writeFile(logPath, `${JSON.stringify(record)}\n`, "utf8"); + const health = await store.health("run-1"); + assert.equal(health.status, "unsafe"); + if (health.status === "unsafe") assert.equal(health.reason, "current-future-schema"); + + const indexPath = path.join(root, "run-index.json"); + const hostile = '{"version":1,"revision":1,"entries":[{"runId":"../escape"}]}\n'; + await fs.writeFile(indexPath, hostile, "utf8"); + const restarted = new CreateImagesRunJournalStore(() => root); + assert.deepEqual(await restarted.indexHealth(), { status: "corrupt" }); + await restarted.initialize(); + const rebuiltHealth = await restarted.indexHealth(); + assert.equal(rebuiltHealth.status, "degraded"); + if (rebuiltHealth.status === "degraded") { + assert.equal(rebuiltHealth.degradedEntryCount, 1); + assert.equal(rebuiltHealth.diagnostic, "rebuilt-corrupt-index"); + assert.equal(rebuiltHealth.quarantinedIndexCount, 1); + } + const quarantine = (await fs.readdir(root)).find((name) => + /^run-index\.corrupt\..+\.json$/u.test(name), + ); + assert.ok(quarantine); + assert.equal(await fs.readFile(path.join(root, quarantine), "utf8"), hostile); + + await fs.writeFile(indexPath, '{"version":2,"revision":1,"entries":[]}\n', "utf8"); + const future = new CreateImagesRunJournalStore(() => root); + assert.deepEqual(await future.indexHealth(), { status: "unsafe" }); + await assert.rejects(future.initialize(), (error: unknown) => { + assert.ok(error instanceof CreateImagesRunJournalLoadError); + assert.equal(error.status, "unsafe"); + return true; + }); +}); + +test("same-process index cache rejects an atomically replaced future schema without overwrite", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + await store.initialize(); + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: true, + hasUnresolvedAmbiguity: false, + }); + + const indexPath = path.join(root, "run-index.json"); + const replacementPath = path.join(root, "run-index.future-replacement.json"); + const futureBytes = '{"version":2,"revision":99,"entries":[],"degraded":[]}\n'; + await fs.writeFile(replacementPath, futureBytes, "utf8"); + await fs.rename(replacementPath, indexPath); + + await assert.rejects(store.auditWorkflowAdmission("workflow-1"), (error: unknown) => { + assert.ok(error instanceof CreateImagesRunJournalLoadError); + assert.equal(error.status, "unsafe"); + return true; + }); + assert.equal(await fs.readFile(indexPath, "utf8"), futureBytes); + assert.deepEqual(await store.indexHealth(), { status: "unsafe" }); +}); + +test("restart preserves future-schema and both-corrupt runs as bounded degraded records", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("future-run"), () => true); + await store.start(startInput("corrupt-run"), () => true); + + const futurePath = path.join(root, "runs", "future-run", "run.json"); + const future = JSON.parse(await fs.readFile(futurePath, "utf8")) as Record; + future.version = 2; + await fs.writeFile(futurePath, `${JSON.stringify(future)}\n`, "utf8"); + await Promise.all([ + fs.writeFile(path.join(root, "runs", "corrupt-run", "run.json"), "{broken-current", "utf8"), + fs.writeFile( + path.join(root, "runs", "corrupt-run", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + const indexHealth = await restarted.indexHealth(); + assert.equal(indexHealth.status, "degraded"); + if (indexHealth.status === "degraded") assert.equal(indexHealth.degradedEntryCount, 2); + assert.deepEqual(await restarted.workflowDegradedCandidates("workflow-1"), [ + { + status: "recovery-required", + runId: "corrupt-run", + workflowId: "workflow-1", + workflowRevision: 3, + reason: "current-corrupt", + canRecover: false, + }, + { + status: "unsafe", + runId: "future-run", + workflowId: "workflow-1", + workflowRevision: 3, + reason: "current-future-schema", + }, + ]); + assert.equal((await restarted.degradedRuns()).length, 2); + assert.deepEqual(await restarted.referenceInventory(), { + complete: false, + records: [ + { runId: "corrupt-run", assetIds: [] }, + { runId: "future-run", assetIds: [] }, + ], + }); +}); + +test("startup revalidates a stale terminal index entry before reconciliation", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("queued-run"), () => true); + const indexPath = path.join(root, "run-index.json"); + const index = JSON.parse(await fs.readFile(indexPath, "utf8")) as { + entries: Array>; + }; + index.entries[0]!.status = "succeeded"; + index.entries[0]!.terminal = true; + await fs.writeFile(indexPath, `${JSON.stringify(index)}\n`, "utf8"); + + const restarted = new CreateImagesRunJournalStore(() => root); + const health = await restarted.initialize(); + assert.deepEqual(health, [ + { + status: "healthy", + runId: "queued-run", + journalRevision: 1, + runStatus: "queued", + }, + ]); + assert.deepEqual( + (await restarted.reconciliationCandidates()).map((journal) => journal.runId), + ["queued-run"], + ); + assert.deepEqual(await restarted.terminalHistory(), []); +}); + +test("startup surfaces a corrupt oldest terminal run beyond a 100-item history window", async (t) => { + const root = await temporaryRoot(t); + const runsPath = path.join(root, "runs"); + await fs.mkdir(runsPath, { recursive: true }); + const snapshot = emptyStartInput("seed").workflowSnapshot; + snapshot.id = "workflow-1"; + const plan = { + scope: { kind: "all" } as const, + orderedNodeIds: ["prompt-only"], + dependencies: { "prompt-only": [] }, + }; + const entries: Array> = []; + for (let index = 1; index <= 101; index += 1) { + const runId = `terminal-${String(index).padStart(3, "0")}`; + const createdAt = new Date(Date.parse(NOW) + index * 10_000).toISOString(); + let journal = createCreateImagesRunJournal({ + runId, + workflowSnapshot: snapshot, + workflowFingerprint: createImagesWorkflowSnapshotFingerprint(snapshot), + plan, + createdAt, + }); + for (const next of [ + { type: "run-started" as const }, + { type: "node-started" as const, nodeId: "prompt-only" }, + { + type: "node-failed" as const, + nodeId: "prompt-only", + errorCode: "test-failure", + }, + { type: "run-terminal" as const, status: "failed" as const }, + ]) { + journal = appendCreateImagesRunEvent(journal, { + ...next, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId, + sequence: journal.events.length + 1, + at: new Date(Date.parse(createdAt) + (journal.events.length + 1) * 1_000).toISOString(), + } as CreateImagesRunEventV1); + } + const directory = path.join(runsPath, runId); + await fs.mkdir(directory); + const serialized = `${JSON.stringify(journal)}\n`; + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), serialized, "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), serialized, "utf8"), + ]); + entries.push({ + runId, + workflowId: "workflow-1", + workflowRevision: snapshot.revision, + journalRevision: journal.journalRevision, + status: "failed", + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + terminal: true, + health: "healthy", + }); + } + await fs.writeFile( + path.join(root, "run-index.json"), + `${JSON.stringify({ version: 1, revision: 1, entries })}\n`, + "utf8", + ); + await fs.writeFile( + path.join(runsPath, "terminal-001", "run.last-known-good.json"), + "{broken-oldest", + "utf8", + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + const degraded = await restarted.workflowDegradedCandidates("workflow-1"); + assert.equal(degraded.length, 1); + assert.equal(degraded[0]?.runId, "terminal-001"); + assert.equal((await restarted.terminalHistory()).length, 100); + const health = await restarted.indexHealth(); + assert.equal(health.status, "degraded"); + assert.equal((await restarted.referenceInventory()).complete, false); +}); + +test("terminal pruning is explicit, CAS-bound, and releases references only after durable retirement", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(emptyStartInput("run-prune"), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await append(store, journal, "node-started", { + nodeId: "prompt-only", + }); + journal = await append(store, journal, "node-failed", { + nodeId: "prompt-only", + errorCode: "test-failure", + }); + journal = await append(store, journal, "run-terminal", { status: "failed" }); + const plan = await store.planTerminalPrune([ + { runId: journal.runId, journalRevision: journal.journalRevision }, + ]); + assert.match(plan.token, /^[a-f0-9]{64}$/u); + assert.equal((await store.terminalHistory()).length, 1); + assert.deepEqual(await store.terminalPruneStatus(), { status: "none" }); + await assert.rejects( + store.pruneTerminalRuns({ + ...plan, + candidates: [{ runId: journal.runId, journalRevision: journal.journalRevision - 1 }], + }), + /stale|changed/u, + ); + assert.equal((await store.terminalHistory()).length, 1); + const result = await store.pruneTerminalRuns(plan); + assert.deepEqual(result, { + removedRunIds: ["run-prune"], + releasedAssetIds: [], + }); + assert.deepEqual(await store.referenceInventory(), { + complete: true, + records: [], + }); + assert.deepEqual(await store.terminalHistory(), []); + assert.equal((await store.health("run-prune")).status, "missing"); +}); + +test("directory identity mismatches and copied journals fail closed", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("run-source"), () => true); + await fs.cp(path.join(root, "runs", "run-source"), path.join(root, "runs", "run-copy"), { + recursive: true, + }); + const copiedStore = new CreateImagesRunJournalStore(() => root); + const copied = await copiedStore.health("run-copy"); + assert.equal(copied.status, "recovery-required"); + if (copied.status === "recovery-required") { + assert.equal(copied.reason, "current-corrupt"); + assert.equal(copied.canRecover, false); + assert.equal(copied.workflowId, undefined); + } + assert.equal((await copiedStore.referenceInventory()).complete, false); + + const pendingRoot = await temporaryRoot(t); + const crashing = new CreateImagesRunJournalStore(() => pendingRoot, { + afterPendingPublished: async () => { + throw new Error("pending-boundary"); + }, + }); + await assert.rejects( + crashing.start(startInput("run-pending-source"), () => true), + /pending-boundary/u, + ); + await fs.rename( + path.join(pendingRoot, "runs", "run-pending-source"), + path.join(pendingRoot, "runs", "run-pending-copy"), + ); + const pendingHealth = await new CreateImagesRunJournalStore(() => pendingRoot).health( + "run-pending-copy", + ); + assert.equal(pendingHealth.status, "recovery-required"); + if (pendingHealth.status === "recovery-required") { + assert.equal(pendingHealth.reason, "pending-corrupt"); + } +}); + +test("journal and tail caches remain count-and-byte bounded across initialize", async (t) => { + const root = await temporaryRoot(t); + const limits = { + maxJournalCacheCount: 2, + maxJournalCacheBytes: 256 * 1024, + maxTailCacheCount: 2, + maxTailCacheBytes: 4 * 1024, + }; + const store = new CreateImagesRunJournalStore(() => root, {}, limits); + for (let index = 1; index <= 5; index += 1) { + await store.start(startInput(`run-cache-${index}`), () => true); + } + assert.ok(store.cacheStats().journalCount <= 2); + assert.ok(store.cacheStats().journalBytes <= limits.maxJournalCacheBytes); + + const restarted = new CreateImagesRunJournalStore(() => root, {}, limits); + await restarted.initialize(); + const stats = restarted.cacheStats(); + assert.ok(stats.journalCount <= 2); + assert.ok(stats.journalBytes <= limits.maxJournalCacheBytes); + assert.ok(stats.tailCount <= 2); + assert.ok(stats.tailBytes <= limits.maxTailCacheBytes); +}); + +test("terminal prune crash boundaries retain the manifest, tombstone caches, and resume on startup", async (t) => { + for (const boundary of [ + "afterPruneManifestPublished", + "afterRunRetired", + "beforeRetiredDelete", + "afterRetiredDelete", + ] as const) { + const root = await temporaryRoot(t); + let fail = true; + const hook = async () => { + if (!fail) return; + fail = false; + throw new Error(`crash-${boundary}`); + }; + const store = new CreateImagesRunJournalStore(() => root, { + [boundary]: hook, + }); + const journal = await terminalFailedRun(store, `run-prune-${boundary}`); + const plan = await store.planTerminalPrune([ + { runId: journal.runId, journalRevision: journal.journalRevision }, + ]); + await assert.rejects(store.pruneTerminalRuns(plan), new RegExp(`crash-${boundary}`, "u")); + assert.equal((await store.terminalPruneStatus()).status, "pending"); + assert.equal(await store.get(journal.runId), undefined); + await fs.lstat(path.join(root, "run-prune.pending.json")); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + assert.deepEqual(await restarted.terminalPruneStatus(), { status: "none" }); + assert.equal(await restarted.get(journal.runId), undefined); + assert.deepEqual(await restarted.terminalHistory(), []); + } +}); + +test("workflow recovery refresh and retention candidates are bounded index seams", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const first = await terminalFailedRun(store, "run-retention-1", "workflow-retention"); + const second = await terminalFailedRun(store, "run-retention-2", "workflow-retention"); + const global = await store.terminalRetentionCandidates({ + keepLatest: 1, + limit: 100, + }); + assert.equal(global.length, 1); + assert.equal(global[0]?.workflowId, "workflow-retention"); + const scoped = await store.terminalRetentionCandidates({ + workflowId: "workflow-retention", + keepLatest: 1, + limit: 100, + }); + assert.deepEqual(scoped, global); + + await fs.writeFile( + path.join(root, "runs", first.runId, "run.last-known-good.json"), + "{broken", + "utf8", + ); + const refreshed = await store.refreshWorkflowRecoveryMetadata("workflow-retention", [ + first.runId, + second.runId, + ]); + assert.equal(refreshed.length, 1); + assert.equal(refreshed[0]?.runId, first.runId); + assert.equal(refreshed[0]?.canRecover, "from-current"); + assert.deepEqual(await store.workflowRecoveryCandidates("workflow-retention"), refreshed); +}); + +test("run count and aggregate bytes are bounded before publication", async (t) => { + const root = await temporaryRoot(t); + const countBounded = new CreateImagesRunJournalStore(() => root, {}, { maxRunCount: 1 }); + await countBounded.start(startInput("run-1"), () => true); + await assert.rejects( + countBounded.start(startInput("run-2"), () => true), + /run count limit/u, + ); + + const root2 = await temporaryRoot(t); + const byteBounded = new CreateImagesRunJournalStore(() => root2, {}, { maxAggregateRunBytes: 1 }); + await assert.rejects( + byteBounded.start(startInput(), () => true), + /byte limit/u, + ); + assert.equal((await byteBounded.health("run-1")).status, "missing"); +}); diff --git a/main/services/create-images/run-journal-store.ts b/main/services/create-images/run-journal-store.ts new file mode 100644 index 00000000..45559615 --- /dev/null +++ b/main/services/create-images/run-journal-store.ts @@ -0,0 +1,4311 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants, type BigIntStats, type Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + appendCreateImagesRunEvent, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + CREATE_IMAGES_RUN_JOURNAL_VERSION, + createCreateImagesRunJournal, + createImagesWorkflowSnapshotFingerprintMaterial, + isFutureCreateImagesRunJournal, + hasUnresolvedCreateImagesRunAmbiguity, + parseCreateImagesRunJournal, + projectCreateImagesRun, + type CreateImagesCancellationReason, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, + type CreateImagesRunPlanV1, + type CreateImagesRunProjection, + type CreateImagesRunProviderAuthorizationV1, + type CreateImagesRunTerminalStatus, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { decodeUtf8, readRegularFile } from "../regular-file-read.js"; + +const PENDING_VERSION = 1 as const; +const CURRENT_FILE = "run.json"; +const LAST_KNOWN_GOOD_FILE = "run.last-known-good.json"; +const PENDING_FILE = "run.pending.json"; +const CURRENT_EVENTS_FILE = "run.events.jsonl"; +const LAST_KNOWN_GOOD_EVENTS_FILE = "run.last-known-good.events.jsonl"; +const RUN_INDEX_FILE = "run-index.json"; +const PRUNE_PENDING_FILE = "run-prune.pending.json"; +const DISCARD_PENDING_FILE = "run-discard.pending.json"; +const RETIRED_RUNS_DIRECTORY = "retired-runs"; +const DISCARDED_RUNS_DIRECTORY = "discarded-runs"; +const RUN_FILES = new Set([ + CURRENT_FILE, + LAST_KNOWN_GOOD_FILE, + PENDING_FILE, + CURRENT_EVENTS_FILE, + LAST_KNOWN_GOOD_EVENTS_FILE, +]); +const STAGED_FILE_PATTERN = + /^\.(?:run\.json|run\.last-known-good\.json|run\.pending\.json|run\.events\.jsonl|run\.last-known-good\.events\.jsonl)\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u; +const ROOT_STAGED_FILE_PATTERN = + /^\.(?:run-index\.json|run-prune\.pending\.json|run-discard\.pending\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u; +const MAX_STAGED_FILES_PER_RUN = 8; +const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const MAX_PENDING_BYTES = CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES + 64 * 1024; +const MAX_EVENT_LOG_BYTES = CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES; +const MAX_HEALTH_PAGE_SIZE = 250; +const MAX_PRUNE_BATCH_SIZE = 100; +const MAX_DISCARD_DIRECTORY_ENTRIES = 32; +const MAX_DISCARD_FINGERPRINT_BYTES = MAX_PENDING_BYTES * 6; +const DEFAULT_MAX_RUN_COUNT = 1_000; +const DEFAULT_MAX_AGGREGATE_RUN_BYTES = 2 * 1024 * 1024 * 1024; +const DEFAULT_MAX_JOURNAL_CACHE_COUNT = 32; +const DEFAULT_MAX_JOURNAL_CACHE_BYTES = 32 * 1024 * 1024; +const DEFAULT_MAX_TAIL_CACHE_COUNT = 128; +const DEFAULT_MAX_TAIL_CACHE_BYTES = 64 * 1024; +const MAX_INDEX_QUARANTINES = 4; +const INDEX_QUARANTINE_PATTERN = + /^run-index\.corrupt\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/u; + +interface PendingRunStartMutationV1 { + version: typeof PENDING_VERSION; + kind?: "start"; + runId: string; + baseJournalRevision: number | null; + targetJournalRevision: number; + stagedAt: string; + next: CreateImagesRunJournalV1; +} + +interface PendingRunAppendMutationV1 { + version: typeof PENDING_VERSION; + kind: "append"; + runId: string; + baseJournalRevision: number; + targetJournalRevision: number; + stagedAt: string; + event: CreateImagesRunEventV1; + authority: RunAuthorityIdentity; + targetJournalDigest: string; +} + +type PendingRunMutationV1 = PendingRunStartMutationV1 | PendingRunAppendMutationV1; + +interface RunEventLogRecordV1 { + version: 1; + runId: string; + journalRevision: number; + previousDigest: string; + digest: string; + event: CreateImagesRunEventV1; +} + +type RunAuthorityFileIdentity = + | { kind: "missing" } + | { + kind: "file"; + device: string; + inode: string; + size: string; + modifiedAtNs: string; + changedAtNs: string; + } + | { kind: "other" }; + +interface RunAuthorityIdentity { + current: RunAuthorityFileIdentity; + lastKnownGood: RunAuthorityFileIdentity; + currentEvents: RunAuthorityFileIdentity; + lastKnownGoodEvents: RunAuthorityFileIdentity; +} + +interface RunEventLogTailCache { + bytes: number; + digest: string; + identity: RunAuthorityFileIdentity; +} + +interface ParsedRunEventLog { + inspection: FileInspection; + tailDigest?: string; +} + +interface RunIndexEntryV1 { + runId: string; + workflowId: string; + workflowRevision: number; + journalRevision: number; + status: CreateImagesRunProjection["status"]; + createdAt: string; + updatedAt: string; + terminal: boolean; + unresolvedAmbiguity: boolean; + health: "healthy" | "recovery-required" | "unsafe"; + recoveryReason?: CreateImagesRunRecoveryReason; + unsafeReason?: CreateImagesRunUnsafeReason; + canRecover?: "from-last-known-good" | "from-current" | false; + expectedJournalRevision?: number; +} + +interface RunIndexV1 { + version: 1; + revision: number; + entries: RunIndexEntryV1[]; + degraded: RunUnassociatedDegradedEntryV1[]; +} + +interface RunUnassociatedDegradedEntryV1 { + runId: string; + status: "recovery-required" | "unsafe"; + recoveryReason?: CreateImagesRunRecoveryReason; + unsafeReason?: CreateImagesRunUnsafeReason; + canRecover: false; +} + +interface TerminalPruneManifestV1 extends CreateImagesTerminalPrunePlan { + createdAt: string; +} + +interface DegradedRunDiscardManifestV1 extends CreateImagesDegradedRunDiscardPlan { + createdAt: string; +} + +type FileInspection = + | { status: "missing" } + | { status: "healthy"; value: T } + | { status: "corrupt" } + | { status: "unsafe"; reason: "future-schema" | "unsafe-storage" }; + +export interface CreateImagesRunStartInput { + runId: string; + workflowSnapshot: WorkflowDocumentV1; + plan: CreateImagesRunPlanV1; + providerAuthorization?: CreateImagesRunProviderAuthorizationV1; + createdAt: string; +} + +export interface CreateImagesRunJournalDurability { + /** Crash seam after an authorized start/append intent is durable. */ + afterPendingPublished?: (runId: string) => Promise; + /** Crash seam after the new current journal is durable. */ + afterCurrentPublished?: (runId: string) => Promise; + /** Crash seam after the matching recovery copy is durable. */ + afterLastKnownGoodPublished?: (runId: string) => Promise; + /** Failure-injection seam after a terminal prune manifest is durable. */ + afterPruneManifestPublished?: (token: string) => Promise; + /** Failure-injection seam after each run directory is atomically retired. */ + afterRunRetired?: (runId: string) => Promise; + /** Failure-injection seam immediately before retired data is deleted. */ + beforeRetiredDelete?: (token: string) => Promise; + /** Failure-injection seam after deletion and parent fsync, before commit. */ + afterRetiredDelete?: (token: string) => Promise; + /** Failure-injection seam before the derived run index is atomically published. */ + beforeIndexPublished?: (revision: number) => Promise; + /** Failure-injection seam after a degraded-run discard manifest is durable. */ + afterDiscardManifestPublished?: (token: string) => Promise; + /** Failure-injection seam after a degraded run is atomically quarantined. */ + afterDegradedRunRetired?: (runId: string) => Promise; + /** Failure-injection seam after discarded data is deleted and its parent is synced. */ + afterDiscardedRunDeleted?: (token: string) => Promise; +} + +export interface CreateImagesRunJournalStoreLimits { + maxRunCount?: number; + maxAggregateRunBytes?: number; + maxJournalCacheCount?: number; + maxJournalCacheBytes?: number; + maxTailCacheCount?: number; + maxTailCacheBytes?: number; +} + +export type CreateImagesRunRecoveryReason = + | "current-corrupt" + | "current-missing" + | "last-known-good-corrupt" + | "last-known-good-missing" + | "last-known-good-mismatch" + | "pending-corrupt" + | "pending-conflict"; + +export type CreateImagesRunUnsafeReason = + | "current-future-schema" + | "last-known-good-future-schema" + | "pending-future-schema" + | "unsafe-storage"; + +export type CreateImagesRunJournalHealth = + | { + status: "missing"; + runId: string; + } + | { + status: "healthy"; + runId: string; + journalRevision: number; + runStatus: CreateImagesRunProjection["status"]; + } + | { + status: "recovery-required"; + runId: string; + reason: CreateImagesRunRecoveryReason; + canRecover: "from-last-known-good" | "from-current" | false; + workflowId?: string; + workflowRevision?: number; + currentJournalRevision?: number; + lastKnownGoodJournalRevision?: number; + } + | { + status: "unsafe"; + runId: string; + reason: CreateImagesRunUnsafeReason; + workflowId?: string; + workflowRevision?: number; + }; + +export interface CreateImagesTerminalRunSummary { + runId: string; + workflowId: string; + workflowRevision: number; + journalRevision: number; + status: CreateImagesRunTerminalStatus; + createdAt: string; + updatedAt: string; +} + +export interface CreateImagesRunReferenceInventory { + /** False means asset GC must fail closed and retain all assets. */ + complete: boolean; + records: Array<{ runId: string; assetIds: string[] }>; +} + +export interface CreateImagesRunHealthPage { + records: CreateImagesRunJournalHealth[]; + nextCursor?: string; +} + +export interface CreateImagesRunRecoveryCandidate { + runId: string; + workflowId?: string; + workflowRevision?: number; + reason: CreateImagesRunRecoveryReason; + canRecover: "from-last-known-good" | "from-current" | false; + expectedJournalRevision?: number; +} + +export interface CreateImagesRunUnsafeCandidate { + runId: string; + workflowId: string; + workflowRevision: number; + reason: CreateImagesRunUnsafeReason; +} + +export type CreateImagesRunDegradedCandidate = + | ({ status: "recovery-required" } & CreateImagesRunRecoveryCandidate) + | ({ status: "unsafe" } & CreateImagesRunUnsafeCandidate); + +export type CreateImagesRunStorageDegradedRecord = + | CreateImagesRunDegradedCandidate + | { + status: "recovery-required" | "unsafe"; + runId: string; + reason: CreateImagesRunRecoveryReason | CreateImagesRunUnsafeReason; + canRecover: false; + }; + +export interface CreateImagesTerminalPruneCandidate { + runId: string; + journalRevision: number; +} + +export interface CreateImagesTerminalPrunePlan { + version: 1; + candidates: CreateImagesTerminalPruneCandidate[]; + token: string; + assetIds: string[]; +} + +export interface CreateImagesTerminalPruneResult { + removedRunIds: string[]; + releasedAssetIds: string[]; +} + +export interface CreateImagesDegradedRunDiscardPlan { + version: 1; + runId: string; + reason: CreateImagesRunRecoveryReason | CreateImagesRunUnsafeReason; + association: "workflow" | "unassociated"; + workflowId?: string; + expectedCurrentJournalRevision?: number; + expectedLastKnownGoodJournalRevision?: number; + authorizationToken: string; + recordFingerprint: string; +} + +export type CreateImagesDegradedRunDiscardPlanResult = + | { status: "ready"; plan: CreateImagesDegradedRunDiscardPlan } + | { status: "not-found" | "not-degraded" | "recoverable" }; + +export interface CreateImagesDegradedRunDiscardResult { + runId: string; + workflowId?: string; +} + +export interface CreateImagesDegradedRunDiscardRequest { + runId: string; + expectedCurrentJournalRevision?: number; + expectedLastKnownGoodJournalRevision?: number; + authorizationToken: string; +} + +export type CreateImagesDegradedRunDiscardMutationResult = + | { status: "discarded"; result: CreateImagesDegradedRunDiscardResult } + | { status: "conflict" | "not-found" | "not-degraded" | "recoverable" }; + +export type CreateImagesRunIndexHealth = + | { status: "missing" } + | { + status: "healthy"; + revision: number; + entryCount: number; + diagnostic?: "rebuilt-corrupt-index" | "stale-derived-index"; + quarantinedIndexCount?: number; + } + | { + status: "degraded"; + revision: number; + entryCount: number; + degradedEntryCount: number; + diagnostic?: "rebuilt-corrupt-index" | "stale-derived-index"; + quarantinedIndexCount?: number; + } + | { status: "corrupt" } + | { status: "unsafe" }; + +export type CreateImagesTerminalPruneStatus = + | { status: "none" } + | { status: "pending"; plan: CreateImagesTerminalPrunePlan } + | { status: "corrupt" } + | { status: "unsafe" }; + +export interface CreateImagesTerminalRetentionQuery { + workflowId?: string; + keepLatest: number; + olderThan?: string; + limit?: number; +} + +export interface CreateImagesTerminalRetentionCandidate extends CreateImagesTerminalPruneCandidate { + workflowId: string; + updatedAt: string; + assetIds: string[]; +} + +export interface CreateImagesRunCacheStats { + journalCount: number; + journalBytes: number; + tailCount: number; + tailBytes: number; +} + +export interface CreateImagesWorkflowAdmissionAudit { + hasDegradedAuthority: boolean; + hasNonterminalRun: boolean; + hasUnresolvedAmbiguity: boolean; +} + +export class CreateImagesRunJournalLoadError extends Error { + constructor( + readonly status: "corrupt" | "unsafe", + readonly filePath: string, + ) { + super( + status === "unsafe" + ? "The Create Images run belongs to an unsupported schema or unsafe storage and is read-only." + : "The Create Images run journal is damaged and has been kept for recovery.", + ); + this.name = "CreateImagesRunJournalLoadError"; + } +} + +export class CreateImagesRunJournalRevisionConflictError extends Error { + constructor( + readonly runId: string, + readonly expectedJournalRevision: number | null, + readonly actualJournalRevision: number | null, + ) { + super( + `Run "${runId}" changed: expected journal revision ${expectedJournalRevision ?? "absent"}, found ${actualJournalRevision ?? "absent"}.`, + ); + this.name = "CreateImagesRunJournalRevisionConflictError"; + } +} + +const rootMutationTails = new Map>(); + +function serializedAtRoot(root: string, operation: () => Promise): Promise { + const key = path.resolve(root); + const tail = rootMutationTails.get(key) ?? Promise.resolve(); + const result = tail.then(operation, operation); + rootMutationTails.set( + key, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +function validateRunId(runId: string): string { + if (!RUN_ID_PATTERN.test(runId)) throw new Error("Invalid Create Images run ID."); + return runId; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function futureVersion(value: unknown, field: "version"): boolean { + return isRecord(value) && typeof value[field] === "number" && value[field] > 1; +} + +function parsedJournal(value: unknown): CreateImagesRunJournalV1 | undefined { + const parsed = parseCreateImagesRunJournal(value); + return parsed.success ? parsed.value : undefined; +} + +function parseRunAuthorityFileIdentity(value: unknown): RunAuthorityFileIdentity | undefined { + if (!isRecord(value) || typeof value.kind !== "string") return undefined; + if (value.kind === "missing" || value.kind === "other") { + return Object.keys(value).length === 1 ? { kind: value.kind } : undefined; + } + if ( + value.kind !== "file" || + Object.keys(value).some( + (key) => !["kind", "device", "inode", "size", "modifiedAtNs", "changedAtNs"].includes(key), + ) || + ![value.device, value.inode, value.size, value.modifiedAtNs, value.changedAtNs].every( + (field) => typeof field === "string" && /^\d+$/u.test(field), + ) + ) { + return undefined; + } + return value as unknown as RunAuthorityFileIdentity; +} + +function parseRunAuthorityIdentity(value: unknown): RunAuthorityIdentity | undefined { + if ( + !isRecord(value) || + Object.keys(value).length !== 4 || + !["current", "lastKnownGood", "currentEvents", "lastKnownGoodEvents"].every((key) => + Object.prototype.hasOwnProperty.call(value, key), + ) + ) { + return undefined; + } + const current = parseRunAuthorityFileIdentity(value.current); + const lastKnownGood = parseRunAuthorityFileIdentity(value.lastKnownGood); + const currentEvents = parseRunAuthorityFileIdentity(value.currentEvents); + const lastKnownGoodEvents = parseRunAuthorityFileIdentity(value.lastKnownGoodEvents); + return current && lastKnownGood && currentEvents && lastKnownGoodEvents + ? { current, lastKnownGood, currentEvents, lastKnownGoodEvents } + : undefined; +} + +function parsePending(value: unknown): PendingRunMutationV1 | undefined { + if (isRecord(value) && value.kind === "append") { + if ( + Object.keys(value).some( + (key) => + ![ + "version", + "kind", + "runId", + "baseJournalRevision", + "targetJournalRevision", + "stagedAt", + "event", + "authority", + "targetJournalDigest", + ].includes(key), + ) || + value.version !== PENDING_VERSION || + typeof value.runId !== "string" || + !RUN_ID_PATTERN.test(value.runId) || + !Number.isSafeInteger(value.baseJournalRevision) || + (value.baseJournalRevision as number) < 1 || + !Number.isSafeInteger(value.targetJournalRevision) || + value.targetJournalRevision !== (value.baseJournalRevision as number) + 1 || + typeof value.stagedAt !== "string" || + !isRecord(value.event) || + value.event.runId !== value.runId || + value.event.sequence !== value.baseJournalRevision || + value.event.at !== value.stagedAt + ) { + return undefined; + } + const authority = parseRunAuthorityIdentity(value.authority); + if ( + !authority || + typeof value.targetJournalDigest !== "string" || + !/^[a-f0-9]{64}$/u.test(value.targetJournalDigest) + ) { + return undefined; + } + return { + version: PENDING_VERSION, + kind: "append", + runId: value.runId, + baseJournalRevision: value.baseJournalRevision as number, + targetJournalRevision: value.targetJournalRevision as number, + stagedAt: value.stagedAt, + event: value.event as unknown as CreateImagesRunEventV1, + authority, + targetJournalDigest: value.targetJournalDigest, + }; + } + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => + ![ + "version", + "kind", + "runId", + "baseJournalRevision", + "targetJournalRevision", + "stagedAt", + "next", + ].includes(key), + ) + ) { + return undefined; + } + const next = parsedJournal(value.next); + const base = + value.baseJournalRevision === null + ? null + : Number.isSafeInteger(value.baseJournalRevision) && + (value.baseJournalRevision as number) >= 1 + ? (value.baseJournalRevision as number) + : undefined; + const target = + Number.isSafeInteger(value.targetJournalRevision) && + (value.targetJournalRevision as number) >= 1 + ? (value.targetJournalRevision as number) + : undefined; + if ( + value.version !== PENDING_VERSION || + (value.kind !== undefined && value.kind !== "start") || + typeof value.runId !== "string" || + !RUN_ID_PATTERN.test(value.runId) || + base === undefined || + target === undefined || + target !== (base === null ? 1 : base + 1) || + !next || + next.runId !== value.runId || + next.journalRevision !== target || + typeof value.stagedAt !== "string" || + value.stagedAt !== next.updatedAt + ) { + return undefined; + } + return { + version: PENDING_VERSION, + ...(value.kind === "start" ? { kind: "start" as const } : {}), + runId: value.runId, + baseJournalRevision: base, + targetJournalRevision: target, + stagedAt: value.stagedAt, + next, + }; +} + +function identical(left: CreateImagesRunJournalV1, right: CreateImagesRunJournalV1): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function eventRecordDigest( + runId: string, + journalRevision: number, + previousDigest: string, + event: CreateImagesRunEventV1, +): string { + return createHash("sha256") + .update(JSON.stringify({ runId, journalRevision, previousDigest, event }), "utf8") + .digest("hex"); +} + +function journalDigest(journal: CreateImagesRunJournalV1): string { + return createHash("sha256").update(JSON.stringify(journal), "utf8").digest("hex"); +} + +function initialEventDigest(journal: CreateImagesRunJournalV1): string { + return journalDigest(journal); +} + +function serializedEventRecord( + base: CreateImagesRunJournalV1, + event: CreateImagesRunEventV1, + previousDigest: string, +): { record: RunEventLogRecordV1; bytes: Buffer } { + const journalRevision = event.sequence + 1; + const record: RunEventLogRecordV1 = { + version: 1, + runId: base.runId, + journalRevision, + previousDigest, + digest: eventRecordDigest(base.runId, journalRevision, previousDigest, event), + event, + }; + return { record, bytes: Buffer.from(`${JSON.stringify(record)}\n`, "utf8") }; +} + +function referencedAssetIds(journal: CreateImagesRunJournalV1): string[] { + const assetIds = new Set(journal.workflowSnapshot.assetRefs); + for (const event of journal.events) { + if (event.type === "node-succeeded" || event.type === "node-output-published") { + for (const assetId of event.outputAssetIds) assetIds.add(assetId); + } + } + return [...assetIds].sort(); +} + +export function createImagesWorkflowSnapshotFingerprint(snapshot: WorkflowDocumentV1): string { + return createHash("sha256") + .update(createImagesWorkflowSnapshotFingerprintMaterial(snapshot), "utf8") + .digest("hex"); +} + +/** + * Main-owned crash-safe run authority. + * + * Renderer liveness is consulted only until a start intent is durably + * published. Every later append and restart reconciliation is main-owned. + */ +export class CreateImagesRunJournalStore { + private readonly limits: Required; + private inventoryCache?: { + runIds: string[]; + aggregateBytes: number; + runBytes: Map; + }; + private indexCache?: RunIndexV1; + private indexAuthorityCache?: RunAuthorityFileIdentity; + private readonly journalCache = new Map(); + private readonly journalAuthorityCache = new Map(); + private readonly eventLogTailCache = new Map(); + private journalCacheBytes = 0; + private tailCacheBytes = 0; + private indexDiagnostic?: "rebuilt-corrupt-index"; + private indexDirty = false; + private readonly pruneTombstones = new Set(); + private pruneStateLoaded = false; + private discardStateLoaded = false; + constructor( + private readonly rootResolver: () => string, + private readonly durability: CreateImagesRunJournalDurability = {}, + limits: CreateImagesRunJournalStoreLimits = {}, + ) { + this.limits = { + maxRunCount: limits.maxRunCount ?? DEFAULT_MAX_RUN_COUNT, + maxAggregateRunBytes: limits.maxAggregateRunBytes ?? DEFAULT_MAX_AGGREGATE_RUN_BYTES, + maxJournalCacheCount: limits.maxJournalCacheCount ?? DEFAULT_MAX_JOURNAL_CACHE_COUNT, + maxJournalCacheBytes: limits.maxJournalCacheBytes ?? DEFAULT_MAX_JOURNAL_CACHE_BYTES, + maxTailCacheCount: limits.maxTailCacheCount ?? DEFAULT_MAX_TAIL_CACHE_COUNT, + maxTailCacheBytes: limits.maxTailCacheBytes ?? DEFAULT_MAX_TAIL_CACHE_BYTES, + }; + for (const [name, value] of Object.entries(this.limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Invalid Create Images run storage limit: ${name}.`); + } + } + } + + private journalCacheSize(journal: CreateImagesRunJournalV1): number { + return Buffer.byteLength(JSON.stringify(journal), "utf8"); + } + + private getCachedJournal(runId: string): CreateImagesRunJournalV1 | undefined { + const journal = this.journalCache.get(runId); + if (!journal) return undefined; + this.journalCache.delete(runId); + this.journalCache.set(runId, journal); + return journal; + } + + private cacheJournal( + runId: string, + journal: CreateImagesRunJournalV1, + authority: RunAuthorityIdentity, + ): void { + this.evictJournal(runId); + const bytes = this.journalCacheSize(journal); + if (bytes > this.limits.maxJournalCacheBytes) return; + this.journalCache.set(runId, journal); + this.journalAuthorityCache.set(runId, authority); + this.journalCacheBytes += bytes; + while ( + this.journalCache.size > this.limits.maxJournalCacheCount || + this.journalCacheBytes > this.limits.maxJournalCacheBytes + ) { + const oldest = this.journalCache.keys().next().value as string | undefined; + if (!oldest) break; + this.evictJournal(oldest); + } + } + + private evictJournal(runId: string): void { + const existing = this.journalCache.get(runId); + if (existing) { + this.journalCacheBytes -= this.journalCacheSize(existing); + this.journalCache.delete(runId); + } + this.journalAuthorityCache.delete(runId); + } + + private async fileAuthorityIdentity(target: string): Promise { + try { + const info = await fs.lstat(target, { bigint: true }); + return this.authorityIdentityFromStats(info); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" }; + throw error; + } + } + + private authorityIdentityFromStats(info: BigIntStats): RunAuthorityFileIdentity { + if (!info.isFile() || info.isSymbolicLink()) return { kind: "other" }; + return { + kind: "file", + device: info.dev.toString(), + inode: info.ino.toString(), + size: info.size.toString(), + modifiedAtNs: info.mtimeNs.toString(), + changedAtNs: info.ctimeNs.toString(), + }; + } + + private async runAuthorityIdentity(runId: string): Promise { + const paths = this.paths(runId); + const [current, lastKnownGood, currentEvents, lastKnownGoodEvents] = await Promise.all([ + this.fileAuthorityIdentity(paths.current), + this.fileAuthorityIdentity(paths.lastKnownGood), + this.fileAuthorityIdentity(paths.currentEvents), + this.fileAuthorityIdentity(paths.lastKnownGoodEvents), + ]); + return { current, lastKnownGood, currentEvents, lastKnownGoodEvents }; + } + + private clearIndexCache(): void { + this.indexCache = undefined; + this.indexAuthorityCache = undefined; + } + + private sameFileAuthorityIdentity( + left: RunAuthorityFileIdentity | undefined, + right: RunAuthorityFileIdentity, + ): boolean { + return left !== undefined && JSON.stringify(left) === JSON.stringify(right); + } + + private async bindIndexCache(index: RunIndexV1): Promise { + const identity = await this.fileAuthorityIdentity(this.indexPath()); + if (identity.kind !== "file") { + this.clearIndexCache(); + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + this.indexCache = index; + this.indexAuthorityCache = identity; + } + + private async cacheHealthyJournal( + runId: string, + journal: CreateImagesRunJournalV1, + ): Promise { + this.cacheJournal(runId, journal, await this.runAuthorityIdentity(runId)); + } + + private async cachedAuthorityIsCurrent(runId: string): Promise { + const expected = this.journalAuthorityCache.get(runId); + if (!expected) return false; + const pendingExists = await fs + .lstat(this.paths(runId).pending) + .then(() => true) + .catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + }); + if (pendingExists) return false; + return JSON.stringify(expected) === JSON.stringify(await this.runAuthorityIdentity(runId)); + } + + private sameRunAuthorityIdentity( + left: RunAuthorityIdentity | undefined, + right: RunAuthorityIdentity, + ): boolean { + return left !== undefined && JSON.stringify(left) === JSON.stringify(right); + } + + private tailCacheSize(target: string): number { + return Buffer.byteLength(target, "utf8") + 320; + } + + private getCachedTail(target: string): RunEventLogTailCache | undefined { + const tail = this.eventLogTailCache.get(target); + if (!tail) return undefined; + this.eventLogTailCache.delete(target); + this.eventLogTailCache.set(target, tail); + return tail; + } + + private cacheTail(target: string, tail: RunEventLogTailCache): void { + this.evictTail(target); + const bytes = this.tailCacheSize(target); + if (bytes > this.limits.maxTailCacheBytes) return; + this.eventLogTailCache.set(target, tail); + this.tailCacheBytes += bytes; + while ( + this.eventLogTailCache.size > this.limits.maxTailCacheCount || + this.tailCacheBytes > this.limits.maxTailCacheBytes + ) { + const oldest = this.eventLogTailCache.keys().next().value as string | undefined; + if (!oldest) break; + this.evictTail(oldest); + } + } + + private evictTail(target: string): void { + if (!this.eventLogTailCache.delete(target)) return; + this.tailCacheBytes -= this.tailCacheSize(target); + } + + private evictRunCaches(runId: string): void { + this.evictJournal(runId); + const paths = this.paths(runId); + this.evictTail(paths.currentEvents); + this.evictTail(paths.lastKnownGoodEvents); + } + + private root(): string { + return path.resolve(this.rootResolver()); + } + + private runsPath(): string { + return path.join(this.root(), "runs"); + } + + private runDirectory(runId: string): string { + return path.join(this.runsPath(), validateRunId(runId)); + } + + private paths(runId: string) { + const directory = this.runDirectory(runId); + return { + directory, + current: path.join(directory, CURRENT_FILE), + lastKnownGood: path.join(directory, LAST_KNOWN_GOOD_FILE), + pending: path.join(directory, PENDING_FILE), + currentEvents: path.join(directory, CURRENT_EVENTS_FILE), + lastKnownGoodEvents: path.join(directory, LAST_KNOWN_GOOD_EVENTS_FILE), + }; + } + + private indexPath(): string { + return path.join(this.root(), RUN_INDEX_FILE); + } + + private prunePendingPath(): string { + return path.join(this.root(), PRUNE_PENDING_FILE); + } + + private discardPendingPath(): string { + return path.join(this.root(), DISCARD_PENDING_FILE); + } + + private async syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } + + private async ensureDirectory(target: string): Promise { + const created = await fs.mkdir(target, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("Create Images run storage contains an unsafe directory."); + } + if (created !== undefined) await this.syncDirectory(path.dirname(target)); + return created !== undefined; + } + + private async prepare(): Promise { + await this.ensureDirectory(this.root()); + await this.ensureDirectory(this.runsPath()); + let removed = false; + let stagedCount = 0; + const handle = await fs.opendir(this.root()); + for await (const entry of handle) { + if (!ROOT_STAGED_FILE_PATTERN.test(entry.name)) continue; + stagedCount += 1; + if (stagedCount > MAX_STAGED_FILES_PER_RUN) { + throw new CreateImagesRunJournalLoadError("unsafe", this.root()); + } + const target = path.join(this.root(), entry.name); + const info = await fs.lstat(target); + if (!entry.isFile() || entry.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + await fs.rm(target); + removed = true; + } + if (removed) await this.syncDirectory(this.root()); + } + + private async readJson(target: string, maxBytes: number): Promise> { + let bytes: Buffer; + try { + bytes = await readRegularFile(target, maxBytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + if (["ELOOP", "EFTYPE", "ENXIO"].includes((error as NodeJS.ErrnoException).code ?? "")) { + return { status: "unsafe", reason: "unsafe-storage" }; + } + return { status: "corrupt" }; + } + try { + return { + status: "healthy", + value: JSON.parse(decodeUtf8(bytes)) as unknown, + }; + } catch { + return { status: "corrupt" }; + } + } + + private async inspectJournal(target: string): Promise> { + const raw = await this.readJson(target, CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureCreateImagesRunJournal(raw.value)) { + return { status: "unsafe", reason: "future-schema" }; + } + const journal = parsedJournal(raw.value); + if (!journal) return { status: "corrupt" }; + const fingerprint = createImagesWorkflowSnapshotFingerprint(journal.workflowSnapshot); + return fingerprint === journal.workflowFingerprint + ? { status: "healthy", value: journal } + : { status: "corrupt" }; + } + + private parseEventLogBytes( + bytes: Buffer, + checkpoint: CreateImagesRunJournalV1, + ): ParsedRunEventLog { + const text = decodeUtf8(bytes); + if (text.length > 0 && !text.endsWith("\n")) { + return { inspection: { status: "corrupt" } }; + } + const records: RunEventLogRecordV1[] = []; + let previousDigest = initialEventDigest(checkpoint); + let revision = checkpoint.journalRevision; + for (const line of text.split("\n")) { + if (line.length === 0) continue; + let raw: unknown; + try { + raw = JSON.parse(line) as unknown; + } catch { + return { inspection: { status: "corrupt" } }; + } + if (isRecord(raw) && typeof raw.version === "number" && raw.version > 1) { + return { inspection: { status: "unsafe", reason: "future-schema" } }; + } + if ( + !isRecord(raw) || + Object.keys(raw).some( + (key) => + !["version", "runId", "journalRevision", "previousDigest", "digest", "event"].includes( + key, + ), + ) || + raw.version !== 1 || + raw.runId !== checkpoint.runId || + raw.journalRevision !== revision + 1 || + raw.previousDigest !== previousDigest || + typeof raw.digest !== "string" || + !/^[a-f0-9]{64}$/u.test(raw.digest) || + !isRecord(raw.event) + ) { + return { inspection: { status: "corrupt" } }; + } + const event = raw.event as unknown as CreateImagesRunEventV1; + const digest = eventRecordDigest( + checkpoint.runId, + raw.journalRevision, + previousDigest, + event, + ); + if (digest !== raw.digest) return { inspection: { status: "corrupt" } }; + records.push(raw as unknown as RunEventLogRecordV1); + previousDigest = digest; + revision = raw.journalRevision; + } + const last = records[records.length - 1]; + const candidate = { + ...checkpoint, + journalRevision: revision, + updatedAt: last?.event.at ?? checkpoint.updatedAt, + events: [...checkpoint.events, ...records.map((record) => record.event)], + }; + const parsed = parsedJournal(candidate); + return parsed + ? { inspection: { status: "healthy", value: parsed }, tailDigest: previousDigest } + : { inspection: { status: "corrupt" } }; + } + + private async inspectEventLog( + target: string, + checkpoint: CreateImagesRunJournalV1, + ): Promise> { + const identityBeforeRead = await this.fileAuthorityIdentity(target); + if (identityBeforeRead.kind === "other") { + return { status: "unsafe", reason: "unsafe-storage" }; + } + let bytes: Buffer; + try { + bytes = await readRegularFile(target, MAX_EVENT_LOG_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + const identityAfterRead = await this.fileAuthorityIdentity(target); + if ( + identityBeforeRead.kind !== "missing" || + !this.sameFileAuthorityIdentity(identityBeforeRead, identityAfterRead) + ) { + return { status: "corrupt" }; + } + this.cacheTail(target, { + bytes: 0, + digest: initialEventDigest(checkpoint), + identity: identityAfterRead, + }); + return { status: "healthy", value: checkpoint }; + } + if (["ELOOP", "EFTYPE", "ENXIO"].includes((error as NodeJS.ErrnoException).code ?? "")) { + return { status: "unsafe", reason: "unsafe-storage" }; + } + return { status: "corrupt" }; + } + const identityAfterRead = await this.fileAuthorityIdentity(target); + if ( + identityAfterRead.kind !== "file" || + !this.sameFileAuthorityIdentity(identityBeforeRead, identityAfterRead) || + BigInt(bytes.length) !== BigInt(identityAfterRead.size) + ) { + return identityAfterRead.kind === "other" + ? { status: "unsafe", reason: "unsafe-storage" } + : { status: "corrupt" }; + } + const parsed = this.parseEventLogBytes(bytes, checkpoint); + if (parsed.inspection.status === "healthy" && parsed.tailDigest) { + this.cacheTail(target, { + bytes: bytes.length, + digest: parsed.tailDigest, + identity: identityAfterRead, + }); + } + return parsed.inspection; + } + + private async inspectPending(target: string): Promise> { + const raw = await this.readJson(target, MAX_PENDING_BYTES); + if (raw.status !== "healthy") return raw; + if (futureVersion(raw.value, "version")) { + return { status: "unsafe", reason: "future-schema" }; + } + const pending = parsePending(raw.value); + if (!pending) return { status: "corrupt" }; + if (pending.kind === "append") return { status: "healthy", value: pending }; + const fingerprint = createImagesWorkflowSnapshotFingerprint(pending.next.workflowSnapshot); + return fingerprint === pending.next.workflowFingerprint + ? { status: "healthy", value: pending } + : { status: "corrupt" }; + } + + private async inspected(runId: string): Promise<{ + paths: ReturnType; + current: FileInspection; + lastKnownGood: FileInspection; + pending: FileInspection; + }> { + const paths = this.paths(runId); + try { + const info = await fs.lstat(paths.directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + return { + paths, + current: { status: "unsafe", reason: "unsafe-storage" }, + lastKnownGood: { status: "unsafe", reason: "unsafe-storage" }, + pending: { status: "unsafe", reason: "unsafe-storage" }, + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return { + paths, + current: { status: "missing" }, + lastKnownGood: { status: "missing" }, + pending: { status: "missing" }, + }; + } + const [unboundCurrentCheckpoint, unboundLastKnownGoodCheckpoint, unboundPending] = + await Promise.all([ + this.inspectJournal(paths.current), + this.inspectJournal(paths.lastKnownGood), + this.inspectPending(paths.pending), + ]); + const bindJournal = ( + inspection: FileInspection, + ): FileInspection => + inspection.status === "healthy" && inspection.value.runId !== runId + ? { status: "corrupt" } + : inspection; + const currentCheckpoint = bindJournal(unboundCurrentCheckpoint); + const lastKnownGoodCheckpoint = bindJournal(unboundLastKnownGoodCheckpoint); + const pending: FileInspection = + unboundPending.status === "healthy" && unboundPending.value.runId !== runId + ? { status: "corrupt" } + : unboundPending; + const [current, lastKnownGood] = await Promise.all([ + currentCheckpoint.status === "healthy" + ? this.inspectEventLog(paths.currentEvents, currentCheckpoint.value) + : currentCheckpoint, + lastKnownGoodCheckpoint.status === "healthy" + ? this.inspectEventLog(paths.lastKnownGoodEvents, lastKnownGoodCheckpoint.value) + : lastKnownGoodCheckpoint, + ]); + return { paths, current, lastKnownGood, pending }; + } + + private async writeAtomic( + target: string, + value: unknown, + maxBytes: number, + canPublish: () => boolean = () => true, + ): Promise { + const directory = path.dirname(target); + const serialized = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(serialized, "utf8") > maxBytes) { + throw new Error("Create Images run metadata exceeds its storage limit."); + } + const createdDirectory = await this.ensureDirectory(directory); + const staged = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`); + let publicationError: unknown; + try { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new Error("Create Images run storage contains an unsafe file."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await fs.writeFile(staged, serialized, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + const handle = await fs.open(staged, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + if (!canPublish()) throw new Error("The renderer document is no longer active."); + await fs.rename(staged, target); + await this.syncDirectory(directory); + } catch (error) { + publicationError = error; + } + await fs.rm(staged, { force: true }).catch(() => undefined); + if (createdDirectory) { + try { + await fs.rmdir(directory); + await this.syncDirectory(path.dirname(directory)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + publicationError === undefined && + code !== "ENOENT" && + code !== "ENOTEMPTY" && + code !== "EEXIST" + ) { + publicationError = error; + } + } + } + if (publicationError !== undefined) throw publicationError; + } + + private async appendEventRecord( + target: string, + checkpointTarget: string, + base: CreateImagesRunJournalV1, + event: CreateImagesRunEventV1, + expectedCheckpointIdentity?: RunAuthorityFileIdentity, + expectedEventLogIdentity?: RunAuthorityFileIdentity, + ): Promise { + const directory = path.dirname(target); + await this.ensureDirectory(directory); + let previousDigest = initialEventDigest(base); + let created = false; + let existingBytes = 0; + const cachedTail = this.getCachedTail(target); + const checkpointIdentity = await this.fileAuthorityIdentity(checkpointTarget); + const eventLogIdentity = await this.fileAuthorityIdentity(target); + if (checkpointIdentity.kind !== "file" || eventLogIdentity.kind === "other") { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + if ( + (expectedCheckpointIdentity && + !this.sameFileAuthorityIdentity(expectedCheckpointIdentity, checkpointIdentity)) || + (expectedEventLogIdentity && + !this.sameFileAuthorityIdentity(expectedEventLogIdentity, eventLogIdentity)) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + if (eventLogIdentity.kind === "file") { + existingBytes = Number(eventLogIdentity.size); + if ( + cachedTail?.bytes === existingBytes && + this.sameFileAuthorityIdentity(cachedTail.identity, eventLogIdentity) + ) { + previousDigest = cachedTail.digest; + } else { + const checkpoint = await this.inspectJournal(checkpointTarget); + if (checkpoint.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + checkpoint.status === "unsafe" ? "unsafe" : "corrupt", + checkpointTarget, + ); + } + const reconstructed = await this.inspectEventLog(target, checkpoint.value); + if (reconstructed.status !== "healthy" || !identical(reconstructed.value, base)) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + const validatedTail = this.getCachedTail(target); + if ( + !validatedTail || + !this.sameFileAuthorityIdentity(validatedTail.identity, eventLogIdentity) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + previousDigest = validatedTail.digest; + } + } else { + created = true; + } + const journalRevision = event.sequence + 1; + const record: RunEventLogRecordV1 = { + version: 1, + runId: base.runId, + journalRevision, + previousDigest, + digest: eventRecordDigest(base.runId, journalRevision, previousDigest, event), + event, + }; + const serialized = `${JSON.stringify(record)}\n`; + const serializedBytes = Buffer.byteLength(serialized, "utf8"); + if (existingBytes + serializedBytes > MAX_EVENT_LOG_BYTES) { + throw new Error("Create Images run metadata exceeds its storage limit."); + } + if ( + !this.sameFileAuthorityIdentity( + checkpointIdentity, + await this.fileAuthorityIdentity(checkpointTarget), + ) || + !this.sameFileAuthorityIdentity(eventLogIdentity, await this.fileAuthorityIdentity(target)) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + const flags = created + ? constants.O_WRONLY | + constants.O_APPEND | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW + : constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW; + let handle: fs.FileHandle; + try { + handle = await fs.open(target, flags, 0o600); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + throw new CreateImagesRunJournalLoadError( + ["ELOOP", "EFTYPE", "ENXIO"].includes(code ?? "") ? "unsafe" : "corrupt", + target, + ); + } + let finalIdentity: RunAuthorityFileIdentity; + try { + const descriptorIdentity = this.authorityIdentityFromStats( + await handle.stat({ bigint: true }), + ); + if ( + descriptorIdentity.kind !== "file" || + (!created && !this.sameFileAuthorityIdentity(eventLogIdentity, descriptorIdentity)) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + await handle.writeFile(serialized, { encoding: "utf8" }); + await handle.sync(); + finalIdentity = this.authorityIdentityFromStats(await handle.stat({ bigint: true })); + if ( + finalIdentity.kind !== "file" || + Number(finalIdentity.size) !== existingBytes + serializedBytes + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + } finally { + await handle.close(); + } + const publishedIdentity = await this.fileAuthorityIdentity(target); + if ( + !this.sameFileAuthorityIdentity(finalIdentity, publishedIdentity) || + !this.sameFileAuthorityIdentity( + checkpointIdentity, + await this.fileAuthorityIdentity(checkpointTarget), + ) + ) { + throw new CreateImagesRunJournalLoadError( + publishedIdentity.kind === "other" ? "unsafe" : "corrupt", + target, + ); + } + this.cacheTail(target, { + bytes: existingBytes + serializedBytes, + digest: record.digest, + identity: publishedIdentity, + }); + if (created) await this.syncDirectory(directory); + return publishedIdentity; + } + + private async replaceTornEventLog( + target: string, + bytes: Buffer, + expectedTornIdentity: RunAuthorityFileIdentity, + tailDigest: string, + ): Promise { + if (bytes.length > MAX_EVENT_LOG_BYTES || expectedTornIdentity.kind !== "file") { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + const directory = path.dirname(target); + await this.ensureDirectory(directory); + const staged = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`); + try { + if ( + !this.sameFileAuthorityIdentity( + expectedTornIdentity, + await this.fileAuthorityIdentity(target), + ) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + await fs.writeFile(staged, bytes, { flag: "wx", mode: 0o600 }); + const stagedHandle = await fs.open(staged, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + await stagedHandle.sync(); + } finally { + await stagedHandle.close(); + } + if ( + !this.sameFileAuthorityIdentity( + expectedTornIdentity, + await this.fileAuthorityIdentity(target), + ) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + await fs.rename(staged, target); + await this.syncDirectory(directory); + } catch (error) { + await fs.rm(staged, { force: true }).catch(() => undefined); + throw error; + } + const identity = await this.fileAuthorityIdentity(target); + if (identity.kind !== "file" || Number(identity.size) !== bytes.length) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + this.cacheTail(target, { bytes: bytes.length, digest: tailDigest, identity }); + return identity; + } + + private async replaceCheckpoint( + checkpointPath: string, + eventLogPath: string, + journal: CreateImagesRunJournalV1, + ): Promise { + await this.writeAtomic(checkpointPath, journal, CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + await this.removeDurably(eventLogPath); + this.cacheTail(eventLogPath, { + bytes: 0, + digest: initialEventDigest(journal), + identity: { kind: "missing" }, + }); + } + + private async removeDurably(target: string): Promise { + try { + await fs.rm(target); + await this.syncDirectory(path.dirname(target)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + private async boundedEntries(directory: string, maxEntries: number): Promise { + const entries: Dirent[] = []; + const handle = await fs.opendir(directory); + for await (const entry of handle) { + entries.push(entry); + if (entries.length > maxEntries) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + } + return entries; + } + + private async inventory(force = false): Promise<{ + runIds: string[]; + aggregateBytes: number; + runBytes: Map; + }> { + if (!force && this.inventoryCache) return this.inventoryCache; + const runIds: string[] = []; + const runBytes = new Map(); + let aggregateBytes = 0; + for (const entry of await this.boundedEntries(this.runsPath(), this.limits.maxRunCount)) { + const entryPath = path.join(this.runsPath(), entry.name); + const info = await fs.lstat(entryPath); + if ( + !RUN_ID_PATTERN.test(entry.name) || + !entry.isDirectory() || + entry.isSymbolicLink() || + !info.isDirectory() || + info.isSymbolicLink() + ) { + throw new CreateImagesRunJournalLoadError("unsafe", entryPath); + } + runIds.push(entry.name); + let bytesForRun = 0; + let removedStagedFile = false; + for (const child of await this.boundedEntries( + entryPath, + RUN_FILES.size + MAX_STAGED_FILES_PER_RUN, + )) { + const childPath = path.join(entryPath, child.name); + const childInfo = await fs.lstat(childPath); + if (STAGED_FILE_PATTERN.test(child.name)) { + if ( + !child.isFile() || + child.isSymbolicLink() || + !childInfo.isFile() || + childInfo.isSymbolicLink() + ) { + throw new CreateImagesRunJournalLoadError("unsafe", childPath); + } + await fs.rm(childPath); + removedStagedFile = true; + continue; + } + if ( + !RUN_FILES.has(child.name) || + !child.isFile() || + child.isSymbolicLink() || + !childInfo.isFile() || + childInfo.isSymbolicLink() + ) { + throw new CreateImagesRunJournalLoadError("unsafe", childPath); + } + aggregateBytes += childInfo.size; + bytesForRun += childInfo.size; + if ( + !Number.isSafeInteger(aggregateBytes) || + aggregateBytes > this.limits.maxAggregateRunBytes + ) { + throw new Error("Create Images run storage has reached its aggregate byte limit."); + } + } + runBytes.set(entry.name, bytesForRun); + if (removedStagedFile) await this.syncDirectory(entryPath); + } + this.inventoryCache = { runIds: runIds.sort(), aggregateBytes, runBytes }; + return this.inventoryCache; + } + + private async refreshInventoryRun(runId: string): Promise { + if (!this.inventoryCache) return; + const oldBytes = this.inventoryCache.runBytes.get(runId) ?? 0; + let nextBytes = 0; + try { + for (const child of await this.boundedEntries( + this.runDirectory(runId), + RUN_FILES.size + MAX_STAGED_FILES_PER_RUN, + )) { + if (!RUN_FILES.has(child.name)) continue; + const info = await fs.lstat(path.join(this.runDirectory(runId), child.name)); + if (!info.isFile() || info.isSymbolicLink()) { + this.inventoryCache = undefined; + return; + } + nextBytes += info.size; + } + } catch { + this.inventoryCache = undefined; + return; + } + this.inventoryCache.aggregateBytes += nextBytes - oldBytes; + this.inventoryCache.runBytes.set(runId, nextBytes); + if (!this.inventoryCache.runIds.includes(runId)) { + this.inventoryCache.runIds.push(runId); + this.inventoryCache.runIds.sort(); + } + } + + private serializedBytes(value: unknown): number { + return Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, "utf8"); + } + + private parseIndex(value: unknown): RunIndexV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => !["version", "revision", "entries", "degraded"].includes(key), + ) || + value.version !== 1 || + !Number.isSafeInteger(value.revision) || + (value.revision as number) < 1 || + !Array.isArray(value.entries) || + value.entries.length > this.limits.maxRunCount || + (value.degraded !== undefined && !Array.isArray(value.degraded)) + ) { + return undefined; + } + const statuses = new Set([ + "queued", + "running", + "cancel_requested", + "needs_attention", + "succeeded", + "failed", + "cancelled", + "interrupted", + ]); + const entries: RunIndexEntryV1[] = []; + const seen = new Set(); + for (const candidate of value.entries) { + if ( + !isRecord(candidate) || + Object.keys(candidate).some( + (key) => + ![ + "runId", + "workflowId", + "workflowRevision", + "journalRevision", + "status", + "createdAt", + "updatedAt", + "terminal", + "unresolvedAmbiguity", + // Accepted only for migration from the original derived index. + // References are authoritative in the journals and event logs; + // retaining them here made a 1,000-run index exceed 16 MiB. + "assetIds", + "health", + "recoveryReason", + "unsafeReason", + "canRecover", + "expectedJournalRevision", + ].includes(key), + ) || + typeof candidate.runId !== "string" || + !RUN_ID_PATTERN.test(candidate.runId) || + seen.has(candidate.runId) || + typeof candidate.workflowId !== "string" || + !RUN_ID_PATTERN.test(candidate.workflowId) || + !Number.isSafeInteger(candidate.workflowRevision) || + (candidate.workflowRevision as number) < 1 || + !Number.isSafeInteger(candidate.journalRevision) || + (candidate.journalRevision as number) < 1 || + typeof candidate.status !== "string" || + !statuses.has(candidate.status as CreateImagesRunProjection["status"]) || + typeof candidate.createdAt !== "string" || + !Number.isFinite(Date.parse(candidate.createdAt)) || + typeof candidate.updatedAt !== "string" || + !Number.isFinite(Date.parse(candidate.updatedAt)) || + typeof candidate.terminal !== "boolean" || + (candidate.unresolvedAmbiguity !== undefined && + typeof candidate.unresolvedAmbiguity !== "boolean") || + (candidate.assetIds !== undefined && + (!Array.isArray(candidate.assetIds) || + candidate.assetIds.length > 10_000 || + candidate.assetIds.some( + (assetId) => typeof assetId !== "string" || !/^[a-f0-9]{64}$/u.test(assetId), + ) || + new Set(candidate.assetIds).size !== candidate.assetIds.length)) + ) { + return undefined; + } + const health = candidate.health ?? "healthy"; + if (health !== "healthy" && health !== "recovery-required" && health !== "unsafe") + return undefined; + if (health === "recovery-required") { + if ( + ![ + "current-corrupt", + "current-missing", + "last-known-good-corrupt", + "last-known-good-missing", + "last-known-good-mismatch", + "pending-corrupt", + "pending-conflict", + ].includes(candidate.recoveryReason as string) || + !["from-last-known-good", "from-current", false].includes( + candidate.canRecover as never, + ) || + (candidate.expectedJournalRevision !== undefined && + (!Number.isSafeInteger(candidate.expectedJournalRevision) || + (candidate.expectedJournalRevision as number) < 1)) || + candidate.unsafeReason !== undefined + ) { + return undefined; + } + } else if (health === "unsafe") { + if ( + ![ + "current-future-schema", + "last-known-good-future-schema", + "pending-future-schema", + "unsafe-storage", + ].includes(candidate.unsafeReason as string) || + candidate.recoveryReason !== undefined || + candidate.canRecover !== undefined || + candidate.expectedJournalRevision !== undefined + ) { + return undefined; + } + } else if ( + candidate.recoveryReason !== undefined || + candidate.unsafeReason !== undefined || + candidate.canRecover !== undefined || + candidate.expectedJournalRevision !== undefined + ) { + return undefined; + } + seen.add(candidate.runId); + const { assetIds: _legacyAssetIds, ...metadata } = candidate; + entries.push({ + ...(metadata as unknown as RunIndexEntryV1), + health, + unresolvedAmbiguity: candidate.unresolvedAmbiguity ?? false, + }); + } + const degraded: RunUnassociatedDegradedEntryV1[] = []; + const degradedValues = (value.degraded ?? []) as unknown[]; + if (degradedValues.length + entries.length > this.limits.maxRunCount) return undefined; + for (const candidate of degradedValues) { + if ( + !isRecord(candidate) || + Object.keys(candidate).some( + (key) => + !["runId", "status", "recoveryReason", "unsafeReason", "canRecover"].includes(key), + ) || + typeof candidate.runId !== "string" || + !RUN_ID_PATTERN.test(candidate.runId) || + seen.has(candidate.runId) || + candidate.canRecover !== false + ) { + return undefined; + } + if ( + candidate.status === "recovery-required" && + [ + "current-corrupt", + "current-missing", + "last-known-good-corrupt", + "last-known-good-missing", + "last-known-good-mismatch", + "pending-corrupt", + "pending-conflict", + ].includes(candidate.recoveryReason as string) && + candidate.unsafeReason === undefined + ) { + degraded.push(candidate as unknown as RunUnassociatedDegradedEntryV1); + } else if ( + candidate.status === "unsafe" && + [ + "current-future-schema", + "last-known-good-future-schema", + "pending-future-schema", + "unsafe-storage", + ].includes(candidate.unsafeReason as string) && + candidate.recoveryReason === undefined + ) { + degraded.push(candidate as unknown as RunUnassociatedDegradedEntryV1); + } else { + return undefined; + } + seen.add(candidate.runId); + } + return { + version: 1, + revision: value.revision as number, + entries, + degraded, + }; + } + + private async quarantineCorruptIndex(): Promise { + const existing = await this.quarantinedIndexCount(); + if (existing >= MAX_INDEX_QUARANTINES) { + throw new CreateImagesRunJournalLoadError("corrupt", this.indexPath()); + } + const quarantine = path.join(this.root(), `run-index.corrupt.${randomUUID()}.json`); + await fs.rename(this.indexPath(), quarantine); + await this.syncDirectory(this.root()); + this.indexDiagnostic = "rebuilt-corrupt-index"; + this.clearIndexCache(); + } + + private async quarantinedIndexCount(): Promise { + let count = 0; + const handle = await fs.opendir(this.root()); + for await (const entry of handle) { + if (!INDEX_QUARANTINE_PATTERN.test(entry.name)) continue; + const info = await fs.lstat(path.join(this.root(), entry.name)); + if (!entry.isFile() || entry.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", this.root()); + } + count += 1; + if (count > MAX_INDEX_QUARANTINES) { + throw new CreateImagesRunJournalLoadError("corrupt", this.root()); + } + } + return count; + } + + private async loadIndex(recoverCorrupt = false): Promise { + if (this.indexCache) { + const identity = await this.fileAuthorityIdentity(this.indexPath()); + if (this.sameFileAuthorityIdentity(this.indexAuthorityCache, identity)) { + return this.indexCache; + } + this.clearIndexCache(); + } + const identityBeforeRead = await this.fileAuthorityIdentity(this.indexPath()); + const raw = await this.readJson(this.indexPath(), CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + const identityAfterRead = await this.fileAuthorityIdentity(this.indexPath()); + if (!this.sameFileAuthorityIdentity(identityBeforeRead, identityAfterRead)) { + this.clearIndexCache(); + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + if (raw.status === "missing") return undefined; + if (raw.status === "unsafe") { + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + if (raw.status !== "healthy") { + if (recoverCorrupt && raw.status === "corrupt") { + await this.quarantineCorruptIndex(); + return undefined; + } + throw new CreateImagesRunJournalLoadError("corrupt", this.indexPath()); + } + if (futureVersion(raw.value, "version")) { + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + const index = this.parseIndex(raw.value); + if (!index && recoverCorrupt) { + await this.quarantineCorruptIndex(); + return undefined; + } + if (!index) throw new CreateImagesRunJournalLoadError("corrupt", this.indexPath()); + if (identityAfterRead.kind !== "file") { + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + this.indexCache = index; + this.indexAuthorityCache = identityAfterRead; + return index; + } + + private entryFor(journal: CreateImagesRunJournalV1): RunIndexEntryV1 { + const projection = projectCreateImagesRun(journal); + return { + runId: journal.runId, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + journalRevision: journal.journalRevision, + status: projection.status, + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + terminal: projection.terminal !== undefined, + unresolvedAmbiguity: hasUnresolvedCreateImagesRunAmbiguity(projection), + health: "healthy", + }; + } + + private entryForState( + runId: string, + state: Awaited>, + prior?: RunIndexEntryV1, + ): RunIndexEntryV1 | undefined { + const health = this.healthOf(runId, state); + if (health.status === "healthy" && state.current.status === "healthy") { + return this.entryFor(state.current.value); + } + const authority = + state.current.status === "healthy" + ? state.current.value + : state.lastKnownGood.status === "healthy" + ? state.lastKnownGood.value + : undefined; + const base = authority ? this.entryFor(authority) : prior; + if (!base) return undefined; + const { + recoveryReason: _recoveryReason, + unsafeReason: _unsafeReason, + canRecover: _canRecover, + expectedJournalRevision: _expectedJournalRevision, + ...cleanBase + } = base; + if (health.status === "unsafe") { + return { + ...cleanBase, + health: "unsafe", + unsafeReason: health.reason, + }; + } + if (health.status !== "recovery-required") return undefined; + return { + ...cleanBase, + health: "recovery-required", + recoveryReason: health.reason, + canRecover: health.canRecover, + ...(health.canRecover === "from-last-known-good" && + health.lastKnownGoodJournalRevision !== undefined + ? { expectedJournalRevision: health.lastKnownGoodJournalRevision } + : health.canRecover === "from-current" && health.currentJournalRevision !== undefined + ? { expectedJournalRevision: health.currentJournalRevision } + : {}), + }; + } + + private unassociatedDegradedForState( + runId: string, + state: Awaited>, + ): RunUnassociatedDegradedEntryV1 | undefined { + const health = this.healthOf(runId, state); + if (health.status === "unsafe") { + return { + runId, + status: "unsafe", + unsafeReason: health.reason, + canRecover: false, + }; + } + if (health.status === "recovery-required") { + return { + runId, + status: "recovery-required", + recoveryReason: health.reason, + canRecover: false, + }; + } + return undefined; + } + + private async publishIndex( + entries: RunIndexEntryV1[], + revision?: number, + degraded: RunUnassociatedDegradedEntryV1[] = this.indexCache?.degraded ?? [], + ): Promise { + const current = this.indexCache; + const next: RunIndexV1 = { + version: 1, + revision: revision ?? (current?.revision ?? 0) + 1, + entries: [...entries].sort((left, right) => left.runId.localeCompare(right.runId)), + degraded: [...degraded].sort((left, right) => left.runId.localeCompare(right.runId)), + }; + await this.durability.beforeIndexPublished?.(next.revision); + await this.writeAtomic(this.indexPath(), next, CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + await this.bindIndexCache(next); + this.indexDirty = false; + } + + private markIndexDirty(): void { + this.indexDirty = true; + this.clearIndexCache(); + } + + private async updateIndexEntry(journal: CreateImagesRunJournalV1): Promise { + const index = await this.indexed(); + const entries = index.entries.filter((entry) => entry.runId !== journal.runId); + entries.push(this.entryFor(journal)); + await this.publishIndex( + entries, + index.revision + 1, + index.degraded.filter((entry) => entry.runId !== journal.runId), + ); + } + + private async updateIndexState( + runId: string, + state: Awaited>, + ): Promise { + const index = await this.indexed(); + const prior = index.entries.find((candidate) => candidate.runId === runId); + const entry = this.entryForState(runId, state, prior); + const entries = index.entries.filter((candidate) => candidate.runId !== runId); + if (entry) entries.push(entry); + const degraded = index.degraded.filter((candidate) => candidate.runId !== runId); + if (!entry) { + const unassociated = this.unassociatedDegradedForState(runId, state); + if (unassociated) degraded.push(unassociated); + } + const sorted = entries.sort((left, right) => left.runId.localeCompare(right.runId)); + degraded.sort((left, right) => left.runId.localeCompare(right.runId)); + if ( + JSON.stringify(sorted) !== JSON.stringify(index.entries) || + JSON.stringify(degraded) !== JSON.stringify(index.degraded) + ) { + await this.publishIndex(sorted, index.revision + 1, degraded); + } + } + + private async enrichDegradedHealth( + health: CreateImagesRunJournalHealth, + ): Promise { + if ( + (health.status !== "recovery-required" && health.status !== "unsafe") || + health.workflowId !== undefined + ) { + return health; + } + const prior = (await this.indexed()).entries.find((entry) => entry.runId === health.runId); + return prior + ? { + ...health, + workflowId: prior.workflowId, + workflowRevision: prior.workflowRevision, + } + : health; + } + + private async rebuildIndex(runIds: readonly string[], prior?: RunIndexV1): Promise { + const entries: RunIndexEntryV1[] = []; + const degraded: RunUnassociatedDegradedEntryV1[] = []; + const priorEntries = new Map(prior?.entries.map((entry) => [entry.runId, entry]) ?? []); + for (const runId of runIds) { + const state = await this.inspected(runId); + const entry = this.entryForState(runId, state, priorEntries.get(runId)); + if (entry) entries.push(entry); + else { + const unassociated = this.unassociatedDegradedForState(runId, state); + if (unassociated) degraded.push(unassociated); + } + } + await this.publishIndex(entries, undefined, degraded); + } + + private async indexed(): Promise { + if (this.indexDirty) { + const prior = await this.loadIndex(true); + const { runIds } = await this.inventory(true); + await this.rebuildIndex(runIds, prior); + return this.indexCache as RunIndexV1; + } + const existing = await this.loadIndex(); + if (existing) return existing; + const { runIds } = await this.inventory(); + await this.rebuildIndex(runIds); + return this.indexCache as RunIndexV1; + } + + private pruneToken( + candidates: readonly CreateImagesTerminalPruneCandidate[], + assetIds: readonly string[], + ): string { + return createHash("sha256") + .update(JSON.stringify({ version: 1, candidates, assetIds }), "utf8") + .digest("hex"); + } + + private discardToken( + plan: Omit, + ): string { + return createHash("sha256").update(JSON.stringify(plan), "utf8").digest("hex"); + } + + private async degradedRecordFingerprint(runId: string): Promise { + const directory = this.runDirectory(runId); + const directoryInfo = await fs.lstat(directory); + if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + const entries = await fs.readdir(directory, { withFileTypes: true }); + if (entries.length > MAX_DISCARD_DIRECTORY_ENTRIES) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + const digest = createHash("sha256"); + let totalBytes = 0; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const target = path.join(directory, entry.name); + const info = await fs.lstat(target, { bigint: true }); + digest.update( + JSON.stringify({ + name: entry.name, + mode: info.mode.toString(), + size: info.size.toString(), + mtimeNs: info.mtimeNs.toString(), + ctimeNs: info.ctimeNs.toString(), + type: entry.isFile() + ? "file" + : entry.isSymbolicLink() + ? "symlink" + : entry.isDirectory() + ? "directory" + : "other", + }), + "utf8", + ); + if (entry.isFile() && !entry.isSymbolicLink()) { + const size = Number(info.size); + if (!Number.isSafeInteger(size) || size > MAX_PENDING_BYTES) { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + totalBytes += size; + if (totalBytes > MAX_DISCARD_FINGERPRINT_BYTES) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + digest.update(await readRegularFile(target, MAX_PENDING_BYTES)); + } else if (entry.isSymbolicLink()) { + digest.update(await fs.readlink(target), "utf8"); + } + } + return digest.digest("hex"); + } + + private parseDiscardManifest(value: unknown): DegradedRunDiscardManifestV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => + ![ + "version", + "runId", + "reason", + "association", + "workflowId", + "expectedCurrentJournalRevision", + "expectedLastKnownGoodJournalRevision", + "authorizationToken", + "recordFingerprint", + "createdAt", + ].includes(key), + ) || + value.version !== 1 || + typeof value.runId !== "string" || + !RUN_ID_PATTERN.test(value.runId) || + typeof value.reason !== "string" || + ![ + "current-corrupt", + "current-missing", + "last-known-good-corrupt", + "last-known-good-missing", + "last-known-good-mismatch", + "pending-corrupt", + "pending-conflict", + "current-future-schema", + "last-known-good-future-schema", + "pending-future-schema", + "unsafe-storage", + ].includes(value.reason) || + (value.association !== "workflow" && value.association !== "unassociated") || + (value.association === "workflow" + ? typeof value.workflowId !== "string" || !RUN_ID_PATTERN.test(value.workflowId) + : value.workflowId !== undefined) || + typeof value.recordFingerprint !== "string" || + !/^[a-f0-9]{64}$/u.test(value.recordFingerprint) || + typeof value.authorizationToken !== "string" || + !/^[a-f0-9]{64}$/u.test(value.authorizationToken) || + typeof value.createdAt !== "string" || + !Number.isFinite(Date.parse(value.createdAt)) + ) { + return undefined; + } + for (const revision of [ + value.expectedCurrentJournalRevision, + value.expectedLastKnownGoodJournalRevision, + ]) { + if (revision !== undefined && (!Number.isSafeInteger(revision) || (revision as number) < 1)) { + return undefined; + } + } + const withoutToken: Omit = { + version: 1, + runId: value.runId, + reason: value.reason as CreateImagesRunRecoveryReason | CreateImagesRunUnsafeReason, + association: value.association, + ...(value.workflowId === undefined ? {} : { workflowId: value.workflowId as string }), + ...(value.expectedCurrentJournalRevision === undefined + ? {} + : { + expectedCurrentJournalRevision: value.expectedCurrentJournalRevision as number, + }), + ...(value.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { + expectedLastKnownGoodJournalRevision: + value.expectedLastKnownGoodJournalRevision as number, + }), + recordFingerprint: value.recordFingerprint, + }; + if (this.discardToken(withoutToken) !== value.authorizationToken) return undefined; + return { + ...withoutToken, + authorizationToken: value.authorizationToken, + createdAt: value.createdAt, + }; + } + + private async inspectDiscardManifest(): Promise> { + const raw = await this.readJson(this.discardPendingPath(), 64 * 1024); + if (raw.status !== "healthy") return raw; + if (futureVersion(raw.value, "version")) { + return { status: "unsafe", reason: "future-schema" }; + } + const manifest = this.parseDiscardManifest(raw.value); + return manifest ? { status: "healthy", value: manifest } : { status: "corrupt" }; + } + + private parsePruneManifest(value: unknown): TerminalPruneManifestV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => !["version", "candidates", "token", "assetIds", "createdAt"].includes(key), + ) || + value.version !== 1 || + !Array.isArray(value.candidates) || + value.candidates.length < 1 || + value.candidates.length > MAX_PRUNE_BATCH_SIZE || + !Array.isArray(value.assetIds) || + value.assetIds.length > 10_000 || + value.assetIds.some( + (assetId) => typeof assetId !== "string" || !/^[a-f0-9]{64}$/u.test(assetId), + ) || + typeof value.token !== "string" || + !/^[a-f0-9]{64}$/u.test(value.token) || + typeof value.createdAt !== "string" || + !Number.isFinite(Date.parse(value.createdAt)) + ) { + return undefined; + } + const candidates: CreateImagesTerminalPruneCandidate[] = []; + const seen = new Set(); + for (const candidate of value.candidates) { + if ( + !isRecord(candidate) || + Object.keys(candidate).some((key) => !["runId", "journalRevision"].includes(key)) || + typeof candidate.runId !== "string" || + !RUN_ID_PATTERN.test(candidate.runId) || + seen.has(candidate.runId) || + !Number.isSafeInteger(candidate.journalRevision) || + (candidate.journalRevision as number) < 1 + ) { + return undefined; + } + seen.add(candidate.runId); + candidates.push({ + runId: candidate.runId, + journalRevision: candidate.journalRevision as number, + }); + } + const assetIds = [...(value.assetIds as string[])]; + if ( + new Set(assetIds).size !== assetIds.length || + value.token !== this.pruneToken(candidates, assetIds) + ) { + return undefined; + } + return { + version: 1, + candidates, + token: value.token, + assetIds, + createdAt: value.createdAt, + }; + } + + private async inspectPruneManifest(): Promise> { + const raw = await this.readJson(this.prunePendingPath(), 1024 * 1024); + if (raw.status !== "healthy") return raw; + if (futureVersion(raw.value, "version")) { + return { status: "unsafe", reason: "future-schema" }; + } + const manifest = this.parsePruneManifest(raw.value); + return manifest ? { status: "healthy", value: manifest } : { status: "corrupt" }; + } + + private async ensurePruneTombstones(): Promise { + if (!this.pruneStateLoaded) { + const state = await this.inspectPruneManifest(); + if (state.status === "unsafe" || state.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + state.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + if (state.status === "healthy") { + for (const candidate of state.value.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + } + this.pruneStateLoaded = true; + } + if (!this.discardStateLoaded) { + const discard = await this.inspectDiscardManifest(); + if (discard.status === "unsafe" || discard.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + discard.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + if (discard.status === "healthy") { + this.pruneTombstones.add(discard.value.runId); + this.evictRunCaches(discard.value.runId); + } + this.discardStateLoaded = true; + } + } + + private async buildDegradedRunDiscardPlan( + runId: string, + ): Promise { + const index = await this.indexed(); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status === "missing") return { status: "not-found" }; + if (health.status === "healthy") return { status: "not-degraded" }; + if (health.status === "recovery-required" && health.canRecover !== false) { + return { status: "recoverable" }; + } + const prior = index.entries.find((entry) => entry.runId === runId); + const workflowId = + "workflowId" in health && typeof health.workflowId === "string" + ? health.workflowId + : prior?.workflowId; + const withoutToken: Omit = { + version: 1, + runId, + reason: health.reason, + association: workflowId ? "workflow" : "unassociated", + ...(workflowId ? { workflowId } : {}), + ...(state.current.status === "healthy" + ? { + expectedCurrentJournalRevision: state.current.value.journalRevision, + } + : {}), + ...(state.lastKnownGood.status === "healthy" + ? { + expectedLastKnownGoodJournalRevision: state.lastKnownGood.value.journalRevision, + } + : {}), + recordFingerprint: await this.degradedRecordFingerprint(runId), + }; + return { + status: "ready", + plan: { + ...withoutToken, + authorizationToken: this.discardToken(withoutToken), + }, + }; + } + + private async assertWithinLimits( + runId: string, + replacements: ReadonlyMap, + additionalBytes = 0, + ): Promise { + const inventory = await this.inventory(); + const isNew = !inventory.runIds.includes(runId); + if (inventory.runIds.length + (isNew ? 1 : 0) > this.limits.maxRunCount) { + throw new Error("Create Images run storage has reached its run count limit."); + } + let projected = inventory.aggregateBytes; + for (const [target, replacement] of replacements) { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + projected -= existing.size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (replacement !== undefined) projected += this.serializedBytes(replacement); + } + projected += additionalBytes; + if (!Number.isSafeInteger(projected) || projected > this.limits.maxAggregateRunBytes) { + throw new Error("Create Images run storage has reached its aggregate byte limit."); + } + } + + private healthOf( + runId: string, + state: Awaited>, + ): CreateImagesRunJournalHealth { + const identity = + state.current.status === "healthy" + ? { + workflowId: state.current.value.workflowId, + workflowRevision: state.current.value.workflowRevision, + } + : state.lastKnownGood.status === "healthy" + ? { + workflowId: state.lastKnownGood.value.workflowId, + workflowRevision: state.lastKnownGood.value.workflowRevision, + } + : {}; + if ( + state.current.status === "unsafe" || + state.lastKnownGood.status === "unsafe" || + state.pending.status === "unsafe" + ) { + return { + status: "unsafe", + runId, + ...identity, + reason: + (state.current.status === "unsafe" && state.current.reason === "unsafe-storage") || + (state.lastKnownGood.status === "unsafe" && + state.lastKnownGood.reason === "unsafe-storage") || + (state.pending.status === "unsafe" && state.pending.reason === "unsafe-storage") + ? "unsafe-storage" + : state.current.status === "unsafe" + ? "current-future-schema" + : state.lastKnownGood.status === "unsafe" + ? "last-known-good-future-schema" + : "pending-future-schema", + }; + } + if ( + state.current.status === "missing" && + state.lastKnownGood.status === "missing" && + state.pending.status === "missing" + ) { + return { status: "missing", runId }; + } + const revisions = { + ...(state.current.status === "healthy" + ? { currentJournalRevision: state.current.value.journalRevision } + : {}), + ...(state.lastKnownGood.status === "healthy" + ? { + lastKnownGoodJournalRevision: state.lastKnownGood.value.journalRevision, + } + : {}), + }; + if (state.pending.status === "corrupt") { + return { + status: "recovery-required", + runId, + reason: "pending-corrupt", + canRecover: false, + ...identity, + ...revisions, + }; + } + if (state.pending.status === "healthy") { + return { + status: "recovery-required", + runId, + reason: "pending-conflict", + canRecover: false, + ...identity, + ...revisions, + }; + } + if (state.current.status !== "healthy") { + return { + status: "recovery-required", + runId, + reason: state.current.status === "missing" ? "current-missing" : "current-corrupt", + canRecover: state.lastKnownGood.status === "healthy" ? "from-last-known-good" : false, + ...identity, + ...revisions, + }; + } + if (state.lastKnownGood.status !== "healthy") { + return { + status: "recovery-required", + runId, + reason: + state.lastKnownGood.status === "missing" + ? "last-known-good-missing" + : "last-known-good-corrupt", + canRecover: state.current.status === "healthy" ? "from-current" : false, + ...identity, + ...revisions, + }; + } + if (!identical(state.current.value, state.lastKnownGood.value)) { + return { + status: "recovery-required", + runId, + reason: "last-known-good-mismatch", + canRecover: false, + ...identity, + ...revisions, + }; + } + return { + status: "healthy", + runId, + journalRevision: state.current.value.journalRevision, + runStatus: projectCreateImagesRun(state.current.value).status, + }; + } + + private plausibleTornAppendIdentity( + original: RunAuthorityFileIdentity, + current: RunAuthorityFileIdentity, + ): boolean { + if (current.kind !== "file") return false; + if (original.kind === "missing") return BigInt(current.size) > 0n; + return ( + original.kind === "file" && + original.device === current.device && + original.inode === current.inode && + BigInt(current.size) > BigInt(original.size) + ); + } + + private async recoverTornEventAppend( + checkpointTarget: string, + eventLogTarget: string, + originalCheckpointIdentity: RunAuthorityFileIdentity, + originalEventLogIdentity: RunAuthorityFileIdentity, + pending: PendingRunAppendMutationV1, + ): Promise { + const checkpointIdentity = await this.fileAuthorityIdentity(checkpointTarget); + const tornIdentity = await this.fileAuthorityIdentity(eventLogTarget); + if ( + !this.sameFileAuthorityIdentity(originalCheckpointIdentity, checkpointIdentity) || + !this.plausibleTornAppendIdentity(originalEventLogIdentity, tornIdentity) + ) { + return false; + } + if (tornIdentity.kind !== "file") return false; + let bytes: Buffer; + try { + bytes = await readRegularFile(eventLogTarget, MAX_EVENT_LOG_BYTES); + } catch { + return false; + } + if ( + !this.sameFileAuthorityIdentity( + tornIdentity, + await this.fileAuthorityIdentity(eventLogTarget), + ) || + bytes.length !== Number(tornIdentity.size) + ) { + return false; + } + const originalBytes = + originalEventLogIdentity.kind === "file" ? Number(originalEventLogIdentity.size) : 0; + if (originalBytes < 0 || originalBytes >= bytes.length) return false; + const checkpoint = await this.inspectJournal(checkpointTarget); + if ( + checkpoint.status !== "healthy" || + checkpoint.value.journalRevision > pending.baseJournalRevision || + !this.sameFileAuthorityIdentity( + originalCheckpointIdentity, + await this.fileAuthorityIdentity(checkpointTarget), + ) + ) { + return false; + } + const prefix = bytes.subarray(0, originalBytes); + const parsedPrefix = this.parseEventLogBytes(prefix, checkpoint.value); + if ( + parsedPrefix.inspection.status !== "healthy" || + !parsedPrefix.tailDigest || + parsedPrefix.inspection.value.journalRevision !== pending.baseJournalRevision + ) { + return false; + } + let next: CreateImagesRunJournalV1; + try { + next = appendCreateImagesRunEvent(parsedPrefix.inspection.value, pending.event); + } catch { + return false; + } + if (journalDigest(next) !== pending.targetJournalDigest) return false; + const expected = serializedEventRecord( + parsedPrefix.inspection.value, + pending.event, + parsedPrefix.tailDigest, + ); + const suffix = bytes.subarray(originalBytes); + if ( + suffix.length === 0 || + suffix.length >= expected.bytes.length || + !suffix.equals(expected.bytes.subarray(0, suffix.length)) + ) { + return false; + } + await this.replaceTornEventLog( + eventLogTarget, + Buffer.concat([prefix, expected.bytes]), + tornIdentity, + expected.record.digest, + ); + return true; + } + + private async finishPending( + state: Awaited>, + invokeCrashSeams: boolean, + observedAuthority?: RunAuthorityIdentity, + ): Promise { + if (state.pending.status !== "healthy") return false; + const pending = state.pending.value; + if (pending.kind === "append") { + let authorityAtFinish = await this.runAuthorityIdentity(pending.runId); + if ( + observedAuthority && + !this.sameRunAuthorityIdentity(observedAuthority, authorityAtFinish) + ) { + return false; + } + const eventMatches = (journal: CreateImagesRunJournalV1): boolean => + JSON.stringify(journal.events[journal.events.length - 1]) === JSON.stringify(pending.event); + const targetMatches = (journal: CreateImagesRunJournalV1): boolean => + journal.journalRevision === pending.targetJournalRevision && + eventMatches(journal) && + journalDigest(journal) === pending.targetJournalDigest; + const refreshAfterRepair = async (): Promise => { + const authorityBeforeInspection = await this.runAuthorityIdentity(pending.runId); + const repairedState = await this.inspected(pending.runId); + const authorityAfterInspection = await this.runAuthorityIdentity(pending.runId); + if (!this.sameRunAuthorityIdentity(authorityBeforeInspection, authorityAfterInspection)) { + return false; + } + state = repairedState; + authorityAtFinish = authorityAfterInspection; + return true; + }; + const currentCanRecoverFromTornAppend = + state.current.status === "corrupt" && + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision && + this.sameFileAuthorityIdentity( + pending.authority.lastKnownGood, + authorityAtFinish.lastKnownGood, + ) && + this.sameFileAuthorityIdentity( + pending.authority.lastKnownGoodEvents, + authorityAtFinish.lastKnownGoodEvents, + ); + if (currentCanRecoverFromTornAppend) { + const recovered = await this.recoverTornEventAppend( + state.paths.current, + state.paths.currentEvents, + pending.authority.current, + pending.authority.currentEvents, + pending, + ); + if (recovered && !(await refreshAfterRepair())) return false; + } + const lastKnownGoodCanRecoverFromTornAppend = + state.lastKnownGood.status === "corrupt" && + state.current.status === "healthy" && + targetMatches(state.current.value) && + this.sameFileAuthorityIdentity(pending.authority.current, authorityAtFinish.current); + if (lastKnownGoodCanRecoverFromTornAppend) { + const recovered = await this.recoverTornEventAppend( + state.paths.lastKnownGood, + state.paths.lastKnownGoodEvents, + pending.authority.lastKnownGood, + pending.authority.lastKnownGoodEvents, + pending, + ); + if (recovered && !(await refreshAfterRepair())) return false; + } + const currentIsBase = + state.current.status === "healthy" && + state.current.value.journalRevision === pending.baseJournalRevision; + const currentIsTarget = + state.current.status === "healthy" && targetMatches(state.current.value); + const lastKnownGoodIsBase = + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision; + const lastKnownGoodIsTarget = + state.lastKnownGood.status === "healthy" && targetMatches(state.lastKnownGood.value); + const original = pending.authority; + const checkpointsUnchanged = + this.sameFileAuthorityIdentity(original.current, authorityAtFinish.current) && + this.sameFileAuthorityIdentity(original.lastKnownGood, authorityAtFinish.lastKnownGood); + const currentBaseLogUnchanged = this.sameFileAuthorityIdentity( + original.currentEvents, + authorityAtFinish.currentEvents, + ); + const lastKnownGoodBaseLogUnchanged = this.sameFileAuthorityIdentity( + original.lastKnownGoodEvents, + authorityAtFinish.lastKnownGoodEvents, + ); + if ( + !checkpointsUnchanged || + (currentIsBase && !currentBaseLogUnchanged) || + (lastKnownGoodIsBase && !lastKnownGoodBaseLogUnchanged) || + (!currentIsBase && !currentIsTarget) || + (!lastKnownGoodIsBase && !lastKnownGoodIsTarget) || + (currentIsBase && lastKnownGoodIsTarget) + ) { + return false; + } + let expectedAuthority = authorityAtFinish; + let next: CreateImagesRunJournalV1; + if ( + state.current.status === "healthy" && + state.current.value.journalRevision === pending.targetJournalRevision + ) { + if (!targetMatches(state.current.value)) { + return false; + } + next = state.current.value; + } else { + const base = + state.current.status === "healthy" && + state.current.value.journalRevision === pending.baseJournalRevision + ? state.current.value + : state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision + ? state.lastKnownGood.value + : undefined; + if (!base) return false; + try { + next = appendCreateImagesRunEvent(base, pending.event); + } catch { + return false; + } + if (state.current.status !== "healthy") { + return false; + } else { + const currentEvents = await this.appendEventRecord( + state.paths.currentEvents, + state.paths.current, + state.current.value, + pending.event, + expectedAuthority.current, + expectedAuthority.currentEvents, + ); + expectedAuthority = { ...expectedAuthority, currentEvents }; + } + if (invokeCrashSeams) await this.durability.afterCurrentPublished?.(pending.runId); + } + if ( + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.targetJournalRevision + ) { + if (!identical(state.lastKnownGood.value, next)) return false; + } else if ( + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision + ) { + if ( + !this.sameRunAuthorityIdentity( + expectedAuthority, + await this.runAuthorityIdentity(pending.runId), + ) + ) { + return false; + } + const lastKnownGoodEvents = await this.appendEventRecord( + state.paths.lastKnownGoodEvents, + state.paths.lastKnownGood, + state.lastKnownGood.value, + pending.event, + expectedAuthority.lastKnownGood, + expectedAuthority.lastKnownGoodEvents, + ); + expectedAuthority = { ...expectedAuthority, lastKnownGoodEvents }; + } else if ( + state.lastKnownGood.status !== "unsafe" && + state.current.status === "healthy" && + state.current.value.journalRevision === pending.targetJournalRevision + ) { + return false; + } else { + return false; + } + if (invokeCrashSeams) await this.durability.afterLastKnownGoodPublished?.(pending.runId); + if ( + !this.sameRunAuthorityIdentity( + expectedAuthority, + await this.runAuthorityIdentity(pending.runId), + ) + ) { + return false; + } + await this.removeDurably(state.paths.pending); + return true; + } + if (state.current.status === "unsafe" || state.current.status === "corrupt") return false; + if (state.lastKnownGood.status === "unsafe" || state.lastKnownGood.status === "corrupt") + return false; + if (state.current.status === "missing") { + if (pending.baseJournalRevision !== null) return false; + await this.writeAtomic( + state.paths.current, + pending.next, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + ); + await this.removeDurably(state.paths.currentEvents); + if (invokeCrashSeams) await this.durability.afterCurrentPublished?.(pending.runId); + } else if (state.current.value.journalRevision === pending.targetJournalRevision) { + if (!identical(state.current.value, pending.next)) return false; + } else if ( + pending.baseJournalRevision !== null && + state.current.value.journalRevision === pending.baseJournalRevision + ) { + await this.writeAtomic( + state.paths.current, + pending.next, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + ); + await this.removeDurably(state.paths.currentEvents); + if (invokeCrashSeams) await this.durability.afterCurrentPublished?.(pending.runId); + } else { + return false; + } + await this.writeAtomic( + state.paths.lastKnownGood, + pending.next, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + ); + await this.removeDurably(state.paths.lastKnownGoodEvents); + if (invokeCrashSeams) await this.durability.afterLastKnownGoodPublished?.(pending.runId); + await this.removeDurably(state.paths.pending); + return true; + } + + private async reconcile(runId: string): Promise { + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) { + this.evictRunCaches(runId); + return; + } + if (this.getCachedJournal(runId)) { + try { + if (await this.cachedAuthorityIsCurrent(runId)) return; + } catch { + // Fall through to the authoritative parser so storage failures are + // classified consistently with uncached reads. + } + if (this.journalCache.has(runId)) { + // Cached state is an optimization, never mutation authority. Any + // identity change (including same-size writes with restored mtime) + // invalidates the journal and event-log tails before a full parse. + this.evictRunCaches(runId); + } + } + const authorityBeforeInspection = await this.runAuthorityIdentity(runId); + const state = await this.inspected(runId); + const authorityAfterInspection = await this.runAuthorityIdentity(runId); + if (!this.sameRunAuthorityIdentity(authorityBeforeInspection, authorityAfterInspection)) { + this.evictRunCaches(runId); + return; + } + if (state.pending.status === "healthy") { + await this.finishPending(state, false, authorityAfterInspection); + } + const reconciled = await this.inspected(runId); + if ( + this.healthOf(runId, reconciled).status === "healthy" && + reconciled.current.status === "healthy" + ) { + await this.cacheHealthyJournal(runId, reconciled.current.value); + } else { + this.evictJournal(runId); + } + } + + private cachedState( + runId: string, + ): Awaited> | undefined { + const journal = this.getCachedJournal(runId); + if (!journal) return undefined; + return { + paths: this.paths(runId), + current: { status: "healthy", value: journal }, + lastKnownGood: { status: "healthy", value: journal }, + pending: { status: "missing" }, + }; + } + + private async appendInternal( + runId: string, + expectedJournalRevision: number, + eventFactory: (journal: CreateImagesRunJournalV1) => CreateImagesRunEventV1, + ): Promise { + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", this.paths(runId).current); + } + let state = this.cachedState(runId); + let authority: RunAuthorityIdentity; + if (state) { + authority = await this.runAuthorityIdentity(runId); + if (!this.sameRunAuthorityIdentity(this.journalAuthorityCache.get(runId), authority)) { + this.evictRunCaches(runId); + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + } else { + const authorityBeforeInspection = await this.runAuthorityIdentity(runId); + state = await this.inspected(runId); + authority = await this.runAuthorityIdentity(runId); + if (!this.sameRunAuthorityIdentity(authorityBeforeInspection, authority)) { + this.evictRunCaches(runId); + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + } + const health = this.healthOf(runId, state); + if (health.status !== "healthy" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.current.value.journalRevision !== expectedJournalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + runId, + expectedJournalRevision, + state.current.value.journalRevision, + ); + } + const next = appendCreateImagesRunEvent(state.current.value, eventFactory(state.current.value)); + const pending: PendingRunMutationV1 = { + version: PENDING_VERSION, + kind: "append", + runId, + baseJournalRevision: expectedJournalRevision, + targetJournalRevision: next.journalRevision, + stagedAt: next.updatedAt, + event: next.events[next.events.length - 1] as CreateImagesRunEventV1, + authority, + targetJournalDigest: journalDigest(next), + }; + const recordBytes = this.serializedBytes({ + version: 1, + runId, + journalRevision: next.journalRevision, + previousDigest: "0".repeat(64), + digest: "0".repeat(64), + event: pending.event, + }); + await this.assertWithinLimits( + runId, + new Map([[state.paths.pending, pending]]), + recordBytes * 2, + ); + await this.writeAtomic(state.paths.pending, pending, MAX_PENDING_BYTES); + await this.durability.afterPendingPublished?.(runId); + const pendingState = { + ...state, + pending: { status: "healthy", value: pending }, + } as Awaited>; + if (!(await this.finishPending(pendingState, true, authority))) { + this.evictRunCaches(runId); + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.pending); + } + await this.refreshInventoryRun(runId); + if ( + pending.event.type === "run-terminal" || + pending.event.type === "run-ambiguity-acknowledged" + ) { + try { + await this.updateIndexEntry(next); + } catch (error) { + // The journal is already authoritative. Never let a stale derived + // index authorize work after its publication failed. + this.markIndexDirty(); + await this.cacheHealthyJournal(runId, next); + throw error; + } + } + await this.cacheHealthyJournal(runId, next); + return next; + } + + async initialize(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + this.clearIndexCache(); + const loadedIndex = await this.loadIndex(true); + const prune = await this.inspectPruneManifest(); + if (prune.status === "unsafe" || prune.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + prune.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + if (prune.status === "healthy") { + this.pruneStateLoaded = true; + for (const candidate of prune.value.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + await this.resumeTerminalPrune(prune.value); + } else { + this.pruneStateLoaded = true; + } + const discard = await this.inspectDiscardManifest(); + if (discard.status === "unsafe" || discard.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + discard.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + if (discard.status === "healthy") { + this.discardStateLoaded = true; + this.pruneTombstones.add(discard.value.runId); + this.evictRunCaches(discard.value.runId); + await this.resumeDegradedRunDiscard(discard.value); + } else { + this.discardStateLoaded = true; + } + const { runIds } = await this.inventory(true); + const index = this.indexCache ?? loadedIndex; + const indexedEntries = new Map(index?.entries.map((entry) => [entry.runId, entry]) ?? []); + const results: CreateImagesRunJournalHealth[] = []; + const refreshedEntries = new Map(); + const refreshedDegraded = new Map(); + // The index is a derived history accelerator, never execution + // authority. Revalidate every bounded run directory before restart + // reconciliation so a stale terminal bit cannot hide queued work. + for (const runId of runIds) { + await this.reconcile(runId); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + results.push(health); + const entry = this.entryForState(runId, state, indexedEntries.get(runId)); + if (entry) refreshedEntries.set(runId, entry); + else { + const degraded = this.unassociatedDegradedForState(runId, state); + if (degraded) refreshedDegraded.set(runId, degraded); + } + } + if (runIds.length > 0) await this.inventory(true); + const entries = [...refreshedEntries.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + const degraded = [...refreshedDegraded.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + if (!index) { + await this.publishIndex(entries, undefined, degraded); + } else if ( + JSON.stringify(entries) !== JSON.stringify(index.entries) || + JSON.stringify(degraded) !== JSON.stringify(index.degraded) + ) { + await this.publishIndex(entries, index.revision + 1, degraded); + } else { + // A complete authoritative scan proved that an earlier ambiguous + // write outcome already contains the exact derived state. + this.indexDirty = false; + } + return results; + }); + } + + async health(runId: string): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) return { status: "missing", runId }; + const state = await this.inspected(runId); + const health = await this.enrichDegradedHealth(this.healthOf(runId, state)); + if (health.status === "healthy" && state.current.status === "healthy") { + await this.cacheHealthyJournal(runId, state.current.value); + } else { + this.evictJournal(runId); + } + if (health.status === "recovery-required" || health.status === "unsafe") { + await this.updateIndexState(runId, state); + } + return health; + }); + } + + async indexHealth(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const raw = await this.readJson(this.indexPath(), CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + if (raw.status === "missing") { + return this.indexDirty + ? { + status: "degraded", + revision: 0, + entryCount: 0, + degradedEntryCount: 1, + diagnostic: "stale-derived-index", + } + : { status: "missing" }; + } + if (raw.status === "unsafe") return { status: "unsafe" }; + if (raw.status === "corrupt" || futureVersion(raw.value, "version")) { + return futureVersion(raw.status === "healthy" ? raw.value : undefined, "version") + ? { status: "unsafe" } + : { status: "corrupt" }; + } + const index = this.parseIndex(raw.value); + if (!index) return { status: "corrupt" }; + const quarantinedIndexCount = await this.quarantinedIndexCount(); + const degradedEntryCount = + index.degraded.length + index.entries.filter((entry) => entry.health !== "healthy").length; + const details = { + revision: index.revision, + entryCount: index.entries.length + index.degraded.length, + ...(this.indexDirty + ? { diagnostic: "stale-derived-index" as const } + : this.indexDiagnostic + ? { diagnostic: this.indexDiagnostic } + : {}), + ...(quarantinedIndexCount > 0 ? { quarantinedIndexCount } : {}), + }; + return degradedEntryCount > 0 || this.indexDirty + ? { + status: "degraded", + ...details, + degradedEntryCount: Math.max(1, degradedEntryCount), + } + : { status: "healthy", ...details }; + }); + } + + async terminalPruneStatus(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspectPruneManifest(); + if (state.status === "missing") return { status: "none" }; + if (state.status === "unsafe") return { status: "unsafe" }; + if (state.status === "corrupt") return { status: "corrupt" }; + const { createdAt: _createdAt, ...plan } = state.value; + return { status: "pending", plan }; + }); + } + + async get(runId: string): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) return undefined; + const cached = this.getCachedJournal(runId); + if (cached) return cached; + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status === "missing") return undefined; + if (health.status !== "healthy" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + return state.current.value; + }); + } + + async start( + input: CreateImagesRunStartInput, + isRendererCurrent: () => boolean, + ): Promise { + validateRunId(input.runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.reconcile(input.runId); + if (this.pruneTombstones.has(input.runId)) { + throw new CreateImagesRunJournalRevisionConflictError(input.runId, null, null); + } + const state = await this.inspected(input.runId); + const health = this.healthOf(input.runId, state); + if (health.status !== "missing") { + throw new CreateImagesRunJournalRevisionConflictError( + input.runId, + null, + state.current.status === "healthy" ? state.current.value.journalRevision : null, + ); + } + const next = createCreateImagesRunJournal({ + ...input, + workflowFingerprint: createImagesWorkflowSnapshotFingerprint(input.workflowSnapshot), + }); + const pending: PendingRunMutationV1 = { + version: PENDING_VERSION, + runId: input.runId, + baseJournalRevision: null, + targetJournalRevision: 1, + stagedAt: input.createdAt, + next, + }; + await this.assertWithinLimits( + input.runId, + new Map([ + [state.paths.pending, pending], + [state.paths.current, next], + [state.paths.lastKnownGood, next], + ]), + ); + await this.writeAtomic(state.paths.pending, pending, MAX_PENDING_BYTES, isRendererCurrent); + await this.durability.afterPendingPublished?.(input.runId); + const pendingState = await this.inspected(input.runId); + if (!(await this.finishPending(pendingState, true))) { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.pending); + } + await this.refreshInventoryRun(input.runId); + try { + await this.updateIndexEntry(next); + } catch (error) { + this.markIndexDirty(); + await this.cacheHealthyJournal(input.runId, next); + throw error; + } + await this.cacheHealthyJournal(input.runId, next); + return next; + }); + } + + async append( + runId: string, + expectedJournalRevision: number, + event: CreateImagesRunEventV1, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + try { + return await this.appendInternal(runId, expectedJournalRevision, () => event); + } catch (error) { + this.evictJournal(runId); + throw error; + } + }); + } + + async requestCancellation( + runId: string, + expectedJournalRevision: number, + input: { at: string; reason: CreateImagesCancellationReason }, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + try { + return await this.appendInternal(runId, expectedJournalRevision, (journal) => ({ + type: "run-cancel-requested", + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: input.at, + reason: input.reason, + })); + } catch (error) { + this.evictJournal(runId); + throw error; + } + }); + } + + async reconciliationCandidates(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const runIds = (await this.indexed()).entries + .filter( + (entry) => + entry.health === "healthy" && !entry.terminal && !this.pruneTombstones.has(entry.runId), + ) + .map((entry) => entry.runId); + const candidates: CreateImagesRunJournalV1[] = []; + for (const runId of runIds) { + await this.reconcile(runId); + const state = await this.inspected(runId); + if ( + this.healthOf(runId, state).status === "healthy" && + state.current.status === "healthy" && + !projectCreateImagesRun(state.current.value).terminal + ) { + candidates.push(state.current.value); + } + } + return candidates.sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.runId.localeCompare(right.runId), + ); + }); + } + + async terminalHistory(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const summaries = (await this.indexed()).entries + .filter( + ( + entry, + ): entry is RunIndexEntryV1 & { + status: CreateImagesRunTerminalStatus; + } => + entry.health === "healthy" && entry.terminal && !this.pruneTombstones.has(entry.runId), + ) + .map( + ({ + runId, + workflowId, + workflowRevision, + journalRevision, + status, + createdAt, + updatedAt, + }) => ({ + runId, + workflowId, + workflowRevision, + journalRevision, + status, + createdAt, + updatedAt, + }), + ); + return summaries.sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || right.runId.localeCompare(left.runId), + ); + }); + } + + async referenceInventory(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const pruneManifest = await this.inspectPruneManifest(); + const discardManifest = await this.inspectDiscardManifest(); + if ( + pruneManifest.status === "unsafe" || + pruneManifest.status === "corrupt" || + discardManifest.status === "unsafe" || + discardManifest.status === "corrupt" + ) { + return { complete: false, records: [] }; + } + const { runIds } = await this.inventory(); + const records: CreateImagesRunReferenceInventory["records"] = []; + let complete = + (pruneManifest.status === "missing" || pruneManifest.status === "healthy") && + discardManifest.status === "missing"; + for (const runId of runIds) { + await this.reconcile(runId); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status !== "healthy") complete = false; + const candidates = [ + state.current.status === "healthy" ? state.current.value : undefined, + state.lastKnownGood.status === "healthy" ? state.lastKnownGood.value : undefined, + state.pending.status === "healthy" && state.pending.value.kind !== "append" + ? state.pending.value.next + : undefined, + ].filter((candidate): candidate is CreateImagesRunJournalV1 => candidate !== undefined); + const assetIds = new Set(); + for (const candidate of candidates) { + for (const assetId of referencedAssetIds(candidate)) assetIds.add(assetId); + } + if ( + state.pending.status === "healthy" && + state.pending.value.kind === "append" && + (state.pending.value.event.type === "node-output-published" || + state.pending.value.event.type === "node-succeeded") + ) { + for (const assetId of state.pending.value.event.outputAssetIds) assetIds.add(assetId); + } + records.push({ runId, assetIds: [...assetIds].sort() }); + } + if (pruneManifest.status === "healthy") { + const retainedByManifest = new Set(pruneManifest.value.assetIds); + const firstRunId = pruneManifest.value.candidates[0]?.runId; + if (firstRunId) { + const existing = records.find((record) => record.runId === firstRunId); + if (existing) { + for (const assetId of existing.assetIds) retainedByManifest.add(assetId); + existing.assetIds = [...retainedByManifest].sort(); + } else { + records.push({ + runId: firstRunId, + assetIds: [...retainedByManifest].sort(), + }); + } + } + } + return { complete, records }; + }); + } + + private async buildTerminalPrunePlan( + requested: readonly CreateImagesTerminalPruneCandidate[], + ): Promise { + if (requested.length < 1 || requested.length > MAX_PRUNE_BATCH_SIZE) { + throw new Error("Create Images terminal prune batch is outside its bounded limit."); + } + const candidates = [...requested].sort((left, right) => left.runId.localeCompare(right.runId)); + if (new Set(candidates.map((candidate) => candidate.runId)).size !== candidates.length) { + throw new Error("Create Images terminal prune candidates must be unique."); + } + const assetIds = new Set(); + for (const candidate of candidates) { + validateRunId(candidate.runId); + if (!Number.isSafeInteger(candidate.journalRevision) || candidate.journalRevision < 1) { + throw new Error("Invalid Create Images terminal prune revision."); + } + const state = await this.inspected(candidate.runId); + const health = this.healthOf(candidate.runId, state); + if (health.status !== "healthy" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.current.value.journalRevision !== candidate.journalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + candidate.runId, + candidate.journalRevision, + state.current.value.journalRevision, + ); + } + const projection = projectCreateImagesRun(state.current.value); + if (!projection.terminal) { + throw new Error("Only terminal Create Images runs can be retired."); + } + if (hasUnresolvedCreateImagesRunAmbiguity(projection)) { + throw new Error( + "Unresolved Create Images submissions must be acknowledged before retirement.", + ); + } + for (const assetId of referencedAssetIds(state.current.value)) assetIds.add(assetId); + if (assetIds.size > 10_000) { + throw new Error("Create Images terminal prune references exceed the bounded limit."); + } + } + const sortedAssetIds = [...assetIds].sort(); + return { + version: 1, + candidates, + token: this.pruneToken(candidates, sortedAssetIds), + assetIds: sortedAssetIds, + }; + } + + async planTerminalPrune( + requested: readonly CreateImagesTerminalPruneCandidate[], + ): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const pending = await this.inspectPruneManifest(); + if (pending.status !== "missing") { + throw new CreateImagesRunJournalLoadError( + pending.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + return this.buildTerminalPrunePlan(requested); + }); + } + + private async resumeTerminalPrune( + manifest: TerminalPruneManifestV1, + ): Promise { + for (const candidate of manifest.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + const retiredRoot = path.join(this.root(), RETIRED_RUNS_DIRECTORY); + const retiredBatch = path.join(retiredRoot, manifest.token); + await this.ensureDirectory(retiredRoot); + await this.ensureDirectory(retiredBatch); + const index = await this.indexed(); + const indexedRunIds = new Set(index.entries.map((entry) => entry.runId)); + for (const candidate of manifest.candidates) { + const source = this.runDirectory(candidate.runId); + const destination = path.join(retiredBatch, candidate.runId); + const [sourceInfo, destinationInfo] = await Promise.all([ + fs.lstat(source).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + fs.lstat(destination).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + ]); + if (sourceInfo && destinationInfo) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + if (sourceInfo) { + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + await fs.rename(source, destination); + await this.syncDirectory(this.runsPath()); + await this.syncDirectory(retiredBatch); + await this.durability.afterRunRetired?.(candidate.runId); + } else if (destinationInfo) { + if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", destination); + } + } else if (indexedRunIds.has(candidate.runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", source); + } + } + + const removed = new Set(manifest.candidates.map((candidate) => candidate.runId)); + if (index.entries.some((entry) => removed.has(entry.runId))) { + await this.publishIndex( + index.entries.filter((entry) => !removed.has(entry.runId)), + index.revision + 1, + ); + } + await this.durability.beforeRetiredDelete?.(manifest.token); + await fs.rm(retiredBatch, { recursive: true, force: true }); + await this.syncDirectory(retiredRoot); + await this.durability.afterRetiredDelete?.(manifest.token); + await this.removeDurably(this.prunePendingPath()); + this.inventoryCache = undefined; + for (const candidate of manifest.candidates) { + this.evictRunCaches(candidate.runId); + this.pruneTombstones.delete(candidate.runId); + } + return { + removedRunIds: manifest.candidates.map((candidate) => candidate.runId), + releasedAssetIds: [...manifest.assetIds], + }; + } + + async pruneTerminalRuns( + plan: CreateImagesTerminalPrunePlan, + ): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + let manifestState = await this.inspectPruneManifest(); + let manifest: TerminalPruneManifestV1; + if (manifestState.status === "healthy") { + manifest = manifestState.value; + if ( + manifest.token !== plan.token || + JSON.stringify(manifest.candidates) !== JSON.stringify(plan.candidates) || + JSON.stringify(manifest.assetIds) !== JSON.stringify(plan.assetIds) + ) { + throw new Error("Another Create Images terminal prune is already in progress."); + } + } else if (manifestState.status === "missing") { + const verified = await this.buildTerminalPrunePlan(plan.candidates); + if ( + verified.token !== plan.token || + JSON.stringify(verified.assetIds) !== JSON.stringify(plan.assetIds) + ) { + throw new Error("Create Images terminal prune authorization is stale."); + } + manifest = { ...verified, createdAt: new Date().toISOString() }; + await this.writeAtomic(this.prunePendingPath(), manifest, 1024 * 1024); + this.pruneStateLoaded = true; + for (const candidate of manifest.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + await this.durability.afterPruneManifestPublished?.(manifest.token); + manifestState = { status: "healthy", value: manifest }; + } else { + throw new CreateImagesRunJournalLoadError( + manifestState.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + + return this.resumeTerminalPrune(manifest); + }); + } + + async planDegradedRunDiscard(runId: string): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) return { status: "not-found" }; + const pending = await this.inspectDiscardManifest(); + if (pending.status !== "missing") { + throw new CreateImagesRunJournalLoadError( + pending.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + return this.buildDegradedRunDiscardPlan(runId); + }); + } + + private async resumeDegradedRunDiscard( + manifest: DegradedRunDiscardManifestV1, + ): Promise { + this.pruneTombstones.add(manifest.runId); + this.evictRunCaches(manifest.runId); + const discardedRoot = path.join(this.root(), DISCARDED_RUNS_DIRECTORY); + const discardedBatch = path.join(discardedRoot, manifest.authorizationToken); + const source = this.runDirectory(manifest.runId); + const destination = path.join(discardedBatch, manifest.runId); + await this.ensureDirectory(discardedRoot); + await this.ensureDirectory(discardedBatch); + const index = await this.indexed(); + const [sourceInfo, destinationInfo] = await Promise.all([ + fs.lstat(source).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + fs.lstat(destination).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + ]); + if (sourceInfo && destinationInfo) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + if (sourceInfo) { + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + const verified = await this.buildDegradedRunDiscardPlan(manifest.runId); + if ( + verified.status !== "ready" || + verified.plan.authorizationToken !== manifest.authorizationToken || + verified.plan.recordFingerprint !== manifest.recordFingerprint || + verified.plan.reason !== manifest.reason || + verified.plan.association !== manifest.association || + verified.plan.workflowId !== manifest.workflowId || + verified.plan.expectedCurrentJournalRevision !== manifest.expectedCurrentJournalRevision || + verified.plan.expectedLastKnownGoodJournalRevision !== + manifest.expectedLastKnownGoodJournalRevision + ) { + throw new CreateImagesRunJournalRevisionConflictError( + manifest.runId, + manifest.expectedCurrentJournalRevision ?? null, + verified.status === "ready" + ? (verified.plan.expectedCurrentJournalRevision ?? null) + : null, + ); + } + await fs.rename(source, destination); + await this.syncDirectory(this.runsPath()); + await this.syncDirectory(discardedBatch); + await this.durability.afterDegradedRunRetired?.(manifest.runId); + } else if (destinationInfo) { + if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", destination); + } + } else if ( + index.entries.some((entry) => entry.runId === manifest.runId) || + index.degraded.some((entry) => entry.runId === manifest.runId) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", source); + } + + if ( + index.entries.some((entry) => entry.runId === manifest.runId) || + index.degraded.some((entry) => entry.runId === manifest.runId) + ) { + try { + await this.publishIndex( + index.entries.filter((entry) => entry.runId !== manifest.runId), + index.revision + 1, + index.degraded.filter((entry) => entry.runId !== manifest.runId), + ); + } catch (error) { + this.markIndexDirty(); + throw error; + } + } + await fs.rm(discardedBatch, { recursive: true, force: true }); + await this.syncDirectory(discardedRoot); + await this.durability.afterDiscardedRunDeleted?.(manifest.authorizationToken); + await this.removeDurably(this.discardPendingPath()); + this.inventoryCache = undefined; + this.evictRunCaches(manifest.runId); + this.pruneTombstones.delete(manifest.runId); + return { + runId: manifest.runId, + ...(manifest.workflowId ? { workflowId: manifest.workflowId } : {}), + }; + } + + async discardDegradedRun( + input: CreateImagesDegradedRunDiscardRequest, + ): Promise { + validateRunId(input.runId); + if (!/^[a-f0-9]{64}$/u.test(input.authorizationToken)) { + throw new Error("Invalid Create Images degraded-run discard authorization."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + let pending = await this.inspectDiscardManifest(); + if (this.pruneTombstones.has(input.runId) && pending.status === "missing") { + return { status: "not-found" }; + } + let manifest: DegradedRunDiscardManifestV1; + if (pending.status === "healthy") { + manifest = pending.value; + if ( + manifest.runId !== input.runId || + manifest.authorizationToken !== input.authorizationToken || + manifest.expectedCurrentJournalRevision !== input.expectedCurrentJournalRevision || + manifest.expectedLastKnownGoodJournalRevision !== + input.expectedLastKnownGoodJournalRevision + ) { + return { status: "conflict" }; + } + } else if (pending.status === "missing") { + const planned = await this.buildDegradedRunDiscardPlan(input.runId); + if (planned.status !== "ready") return planned; + if ( + planned.plan.authorizationToken !== input.authorizationToken || + planned.plan.expectedCurrentJournalRevision !== input.expectedCurrentJournalRevision || + planned.plan.expectedLastKnownGoodJournalRevision !== + input.expectedLastKnownGoodJournalRevision + ) { + return { status: "conflict" }; + } + manifest = { ...planned.plan, createdAt: new Date().toISOString() }; + await this.writeAtomic(this.discardPendingPath(), manifest, 64 * 1024); + this.discardStateLoaded = true; + this.pruneTombstones.add(manifest.runId); + this.evictRunCaches(manifest.runId); + await this.durability.afterDiscardManifestPublished?.(manifest.authorizationToken); + pending = { status: "healthy", value: manifest }; + } else { + throw new CreateImagesRunJournalLoadError( + pending.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + return { + status: "discarded", + result: await this.resumeDegradedRunDiscard(manifest), + }; + }); + } + + async recoverFromLastKnownGood( + runId: string, + expectedJournalRevision: number, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", this.paths(runId).current); + } + const state = await this.inspected(runId); + if (state.pending.status !== "missing" || state.lastKnownGood.status !== "healthy") { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + if (state.lastKnownGood.value.journalRevision !== expectedJournalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + runId, + expectedJournalRevision, + state.lastKnownGood.value.journalRevision, + ); + } + if (state.current.status === "unsafe") { + throw new CreateImagesRunJournalLoadError("unsafe", state.paths.current); + } + if ( + state.current.status === "healthy" && + identical(state.current.value, state.lastKnownGood.value) + ) { + return state.current.value; + } + await this.assertWithinLimits( + runId, + new Map([[state.paths.current, state.lastKnownGood.value]]), + ); + await this.replaceCheckpoint( + state.paths.current, + state.paths.currentEvents, + state.lastKnownGood.value, + ); + await this.refreshInventoryRun(runId); + await this.updateIndexEntry(state.lastKnownGood.value); + return state.lastKnownGood.value; + }); + } + + async recoverLastKnownGoodFromCurrent( + runId: string, + expectedJournalRevision: number, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", this.paths(runId).lastKnownGood); + } + const state = await this.inspected(runId); + if (state.pending.status !== "missing" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.lastKnownGood); + } + if (state.current.value.journalRevision !== expectedJournalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + runId, + expectedJournalRevision, + state.current.value.journalRevision, + ); + } + if (state.lastKnownGood.status === "unsafe") { + throw new CreateImagesRunJournalLoadError("unsafe", state.paths.lastKnownGood); + } + if ( + state.lastKnownGood.status === "healthy" && + identical(state.current.value, state.lastKnownGood.value) + ) { + return state.current.value; + } + await this.assertWithinLimits( + runId, + new Map([[state.paths.lastKnownGood, state.current.value]]), + ); + await this.replaceCheckpoint( + state.paths.lastKnownGood, + state.paths.lastKnownGoodEvents, + state.current.value, + ); + await this.refreshInventoryRun(runId); + await this.updateIndexEntry(state.current.value); + return state.current.value; + }); + } + + async healthPage(cursor?: string, limit = 100): Promise { + if (cursor !== undefined) validateRunId(cursor); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_HEALTH_PAGE_SIZE) { + throw new Error("Invalid Create Images run health page size."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const { runIds } = await this.inventory(); + const start = cursor === undefined ? 0 : runIds.findIndex((runId) => runId > cursor); + if (start < 0) return { records: [] }; + const selected = runIds.slice(start, start + limit); + const records: CreateImagesRunJournalHealth[] = []; + for (const runId of selected) { + await this.reconcile(runId); + records.push( + this.pruneTombstones.has(runId) + ? { status: "missing", runId } + : this.healthOf(runId, await this.inspected(runId)), + ); + } + const last = selected[selected.length - 1]; + return { + records, + ...(last && start + selected.length < runIds.length ? { nextCursor: last } : {}), + }; + }); + } + + async recoveryCandidates(limit = 100): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_HEALTH_PAGE_SIZE) { + throw new Error("Invalid Create Images run recovery candidate limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries + .filter( + ( + entry, + ): entry is RunIndexEntryV1 & { + recoveryReason: CreateImagesRunRecoveryReason; + } => + entry.health === "recovery-required" && + entry.recoveryReason !== undefined && + !this.pruneTombstones.has(entry.runId), + ) + .slice(0, limit) + .map((entry) => this.recoveryCandidateFromEntry(entry)); + }); + } + + private recoveryCandidateFromEntry( + entry: RunIndexEntryV1 & { recoveryReason: CreateImagesRunRecoveryReason }, + ): CreateImagesRunRecoveryCandidate { + return { + runId: entry.runId, + workflowId: entry.workflowId, + workflowRevision: entry.workflowRevision, + reason: entry.recoveryReason, + canRecover: entry.canRecover ?? false, + ...(entry.expectedJournalRevision !== undefined + ? { expectedJournalRevision: entry.expectedJournalRevision } + : {}), + }; + } + + private degradedCandidateFromEntry( + entry: RunIndexEntryV1, + ): CreateImagesRunDegradedCandidate | undefined { + if (entry.health === "recovery-required" && entry.recoveryReason) { + return { + status: "recovery-required", + ...this.recoveryCandidateFromEntry({ + ...entry, + recoveryReason: entry.recoveryReason, + }), + }; + } + if (entry.health === "unsafe" && entry.unsafeReason) { + return { + status: "unsafe", + runId: entry.runId, + workflowId: entry.workflowId, + workflowRevision: entry.workflowRevision, + reason: entry.unsafeReason, + }; + } + return undefined; + } + + /** + * Bounded, path-free diagnostics for every degraded run known to the + * derived index. Workflow-less records are deliberately not authorizable. + */ + async degradedRuns( + limit = this.limits.maxRunCount, + ): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.limits.maxRunCount) { + throw new Error("Invalid Create Images degraded run limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const index = await this.indexed(); + const associated = index.entries + .map((entry) => this.degradedCandidateFromEntry(entry)) + .filter( + (candidate): candidate is CreateImagesRunDegradedCandidate => + candidate !== undefined && !this.pruneTombstones.has(candidate.runId), + ); + const unassociated = index.degraded + .filter((entry) => !this.pruneTombstones.has(entry.runId)) + .map( + (entry): CreateImagesRunStorageDegradedRecord => ({ + status: entry.status, + runId: entry.runId, + reason: + entry.status === "unsafe" + ? (entry.unsafeReason as CreateImagesRunUnsafeReason) + : (entry.recoveryReason as CreateImagesRunRecoveryReason), + canRecover: false, + }), + ); + return [...associated, ...unassociated] + .sort((left, right) => left.runId.localeCompare(right.runId)) + .slice(0, limit); + }); + } + + async degradedRunCount(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const index = await this.indexed(); + return ( + index.entries.filter( + (entry) => entry.health !== "healthy" && !this.pruneTombstones.has(entry.runId), + ).length + index.degraded.filter((entry) => !this.pruneTombstones.has(entry.runId)).length + ); + }); + } + + async refreshWorkflowDegradedMetadata( + workflowId: string, + runIds: readonly string[], + ): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + if ( + runIds.length > MAX_PRUNE_BATCH_SIZE || + new Set(runIds).size !== runIds.length || + runIds.some((runId) => !RUN_ID_PATTERN.test(runId)) + ) { + throw new Error("Invalid bounded Create Images recovery refresh."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const index = await this.indexed(); + const refreshed = new Map(index.entries.map((entry) => [entry.runId, entry])); + const degraded = new Map(index.degraded.map((entry) => [entry.runId, entry])); + for (const runId of runIds) { + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) { + refreshed.delete(runId); + degraded.delete(runId); + continue; + } + const state = await this.inspected(runId); + const entry = this.entryForState(runId, state, refreshed.get(runId)); + if (entry) { + refreshed.set(runId, entry); + degraded.delete(runId); + } else { + refreshed.delete(runId); + const unassociated = this.unassociatedDegradedForState(runId, state); + if (unassociated) degraded.set(runId, unassociated); + } + } + const entries = [...refreshed.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + const degradedEntries = [...degraded.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + if ( + JSON.stringify(entries) !== JSON.stringify(index.entries) || + JSON.stringify(degradedEntries) !== JSON.stringify(index.degraded) + ) { + await this.publishIndex(entries, index.revision + 1, degradedEntries); + } + return entries + .filter( + (entry) => entry.workflowId === workflowId && !this.pruneTombstones.has(entry.runId), + ) + .map((entry) => this.degradedCandidateFromEntry(entry)) + .filter( + (candidate): candidate is CreateImagesRunDegradedCandidate => candidate !== undefined, + ); + }); + } + + async refreshWorkflowRecoveryMetadata( + workflowId: string, + runIds: readonly string[], + ): Promise { + const degraded = await this.refreshWorkflowDegradedMetadata(workflowId, runIds); + return degraded + .filter( + ( + candidate, + ): candidate is Extract< + CreateImagesRunDegradedCandidate, + { status: "recovery-required" } + > => candidate.status === "recovery-required", + ) + .map(({ status: _status, ...candidate }) => candidate); + } + + async workflowDegradedCandidates( + workflowId: string, + limit = this.limits.maxRunCount, + ): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.limits.maxRunCount) { + throw new Error("Invalid Create Images degraded run limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries + .filter( + (entry) => + entry.workflowId === workflowId && + entry.health !== "healthy" && + !this.pruneTombstones.has(entry.runId), + ) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.runId.localeCompare(right.runId), + ) + .map((entry) => this.degradedCandidateFromEntry(entry)) + .filter( + (candidate): candidate is CreateImagesRunDegradedCandidate => candidate !== undefined, + ) + .slice(0, limit); + }); + } + + /** + * Revalidates start authority from the bounded on-disk run inventory. + * The derived index is used only as an association hint for records whose + * two authoritative checkpoints are damaged; its status bits never admit a + * new run. + */ + async auditWorkflowAdmission(workflowId: string): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const prior = await this.loadIndex(true); + const { runIds } = await this.inventory(true); + const inventoryRunIds = new Set(runIds); + const priorEntries = new Map(prior?.entries.map((entry) => [entry.runId, entry]) ?? []); + for (const entry of prior?.entries ?? []) { + if ( + entry.workflowId === workflowId && + !inventoryRunIds.has(entry.runId) && + !this.pruneTombstones.has(entry.runId) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", this.runDirectory(entry.runId)); + } + } + if ( + (prior?.degraded ?? []).some( + (entry) => !inventoryRunIds.has(entry.runId) && !this.pruneTombstones.has(entry.runId), + ) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", this.runsPath()); + } + + const audit: CreateImagesWorkflowAdmissionAudit = { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }; + const entries: RunIndexEntryV1[] = []; + const degraded: RunUnassociatedDegradedEntryV1[] = []; + for (const runId of runIds) { + if (this.pruneTombstones.has(runId)) continue; + await this.reconcile(runId); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status === "missing") { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + const entry = this.entryForState(runId, state, priorEntries.get(runId)); + if (entry) { + entries.push(entry); + if (entry.health !== "healthy") { + // A derived-index association is useful for recovery UI, but it + // cannot scope admission when neither checkpoint still proves + // identity. Unassociated damage therefore blocks every workflow. + const trustedWorkflowId = "workflowId" in health ? health.workflowId : undefined; + if (trustedWorkflowId === undefined || trustedWorkflowId === workflowId) { + audit.hasDegradedAuthority = true; + } + } else if (entry.workflowId === workflowId) { + audit.hasNonterminalRun ||= !entry.terminal; + audit.hasUnresolvedAmbiguity ||= entry.unresolvedAmbiguity; + } + } else { + const unassociated = this.unassociatedDegradedForState(runId, state); + if (!unassociated) { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + degraded.push(unassociated); + audit.hasDegradedAuthority = true; + } + if (health.status === "healthy" && state.current.status === "healthy") { + await this.cacheHealthyJournal(runId, state.current.value); + } else { + this.evictJournal(runId); + } + } + + entries.sort((left, right) => left.runId.localeCompare(right.runId)); + degraded.sort((left, right) => left.runId.localeCompare(right.runId)); + if ( + !prior || + JSON.stringify(entries) !== JSON.stringify(prior.entries) || + JSON.stringify(degraded) !== JSON.stringify(prior.degraded) + ) { + try { + await this.publishIndex(entries, prior ? prior.revision + 1 : undefined, degraded); + } catch (error) { + this.markIndexDirty(); + throw error; + } + } else { + this.indexDirty = false; + } + return audit; + }); + } + + async hasUnresolvedAmbiguity(workflowId: string): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries.some( + (entry) => + entry.workflowId === workflowId && + entry.health === "healthy" && + entry.unresolvedAmbiguity && + !this.pruneTombstones.has(entry.runId), + ); + }); + } + + async hasNonterminalRun(workflowId: string): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries.some( + (entry) => + entry.workflowId === workflowId && + entry.health === "healthy" && + !entry.terminal && + !this.pruneTombstones.has(entry.runId), + ); + }); + } + + async hasUnassociatedDegradedRuns(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).degraded.some( + (entry) => !this.pruneTombstones.has(entry.runId), + ); + }); + } + + async workflowRecoveryCandidates( + workflowId: string, + limit = 100, + ): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_HEALTH_PAGE_SIZE) { + throw new Error("Invalid Create Images run recovery candidate limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries + .filter( + ( + entry, + ): entry is RunIndexEntryV1 & { + recoveryReason: CreateImagesRunRecoveryReason; + } => + entry.workflowId === workflowId && + entry.health === "recovery-required" && + entry.recoveryReason !== undefined && + !this.pruneTombstones.has(entry.runId), + ) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.runId.localeCompare(right.runId), + ) + .slice(0, limit) + .map((entry) => this.recoveryCandidateFromEntry(entry)); + }); + } + + async terminalRetentionCandidates( + query: CreateImagesTerminalRetentionQuery, + ): Promise { + if ( + !Number.isSafeInteger(query.keepLatest) || + query.keepLatest < 0 || + query.keepLatest > 1_000 + ) { + throw new Error("Invalid Create Images terminal retention keep count."); + } + const limit = query.limit ?? MAX_PRUNE_BATCH_SIZE; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PRUNE_BATCH_SIZE) { + throw new Error("Invalid Create Images terminal retention candidate limit."); + } + if (query.workflowId !== undefined && !RUN_ID_PATTERN.test(query.workflowId)) { + throw new Error("Invalid Create Images workflow ID."); + } + if (query.olderThan !== undefined && !Number.isFinite(Date.parse(query.olderThan))) { + throw new Error("Invalid Create Images terminal retention cutoff."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const entries = (await this.indexed()).entries + .filter( + (entry) => + entry.health === "healthy" && + entry.terminal && + !entry.unresolvedAmbiguity && + !this.pruneTombstones.has(entry.runId) && + (query.workflowId === undefined || entry.workflowId === query.workflowId), + ) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || right.runId.localeCompare(left.runId), + ); + const retainedByWorkflow = new Map(); + const candidates: CreateImagesTerminalRetentionCandidate[] = []; + const plannedAssetIds = new Set(); + for (const entry of entries) { + const retentionKey = query.workflowId === undefined ? "__global__" : entry.workflowId; + const retained = retainedByWorkflow.get(retentionKey) ?? 0; + if (retained < query.keepLatest) { + retainedByWorkflow.set(retentionKey, retained + 1); + continue; + } + if (query.olderThan !== undefined && entry.updatedAt >= query.olderThan) continue; + const state = await this.inspected(entry.runId); + const health = this.healthOf(entry.runId, state); + if (health.status !== "healthy" || state.current.status !== "healthy") { + await this.updateIndexState(entry.runId, state); + continue; + } + const assetIds = referencedAssetIds(state.current.value); + const nextAssetIds = new Set(plannedAssetIds); + for (const assetId of assetIds) nextAssetIds.add(assetId); + // The crash-resumable prune manifest is intentionally bounded. A + // high-output history is retired in multiple authorized batches. + if (nextAssetIds.size > 10_000) { + if (candidates.length === 0) { + throw new Error("A Create Images run exceeds the bounded prune reference limit."); + } + break; + } + plannedAssetIds.clear(); + for (const assetId of nextAssetIds) plannedAssetIds.add(assetId); + candidates.push({ + runId: entry.runId, + workflowId: entry.workflowId, + journalRevision: entry.journalRevision, + updatedAt: entry.updatedAt, + assetIds, + }); + if (candidates.length === limit) break; + } + return candidates; + }); + } + + cacheStats(): CreateImagesRunCacheStats { + return { + journalCount: this.journalCache.size, + journalBytes: this.journalCacheBytes, + tailCount: this.eventLogTailCache.size, + tailBytes: this.tailCacheBytes, + }; + } +} + +// Keep the shared contract's version visible to main-only feature-surface tests. +export const CREATE_IMAGES_RUN_STORE_SCHEMA_VERSION = CREATE_IMAGES_RUN_JOURNAL_VERSION; diff --git a/main/services/create-images/run-publication-binding-core.ts b/main/services/create-images/run-publication-binding-core.ts new file mode 100644 index 00000000..0b6754b8 --- /dev/null +++ b/main/services/create-images/run-publication-binding-core.ts @@ -0,0 +1,16 @@ +export interface CreateImagesRunOwnerSnapshot { + status: string; + activeRun?: { runId: string }; +} + +/** + * A transient/busy snapshot proves nothing about run liveness. Renderer + * disconnect ownership may be released only by an authoritative ready list + * that proves this exact run is no longer active. + */ +export function shouldReleaseCreateImagesRunOwner( + runId: string, + snapshot: CreateImagesRunOwnerSnapshot, +): boolean { + return snapshot.status === "ready" && snapshot.activeRun?.runId !== runId; +} diff --git a/main/services/create-images/run-service.test.ts b/main/services/create-images/run-service.test.ts new file mode 100644 index 00000000..c27fd00c --- /dev/null +++ b/main/services/create-images/run-service.test.ts @@ -0,0 +1,3001 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import type { AuthResult } from "@earendil-works/pi-ai"; +import { createWorkflowCoordinatorPlan } from "./scheduler-core.js"; +import { + projectCreateImagesRun, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import type { + AssetIngestRequest, + AssetIngestResult, + AssetMetadataDto, + ContentAddressedAssetStore, +} from "./asset-store-core.js"; +import { + DeterministicMockImageProvider, + type MockImageProviderScript, +} from "./mock-image-provider-core.js"; +import { + CreateImagesRunJournalStore, + type CreateImagesRunJournalDurability, +} from "./run-journal-store.js"; +import { + CreateImagesRunService, + CREATE_IMAGES_MAX_ACTIVE_RUNS, + evaluateCreateImagesWorkflowDeletion, + type CreateImagesRunReferenceAuthority, + type CreateImagesRunReferenceReservation, + type CreateImagesAnnotationRasterizer, +} from "./run-service.js"; +import { WorkflowManifestStore } from "./workflow-manifest-store.js"; +import { GeminiImageProvider } from "./providers/gemini-image-provider-core.js"; +import type { CreateImagesWorkspaceState } from "./workspace-store.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const DURABLE_ASSET_ID = "a".repeat(64); + +test("workflow deletion requires an authoritative empty run lifecycle", () => { + const unavailableMessage = ( + decision: ReturnType, + ): string => { + assert.equal(decision.status, "unavailable"); + return decision.status === "unavailable" ? decision.message : ""; + }; + const empty = { + status: "ready" as const, + authoritative: true as const, + history: [], + recoveries: [], + }; + assert.deepEqual(evaluateCreateImagesWorkflowDeletion(empty), { status: "allowed" }); + assert.deepEqual(evaluateCreateImagesWorkflowDeletion({ status: "not-found" }), { + status: "not-found", + }); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ status: "unavailable", message: "busy" }), + ), + /could not be verified safely/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + activeRun: {} as never, + }), + ), + /Stop the active image run/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + latestTerminalRun: {} as never, + }), + ), + /retained run history/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + history: [{} as never], + }), + ), + /retained run history/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + recoveries: [ + { + status: "unsafe", + workflowId: "workflow-1", + runId: "run-1", + reason: "unsafe-storage", + }, + ], + }), + ), + /retained run recovery records/u, + ); +}); + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-run-service-")); + t.after(() => fs.rm(root, { force: true, recursive: true })); + return root; +} + +function workflow(outputCount: 1 | 2 | 3 | 4 = 1): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Run service", + revision: 1, + createdAt: NOW, + updatedAt: NOW, + nodes: [ + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "A tiny durable image" }, + }, + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: outputCount, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-prompt", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-output", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ], + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function pausedWorkflow(): WorkflowDocumentV1 { + const document = workflow(); + return { + ...document, + schemaVersion: 5, + edges: document.edges.map((edge) => + edge.id === "edge-prompt" ? { ...edge, breakpoint: true } : edge, + ), + }; +} + +function promptListWorkflow(source = "red car\nblue car\ngreen car"): WorkflowDocumentV1 { + const document = workflow(); + return { + ...document, + schemaVersion: 5, + nodes: document.nodes.map((node) => + node.id === "prompt-1" + ? { + id: node.id, + type: "prompt-list" as const, + position: node.position, + data: { source, format: "lines" as const }, + } + : node, + ), + edges: document.edges.map((edge) => + edge.id === "edge-prompt" ? { ...edge, sourcePort: "items" } : edge, + ), + }; +} + +class FakeAssets { + readonly available = new Map(); + readonly bytesById = new Map(); + readonly publicationOrder: string[] = []; + readonly runReferences = new Map(); + failIngest = false; + ingestGate?: Promise; + ingestStarted = false; + + async ingest( + source: AsyncIterable, + request: AssetIngestRequest, + ): Promise { + if (this.failIngest) throw new Error("simulated durable asset publication failure"); + this.ingestStarted = true; + await this.ingestGate; + const chunks: Uint8Array[] = []; + let byteLength = 0; + for await (const chunk of source) { + chunks.push(chunk); + byteLength += chunk.byteLength; + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const assetId = createHash("sha256").update(bytes).digest("hex"); + const asset: AssetMetadataDto = { + assetId, + mediaType: "image/png", + byteLength, + width: Number(request.generationMetadata?.width ?? 1), + height: Number(request.generationMetadata?.height ?? 1), + createdAt: NOW, + origin: request.origin, + ...(request.generationMetadata + ? { generationMetadata: structuredClone(request.generationMetadata) } + : {}), + referenceCount: 0, + thumbnailSizes: [], + }; + this.available.set(assetId, asset); + this.bytesById.set(assetId, bytes); + this.publicationOrder.push(assetId); + return { + asset, + deduplicated: false, + quotaWarning: false, + totalAssetBytes: [...this.available.values()].reduce( + (total, candidate) => total + candidate.byteLength, + 0, + ), + }; + } + + async getAvailable(assetId: string): Promise { + return this.available.get(assetId); + } + + async withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: "image/png" | "image/jpeg"; + }) => Promise, + ): Promise { + const asset = this.available.get(assetId); + if (!asset) throw new Error("asset unavailable"); + return callback({ + filePath: `/main-only/${assetId}.png`, + asset, + byteLength: asset.byteLength, + mediaType: asset.mediaType, + }); + } + + async acquirePreviewLease(assetId: string, ownerId: string) { + if (!this.available.has(assetId)) throw new Error("asset unavailable"); + return { token: `${ownerId}-${assetId.slice(0, 16)}`, assetId, expiresAt: Date.now() + 60_000 }; + } + + async readPreview(token: string) { + const assetId = [...this.available.keys()].find((candidate) => + token.endsWith(candidate.slice(0, 16)), + ); + const asset = assetId ? this.available.get(assetId) : undefined; + const bytes = assetId ? this.bytesById.get(assetId) : undefined; + if (!asset || !bytes) throw new Error("preview unavailable"); + return { asset, bytes: bytes.slice() }; + } + + async releasePreviewLease(): Promise { + return true; + } + + async list(): Promise { + return [...this.available.values()]; + } + + async replaceReferences( + owner: { kind: "workflow" | "run"; id: string }, + assetIds: readonly string[], + ): Promise { + if (owner.kind === "run") this.runReferences.set(owner.id, [...assetIds]); + } +} + +class FakeReferences implements CreateImagesRunReferenceAuthority { + readonly reservations: CreateImagesRunReferenceReservation[] = []; + readonly committed = new Map>(); + readonly order: string[] = []; + reconcileCount = 0; + onReserve?: (reservation: CreateImagesRunReferenceReservation) => void | Promise; + + async reserveRun( + runId: string, + assetIds: readonly string[], + ): Promise { + const reservation = { runId, next: new Set(assetIds), active: true }; + this.reservations.push(reservation); + this.order.push(`reserve:${runId}`); + await this.onReserve?.(reservation); + return reservation; + } + + async commitRun(reservation: CreateImagesRunReferenceReservation): Promise { + reservation.active = false; + this.committed.set(reservation.runId, new Set(reservation.next)); + this.order.push(`commit:${reservation.runId}`); + } + + async releaseRunReservations(runId: string): Promise { + for (const reservation of this.reservations) { + if (reservation.runId === runId) reservation.active = false; + } + this.order.push(`release:${runId}`); + } + + async reconcileRuns(store: CreateImagesRunJournalStore): Promise { + this.reconcileCount += 1; + const inventory = await store.referenceInventory(); + this.committed.clear(); + for (const record of inventory.records) { + this.committed.set(record.runId, new Set(record.assetIds)); + } + for (const reservation of this.reservations) reservation.active = false; + return inventory.complete; + } + + isRunAssetReferenced(runId: string, assetId: string): boolean { + return ( + (this.committed.get(runId)?.has(assetId) ?? false) || + this.reservations.some( + (reservation) => + reservation.active && reservation.runId === runId && reservation.next.has(assetId), + ) + ); + } +} + +interface Harness { + root: string; + workflows: WorkflowManifestStore; + assets: FakeAssets; + references: FakeReferences; + journals: CreateImagesRunJournalStore; + service: CreateImagesRunService; +} + +async function harness( + t: TestContext, + options: { + document?: WorkflowDocumentV1; + script?: MockImageProviderScript; + createRunId?: () => string; + onScript?: () => void; + now?: () => number; + shutdownTimeoutMs?: number; + journalDurability?: CreateImagesRunJournalDurability; + resolveGeminiAuth?: () => Promise; + createGeminiProvider?: () => GeminiImageProvider; + workspaceStatus?: () => Promise<{ configured: boolean; state: CreateImagesWorkspaceState }>; + workspaceRequired?: boolean; + annotationRasterizer?: CreateImagesAnnotationRasterizer; + } = {}, +): Promise { + const root = await temporaryRoot(t); + const workflows = new WorkflowManifestStore(() => root); + await workflows.create(options.document ?? workflow()); + const assets = new FakeAssets(); + const references = new FakeReferences(); + const journals = new CreateImagesRunJournalStore(() => root, options.journalDurability); + let now = Date.parse(NOW); + const service = new CreateImagesRunService({ + rootResolver: () => root, + workflows, + assets: assets as unknown as ContentAddressedAssetStore, + references, + journalStore: journals, + resolveGeminiAuth: options.resolveGeminiAuth, + createGeminiProvider: options.createGeminiProvider, + workspaceStatus: options.workspaceStatus, + workspaceRequired: options.workspaceRequired, + annotationRasterizer: options.annotationRasterizer, + now: options.now ?? (() => now++), + createRunId: options.createRunId ?? (() => "run-1"), + shutdownTimeoutMs: options.shutdownTimeoutMs, + mockScript: (nodeIds) => { + options.onScript?.(); + return ( + options.script ?? { + nodes: Object.fromEntries( + nodeIds.map((nodeId) => [ + nodeId, + [ + { + outcome: "success" as const, + delayMs: 0, + width: 8, + height: 8, + seed: 7, + }, + ], + ]), + ), + } + ); + }, + }); + return { root, workflows, assets, references, journals, service }; +} + +test("does not start provider work when a configured workspace fails the fast preflight", async (t) => { + let providerStarted = false; + const context = await harness(t, { + workspaceStatus: async () => ({ configured: true, state: "drifted" }), + onScript: () => { + providerStarted = true; + }, + }); + const result = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(result.status, "unavailable"); + assert.match(result.message, /workspace is not ready/u); + assert.equal(providerStarted, false); +}); + +test("does not start provider work before the required first-open workspace is configured", async (t) => { + let providerStarted = false; + const context = await harness(t, { + workspaceRequired: true, + workspaceStatus: async () => ({ configured: false, state: "ready" }), + onScript: () => { + providerStarted = true; + }, + }); + const result = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(result.status, "unavailable"); + assert.match(result.message, /workspace is not ready/u); + assert.equal(providerStarted, false); + assert.equal(await context.journals.get("run-1"), undefined); +}); + +async function waitForJournal( + store: CreateImagesRunJournalStore, + runId: string, + predicate: (journal: CreateImagesRunJournalV1) => boolean, +): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + const journal = await store.get(runId); + if (journal && predicate(journal)) return journal; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error(`Timed out waiting for ${runId}.`); +} + +async function waitForTerminal( + store: CreateImagesRunJournalStore, + runId: string, +): Promise { + return waitForJournal( + store, + runId, + (journal) => projectCreateImagesRun(journal).terminal !== undefined, + ); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error("Timed out waiting for run-service state."); +} + +async function waitForAsync(predicate: () => Promise): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error("Timed out waiting for async run-service state."); +} + +async function seedRestartRun( + store: CreateImagesRunJournalStore, + input: { + runId: string; + providerJobId?: string; + durableOutputAssetIds?: string[]; + }, +): Promise { + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + let journal = await store.start( + { + runId: input.runId, + workflowSnapshot: plan.snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + const append = async (event: CreateImagesRunEventV1): Promise => { + journal = await store.append(journal.runId, journal.journalRevision, event); + }; + const base = () => ({ + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: NOW, + }); + await append({ ...base(), type: "run-started" }); + await append({ ...base(), type: "node-started", nodeId: "prompt-1" }); + await append({ + ...base(), + type: "node-output-published", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ + ...base(), + type: "node-succeeded", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ ...base(), type: "node-started", nodeId: "generate-1" }); + await append({ + ...base(), + type: "node-submission-prepared", + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-restart-node-0001", + providerId: "local-mock", + modelId: "deterministic-v1", + }); + if (input.providerJobId) { + await append({ + ...base(), + type: "node-submission-accepted", + nodeId: "generate-1", + attempt: 1, + providerJobId: input.providerJobId, + }); + } + if (input.durableOutputAssetIds) { + await append({ + ...base(), + type: "node-output-published", + nodeId: "generate-1", + outputAssetIds: [...input.durableOutputAssetIds], + }); + } + return journal; +} + +test("successful runs publish assets before journal success and commit durable references", async (t) => { + const context = await harness(t); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "succeeded"); + const generated = projection.nodes["generate-1"]; + assert.equal(generated?.outputAssetIds.length, 1); + const assetId = generated?.outputAssetIds[0]; + assert.ok(assetId); + assert.equal(context.assets.available.has(assetId), true); + assert.equal(context.references.isRunAssetReferenced(journal.runId, assetId), true); + assert.deepEqual(context.assets.runReferences.get(journal.runId), [assetId]); + const acceptedIndex = journal.events.findIndex( + (event) => event.type === "node-submission-accepted", + ); + const publishedIndex = journal.events.findIndex( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ); + const succeededIndex = journal.events.findIndex( + (event) => event.type === "node-succeeded" && event.nodeId === "generate-1", + ); + assert.ok( + acceptedIndex >= 0 && publishedIndex > acceptedIndex && succeededIndex > publishedIndex, + ); + assert.ok(context.references.reconcileCount >= 2); + const recent = await context.service.listRecentOutputs(50); + assert.deepEqual(recent, [ + { + assetId, + runId: journal.runId, + workflowId: "workflow-1", + nodeId: "generate-1", + prompt: "A tiny durable image", + modelLabel: "gemini-3.1-flash-image", + createdAt: journal.updatedAt, + width: 8, + height: 8, + mediaType: "image/png", + }, + ]); + await assert.rejects(() => context.service.listRecentOutputs(51), /recent output limit/u); +}); + +test("paused runs survive service restart and resume the same run without repeating upstream work", async (t) => { + const context = await harness(t, { + document: pausedWorkflow(), + createRunId: () => "paused-restart-run", + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const paused = await waitForJournal( + context.journals, + started.run.runId, + (journal) => projectCreateImagesRun(journal).status === "paused", + ); + const pausedProjection = projectCreateImagesRun(paused); + assert.equal(pausedProjection.nodes["prompt-1"]?.status, "succeeded"); + assert.equal(pausedProjection.nodes["generate-1"]?.status, "queued"); + + const restarted = new CreateImagesRunService({ + rootResolver: () => context.root, + workflows: context.workflows, + assets: context.assets as unknown as ContentAddressedAssetStore, + references: context.references, + journalStore: context.journals, + createRunId: () => "unused-after-restart", + }); + await restarted.initialize(); + const resumed = await restarted.resume({ + workflowId: "workflow-1", + runId: paused.runId, + expectedJournalRevision: paused.journalRevision, + }); + assert.equal(resumed.status, "resumed"); + const terminal = await waitForTerminal(context.journals, paused.runId); + const projection = projectCreateImagesRun(terminal); + assert.equal(projection.status, "succeeded"); + assert.equal(terminal.runId, paused.runId); + assert.equal( + terminal.events.filter( + (event) => event.type === "node-started" && event.nodeId === "prompt-1", + ).length, + 1, + ); + assert.equal(terminal.events.filter((event) => event.type === "run-started").length, 1); + assert.equal(terminal.events.filter((event) => event.type === "run-resumed").length, 1); +}); + +test("local Prompt List batches preserve order and journal every item without provider cost", async (t) => { + const context = await harness(t, { + document: promptListWorkflow(), + createRunId: () => "prompt-list-run", + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "succeeded"); + assert.equal(projection.nodes["generate-1"]?.outputAssetIds.length, 3); + const items = Object.values(projection.nodes["generate-1"]?.batchItems ?? {}).sort( + (left, right) => left.itemIndex - right.itemIndex, + ); + assert.deepEqual( + items.map((item) => ({ index: item.itemIndex, status: item.state, cost: item.cost })), + [ + { index: 0, status: "succeeded", cost: { kind: "actual", amountMicros: 0, currency: "USD" } }, + { index: 1, status: "succeeded", cost: { kind: "actual", amountMicros: 0, currency: "USD" } }, + { index: 2, status: "succeeded", cost: { kind: "actual", amountMicros: 0, currency: "USD" } }, + ], + ); + assert.deepEqual( + items.flatMap((item) => item.outputAssetIds), + projection.nodes["generate-1"]?.outputAssetIds, + ); + assert.equal(context.assets.publicationOrder.length, 3); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status === "ready") { + assert.equal(listed.history[0]?.costLabel, "$0.00 mock actual"); + } +}); + +test("stop durably journals cancellation before terminal node cancellation", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + delayMs: 60_000, + width: 8, + height: 8, + seed: 4, + lateCompletionAfterCancel: true, + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((event) => event.type === "node-submission-accepted"), + ); + const stopping = await context.service.stop("workflow-1", started.run.runId, "user"); + assert.equal(stopping.status, "stopping"); + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(journal).status, "cancelled"); + const cancellationIndex = journal.events.findIndex( + (event) => event.type === "run-cancel-requested", + ); + const cancelledNodeIndex = journal.events.findIndex((event) => event.type === "node-cancelled"); + assert.ok(cancellationIndex >= 0 && cancelledNodeIndex > cancellationIndex); + assert.equal( + journal.events.some( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ), + false, + ); +}); + +test("app-quit cancellation remains journal-monotonic when the wall clock rolls backward", async (t) => { + let now = Date.parse(NOW) + 120_000; + const context = await harness(t, { + now: () => now, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const beforeRollback = await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((event) => event.type === "node-submission-accepted"), + ); + now = Date.parse(NOW) - 120_000; + await context.service.stopAll("app-quit"); + const terminal = await waitForTerminal(context.journals, started.run.runId); + assert.ok(Date.parse(terminal.updatedAt) >= Date.parse(beforeRollback.updatedAt)); + assert.equal(projectCreateImagesRun(terminal).cancellation?.reason, "app-quit"); + let previous = Date.parse(terminal.createdAt); + for (const event of terminal.events) { + assert.ok(Date.parse(event.at) >= previous); + previous = Date.parse(event.at); + } +}); + +test("ambiguous submissions become explicit needs-attention terminal history", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 0, + durableRemoteJob: false, + error: "connection lost after submission", + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(projection.nodes["output-1"]?.status, "blocked"); + assert.equal(JSON.stringify(journal).includes("connection lost"), false); + assert.equal((await context.service.list("workflow-1")).status, "ready"); +}); + +test("a delayed durable cancellation cannot erase a prepared submission ambiguity", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 60_000, + durableRemoteJob: false, + }, + ], + }, + }, + }); + let releaseCancellation: () => void = () => undefined; + let markCancellationEntered: () => void = () => undefined; + const cancellationGate = new Promise((resolve) => { + releaseCancellation = resolve; + }); + const cancellationEntered = new Promise((resolve) => { + markCancellationEntered = resolve; + }); + const requestCancellation = context.journals.requestCancellation.bind(context.journals); + context.journals.requestCancellation = async (...args) => { + markCancellationEntered(); + await cancellationGate; + return requestCancellation(...args); + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((event) => event.type === "node-submission-prepared"), + ); + const stopping = context.service.stop("workflow-1", started.run.runId, "app-quit"); + await cancellationEntered; + const beforeDurableCancel = await context.journals.get(started.run.runId); + assert.equal(projectCreateImagesRun(beforeDurableCancel!).cancellation, undefined); + releaseCancellation(); + assert.equal((await stopping).status, "stopping"); + + const terminal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(terminal); + assert.equal(projection.cancellation?.reason, "app-quit"); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(projection.nodes["output-1"]?.status, "blocked"); + assert.equal( + terminal.events.some( + (event) => event.type === "node-cancelled" && event.nodeId === "generate-1", + ), + false, + ); +}); + +test("unresolved ambiguity blocks admission until a CAS-bound audit acknowledgement", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `ambiguity-${++runNumber}`, + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 0, + durableRemoteJob: false, + error: "connection lost after submission", + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const needsAttention = await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); + + assert.deepEqual( + await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: needsAttention.journalRevision + 1, + resolution: "acknowledge-unresolved-submission", + }), + { + status: "conflict", + expectedJournalRevision: needsAttention.journalRevision + 1, + currentJournalRevision: needsAttention.journalRevision, + }, + ); + const resolved = await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: needsAttention.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + assert.equal(resolved.status, "resolved"); + if (resolved.status !== "resolved") return; + assert.equal(resolved.run.status, "needs_attention"); + assert.equal(resolved.run.ambiguityResolution?.kind, "acknowledged-unresolved-submission"); + assert.equal( + resolved.authoritativeList.history[0]?.ambiguityResolution?.kind, + "acknowledged-unresolved-submission", + ); + const acknowledged = await context.journals.get(needsAttention.runId); + assert.equal( + acknowledged?.events.filter((candidate) => candidate.type === "run-ambiguity-acknowledged") + .length, + 1, + ); + const stale = await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: needsAttention.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + assert.equal(stale.status, "conflict"); + const already = await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: resolved.run.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + assert.equal(already.status, "already-resolved"); + + let journalReads = 0; + const originalGet = context.journals.get.bind(context.journals); + context.journals.get = async (runId) => { + journalReads += 1; + return originalGet(runId); + }; + const admitted = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + context.journals.get = originalGet; + assert.equal(admitted.status, "started"); + assert.equal(journalReads, 0); + if (admitted.status === "started") { + await waitForTerminal(context.journals, admitted.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + } +}); + +test("a failed terminal index publication keeps direct run admission closed", async (t) => { + let rejectIndex = false; + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `dirty-index-${++runNumber}`, + journalDurability: { + beforeIndexPublished: async () => { + if (rejectIndex) throw new Error("simulated terminal index failure"); + }, + }, + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 25, + durableRemoteJob: false, + error: "connection lost after submission", + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + rejectIndex = true; + const terminal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(terminal).status, "needs_attention"); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const blockedWhileDirty = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blockedWhileDirty.status, "unavailable"); + assert.equal(runNumber, 1); + + rejectIndex = false; + const blockedAfterRebuild = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blockedAfterRebuild.status, "unavailable"); + assert.equal(runNumber, 1); +}); + +test("same-process current and recovery corruption blocks a second workflow run", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `authority-run-${++runNumber}`, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const directory = path.join(context.root, "runs", started.run.runId); + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), "{broken-current", "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), "{broken-recovery", "utf8"), + ]); + + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); +}); + +test("same-process future index replacement blocks run allocation and executor launch", async (t) => { + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + createRunId: () => `index-identity-run-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + + const indexPath = path.join(context.root, "run-index.json"); + const replacementPath = path.join(context.root, "run-index.future-replacement.json"); + const futureBytes = '{"version":2,"revision":99,"entries":[],"degraded":[]}\n'; + await fs.writeFile(replacementPath, futureBytes, "utf8"); + await fs.rename(replacementPath, indexPath); + + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + assert.equal(await fs.readFile(indexPath, "utf8"), futureBytes); +}); + +for (const authoritativeFile of [ + "run.json", + "run.last-known-good.json", + "run.events.jsonl", + "run.last-known-good.events.jsonl", +] as const) { + test(`same-process ${authoritativeFile} tampering blocks executor admission`, async (t) => { + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + createRunId: () => `tamper-${authoritativeFile.replace(/\./gu, "-")}-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + assert.equal(providerConstructions, 1); + + const target = path.join(context.root, "runs", started.run.runId, authoritativeFile); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] === 0x5b ? 0x7b : bytes[0] ^ 1; + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + }); +} + +test("post-pending authority replacement aborts before provider execution", async (t) => { + let executorCalls = 0; + let tampered = false; + const originalExecute = DeterministicMockImageProvider.prototype.execute; + DeterministicMockImageProvider.prototype.execute = async function (...args) { + executorCalls += 1; + return originalExecute.apply(this, args); + }; + t.after(() => { + DeterministicMockImageProvider.prototype.execute = originalExecute; + }); + + let root = ""; + const context = await harness(t, { + createRunId: () => "post-pending-authority-run", + journalDurability: { + afterPendingPublished: async (runId) => { + const directory = path.join(root, "runs", runId); + const pending = JSON.parse( + await fs.readFile(path.join(directory, "run.pending.json"), "utf8"), + ) as { kind?: string; event?: { type?: string } }; + if (pending.kind !== "append" || pending.event?.type !== "run-started") return; + const target = path.join(directory, "run.json"); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] ^ 1; + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + assert.equal((await fs.stat(target)).size, before.size); + tampered = true; + }, + }, + }); + root = context.root; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + + assert.equal(tampered, true); + assert.equal(executorCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + const health = await context.journals.health(started.run.runId); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") { + assert.equal(health.reason, "pending-conflict"); + assert.equal(health.canRecover, false); + } + await fs.access(path.join(root, "runs", started.run.runId, "run.pending.json")); +}); + +test("torn prepared-submission append is recovered without provider execution or resubmit", async (t) => { + let executorCalls = 0; + const originalExecute = DeterministicMockImageProvider.prototype.execute; + DeterministicMockImageProvider.prototype.execute = async function (...args) { + executorCalls += 1; + return originalExecute.apply(this, args); + }; + t.after(() => { + DeterministicMockImageProvider.prototype.execute = originalExecute; + }); + + let root = ""; + let torn = false; + const context = await harness(t, { + createRunId: () => "torn-prepared-submission-run", + journalDurability: { + afterPendingPublished: async (runId) => { + const directory = path.join(root, "runs", runId); + const pending = JSON.parse( + await fs.readFile(path.join(directory, "run.pending.json"), "utf8"), + ) as { kind?: string; event?: CreateImagesRunEventV1 }; + if (pending.kind !== "append" || pending.event?.type !== "node-submission-prepared") { + return; + } + const checkpoint = JSON.parse( + await fs.readFile(path.join(directory, "run.json"), "utf8"), + ) as CreateImagesRunJournalV1; + const eventLogPath = path.join(directory, "run.events.jsonl"); + const eventLog = await fs.readFile(eventLogPath, "utf8"); + const lines = eventLog.trimEnd().split("\n"); + const previousDigest = (JSON.parse(lines[lines.length - 1] as string) as { digest: string }) + .digest; + const event = pending.event; + const journalRevision = event.sequence + 1; + const digest = createHash("sha256") + .update(JSON.stringify({ runId, journalRevision, previousDigest, event }), "utf8") + .digest("hex"); + const record = Buffer.from( + `${JSON.stringify({ + version: 1, + runId, + journalRevision, + previousDigest, + digest, + event, + })}\n`, + "utf8", + ); + assert.ok(checkpoint.journalRevision < journalRevision); + await fs.appendFile(eventLogPath, record.subarray(0, Math.floor(record.length / 2))); + torn = true; + throw new Error("simulated process loss during prepared-submission append"); + }, + }, + }); + root = context.root; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + + assert.equal(torn, true); + assert.equal(executorCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + const journal = await context.journals.get(started.run.runId); + assert.ok(journal?.events.some((event) => event.type === "node-submission-prepared")); + assert.equal( + journal?.events.some((event) => event.type === "node-submission-accepted"), + false, + ); + assert.equal(journal && projectCreateImagesRun(journal).status, "needs_attention"); +}); + +test("a failed start index publication reconciles its durable run before retry", async (t) => { + let rejectIndex = false; + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `start-index-${++runNumber}`, + journalDurability: { + beforeIndexPublished: async () => { + if (rejectIndex) throw new Error("simulated start index failure"); + }, + }, + }); + await context.service.initialize(); + rejectIndex = true; + await assert.rejects( + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + /simulated start index failure/u, + ); + const authoritative = await context.journals.get("start-index-1"); + assert.equal(projectCreateImagesRun(authoritative!).terminal?.status, "interrupted"); + assert.equal(projectCreateImagesRun(authoritative!).cancellation, undefined); + assert.equal(runNumber, 1); + + rejectIndex = false; + const retry = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(retry.status, "started"); + assert.equal(runNumber, 2); + if (retry.status === "started") await waitForTerminal(context.journals, retry.run.runId); +}); + +test("same-process launch reconciliation retains ownership until it can interrupt the orphan", async (t) => { + let runNumber = 0; + let scriptCalls = 0; + const context = await harness(t, { + createRunId: () => `launch-orphan-${++runNumber}`, + onScript: () => (scriptCalls += 1), + }); + const originalAppend = context.journals.append.bind(context.journals); + let failedAppendAttempts = 0; + context.journals.append = async (...args) => { + if (failedAppendAttempts < 2) { + failedAppendAttempts += 1; + throw new Error(`simulated transient append failure ${failedAppendAttempts}`); + } + return originalAppend(...args); + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitFor(() => failedAppendAttempts === 2); + assert.deepEqual( + (await context.service.activeRuns()).map((run) => run.runId), + [started.run.runId], + ); + const orphan = await context.journals.get(started.run.runId); + assert.ok(orphan); + assert.equal(projectCreateImagesRun(orphan).terminal, undefined); + assert.equal(scriptCalls, 1); + + context.journals.append = originalAppend; + const admitted = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(admitted.status, "started"); + assert.equal(runNumber, 2); + assert.equal(scriptCalls, 2); + const interrupted = await context.journals.get(started.run.runId); + assert.ok(interrupted); + const interruptedProjection = projectCreateImagesRun(interrupted); + assert.equal(interruptedProjection.status, "interrupted"); + assert.equal(interruptedProjection.cancellation, undefined); + assert.equal(interruptedProjection.nodes["prompt-1"]?.errorCode, "interrupted"); + assert.equal( + interrupted?.events.some( + (event) => event.type === "node-submission-prepared" || event.type === "node-started", + ), + false, + ); + if (admitted.status === "started") await waitForTerminal(context.journals, admitted.run.runId); +}); + +test("failed-launch callers bound a deferred publication-tail reconciliation", async (t) => { + const never = new Promise(() => undefined); + let rejectLaunchMutation: (error: Error) => void = () => undefined; + let launchMutationReached: () => void = () => undefined; + const launchMutation = new Promise((_resolve, reject) => { + rejectLaunchMutation = reject; + }); + const reachedLaunchMutation = new Promise((resolve) => { + launchMutationReached = resolve; + }); + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + shutdownTimeoutMs: 30, + createRunId: () => `publication-orphan-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const originalAppend = context.journals.append.bind(context.journals); + let holdFirstLaunchMutation = true; + context.journals.append = async (...args) => { + if (!holdFirstLaunchMutation) return originalAppend(...args); + holdFirstLaunchMutation = false; + launchMutationReached(); + return launchMutation; + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await reachedLaunchMutation; + const internals = context.service as unknown as { + activeByRun: Map< + string, + { + needsReconciliation?: boolean; + publicationTail: Promise; + } + >; + }; + const active = internals.activeByRun.get(started.run.runId); + assert.ok(active); + active.publicationTail = never; + rejectLaunchMutation(new Error("simulated launch failure before publication join")); + await waitFor(() => active.needsReconciliation === true); + + const listStartedAt = Date.now(); + assert.equal((await context.service.list("workflow-1")).status, "unavailable"); + assert.ok(Date.now() - listStartedAt < 500, "list exceeded its publication-tail deadline"); + const startStartedAt = Date.now(); + assert.equal( + ( + await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ) + ).status, + "unavailable", + ); + assert.ok(Date.now() - startStartedAt < 500, "start exceeded its publication-tail deadline"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); +}); + +test("failed-launch reconciliation bounds list, start, stop, and quit around deferred authority", async (t) => { + const never = new Promise(() => undefined); + let healthReached: () => void = () => undefined; + const reachedHealth = new Promise((resolve) => { + healthReached = resolve; + }); + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + shutdownTimeoutMs: 30, + createRunId: () => `bounded-orphan-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const originalAppend = context.journals.append.bind(context.journals); + let rejectFirstLaunchMutation = true; + context.journals.append = async (...args) => { + if (rejectFirstLaunchMutation) { + rejectFirstLaunchMutation = false; + throw new Error("simulated launch mutation failure"); + } + return originalAppend(...args); + }; + context.journals.health = async () => { + healthReached(); + return never as never; + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await reachedHealth; + const internals = context.service as unknown as { + activeByRun: Map< + string, + { + mutationTail: Promise; + publicationTail: Promise; + } + >; + }; + const active = internals.activeByRun.get(started.run.runId); + assert.ok(active); + active.mutationTail = never; + active.publicationTail = never; + + const listStartedAt = Date.now(); + assert.equal((await context.service.list("workflow-1")).status, "unavailable"); + assert.ok(Date.now() - listStartedAt < 500, "list exceeded its reconciliation deadline"); + + const startStartedAt = Date.now(); + assert.equal( + ( + await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ) + ).status, + "unavailable", + ); + assert.ok(Date.now() - startStartedAt < 500, "start exceeded its reconciliation deadline"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + + const stopStartedAt = Date.now(); + assert.equal( + (await context.service.stop("workflow-1", started.run.runId, "user")).status, + "unavailable", + ); + assert.ok(Date.now() - stopStartedAt < 500, "stop exceeded its durable deadline"); + + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [started.run.runId], + }); + assert.ok(Date.now() - quitStartedAt < 500, "stopAll exceeded its durable deadline"); + const durable = await context.journals.get(started.run.runId); + assert.equal( + durable?.events.some( + (event) => event.type === "node-submission-prepared" || event.type === "node-started", + ), + false, + ); +}); + +test("asset publication failure is terminal and reconciliation releases its reservation", async (t) => { + const context = await harness(t); + context.assets.failIngest = true; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "failed"); + assert.equal(projection.nodes["generate-1"]?.errorCode, "output-publication-failed"); + assert.ok(context.references.reservations.length > 0); + await waitFor(() => context.references.reservations.every((reservation) => !reservation.active)); + assert.equal( + context.references.reservations.some((reservation) => reservation.active), + false, + ); + assert.equal(context.references.isRunAssetReferenced(journal.runId, "a".repeat(64)), false); +}); + +test("prepared restart becomes needs-attention without executing or resubmitting", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { + onScript: () => (scriptCalls += 1), + now: () => Date.parse(NOW) - 60_000, + }); + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + let journal = await context.journals.start( + { + runId: "run-restart", + workflowSnapshot: plan.snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + const append = async (event: CreateImagesRunEventV1) => { + journal = await context.journals.append(journal.runId, journal.journalRevision, event); + }; + const base = () => ({ + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: NOW, + }); + await append({ ...base(), type: "run-started" }); + await append({ ...base(), type: "node-started", nodeId: "prompt-1" }); + await append({ + ...base(), + type: "node-output-published", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ + ...base(), + type: "node-succeeded", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ ...base(), type: "node-started", nodeId: "generate-1" }); + await append({ + ...base(), + type: "node-submission-prepared", + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-restart-node-0001", + providerId: "local-mock", + modelId: "deterministic-v1", + }); + + await context.service.initialize(); + const reconciled = await context.journals.get("run-restart"); + assert.ok(reconciled); + assert.equal(reconciled && projectCreateImagesRun(reconciled).status, "needs_attention"); + assert.equal(scriptCalls, 0); + assert.equal( + reconciled?.events.filter((event) => event.type === "node-submission-prepared").length, + 1, + ); + assert.equal( + reconciled?.events.some((event) => event.type === "node-submission-accepted"), + false, + ); + assert.ok(Date.parse(reconciled?.updatedAt ?? "") >= Date.parse(NOW)); +}); + +test("accepted restart reconciles the durable mock job and truthfully interrupts lost local work", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { + onScript: () => (scriptCalls += 1), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + remoteJobId: "accepted-restart-job", + durableRemoteJob: true, + width: 8, + height: 8, + seed: 37, + }, + ], + }, + }, + }); + await seedRestartRun(context.journals, { + runId: "run-accepted-restart", + providerJobId: "accepted-restart-job", + }); + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-accepted-restart"); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "interrupted"); + assert.equal(projection.nodes["generate-1"]?.status, "succeeded"); + assert.equal(projection.nodes["generate-1"]?.outputAssetIds.length, 1); + assert.equal(projection.nodes["output-1"]?.status, "failed"); + assert.equal(projection.nodes["output-1"]?.errorCode, "interrupted"); + assert.equal(projection.cancellation, undefined); + assert.equal(scriptCalls, 1); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-prepared").length, + 1, + ); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-accepted").length, + 1, + ); + assert.equal( + journal.events.filter( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ).length, + 1, + ); + assert.equal(context.assets.publicationOrder.length, 1); +}); + +test("restart cancellation remains authoritative over an accepted provider job", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { + onScript: () => (scriptCalls += 1), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + remoteJobId: "accepted-cancelled-job", + durableRemoteJob: true, + width: 8, + height: 8, + }, + ], + }, + }, + }); + const accepted = await seedRestartRun(context.journals, { + runId: "run-accepted-cancelled", + providerJobId: "accepted-cancelled-job", + }); + await context.journals.requestCancellation(accepted.runId, accepted.journalRevision, { + at: NOW, + reason: "app-quit", + }); + + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-accepted-cancelled"); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.cancellation?.reason, "app-quit"); + assert.equal(projection.terminal?.status, "cancelled"); + assert.equal(projection.nodes["generate-1"]?.status, "cancelled"); + assert.equal(scriptCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + assert.equal( + journal.events.some( + (candidate) => + candidate.type === "node-output-published" && candidate.nodeId === "generate-1", + ), + false, + ); +}); + +test("restart keeps a prepared submission ambiguous even after durable cancellation", async (t) => { + const context = await harness(t); + const prepared = await seedRestartRun(context.journals, { + runId: "run-prepared-cancelled", + }); + await context.journals.requestCancellation(prepared.runId, prepared.journalRevision, { + at: NOW, + reason: "app-quit", + }); + + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, prepared.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.cancellation?.reason, "app-quit"); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(projection.nodes["output-1"]?.status, "blocked"); + assert.equal( + journal.events.some( + (event) => event.type === "node-cancelled" && event.nodeId === "generate-1", + ), + false, + ); +}); + +test("restart finalizes a node only from its durably published output boundary", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { onScript: () => (scriptCalls += 1) }); + await seedRestartRun(context.journals, { + runId: "run-output-published", + providerJobId: "accepted-output-job", + durableOutputAssetIds: [DURABLE_ASSET_ID, DURABLE_ASSET_ID], + }); + context.assets.available.set(DURABLE_ASSET_ID, { + assetId: DURABLE_ASSET_ID, + mediaType: "image/png", + byteLength: 1, + width: 1, + height: 1, + createdAt: NOW, + origin: { + kind: "provider", + providerId: "local-mock", + modelId: "deterministic-v1", + runId: "run-output-published", + }, + referenceCount: 1, + thumbnailSizes: [], + }); + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-output-published"); + const node = projectCreateImagesRun(journal).nodes["generate-1"]; + assert.equal(node?.status, "succeeded"); + assert.deepEqual(node?.outputAssetIds, [DURABLE_ASSET_ID, DURABLE_ASSET_ID]); + assert.equal(scriptCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + assert.equal( + journal.events.filter( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ).length, + 1, + ); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status === "ready") { + assert.equal( + listed.history.find((entry) => entry.runId === "run-output-published")?.outputCount, + 2, + ); + } +}); + +test("restart refuses to finalize a published output whose durable asset is missing", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { onScript: () => (scriptCalls += 1) }); + await seedRestartRun(context.journals, { + runId: "run-output-missing", + providerJobId: "accepted-output-job", + durableOutputAssetIds: [DURABLE_ASSET_ID], + }); + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-output-missing"); + const node = projectCreateImagesRun(journal).nodes["generate-1"]; + assert.equal(node?.status, "failed"); + assert.equal(node?.errorCode, "output-publication-failed"); + assert.equal(scriptCalls, 0); +}); + +test("service revalidates stale terminal metadata and reconciles queued work before new admission", async (t) => { + const context = await harness(t, { createRunId: () => "new-run" }); + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + await context.journals.start( + { + runId: "concealed-queued", + workflowSnapshot: plan.snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + const indexPath = path.join(context.root, "run-index.json"); + const index = JSON.parse(await fs.readFile(indexPath, "utf8")) as { + entries: Array>; + }; + index.entries[0]!.status = "succeeded"; + index.entries[0]!.terminal = true; + await fs.writeFile(indexPath, `${JSON.stringify(index)}\n`, "utf8"); + + await context.service.initialize(); + const concealed = await context.journals.get("concealed-queued"); + assert.ok(concealed); + const concealedProjection = projectCreateImagesRun(concealed!); + assert.equal(concealedProjection.terminal?.status, "interrupted"); + assert.equal(concealedProjection.cancellation, undefined); + assert.equal(concealedProjection.nodes["prompt-1"]?.errorCode, "interrupted"); + assert.equal( + concealed!.events.some( + (event) => + event.type === "run-cancel-requested" || + event.type === "run-started" || + event.type === "node-started", + ), + false, + ); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status === "started") { + await context.service.stop("workflow-1", started.run.runId, "user"); + await waitForTerminal(context.journals, started.run.runId); + } +}); + +test("production run service distinguishes every mock submission crash boundary", async (t) => { + await t.test( + "crash before send is the only automatically retryable exception boundary", + async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { outcome: "crash-before-send" }, + { outcome: "success", width: 8, height: 8, seed: 12 }, + ], + }, + }, + }); + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(journal).status, "succeeded"); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-prepared").length, + 2, + ); + }, + ); + + for (const [outcome, acceptedCount] of [ + ["accepted-before-response", 0], + ["crash-after-send", 1], + ] as const) { + await t.test(outcome, async (t) => { + const context = await harness(t, { + script: { nodes: { "generate-1": [{ outcome }] } }, + }); + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-accepted").length, + acceptedCount, + ); + const attempts = projection.nodes["generate-1"]?.attempts ?? []; + assert.equal( + attempts[attempts.length - 1]?.submission, + outcome === "accepted-before-response" ? "ambiguous" : "accepted", + ); + }); + } +}); + +test("production run coordination rejects out-of-order mock completion but tolerates duplicates", async (t) => { + for (const [label, script, expected] of [ + ["duplicate", { outcome: "success", duplicateSubmittedEvent: true }, "succeeded"], + ["out-of-order", { outcome: "success", outOfOrderCompletionEvent: true }, "needs_attention"], + ] as const) { + await t.test(label, async (t) => { + const context = await harness(t, { + script: { + nodes: { "generate-1": [{ ...script, width: 8, height: 8 }] }, + }, + }); + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(journal).status, expected); + }); + } +}); + +test("snapshot input assets are reserved before journal publication and committed before launch", async (t) => { + const document = workflow(); + document.nodes.push({ + id: "image-input-1", + type: "image-input", + position: { x: 0, y: 100 }, + data: { assetId: DURABLE_ASSET_ID }, + }); + document.assetRefs = [DURABLE_ASSET_ID]; + const context = await harness(t, { + document, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + context.assets.available.set(DURABLE_ASSET_ID, { + assetId: DURABLE_ASSET_ID, + mediaType: "image/png", + byteLength: 1, + width: 1, + height: 1, + createdAt: NOW, + origin: { kind: "import" }, + referenceCount: 1, + thumbnailSizes: [], + }); + let publicationChecks = 0; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => { + publicationChecks += 1; + assert.equal(context.references.isRunAssetReferenced("run-1", DURABLE_ASSET_ID), true); + return true; + }, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + assert.equal(publicationChecks, 1); + assert.deepEqual(context.references.order.slice(0, 3), [ + "reserve:run-1", + "commit:run-1", + "release:run-1", + ]); + assert.equal(context.references.isRunAssetReferenced("run-1", DURABLE_ASSET_ID), true); + assert.deepEqual(context.assets.runReferences.get("run-1"), [DURABLE_ASSET_ID]); + await context.service.stop("workflow-1", "run-1", "user"); + await waitForTerminal(context.journals, "run-1"); +}); + +test("run detail, recovery, and active-run reads stay workflow-authorized and path-free", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + await context.workflows.create({ + ...workflow(), + id: "workflow-2", + title: "Another workflow", + }); + assert.deepEqual(await context.service.list("missing-workflow"), { + status: "not-found", + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + assert.deepEqual( + (await context.service.activeRuns()).map((run) => run.runId), + [started.run.runId], + ); + assert.equal((await context.service.get("workflow-1", started.run.runId)).status, "ready"); + assert.equal((await context.service.get("workflow-2", started.run.runId)).status, "not-found"); + await context.service.stop("workflow-1", started.run.runId, "user"); + await waitForTerminal(context.journals, started.run.runId); + await fs.writeFile( + path.join(context.root, "runs", started.run.runId, "run.json"), + "{broken", + "utf8", + ); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status !== "ready") return; + assert.equal(listed.authoritative, true); + assert.equal( + listed.history.some((entry) => entry.runId === started.run.runId), + false, + ); + assert.equal(listed.recoveries.length, 1); + const recovery = listed.recoveries[0]; + assert.equal(recovery?.status, "recovery-required"); + if (!recovery || recovery.status !== "recovery-required") return; + assert.equal(recovery.workflowId, "workflow-1"); + assert.equal(recovery.recoverySource, "last-known-good"); + assert.equal(JSON.stringify(recovery).includes(context.root), false); + assert.equal( + (await context.service.get("workflow-1", started.run.runId)).status, + "recovery-required", + ); + const expected = recovery.expectedCandidateJournalRevision; + assert.ok(expected); + const conflict = await context.service.recover( + "workflow-1", + started.run.runId, + "last-known-good", + (expected ?? 1) + 1, + ); + assert.equal(conflict.status, "conflict"); + const recovered = await context.service.recover( + "workflow-1", + started.run.runId, + "last-known-good", + expected ?? 1, + ); + assert.equal(recovered.status, "recovered"); + assert.equal((await context.service.get("workflow-1", started.run.runId)).status, "ready"); + assert.deepEqual(await context.service.activeRuns(), []); +}); + +test("recovery can durably rebuild last-known-good from a healthy current journal", async (t) => { + const context = await harness(t); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await fs.writeFile( + path.join(context.root, "runs", started.run.runId, "run.last-known-good.json"), + "{broken", + "utf8", + ); + + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status !== "ready") return; + const recovery = listed.recoveries[0]; + assert.equal(recovery?.status, "recovery-required"); + if (!recovery || recovery.status !== "recovery-required") return; + assert.equal(recovery.recoverySource, "current"); + assert.ok(recovery.expectedCandidateJournalRevision); + const recovered = await context.service.recover( + "workflow-1", + started.run.runId, + "current", + recovery.expectedCandidateJournalRevision ?? 1, + ); + assert.equal(recovered.status, "recovered"); + assert.equal((await context.service.get("workflow-1", started.run.runId)).status, "ready"); +}); + +test("future-schema and both-corrupt runs remain workflow-authorized list and detail records", async (t) => { + let nextRun = 0; + const context = await harness(t, { + createRunId: () => `degraded-${++nextRun}`, + }); + for (let index = 0; index < 2; index += 1) { + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status === "started") { + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + } + } + + const futurePath = path.join(context.root, "runs", "degraded-1", "run.json"); + const future = JSON.parse(await fs.readFile(futurePath, "utf8")) as Record; + future.version = 2; + await fs.writeFile(futurePath, `${JSON.stringify(future)}\n`, "utf8"); + await Promise.all([ + fs.writeFile( + path.join(context.root, "runs", "degraded-2", "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(context.root, "runs", "degraded-2", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status !== "ready") return; + assert.deepEqual( + listed.recoveries.map((recovery) => ({ + runId: recovery.runId, + status: recovery.status, + })), + [ + { runId: "degraded-1", status: "unsafe" }, + { runId: "degraded-2", status: "recovery-required" }, + ], + ); + const corrupt = listed.recoveries.find((recovery) => recovery.runId === "degraded-2"); + assert.equal(corrupt?.status, "recovery-required"); + if (corrupt?.status === "recovery-required") assert.equal(corrupt.recoverySource, undefined); + assert.equal((await context.service.get("workflow-1", "degraded-1")).status, "unsafe"); + assert.equal((await context.service.get("workflow-1", "degraded-2")).status, "recovery-required"); + assert.equal((await context.service.get("other-workflow", "degraded-1")).status, "not-found"); + assert.equal( + (await context.service.recover("workflow-1", "degraded-1", "current", 1)).status, + "unsafe", + ); +}); + +test("stopAll durably cancels but does not wait forever for stalled publication", async (t) => { + const context = await harness(t, { shutdownTimeoutMs: 250 }); + context.assets.ingestGate = new Promise(() => undefined); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitFor(() => context.assets.ingestStarted); + const startedAt = Date.now(); + const stopped = await context.service.stopAll("app-quit"); + assert.ok(Date.now() - startedAt < 1_000, "stopAll exceeded its bounded deadline"); + assert.deepEqual(stopped, { + status: "safe-to-quit", + unsettledRunIds: [started.run.runId], + }); + const journal = await context.journals.get(started.run.runId); + assert.equal(projectCreateImagesRun(journal!).cancellation?.reason, "app-quit"); +}); + +test("stopAll closes admission before joining an in-flight durable start", async (t) => { + let releaseAdmission: () => void = () => undefined; + let markPending: () => void = () => undefined; + const admissionGate = new Promise((resolve) => { + releaseAdmission = resolve; + }); + const pendingReached = new Promise((resolve) => { + markPending = resolve; + }); + let pauseFirstStart = true; + let runNumber = 0; + const context = await harness(t, { + shutdownTimeoutMs: 300, + createRunId: () => `admission-run-${++runNumber}`, + journalDurability: { + afterPendingPublished: async (runId) => { + if (runId !== "admission-run-1" || !pauseFirstStart) return; + pauseFirstStart = false; + markPending(); + await admissionGate; + }, + }, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + const crossingStart = context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + await pendingReached; + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [], + }); + assert.ok(Date.now() - quitStartedAt < 1_000); + + const laterStart = context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + releaseAdmission(); + assert.equal((await crossingStart).status, "started"); + assert.equal((await laterStart).status, "unavailable"); + assert.equal((await context.service.stopAll("app-quit")).status, "safe-to-quit"); +}); + +test("stop and stopAll report blocked when durable cancellation rejects", async (t) => { + const context = await harness(t, { shutdownTimeoutMs: 30 }); + context.assets.ingestGate = new Promise(() => undefined); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitFor(() => context.assets.ingestStarted); + context.journals.requestCancellation = async () => { + throw new Error("simulated cancellation journal rejection"); + }; + + const stopped = await context.service.stop("workflow-1", started.run.runId, "user"); + assert.equal(stopped.status, "unavailable"); + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [started.run.runId], + }); + assert.ok(Date.now() - quitStartedAt < 500); + const journal = await context.journals.get(started.run.runId); + assert.equal(projectCreateImagesRun(journal!).cancellation, undefined); + assert.equal(projectCreateImagesRun(journal!).terminal, undefined); +}); + +test("after-pending cancellation hangs block quit without a follow-up store read", async (t) => { + let blockCancellation = false; + const never = new Promise(() => undefined); + const context = await harness(t, { + shutdownTimeoutMs: 30, + journalDurability: { + afterPendingPublished: async () => { + if (blockCancellation) await never; + }, + }, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 1_000, width: 8, height: 8 }], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((candidate) => candidate.type === "node-submission-accepted"), + ); + blockCancellation = true; + + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [started.run.runId], + }); + assert.ok(Date.now() - quitStartedAt < 500); + const inspectStartedAt = Date.now(); + assert.deepEqual( + (await context.service.activeRuns()).map((run) => run.runId), + [started.run.runId], + ); + assert.ok(Date.now() - inspectStartedAt < 100); + const pending = JSON.parse( + await fs.readFile( + path.join(context.root, "runs", started.run.runId, "run.pending.json"), + "utf8", + ), + ) as { event?: { type?: string } }; + assert.equal(pending.event?.type, "run-cancel-requested"); +}); + +test("run history retention requires a fresh CAS plan and reconciles released run references", async (t) => { + const context = await harness(t); + const candidates = [ + { + runId: "old-run-1", + workflowId: "workflow-1", + journalRevision: 7, + updatedAt: NOW, + assetIds: [DURABLE_ASSET_ID], + }, + ]; + const token = "b".repeat(64); + context.references.committed.set("old-run-1", new Set([DURABLE_ASSET_ID])); + context.assets.runReferences.set("old-run-1", [DURABLE_ASSET_ID]); + context.journals.terminalRetentionCandidates = async (query) => { + assert.deepEqual(query, { keepLatest: 100, limit: 100 }); + return candidates; + }; + context.journals.planTerminalPrune = async (requested) => { + assert.deepEqual(requested, candidates); + return { + version: 1, + candidates: [{ runId: "old-run-1", journalRevision: 7 }], + token, + assetIds: [DURABLE_ASSET_ID], + }; + }; + let pruned = false; + context.journals.pruneTerminalRuns = async (plan) => { + assert.equal(plan.token, token); + pruned = true; + return { + removedRunIds: ["old-run-1"], + releasedAssetIds: [DURABLE_ASSET_ID], + }; + }; + + assert.deepEqual(await context.service.planHistoryPrune(100), { + status: "ready", + scope: "all-workflows", + mayReleaseUniqueOutputs: true, + authorizationToken: token, + keepLatest: 100, + candidateRunCount: 1, + releasedAssetCount: 1, + }); + assert.equal((await context.service.pruneHistory(100, "c".repeat(64))).status, "conflict"); + assert.equal(pruned, false); + assert.deepEqual(await context.service.pruneHistory(100, token), { + status: "pruned", + removedRunCount: 1, + releasedAssetCount: 1, + }); + assert.equal(pruned, true); + assert.deepEqual(context.assets.runReferences.get("old-run-1"), []); + assert.equal(context.references.isRunAssetReferenced("old-run-1", DURABLE_ASSET_ID), false); +}); + +test("service requires a fresh explicit plan before discarding irrecoverable run authority", async (t) => { + const context = await harness(t, { createRunId: () => "replacement-run" }); + await context.service.initialize(); + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + await context.journals.start( + { + runId: "damaged-run", + workflowSnapshot: plan.snapshot, + plan: { + scope: structuredClone(plan.scope), + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, dependencies]) => [ + nodeId, + [...dependencies], + ]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + await Promise.all([ + fs.writeFile( + path.join(context.root, "runs", "damaged-run", "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(context.root, "runs", "damaged-run", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + assert.notEqual((await context.journals.health("damaged-run")).status, "healthy"); + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + + const planned = await context.service.planDegradedRunDiscard("damaged-run"); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") return; + assert.equal(planned.mayLoseOutputs, true); + assert.equal(planned.mayDuplicateProviderWork, true); + assert.deepEqual( + await context.service.discardDegradedRun({ + runId: planned.runId, + authorizationToken: "f".repeat(64), + confirmed: true, + }), + { status: "conflict" }, + ); + const discarded = await context.service.discardDegradedRun({ + runId: planned.runId, + authorizationToken: planned.authorizationToken, + ...(planned.expectedCurrentJournalRevision === undefined + ? {} + : { + expectedCurrentJournalRevision: planned.expectedCurrentJournalRevision, + }), + ...(planned.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { + expectedLastKnownGoodJournalRevision: planned.expectedLastKnownGoodJournalRevision, + }), + confirmed: true, + }); + assert.equal(discarded.status, "discarded"); + if (discarded.status === "discarded") { + assert.equal(discarded.authoritativeList?.status, "ready"); + } + assert.equal(await context.journals.degradedRunCount(), 0); + const admitted = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(admitted.status, "started"); + await context.service.stopAll("app-quit"); +}); + +test("main admission rejects a forged rejoining downstream path without creating a journal", async (t) => { + const document = workflow(); + const firstGeneration = document.nodes.find((node) => node.id === "generate-1")!; + document.nodes = [ + document.nodes.find((node) => node.id === "prompt-1")!, + { ...structuredClone(firstGeneration), id: "generation-a" }, + { ...structuredClone(firstGeneration), id: "generation-b" }, + { + id: "gallery", + type: "output-gallery", + position: { x: 200, y: 0 }, + data: {}, + }, + ]; + document.edges = [ + { + id: "prompt-a", + source: "prompt-1", + sourcePort: "text", + target: "generation-a", + targetPort: "prompt", + }, + { + id: "prompt-b", + source: "prompt-1", + sourcePort: "text", + target: "generation-b", + targetPort: "prompt", + }, + { + id: "images-a", + source: "generation-a", + sourcePort: "images", + target: "gallery", + targetPort: "images", + }, + { + id: "images-b", + source: "generation-b", + sourcePort: "images", + target: "gallery", + targetPort: "images", + }, + ]; + let allocatedRunIds = 0; + const context = await harness(t, { + document, + createRunId: () => `forged-run-${++allocatedRunIds}`, + }); + const result = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { + kind: "from-node", + nodeId: "prompt-1", + downstreamPath: ["generation-a", "gallery"], + }, + }, + () => true, + ); + assert.equal(result.status, "invalid"); + assert.equal(allocatedRunIds, 0); + assert.deepEqual(await context.journals.reconciliationCandidates(), []); + assert.deepEqual(await context.journals.terminalHistory(), []); +}); + +test("start enforces exact workflow revision, renderer liveness, and one active run per workflow", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `run-${++runNumber}`, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8, seed: 9 }], + }, + }, + }); + const conflict = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 2, scope: { kind: "all" } }, + () => true, + ); + assert.deepEqual(conflict, { + status: "conflict", + expectedRevision: 2, + currentRevision: 1, + }); + let staleChecks = 0; + await assert.rejects( + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => { + staleChecks += 1; + return false; + }, + ), + /no longer active/u, + ); + assert.equal(staleChecks, 1); + assert.equal( + context.references.reservations.find((reservation) => reservation.runId === "run-1")?.active, + false, + ); + assert.equal((await context.journals.health("run-1")).status, "missing"); + + const starts = await Promise.all([ + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + ]); + assert.deepEqual(starts.map((result) => result.status).sort(), ["already-running", "started"]); + const running = starts.find((result) => result.status === "started"); + assert.ok(running?.status === "started"); + await context.service.stop("workflow-1", running.run.runId, "user"); + await waitForTerminal(context.journals, running.run.runId); +}); + +test("multi-image output persists every unique durable asset and counts the batch once", async (t) => { + const context = await harness(t, { document: workflow(3) }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const outputAssetIds = projectCreateImagesRun(journal).nodes["generate-1"]?.outputAssetIds ?? []; + assert.equal(outputAssetIds.length, 3); + assert.equal(new Set(outputAssetIds).size, 3); + assert.ok(outputAssetIds.every((assetId) => context.assets.available.has(assetId))); + assert.deepEqual(context.assets.runReferences.get(journal.runId), [...outputAssetIds].sort()); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status === "ready") assert.equal(listed.history[0]?.outputCount, 3); +}); + +test("active-run admission is globally capped before a fifth journal is published", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `run-cap-${++runNumber}`, + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + delayMs: 60_000, + width: 8, + height: 8, + seed: 11, + }, + ], + }, + }, + }); + const workflowIds = Array.from( + { length: CREATE_IMAGES_MAX_ACTIVE_RUNS + 1 }, + (_, index) => `workflow-${index + 1}`, + ); + for (const workflowId of workflowIds.slice(1)) { + await context.workflows.create({ + ...workflow(), + id: workflowId, + title: `Workflow ${workflowId}`, + }); + } + const results = await Promise.all( + workflowIds.map((workflowId) => + context.service.start( + { workflowId, expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + ), + ); + assert.equal(results.filter((result) => result.status === "started").length, 4); + assert.equal(results.filter((result) => result.status === "unavailable").length, 1); + assert.equal((await context.journals.initialize()).length, CREATE_IMAGES_MAX_ACTIVE_RUNS); + await context.service.stopAll("app-quit"); +}); + +test("Gemini launch requires one-shot main consent and durably binds provider authority", async (t) => { + const secret = "AIzaSy_PHASE4_TEST_KEY_NEVER_PERSIST"; + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + let fetchCount = 0; + let runCount = 0; + const context = await harness(t, { + createRunId: () => `gemini-run-${++runCount}`, + resolveGeminiAuth: async () => ({ auth: { apiKey: secret }, source: "test API key" }), + createGeminiProvider: () => + new GeminiImageProvider({ + fetch: (async () => { + fetchCount += 1; + return new Response( + JSON.stringify({ + id: "interactions/phase4-test", + status: "completed", + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: png }], + }, + ], + usage: { total_input_tokens: 3, total_output_tokens: 4, total_tokens: 7 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof globalThis.fetch, + }), + }); + + const withoutConsent = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + }, + () => true, + ); + assert.equal(withoutConsent.status, "invalid"); + assert.equal(runCount, 0); + assert.equal(fetchCount, 0); + + const prepared = await context.service.prepareGeminiRun({ + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }); + assert.equal(prepared.status, "ready"); + if (prepared.status !== "ready") return; + assert.equal(prepared.plan.accounting.initialRequestCount, 1); + assert.equal(prepared.plan.accounting.maximumAttempts, 1); + assert.equal(prepared.plan.accounting.dataLeavesDevice, true); + assert.equal(prepared.plan.estimate.kind, "unavailable"); + assert.doesNotMatch(JSON.stringify(prepared), new RegExp(secret, "u")); + + const consent = { + version: 1 as const, + authorizationId: prepared.plan.authorizationId, + consentFingerprint: prepared.plan.consentFingerprint, + token: prepared.plan.token, + reviewed: true as const, + }; + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: consent, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(fetchCount, 1); + assert.equal( + projectCreateImagesRun(journal).terminal?.status, + "succeeded", + JSON.stringify({ + projection: projectCreateImagesRun(journal), + assets: [...context.assets.available.values()], + reservations: context.references.reservations.map((item) => [...item.next]), + }), + ); + assert.equal(journal.providerAuthorization?.executionMode, "gemini"); + assert.equal(journal.providerAuthorization?.maximumAttempts, 1); + assert.equal(journal.providerAuthorization?.credentialRecordId.startsWith("google-"), true); + const serialized = JSON.stringify(journal); + assert.doesNotMatch(serialized, new RegExp(secret, "u")); + assert.doesNotMatch(serialized, new RegExp(prepared.plan.token, "u")); + assert.ok( + journal.events.some( + (event) => + event.type === "node-submission-prepared" && + event.providerId === "gemini" && + event.modelId === "gemini-3.1-flash-image", + ), + ); + const generatedAsset = [...context.assets.available.values()][0]; + assert.equal( + generatedAsset?.origin.kind === "provider" ? generatedAsset.origin.providerId : undefined, + "gemini", + ); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status === "ready") { + assert.equal(listed.history[0]?.costLabel, "Actual cost unknown"); + } + + const replay = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: consent, + }, + () => true, + ); + assert.equal(replay.status, "invalid"); + assert.equal(runCount, 1); + assert.equal(fetchCount, 1); +}); + +test("Gemini consent accounts for a durable reference and submits only its bounded bytes", async (t) => { + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + const pngBytes = Uint8Array.from(Buffer.from(pngBase64, "base64")); + const assetId = createHash("sha256").update(pngBytes).digest("hex"); + const document = workflow(); + document.nodes.splice(1, 0, { + id: "reference-1", + type: "image-input", + position: { x: 0, y: 100 }, + data: { assetId, label: "Reference" }, + }); + document.edges.splice(1, 0, { + id: "edge-reference", + source: "reference-1", + sourcePort: "image", + target: "generate-1", + targetPort: "references", + }); + document.assetRefs = [assetId]; + let requestBody = ""; + const context = await harness(t, { + document, + createRunId: () => "gemini-reference-run", + resolveGeminiAuth: async () => ({ auth: { apiKey: "reference-test-key" }, source: "test" }), + createGeminiProvider: () => + new GeminiImageProvider({ + fetch: (async (_url, init) => { + requestBody = String(init?.body ?? ""); + return new Response( + JSON.stringify({ + status: "completed", + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: pngBase64 }], + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof globalThis.fetch, + }), + }); + const reference: AssetMetadataDto = { + assetId, + mediaType: "image/png", + byteLength: pngBytes.byteLength, + width: 1, + height: 1, + createdAt: NOW, + origin: { kind: "import" }, + referenceCount: 1, + thumbnailSizes: [], + }; + context.assets.available.set(assetId, reference); + context.assets.bytesById.set(assetId, pngBytes); + const prepared = await context.service.prepareGeminiRun({ + workflowId: document.id, + expectedRevision: document.revision, + scope: { kind: "all" }, + }); + assert.equal(prepared.status, "ready"); + if (prepared.status !== "ready") return; + assert.equal(prepared.plan.accounting.referenceImageCount, 1); + assert.equal(prepared.plan.accounting.referenceImageBytes, pngBytes.byteLength); + const started = await context.service.start( + { + workflowId: document.id, + expectedRevision: document.revision, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: { + version: 1, + authorizationId: prepared.plan.authorizationId, + consentFingerprint: prepared.plan.consentFingerprint, + token: prepared.plan.token, + reviewed: true, + }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + assert.equal( + projectCreateImagesRun(await waitForTerminal(context.journals, started.run.runId)).terminal + ?.status, + "succeeded", + ); + assert.match(requestBody, new RegExp(pngBase64.replace(/[+]/gu, "\\+"), "u")); + assert.doesNotMatch(requestBody, /reference-test-key/u); +}); + +test("Gemini credential drift after durable preparation fails before transport", async (t) => { + let authReads = 0; + let fetchCount = 0; + const context = await harness(t, { + createRunId: () => "gemini-drift-run", + resolveGeminiAuth: async () => ({ + auth: { apiKey: authReads++ < 2 ? "reviewed-key" : "changed-key" }, + source: "test", + }), + createGeminiProvider: () => + new GeminiImageProvider({ + fetch: (async () => { + fetchCount += 1; + throw new Error("must not execute"); + }) as typeof globalThis.fetch, + }), + }); + const prepared = await context.service.prepareGeminiRun({ + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }); + assert.equal(prepared.status, "ready"); + if (prepared.status !== "ready") return; + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: { + version: 1, + authorizationId: prepared.plan.authorizationId, + consentFingerprint: prepared.plan.consentFingerprint, + token: prepared.plan.token, + reviewed: true, + }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(fetchCount, 0); + assert.equal(projectCreateImagesRun(journal).terminal?.status, "failed"); + assert.ok(journal.events.some((event) => event.type === "node-submission-prepared")); + assert.equal( + journal.events.some((event) => event.type === "node-submission-accepted"), + false, + ); +}); + +test("annotation rasterization remains main-owned and publishes one immutable PNG", async (t) => { + const document: WorkflowDocumentV1 = { + schemaVersion: 3, + id: "workflow-annotation", + title: "Annotation workflow", + revision: 1, + createdAt: NOW, + updatedAt: NOW, + nodes: [ + { + id: "input-1", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId: DURABLE_ASSET_ID }, + }, + { + id: "annotation-1", + type: "annotation", + position: { x: 300, y: 0 }, + data: { + shapes: [ + { + id: "shape-1", + type: "rectangle", + x: 0.1, + y: 0.1, + width: 0.4, + height: 0.3, + stroke: "accent", + strokeWidth: 4, + }, + ], + }, + }, + { id: "output-1", type: "output", position: { x: 600, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-input", + source: "input-1", + sourcePort: "image", + target: "annotation-1", + targetPort: "image", + }, + { + id: "edge-output", + source: "annotation-1", + sourcePort: "image", + target: "output-1", + targetPort: "images", + }, + ], + assetRefs: [DURABLE_ASSET_ID], + settings: { concurrency: 1 }, + }; + let rasterizedPath = ""; + const outputBytes = new Uint8Array(40).fill(7); + const context = await harness(t, { + document, + createRunId: () => "annotation-run", + annotationRasterizer: { + async rasterize(input) { + rasterizedPath = input.sourcePath; + assert.equal(input.shapes.length, 1); + return { bytes: outputBytes, width: 800, height: 600 }; + }, + }, + }); + context.assets.available.set(DURABLE_ASSET_ID, { + assetId: DURABLE_ASSET_ID, + mediaType: "image/png", + byteLength: 40, + width: 800, + height: 600, + createdAt: NOW, + origin: { kind: "import" }, + referenceCount: 1, + thumbnailSizes: [], + }); + const started = await context.service.start( + { workflowId: document.id, expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + const outputAssetId = createHash("sha256").update(outputBytes).digest("hex"); + assert.equal(projection.terminal?.status, "succeeded"); + assert.deepEqual(projection.nodes["annotation-1"]?.outputAssetIds, [outputAssetId]); + assert.match(rasterizedPath, /^\/main-only\//u); + assert.deepEqual(context.assets.available.get(outputAssetId)?.origin, { + kind: "annotation", + sourceAssetId: DURABLE_ASSET_ID, + }); +}); diff --git a/main/services/create-images/run-service.ts b/main/services/create-images/run-service.ts new file mode 100644 index 00000000..be52ce41 --- /dev/null +++ b/main/services/create-images/run-service.ts @@ -0,0 +1,3503 @@ +import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto"; +import type { AuthResult } from "@earendil-works/pi-ai"; +import { CREATE_IMAGES_NODE_DEFINITIONS } from "../../../renderer/shared/create-images/ports.js"; +import { + hasUnresolvedCreateImagesRunAmbiguity, + projectCreateImagesRun, + type CreateImagesCancellationReason, + type CreateImagesBatchItemState, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, + type CreateImagesRunProviderAuthorizationV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { + CreateImagesDegradedRunDiscardPlanResult, + CreateImagesDegradedRunDiscardResult, + CreateImagesDiscardDegradedRunRequest, + CreateImagesRunDetailResult, + CreateImagesRunAmbiguityResolutionResult, + CreateImagesRunListResult, + CreateImagesRunHistoryPrunePlanResult, + CreateImagesRunHistoryPruneResult, + CreateImagesRunMutationResult, + CreateImagesPrepareRunResult, + CreateImagesProviderConsentPlanView, + CreateImagesRecentOutputView, + CreateImagesRunRecoveryMutationResult, + CreateImagesRunRecoveryRequiredView, + CreateImagesRunRecoveryView, + CreateImagesRunUnsafeRecoveryView, + CreateImagesRunView, + CreateImagesResolveRunAmbiguityRequest, + CreateImagesResumeRunRequest, + CreateImagesTerminalRunView, +} from "../../../renderer/shared/create-images/ipc.js"; +import type { WorkflowRunScope } from "../../../renderer/shared/create-images/execution.js"; +import { CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY } from "../../../renderer/shared/create-images/retry-policy.js"; +import { resolveCreateImagesPromptVariables } from "../../../renderer/shared/create-images/prompt-variables.js"; +import { parseCreateImagesPromptList } from "../../../renderer/shared/create-images/prompt-list.js"; +import type { + CreateImagesAnnotationShape, + WorkflowNodeV1, +} from "../../../renderer/shared/create-images/schema.js"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import { + admitCreateImagesProviderExecution, + createCreateImagesMainCredentialBinding, + createCreateImagesProviderCapabilitySnapshot, + CreateImagesProviderAdmissionError, + CreateImagesProviderAdmissionGate, + prepareCreateImagesProviderExecutionConsent, + type CreateImagesMainCredentialBindingV1, + type CreateImagesProviderConsentAuthority, + type CreateImagesProviderConsentClaimV1, + type CreateImagesProviderExecutionConsentPlanV1, + type CreateImagesProviderInvocationFactsV1, +} from "./image-provider-execution-core.js"; +import type { + ImageGenerationReference, + ValidatedImageGenerationRequest, +} from "./provider-contract.js"; +import { + GeminiImageProvider, + type GeminiImageProviderErrorCode, + type GeminiImageProviderOutput, +} from "./providers/gemini-image-provider-core.js"; +import { + DeterministicMockImageProvider, + MockProviderEventCoordinator, + type MockImageOutputBatch, + type MockImageProviderScript, +} from "./mock-image-provider-core.js"; +import { + CoordinatorCancellationRequest, + createWorkflowCoordinatorPlan, + reconcileRestartNode, + runWorkflowCoordinator, + type CoordinatorClock, + type CoordinatorErrorCode, + type CoordinatorDurability, + type CoordinatorNodeExecutionContext, +} from "./scheduler-core.js"; +import { + CreateImagesRunJournalLoadError, + CreateImagesRunJournalRevisionConflictError, + CreateImagesRunJournalStore, + type CreateImagesRunJournalHealth, + type CreateImagesRunStartInput, + type CreateImagesWorkflowAdmissionAudit, +} from "./run-journal-store.js"; +import type { WorkflowManifestStore } from "./workflow-manifest-store.js"; +import type { CreateImagesWorkspaceState } from "./workspace-store.js"; + +interface DurableNodeOutput { + kind: "images" | "text" | "text-list"; + assetIds: string[]; + text?: string; + items?: string[]; +} + +interface AnnotationImageOutput { + kind: "annotation-image"; + sourceAssetId: string; + bytes: Uint8Array; + width: number; + height: number; +} + +function isAnnotationImageOutput(value: unknown): value is AnnotationImageOutput { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + candidate.kind === "annotation-image" && + typeof candidate.sourceAssetId === "string" && + /^[a-f0-9]{64}$/u.test(candidate.sourceAssetId) && + candidate.bytes instanceof Uint8Array && + candidate.bytes.byteLength >= 33 && + candidate.bytes.byteLength <= 64 * 1024 * 1024 && + Number.isSafeInteger(candidate.width) && + Number.isSafeInteger(candidate.height) && + candidate.width! >= 1 && + candidate.height! >= 1 + ); +} + +export interface CreateImagesAnnotationRasterizer { + rasterize(input: { + sourcePath: string; + shapes: readonly CreateImagesAnnotationShape[]; + }): Promise<{ bytes: Uint8Array; width: number; height: number }>; +} + +type CreateImagesRunExecution = { mode: "local-mock" } | { mode: "gemini"; auth: AuthResult }; + +export interface CreateImagesRunReferenceReservation { + runId: string; + next: ReadonlySet; + active: boolean; +} + +export interface CreateImagesRunReferenceAuthority { + reserveRun( + runId: string, + assetIds: readonly string[], + ): Promise; + commitRun(reservation: CreateImagesRunReferenceReservation): Promise; + releaseRunReservations(runId: string): Promise; + reconcileRuns(store: CreateImagesRunJournalStore): Promise; + isRunAssetReferenced(runId: string, assetId: string): boolean; +} + +interface ActiveRun { + runId: string; + workflowId: string; + journal: CreateImagesRunJournalV1; + execution: CreateImagesRunExecution; + controller: AbortController; + mutationTail: Promise; + publicationTail: Promise; + publishedOutputs: Map; + reservations: Map; + cancelDurable: Promise; + resolveCancelDurable(): void; + cancellationRequest?: Promise; + needsReconciliation?: boolean; + reconciliationAttempt?: Promise; + ownershipReleaseAttempt?: Promise; + settled: Promise; + pendingPause?: { checkpointId: string; beforeNodeId: string; edgeIds: string[] }; +} + +export type CreateImagesRunStopAllResult = + | { status: "safe-to-quit"; unsettledRunIds: string[] } + | { status: "blocked"; failedRunIds: string[] }; + +export type CreateImagesWorkflowDeletionDecision = + | { status: "allowed" } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export function evaluateCreateImagesWorkflowDeletion( + snapshot: CreateImagesRunListResult, +): CreateImagesWorkflowDeletionDecision { + if (snapshot.status === "not-found") return { status: "not-found" }; + if (snapshot.status !== "ready" || snapshot.authoritative !== true) { + return { + status: "unavailable", + message: "Run history could not be verified safely. No workflow was deleted.", + }; + } + if (snapshot.activeRun) { + return { + status: "unavailable", + message: "Stop the active image run before deleting this workflow.", + }; + } + if (snapshot.recoveries.length > 0) { + return { + status: "unavailable", + message: + "This workflow has retained run recovery records. Resolve or retain them; workflow deletion is unavailable while those records remain.", + }; + } + if (snapshot.latestTerminalRun || snapshot.history.length > 0) { + return { + status: "unavailable", + message: + "This workflow has retained run history. Workflow deletion is unavailable while those records remain; Aiden will not remove them implicitly.", + }; + } + return { status: "allowed" }; +} + +export interface CreateImagesRunServiceOptions { + rootResolver: () => string; + workflows: WorkflowManifestStore; + assets: ContentAddressedAssetStore; + references: CreateImagesRunReferenceAuthority; + journalStore?: CreateImagesRunJournalStore; + now?: () => number; + createRunId?: () => string; + mockScript?: (nodeIds: readonly string[]) => MockImageProviderScript; + resolveGeminiAuth?: () => Promise; + createGeminiProvider?: () => GeminiImageProvider; + annotationRasterizer?: CreateImagesAnnotationRasterizer; + /** Fast root/config check before a run can start provider work. */ + workspaceStatus?: () => Promise<{ configured: boolean; state: CreateImagesWorkspaceState }>; + workspaceRequired?: boolean; + shutdownTimeoutMs?: number; +} + +export interface CreateImagesRunStartRequest { + workflowId: string; + expectedRevision: number; + scope: WorkflowRunScope; + executionMode?: "local-mock" | "gemini"; + providerConsent?: CreateImagesProviderConsentClaimV1; +} + +export interface CreateImagesPrepareGeminiRunRequest { + workflowId: string; + expectedRevision: number; + scope: WorkflowRunScope; +} + +interface PendingGeminiConsent { + mainPlan: CreateImagesProviderExecutionConsentPlanV1; + scope: WorkflowRunScope; +} + +const TERMINAL_RUN_STATUSES = new Set([ + "succeeded", + "failed", + "cancelled", + "interrupted", + "needs_attention", +]); +export const CREATE_IMAGES_MAX_ACTIVE_RUNS = 4; +const CREATE_IMAGES_MAX_CACHED_WORKFLOW_HISTORIES = 64; +const CREATE_IMAGES_GEMINI_RETRY_POLICY = Object.freeze({ + maxRetriesPerNode: 0, + baseDelayMs: 0, + maxDelayMs: 0, + maxTotalDelayMs: 0, + jitterRatio: 0, + retryRemoteNotSubmitted: false, + retryRemoteIdempotent: false, +}); +const CREATE_IMAGES_GEMINI_CATALOG_REVISION = 1; +const CREATE_IMAGES_GEMINI_CATALOG_OBSERVED_AT = "2026-08-11T00:00:00.000Z"; +const CREATE_IMAGES_GEMINI_CONSENT_LIFETIME_MS = 15 * 60_000; +const CREATE_IMAGES_MAX_PENDING_GEMINI_CONSENTS = 32; +const CREATE_IMAGES_GEMINI_ESTIMATE_SOURCE_FINGERPRINT = createHash("sha256") + .update("google-gemini-interactions-pricing-unavailable-2026-08-11") + .digest("hex"); + +function realClock(now: () => number): CoordinatorClock { + return { + now, + sleep(delayMs, signal) { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timeout = setTimeout(finish, delayMs); + const abort = () => { + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + reject(signal.reason); + }; + function finish(): void { + signal.removeEventListener("abort", abort); + resolve(); + } + signal.addEventListener("abort", abort, { once: true }); + }); + }, + }; +} + +function defaultMockScript(nodeIds: readonly string[]): MockImageProviderScript { + return { + nodes: Object.fromEntries( + nodeIds.map((nodeId, index) => [ + nodeId, + [ + { + outcome: "success" as const, + delayMs: 450 + (index % 3) * 150, + seed: index + 1, + width: 96, + height: 96, + }, + ], + ]), + ), + }; +} + +function isDurableNodeOutput(value: unknown): value is DurableNodeOutput { + return ( + typeof value === "object" && + value !== null && + (value as DurableNodeOutput).kind !== undefined && + Array.isArray((value as DurableNodeOutput).assetIds) + ); +} + +type ProviderImageOutputBatch = MockImageOutputBatch | GeminiImageProviderOutput; + +function geminiCoordinatorErrorCode(code: GeminiImageProviderErrorCode): CoordinatorErrorCode { + if (code === "authentication-required") return "authentication-required"; + if (code === "permission-denied") return "permission-denied"; + if (code === "invalid-request" || code === "request-rejected") return "request-rejected"; + if (code === "refused") return "provider-refused"; + if ( + code === "response-too-large" || + code === "response-malformed" || + code === "response-mime-mismatch" || + code === "incomplete" + ) { + return "output-invalid"; + } + return code === "rate-limited" ? "rate-limited" : "provider-unavailable"; +} + +function isProviderImageOutputBatch(value: unknown): value is ProviderImageOutputBatch { + return ( + typeof value === "object" && + value !== null && + Array.isArray((value as ProviderImageOutputBatch).images) && + (value as ProviderImageOutputBatch).images.length > 0 && + (value as ProviderImageOutputBatch).images.every( + (image) => image?.bytes instanceof Uint8Array && typeof image.metadata === "object", + ) + ); +} + +function assetIdsFrom(value: unknown): string[] { + return isDurableNodeOutput(value) ? [...value.assetIds] : []; +} + +async function* bytesOf(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function iso(atMs: number): string { + return new Date(atMs).toISOString(); +} + +function createEventBase(journal: CreateImagesRunJournalV1, atMs: number) { + const durableAtMs = Date.parse(journal.updatedAt); + return { + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: iso(Math.max(atMs, durableAtMs)), + }; +} + +function runView(journal: CreateImagesRunJournalV1): CreateImagesRunView { + const projection = projectCreateImagesRun(journal); + const nodes = new Map(journal.workflowSnapshot.nodes.map((node) => [node.id, node])); + return { + runId: journal.runId, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + journalRevision: journal.journalRevision, + status: projection.status, + lastSequence: projection.lastSequence, + scope: structuredClone(journal.plan.scope), + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + executionMode: journal.providerAuthorization ? "gemini" : "local-mock", + ...(projection.status === "paused" && projection.pause + ? { + pause: { + checkpointId: projection.pause.checkpointId, + beforeNodeId: projection.pause.beforeNodeId, + edgeIds: [...projection.pause.edgeIds], + pausedAt: projection.pause.pausedAt, + }, + } + : {}), + ...(projection.ambiguityResolution + ? { ambiguityResolution: { ...projection.ambiguityResolution } } + : {}), + nodes: journal.plan.orderedNodeIds.map((nodeId) => { + const node = nodes.get(nodeId); + const projected = projection.nodes[nodeId]!; + const attempt = projected.attempts[projected.attempts.length - 1]; + return { + nodeId, + label: `${node ? CREATE_IMAGES_NODE_DEFINITIONS[node.type].title : "Workflow node"} · ${nodeId}`, + status: projected.status, + attempt: attempt?.attempt ?? 0, + outputAssetIds: [...projected.outputAssetIds], + ...(projected.errorCode ? { errorCode: projected.errorCode } : {}), + ...(attempt?.retry ? { retrySafety: attempt.retry.safety } : {}), + }; + }), + }; +} + +function terminalCostLabel( + projection: ReturnType, + isGemini: boolean, +): string { + if (!isGemini) return "$0.00 mock actual"; + const costs = Object.values(projection.nodes).flatMap((node) => + Object.values(node.batchItems ?? {}).flatMap((item) => (item.cost ? [item.cost] : [])), + ); + if (costs.length === 0 || costs.some((cost) => cost.kind === "unknown")) { + return "Actual cost unknown"; + } + const actual = costs.filter( + (cost): cost is Extract<(typeof costs)[number], { kind: "actual" }> => cost.kind === "actual", + ); + const currencies = new Set(actual.map((cost) => cost.currency)); + if (actual.length !== costs.length || currencies.size !== 1) return "Actual cost unknown"; + const amount = actual.reduce((total, cost) => total + cost.amountMicros, 0) / 1_000_000; + const currency = actual[0]!.currency; + const digits = amount === 0 ? "0.00" : amount.toFixed(4).replace(/0+$/u, "").replace(/[.]$/u, ""); + return `Actual ${currency === "USD" ? "$" : `${currency} `}${digits}`; +} + +function terminalView(journal: CreateImagesRunJournalV1): CreateImagesTerminalRunView | undefined { + const projection = projectCreateImagesRun(journal); + if (!projection.terminal) return undefined; + const providerNodeIds = new Set( + journal.workflowSnapshot.nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id), + ); + const outputCount = [...providerNodeIds].reduce( + (total, nodeId) => total + (projection.nodes[nodeId]?.outputAssetIds.length ?? 0), + 0, + ); + const geminiModelId = journal.workflowSnapshot.nodes.find( + (node) => node.type === "generate-image", + )?.data.modelId; + const isGemini = journal.providerAuthorization !== undefined; + return { + runId: journal.runId, + workflowRevision: journal.workflowRevision, + status: projection.terminal.status, + scope: structuredClone(journal.plan.scope), + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + executionMode: isGemini ? "gemini" : "local-mock", + providerLabel: isGemini ? "Google Gemini" : "Aiden local mock", + modelLabel: isGemini ? (geminiModelId ?? "Gemini image model") : "Deterministic Phase 3", + costLabel: terminalCostLabel(projection, isGemini), + ...(projection.ambiguityResolution + ? { ambiguityResolution: { ...projection.ambiguityResolution } } + : {}), + requestCount: [...providerNodeIds].reduce( + (total, nodeId) => total + (projection.nodes[nodeId]?.attempts.length ?? 0), + 0, + ), + outputCount, + completedNodeCount: Object.values(projection.nodes).filter( + (node) => node.status === "succeeded", + ).length, + totalNodeCount: journal.plan.orderedNodeIds.length, + }; +} + +function recoveryRequiredView( + health: Pick< + Extract, + | "workflowId" + | "runId" + | "reason" + | "canRecover" + | "currentJournalRevision" + | "lastKnownGoodJournalRevision" + > & { expectedJournalRevision?: number }, +): CreateImagesRunRecoveryRequiredView | undefined { + if (!health.workflowId) return undefined; + const recoverySource = + health.canRecover === "from-last-known-good" + ? "last-known-good" + : health.canRecover === "from-current" + ? "current" + : undefined; + const expectedCandidateJournalRevision = + health.expectedJournalRevision ?? + (recoverySource === "last-known-good" + ? health.lastKnownGoodJournalRevision + : recoverySource === "current" + ? health.currentJournalRevision + : undefined); + return { + status: "recovery-required", + workflowId: health.workflowId, + runId: health.runId, + reason: health.reason, + ...(recoverySource && expectedCandidateJournalRevision !== undefined + ? { recoverySource, expectedCandidateJournalRevision } + : {}), + ...(health.currentJournalRevision === undefined + ? {} + : { currentJournalRevision: health.currentJournalRevision }), + ...(health.lastKnownGoodJournalRevision === undefined + ? {} + : { lastKnownGoodJournalRevision: health.lastKnownGoodJournalRevision }), + }; +} + +function unsafeRecoveryView( + health: Pick< + Extract, + "workflowId" | "runId" | "reason" + >, +): CreateImagesRunUnsafeRecoveryView | undefined { + if (!health.workflowId) return undefined; + return { + status: "unsafe", + workflowId: health.workflowId, + runId: health.runId, + reason: health.reason, + }; +} + +export class CreateImagesRunService { + readonly journals: CreateImagesRunJournalStore; + private readonly now: () => number; + private readonly createRunId: () => string; + private readonly mockScript: (nodeIds: readonly string[]) => MockImageProviderScript; + private readonly shutdownTimeoutMs: number; + private readonly activeByWorkflow = new Map(); + private readonly activeByRun = new Map(); + private startAdmissionTail: Promise = Promise.resolve(); + private shutdownAdmissionBarrier = false; + private readonly listeners = new Set<(workflowId: string) => void>(); + private readonly terminalCache = new Map< + string, + { + history: CreateImagesTerminalRunView[]; + latestTerminalRun?: CreateImagesRunView; + } + >(); + private initializePromise: Promise | undefined; + private readonly providerConsentAuthority: CreateImagesProviderConsentAuthority = { + secret: randomBytes(32), + }; + private readonly pendingGeminiConsents = new Map(); + private readonly providerAdmissionGate = new CreateImagesProviderAdmissionGate([ + { + providerId: "gemini", + maxConcurrency: 2, + maxStartsPerWindow: 500, + windowMs: 60_000, + minimumStartIntervalMs: 0, + }, + ]); + + constructor(private readonly options: CreateImagesRunServiceOptions) { + this.journals = options.journalStore ?? new CreateImagesRunJournalStore(options.rootResolver); + this.now = options.now ?? Date.now; + this.createRunId = options.createRunId ?? randomUUID; + this.mockScript = options.mockScript ?? defaultMockScript; + this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? 5_000; + if (!Number.isSafeInteger(this.shutdownTimeoutMs) || this.shutdownTimeoutMs < 1) { + throw new Error("Invalid Create Images shutdown timeout."); + } + } + + subscribe(listener: (workflowId: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(workflowId: string): void { + for (const listener of this.listeners) { + try { + listener(workflowId); + } catch { + // Publication is already durable. Observers cannot roll it back. + } + } + } + + private cacheTerminalHistory( + workflowId: string, + value: { + history: CreateImagesTerminalRunView[]; + latestTerminalRun?: CreateImagesRunView; + }, + ): void { + this.terminalCache.delete(workflowId); + this.terminalCache.set(workflowId, value); + while (this.terminalCache.size > CREATE_IMAGES_MAX_CACHED_WORKFLOW_HISTORIES) { + const oldest = this.terminalCache.keys().next().value as string | undefined; + if (!oldest) break; + this.terminalCache.delete(oldest); + } + } + + private async recoveryViewsForWorkflow( + workflowId: string, + ): Promise { + const candidates = await this.journals.workflowDegradedCandidates(workflowId); + return this.recoveryViews(candidates); + } + + private recoveryViews( + candidates: Awaited>, + ): CreateImagesRunRecoveryView[] { + const recoveries: CreateImagesRunRecoveryView[] = []; + for (const candidate of candidates) { + const view = + candidate.status === "unsafe" + ? unsafeRecoveryView(candidate) + : recoveryRequiredView(candidate); + if (view) recoveries.push(view); + } + return recoveries; + } + + async initialize(): Promise { + this.initializePromise ??= (async () => { + await this.journals.initialize(); + await this.options.references.reconcileRuns(this.journals); + for (const journal of await this.journals.reconciliationCandidates()) { + const projection = projectCreateImagesRun(journal); + if (projection.terminal) continue; + if (projection.status === "paused") { + const active = this.recoveryActive(journal); + active.publishedOutputs = new Map(await this.resumeOutputs(journal)); + this.activeByRun.set(active.runId, active); + this.activeByWorkflow.set(active.workflowId, active); + continue; + } + await this.reconcileAfterRestart(journal); + } + await this.options.references.reconcileRuns(this.journals); + })(); + try { + await this.initializePromise; + } catch (error) { + this.initializePromise = undefined; + throw error; + } + } + + private recoveryActive(journal: CreateImagesRunJournalV1): ActiveRun { + let resolveCancelDurable: () => void = () => undefined; + const cancelDurable = new Promise((resolve) => { + resolveCancelDurable = resolve; + }); + return { + runId: journal.runId, + workflowId: journal.workflowId, + journal, + execution: { mode: "local-mock" }, + controller: new AbortController(), + mutationTail: Promise.resolve(), + publicationTail: Promise.resolve(), + publishedOutputs: new Map(), + reservations: new Map(), + cancelDurable, + resolveCancelDurable, + settled: Promise.resolve(), + }; + } + + private async reconcileAfterRestart(initial: CreateImagesRunJournalV1): Promise { + const active = this.recoveryActive(initial); + const durability = this.durability(active); + const nodes = new Map(initial.workflowSnapshot.nodes.map((node) => [node.id, node])); + let provider: DeterministicMockImageProvider | undefined; + const providerForAcceptedJob = (): DeterministicMockImageProvider => { + provider ??= new DeterministicMockImageProvider({ + clock: realClock(this.now), + script: this.mockScript( + initial.workflowSnapshot.nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id), + ), + }); + return provider; + }; + const appendNode = async ( + nodeId: string, + status: "succeeded" | "failed" | "cancelled" | "blocked" | "ambiguous", + attempt: number, + errorCode?: + | "execution-failed" + | "interrupted" + | "output-publication-failed" + | "cancelled" + | "submission-ambiguous", + ): Promise => + durability.appendEvent({ + workflowId: active.workflowId, + workflowRevision: active.journal.workflowRevision, + runId: active.runId, + sequence: projectCreateImagesRun(active.journal).lastSequence + 1, + atMs: Math.max(this.now(), Date.parse(active.journal.updatedAt)), + kind: "node", + nodeId, + status, + attempt, + ...(errorCode ? { errorCode } : {}), + }); + let recoveryInterrupted = false; + + for (const nodeId of initial.plan.orderedNodeIds) { + let projection = projectCreateImagesRun(active.journal); + const nodeRun = projection.nodes[nodeId]; + if ( + !nodeRun || + ["succeeded", "failed", "cancelled", "blocked", "ambiguous"].includes(nodeRun.status) + ) { + continue; + } + if (nodeRun.status === "queued") continue; + const node = nodes.get(nodeId); + const attempt = nodeRun.attempts[nodeRun.attempts.length - 1]; + if (nodeRun.durableOutputAssetIds !== undefined) { + const decision = reconcileRestartNode({ + phase: "output-publishing", + lane: node?.type === "generate-image" ? "remote" : "local", + durableOutputAvailable: true, + }); + if (decision.category !== "resume-output-publication") { + throw new Error("Durable output publication did not produce a resumable decision."); + } + const uniqueAssetIds = [...new Set(nodeRun.durableOutputAssetIds)]; + const availability = await Promise.all( + uniqueAssetIds.map((assetId) => this.options.assets.getAvailable(assetId)), + ); + if (availability.some((asset) => asset === undefined)) { + await appendNode(nodeId, "failed", attempt?.attempt ?? 0, "output-publication-failed"); + continue; + } + active.publishedOutputs.set(nodeId, { + kind: node?.type === "prompt" ? "text" : "images", + assetIds: [...nodeRun.durableOutputAssetIds], + }); + await appendNode(nodeId, "succeeded", attempt?.attempt ?? 0); + continue; + } + if ( + node?.type === "generate-image" && + attempt && + (attempt.submission === "prepared" || attempt.submission === "ambiguous") + ) { + const decision = reconcileRestartNode({ + phase: "remote-submitting", + lane: "remote", + }); + if (decision.category !== "ambiguous-submit") { + throw new Error("Prepared submission did not produce an ambiguous restart decision."); + } + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + continue; + } + if (projection.cancellation) { + const decision = reconcileRestartNode({ + phase: "cancel-requested", + lane: node?.type === "generate-image" ? "remote" : "local", + ...(attempt?.providerJobId ? { remoteJobId: attempt.providerJobId } : {}), + }); + if (decision.category !== "reconcile-cancel" && decision.category !== "finalize-cancel") { + throw new Error("Durable cancellation did not produce a cancellation restart decision."); + } + await appendNode(nodeId, "cancelled", attempt?.attempt ?? 0, "cancelled"); + continue; + } + if (node?.type !== "generate-image" || !attempt) { + if (nodeRun.status === "running") { + recoveryInterrupted = true; + await appendNode(nodeId, "failed", attempt?.attempt ?? 0, "interrupted"); + } + continue; + } + if (attempt.submission !== "accepted") { + recoveryInterrupted = true; + await appendNode(nodeId, "failed", attempt.attempt, "interrupted"); + continue; + } + if (active.journal.providerAuthorization) { + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + continue; + } + const decision = reconcileRestartNode({ + phase: "remote-submitted", + lane: "remote", + ...(attempt.providerJobId ? { remoteJobId: attempt.providerJobId } : {}), + }); + if (decision.category !== "reconcile-remote-job" || !decision.remoteJobId) { + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + continue; + } + const result = providerForAcceptedJob().reconcileAccepted({ + runId: active.runId, + node, + attempt: attempt.attempt, + idempotencyKey: attempt.idempotencyKey, + remoteJobId: decision.remoteJobId, + }); + if (result.kind === "success") { + await durability.publishOutput({ + workflowId: active.workflowId, + workflowRevision: active.journal.workflowRevision, + runId: active.runId, + nodeId, + output: result.output, + }); + await appendNode(nodeId, "succeeded", attempt.attempt); + } else if (result.kind === "ambiguous-submit") { + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + } else { + await appendNode(nodeId, "failed", attempt.attempt, "execution-failed"); + } + } + + let projection = projectCreateImagesRun(active.journal); + for (const nodeId of active.journal.plan.orderedNodeIds) { + const nodeRun = projection.nodes[nodeId]; + if (!nodeRun || nodeRun.status !== "queued") continue; + const upstream = active.journal.plan.dependencies[nodeId] ?? []; + if ( + upstream.some((dependency) => + ["failed", "cancelled", "blocked", "ambiguous"].includes( + projection.nodes[dependency]?.status ?? "", + ), + ) + ) { + await appendNode(nodeId, "blocked", 0); + projection = projectCreateImagesRun(active.journal); + continue; + } + if (projection.cancellation) { + await appendNode(nodeId, "cancelled", 0, "cancelled"); + } else { + recoveryInterrupted = true; + await appendNode(nodeId, "failed", 0, "interrupted"); + } + projection = projectCreateImagesRun(active.journal); + } + const finalProjection = projectCreateImagesRun(active.journal); + const values = Object.values(finalProjection.nodes); + const terminalStatus = values.some((node) => node.status === "ambiguous") + ? "needs_attention" + : finalProjection.cancellation + ? "cancelled" + : recoveryInterrupted + ? "interrupted" + : values.every((node) => node.status === "succeeded") + ? "succeeded" + : values.some((node) => node.status === "failed" || node.status === "blocked") + ? "failed" + : "interrupted"; + await this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, { + ...createEventBase(journal, this.now()), + type: "run-terminal", + status: terminalStatus, + }), + ); + await active.publicationTail; + await active.mutationTail; + await this.options.references.releaseRunReservations(active.runId).catch(() => undefined); + this.terminalCache.delete(active.workflowId); + await this.options.references.reconcileRuns(this.journals); + this.notify(active.workflowId); + } + + private async releaseActiveOwnershipIfTerminalOrDegradedCore( + active: ActiveRun, + ): Promise { + let releasable = projectCreateImagesRun(active.journal).terminal !== undefined; + if (!releasable) { + try { + const health = await this.journals.health(active.runId); + if (health.status === "healthy") { + const journal = await this.journals.get(active.runId); + if (!journal) return false; + active.journal = journal; + releasable = projectCreateImagesRun(journal).terminal !== undefined; + } else { + releasable = health.status === "recovery-required" || health.status === "unsafe"; + } + } catch { + return false; + } + } + if (!releasable) return false; + + await active.publicationTail; + await active.mutationTail; + await this.options.references.releaseRunReservations(active.runId).catch(() => undefined); + await this.options.references.reconcileRuns(this.journals).catch(() => undefined); + this.activeByRun.delete(active.runId); + if (this.activeByWorkflow.get(active.workflowId) === active) { + this.activeByWorkflow.delete(active.workflowId); + } + active.needsReconciliation = false; + this.notify(active.workflowId); + return true; + } + + private async releaseActiveOwnershipIfTerminalOrDegraded( + active: ActiveRun, + deadline = Date.now() + this.shutdownTimeoutMs, + ): Promise { + if (!active.ownershipReleaseAttempt) { + const operation = this.releaseActiveOwnershipIfTerminalOrDegradedCore(active); + const attempt = operation.finally(() => { + if (active.ownershipReleaseAttempt === attempt) { + active.ownershipReleaseAttempt = undefined; + } + }); + active.ownershipReleaseAttempt = attempt; + } + const attempt = active.ownershipReleaseAttempt; + if (!(await this.waitUntilDeadline(attempt, deadline))) return false; + return attempt.catch(() => false); + } + + private async reconcileFailedLaunch( + active: ActiveRun, + deadline = Date.now() + this.shutdownTimeoutMs, + ): Promise { + if (!active.needsReconciliation) return true; + + const joinedExistingAttempt = active.reconciliationAttempt !== undefined; + if (!active.reconciliationAttempt) { + const operation = (async () => { + try { + await active.publicationTail; + await active.mutationTail; + const health = await this.journals.health(active.runId); + if (health.status === "healthy") { + const journal = await this.journals.get(active.runId); + if (journal) { + active.journal = journal; + if (!projectCreateImagesRun(journal).terminal) { + await this.reconcileAfterRestart(journal); + } + } + } + } catch { + // Keep ownership of healthy nonterminal work. A later list or + // admission can join this single attempt, but never resubmits work. + } + await this.releaseActiveOwnershipIfTerminalOrDegraded(active); + })(); + const attempt = operation.finally(() => { + if (active.reconciliationAttempt === attempt) active.reconciliationAttempt = undefined; + }); + active.reconciliationAttempt = attempt; + } + if (!(await this.waitUntilDeadline(active.reconciliationAttempt, deadline))) return false; + // A foreground caller that merely joined an earlier failed attempt gets + // one fresh reconciliation opportunity within the same deadline. The + // recovery path never invokes the live executor or resubmits provider work. + if (joinedExistingAttempt && active.needsReconciliation && !active.reconciliationAttempt) { + return this.reconcileFailedLaunch(active, deadline); + } + return true; + } + + private mutateJournal( + active: ActiveRun, + mutate: (journal: CreateImagesRunJournalV1) => Promise, + ): Promise { + const operation = active.mutationTail.then(async () => { + active.journal = await mutate(active.journal); + this.notify(active.workflowId); + }); + active.mutationTail = operation.catch(() => undefined); + return operation; + } + + private durability(active: ActiveRun): CoordinatorDurability { + const append = (create: (journal: CreateImagesRunJournalV1) => CreateImagesRunEventV1) => + this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, create(journal)), + ); + return { + persistPlan: async (record) => { + if ( + record.runId !== active.runId || + record.workflowId !== active.workflowId || + record.workflowRevision !== active.journal.workflowRevision || + record.plan.workflowId !== active.journal.workflowId || + record.plan.workflowRevision !== active.journal.workflowRevision || + JSON.stringify(record.plan.scope) !== JSON.stringify(active.journal.plan.scope) || + JSON.stringify(record.plan.snapshot) !== + JSON.stringify(active.journal.workflowSnapshot) || + JSON.stringify(record.plan.orderedNodeIds) !== + JSON.stringify(active.journal.plan.orderedNodeIds) || + JSON.stringify(record.plan.dependencies) !== + JSON.stringify(active.journal.plan.dependencies) + ) { + throw new Error("The coordinator plan does not match the durable run snapshot."); + } + }, + persistCancelIntent: async (intent) => { + await this.mutateJournal(active, (journal) => + projectCreateImagesRun(journal).cancellation + ? Promise.resolve(journal) + : this.journals.requestCancellation(journal.runId, journal.journalRevision, { + at: createEventBase(journal, this.now()).at, + reason: intent.reason, + }), + ); + active.resolveCancelDurable(); + }, + persistSubmissionPrepared: (record) => { + const node = active.journal.workflowSnapshot.nodes.find( + (candidate) => candidate.id === record.nodeId, + ); + if (active.execution.mode === "gemini" && node?.type !== "generate-image") { + throw new Error("Gemini submission preparation requires a generation node."); + } + const modelId = + active.execution.mode === "gemini" && node?.type === "generate-image" + ? node.data.modelId + : "deterministic-v1"; + if (!modelId) throw new Error("Gemini submission preparation requires a curated model."); + return append((journal) => ({ + ...createEventBase(journal, this.now()), + type: "node-submission-prepared", + nodeId: record.nodeId, + attempt: record.attempt, + idempotencyKey: record.idempotencyKey, + providerId: active.execution.mode === "gemini" ? "gemini" : "local-mock", + modelId, + })); + }, + persistRemoteJob: (record) => + append((journal) => ({ + ...createEventBase(journal, this.now()), + type: "node-submission-accepted", + nodeId: record.nodeId, + attempt: record.attempt, + providerJobId: record.remoteJobId, + })), + publishOutput: async (record) => { + const durable = await this.publishNodeOutput(active, record.nodeId, record.output); + active.publishedOutputs.set(record.nodeId, durable); + await append((journal) => ({ + ...createEventBase(journal, this.now()), + type: "node-output-published", + nodeId: record.nodeId, + outputAssetIds: [...durable.assetIds], + })); + return durable; + }, + appendEvent: async (event) => { + if (event.kind === "remote-job" || (event.kind === "node" && event.status === "queued")) { + return; + } + if (event.kind === "run") { + if (event.status === "paused") { + const checkpoint = active.pendingPause; + if (!checkpoint) throw new Error("A durable pause requires a prepared checkpoint."); + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "run-paused", + ...checkpoint, + })); + active.pendingPause = undefined; + return; + } + if (event.status !== "running") this.terminalCache.delete(active.workflowId); + await append( + (journal) => + ({ + ...createEventBase(journal, event.atMs), + type: event.status === "running" ? "run-started" : "run-terminal", + ...(event.status === "running" ? {} : { status: event.status }), + }) as CreateImagesRunEventV1, + ); + return; + } + if (event.status === "running") { + if (event.attempt === 1) { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-started", + nodeId: event.nodeId, + })); + } + return; + } + if (event.status === "retry_wait") { + const safety = event.retrySafety; + if (safety !== "confirmed-not-submitted" && safety !== "same-idempotency-key") { + throw new Error("Remote retries require a durable safety classification."); + } + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-retry-scheduled", + nodeId: event.nodeId, + attempt: event.attempt, + errorCode: event.errorCode ?? "execution-failed", + delayMs: event.retryDelayMs ?? 0, + retrySafety: safety, + })); + return; + } + if (event.status === "ambiguous") { + const projection = projectCreateImagesRun(active.journal); + const attempts = projection.nodes[event.nodeId]?.attempts ?? []; + const attempt = attempts[attempts.length - 1]; + if (attempt?.submission === "prepared") { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-submission-ambiguous", + nodeId: event.nodeId, + attempt: event.attempt, + })); + } + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-ambiguous", + nodeId: event.nodeId, + attempt: event.attempt, + })); + return; + } + if (event.status === "succeeded") { + const output = active.publishedOutputs.get(event.nodeId); + if (!output) { + throw new Error("A node cannot succeed before its output is durably published."); + } + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-succeeded", + nodeId: event.nodeId, + outputAssetIds: [...output.assetIds], + })); + const reservation = active.reservations.get(event.nodeId); + if (reservation) { + try { + await this.options.references.commitRun(reservation); + } catch (error) { + if (!(await this.options.references.reconcileRuns(this.journals))) throw error; + } + active.reservations.delete(event.nodeId); + await this.options.assets + .replaceReferences({ kind: "run", id: active.runId }, [...reservation.next].sort()) + .catch(() => undefined); + } + return; + } + if (event.status === "failed") { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-failed", + nodeId: event.nodeId, + errorCode: event.errorCode ?? "execution-failed", + })); + return; + } + if (event.status === "cancelled") { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-cancelled", + nodeId: event.nodeId, + })); + return; + } + const projection = projectCreateImagesRun(active.journal); + const upstreamNodeIds = (active.journal.plan.dependencies[event.nodeId] ?? []).filter( + (nodeId) => + ["failed", "cancelled", "blocked", "ambiguous"].includes( + projection.nodes[nodeId]?.status ?? "", + ), + ); + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-blocked", + nodeId: event.nodeId, + upstreamNodeIds, + })); + }, + }; + } + + private async publishNodeOutput( + active: ActiveRun, + nodeId: string, + output: unknown, + ): Promise { + const operation = active.publicationTail.then(() => + this.publishNodeOutputInternal(active, nodeId, output), + ); + active.publicationTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private cumulativeRunAssetIds(active: ActiveRun, nextAssetIds: readonly string[]): string[] { + const cumulative = new Set(nextAssetIds); + for (const published of active.publishedOutputs.values()) { + for (const assetId of published.assetIds) cumulative.add(assetId); + } + return [...cumulative].sort(); + } + + private async publishNodeOutputInternal( + active: ActiveRun, + nodeId: string, + output: unknown, + ): Promise { + if (isDurableNodeOutput(output)) { + if (output.assetIds.length === 0) { + return { ...output, assetIds: [] }; + } + const reservation = await this.options.references.reserveRun( + active.runId, + this.cumulativeRunAssetIds(active, output.assetIds), + ); + active.reservations.set(nodeId, reservation); + return { ...output, assetIds: [...output.assetIds] }; + } + if (isAnnotationImageOutput(output)) { + const expectedAssetId = createHash("sha256").update(output.bytes).digest("hex"); + const reservation = await this.options.references.reserveRun( + active.runId, + this.cumulativeRunAssetIds(active, [expectedAssetId]), + ); + active.reservations.set(nodeId, reservation); + const ingested = await this.options.assets.ingest(bytesOf(output.bytes), { + origin: { kind: "annotation", sourceAssetId: output.sourceAssetId }, + declaredMimeType: "image/png", + displayName: "Annotated image.png", + generationMetadata: { + source: "aiden-annotation", + width: output.width, + height: output.height, + }, + }); + if (ingested.asset.assetId !== expectedAssetId) { + throw new Error("The durable annotation digest differs from its validated output."); + } + return { kind: "images", assetIds: [expectedAssetId] }; + } + if (!isProviderImageOutputBatch(output)) { + throw new Error("The node executor returned an unsupported output."); + } + const batch = output; + const remoteMetadata = + batch.metadata.source === "gemini-interactions" ? batch.metadata : undefined; + const remote = remoteMetadata !== undefined; + const providerId = remote ? "gemini" : "local-mock"; + const modelId = remoteMetadata?.modelId ?? "deterministic-v1"; + const expectedIds = batch.images.map((image) => + createHash("sha256").update(image.bytes).digest("hex"), + ); + const reservation = await this.options.references.reserveRun( + active.runId, + this.cumulativeRunAssetIds(active, expectedIds), + ); + active.reservations.set(nodeId, reservation); + const assetIds: string[] = []; + for (const [index, image] of batch.images.entries()) { + const ingested = await this.options.assets.ingest(bytesOf(image.bytes), { + origin: { + kind: "provider", + providerId, + modelId, + runId: active.runId, + }, + declaredMimeType: image.metadata.mimeType, + generationMetadata: { + source: image.metadata.source, + mock: !remote, + outputIndex: index, + width: image.metadata.width, + height: image.metadata.height, + ...(image.metadata.source === "deterministic-local-mock" + ? { seed: image.metadata.seed } + : { + modelId: image.metadata.modelId, + ...(remoteMetadata?.interactionId + ? { interactionId: remoteMetadata.interactionId } + : {}), + ...(remoteMetadata?.usage?.totalInputTokens === undefined + ? {} + : { inputTokens: remoteMetadata.usage.totalInputTokens }), + ...(remoteMetadata?.usage?.totalOutputTokens === undefined + ? {} + : { outputTokens: remoteMetadata.usage.totalOutputTokens }), + ...(remoteMetadata?.usage?.totalThoughtTokens === undefined + ? {} + : { thoughtTokens: remoteMetadata.usage.totalThoughtTokens }), + ...(remoteMetadata?.usage?.totalTokens === undefined + ? {} + : { totalTokens: remoteMetadata.usage.totalTokens }), + }), + }, + }); + if (ingested.asset.assetId !== expectedIds[index]) { + throw new Error("The durable asset digest differs from the validated mock output."); + } + assetIds.push(ingested.asset.assetId); + } + return { kind: "images", assetIds }; + } + + private async executeNode( + active: ActiveRun, + provider: DeterministicMockImageProvider, + providerEvents: MockProviderEventCoordinator, + context: CoordinatorNodeExecutionContext, + ) { + if (context.node.type === "generate-image") { + const promptList = [...context.dependencyOutputs.values()].find( + (value): value is DurableNodeOutput & { items: string[] } => + isDurableNodeOutput(value) && + value.kind === "text-list" && + Array.isArray(value.items), + ); + if (promptList) { + if (promptList.items.length * context.node.data.count > 8) { + return { + kind: "failure" as const, + error: "A local batch is limited to eight planned outputs.", + retrySafety: "never" as const, + }; + } + const itemIds = promptList.items.map((_, index) => + `batch-${createHash("sha256") + .update(active.runId) + .update("\0") + .update(context.node.id) + .update("\0") + .update(String(index)) + .digest("hex") + .slice(0, 40)}`, + ); + for (const [index, itemId] of itemIds.entries()) { + await this.appendBatchItemState(active, context.node.id, itemId, index, "queued"); + } + const outputAssetIds: string[] = []; + for (const [index, itemId] of itemIds.entries()) { + if (context.signal.aborted) { + for (let pending = index; pending < itemIds.length; pending += 1) { + await this.appendBatchItemState( + active, + context.node.id, + itemIds[pending]!, + pending, + "cancelled", + ); + } + return { kind: "cancelled" as const }; + } + await this.appendBatchItemState( + active, + context.node.id, + itemId, + index, + "submission_prepared", + ); + const itemProvider = new DeterministicMockImageProvider({ + clock: realClock(this.now), + script: { + nodes: { + [context.node.id]: [ + { + outcome: "success", + seed: index + 1, + width: 96, + height: 96, + }, + ], + }, + }, + }); + const result = await itemProvider.execute(context); + if (result.kind !== "success") { + await this.appendBatchItemState(active, context.node.id, itemId, index, "failed", { + errorCode: "execution-failed", + cost: { kind: "actual", amountMicros: 0, currency: "USD" }, + }); + for (let pending = index + 1; pending < itemIds.length; pending += 1) { + await this.appendBatchItemState( + active, + context.node.id, + itemIds[pending]!, + pending, + "blocked", + ); + } + return { + kind: "failure" as const, + error: "A deterministic batch item failed.", + retrySafety: "never" as const, + }; + } + await this.appendBatchItemState(active, context.node.id, itemId, index, "submitted"); + const reservationId = `${context.node.id}-batch-${index + 1}`; + const durable = await this.publishNodeOutput(active, reservationId, result.output); + active.publishedOutputs.set(reservationId, durable); + await this.commitBatchReservation(active, reservationId); + outputAssetIds.push(...durable.assetIds); + await this.appendBatchItemState(active, context.node.id, itemId, index, "succeeded", { + outputAssetIds: durable.assetIds, + cost: { kind: "actual", amountMicros: 0, currency: "USD" }, + }); + } + return { + kind: "success" as const, + output: { kind: "images", assetIds: outputAssetIds } satisfies DurableNodeOutput, + }; + } + const result = await provider.execute(context); + if ( + result.kind === "success" && + providerEvents.acceptedTerminalKind({ + runId: context.runId, + nodeId: context.node.id, + attempt: context.attempt, + }) !== "completed" + ) { + return { + kind: "ambiguous-submit" as const, + error: "The mock provider completion was not accepted by the ordered event reducer.", + }; + } + return result; + } + if (context.node.type === "prompt") { + const variables = context.node.data.variables ?? []; + const valuesById: Record = {}; + for (const variable of variables) { + const edge = active.journal.workflowSnapshot.edges.find( + (candidate) => + candidate.target === context.node.id && + candidate.targetPort === `variable-${variable.id}`, + ); + const output = edge ? context.dependencyOutputs.get(edge.source) : undefined; + valuesById[variable.id] = + isDurableNodeOutput(output) && output.kind === "text" ? output.text : undefined; + } + const resolved = resolveCreateImagesPromptVariables( + context.node.data.text, + variables, + valuesById, + ); + if (resolved.status === "invalid") { + return { + kind: "failure" as const, + error: resolved.message, + retrySafety: "never" as const, + }; + } + return { + kind: "success" as const, + output: { + kind: "text", + text: resolved.text, + assetIds: [], + } satisfies DurableNodeOutput, + }; + } + if (context.node.type === "prompt-list") { + const parsed = parseCreateImagesPromptList(context.node.data.source, context.node.data.format); + return parsed.status === "ready" + ? { + kind: "success" as const, + output: { + kind: "text-list", + items: parsed.items, + assetIds: [], + } satisfies DurableNodeOutput, + } + : { + kind: "failure" as const, + error: parsed.message, + retrySafety: "never" as const, + }; + } + if (context.node.type === "image-input") { + const assetId = context.node.data.assetId; + if (!assetId || !(await this.options.assets.getAvailable(assetId))) { + return { + kind: "failure" as const, + error: "The referenced image is unavailable.", + retrySafety: "never" as const, + }; + } + return { + kind: "success" as const, + output: { + kind: "images", + assetIds: [assetId], + } satisfies DurableNodeOutput, + }; + } + if (context.node.type === "annotation") { + const assetIds = new Set(); + for (const value of context.dependencyOutputs.values()) { + for (const assetId of assetIdsFrom(value)) assetIds.add(assetId); + } + const sourceAssetId = [...assetIds][0]; + if (assetIds.size !== 1 || !sourceAssetId) { + return { + kind: "failure" as const, + error: "Annotation requires exactly one available source image.", + retrySafety: "never" as const, + }; + } + if (!this.options.annotationRasterizer) { + return { + kind: "failure" as const, + error: "The packaged annotation rasterizer is unavailable.", + retrySafety: "never" as const, + }; + } + const shapes = context.node.data.shapes; + try { + const rasterized = await this.options.assets.withAssetFile(sourceAssetId, ({ filePath }) => + this.options.annotationRasterizer!.rasterize({ + sourcePath: filePath, + shapes, + }), + ); + return { + kind: "success" as const, + output: { + kind: "annotation-image", + sourceAssetId, + ...rasterized, + } satisfies AnnotationImageOutput, + }; + } catch { + return { + kind: "failure" as const, + error: "Aiden could not rasterize the bounded annotation.", + retrySafety: "local-safe" as const, + }; + } + } + const assetIds = new Set(); + for (const value of context.dependencyOutputs.values()) { + for (const assetId of assetIdsFrom(value)) assetIds.add(assetId); + } + return { + kind: "success" as const, + output: { + kind: "images", + assetIds: [...assetIds], + } satisfies DurableNodeOutput, + }; + } + + private async executeGeminiNode( + active: ActiveRun, + provider: GeminiImageProvider, + context: CoordinatorNodeExecutionContext, + ) { + if (context.node.type !== "generate-image") { + return this.executeNode( + active, + new DeterministicMockImageProvider({ clock: realClock(this.now), script: { nodes: {} } }), + new MockProviderEventCoordinator(), + context, + ); + } + if (active.execution.mode !== "gemini") { + return { + kind: "failure" as const, + error: "Gemini execution authority is unavailable.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + if (!this.options.resolveGeminiAuth || !active.journal.providerAuthorization) { + return { + kind: "failure" as const, + error: "Gemini credential authority is unavailable after durable preparation.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + let currentAuth: AuthResult; + try { + currentAuth = await this.options.resolveGeminiAuth(); + const binding = this.geminiCredentialBinding(currentAuth); + if ( + binding.recordId !== active.journal.providerAuthorization.credentialRecordId || + binding.revision !== active.journal.providerAuthorization.credentialRevision + ) { + return { + kind: "failure" as const, + error: "The reviewed Gemini credential changed before submission.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + } catch { + return { + kind: "failure" as const, + error: "The reviewed Gemini credential is no longer available.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + const promptOutputs = [...context.dependencyOutputs.values()].filter( + (value): value is DurableNodeOutput & { text: string } => + isDurableNodeOutput(value) && + value.kind === "text" && + typeof value.text === "string" && + value.text.trim().length > 0, + ); + const promptListOutputs = [...context.dependencyOutputs.values()].filter( + (value): value is DurableNodeOutput & { items: string[] } => + isDurableNodeOutput(value) && + value.kind === "text-list" && + Array.isArray(value.items) && + value.items.length > 0, + ); + if (promptOutputs.length + promptListOutputs.length !== 1) { + return { + kind: "failure" as const, + error: "Gemini generation requires exactly one durable prompt or prompt-list input.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + const promptItems = promptListOutputs[0]?.items ?? [promptOutputs[0]!.text]; + if ( + promptItems.length * context.node.data.count > 8 || + promptItems.length > active.journal.providerAuthorization.initialRequestCount + ) { + return { + kind: "failure" as const, + error: "The durable batch no longer matches its confirmed request count.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + const referenceIds = [ + ...new Set( + [...context.dependencyOutputs.values()].flatMap((value) => + isDurableNodeOutput(value) && value.kind === "images" ? value.assetIds : [], + ), + ), + ]; + const ownerId = `provider-${createHash("sha256") + .update(active.runId) + .update("\0") + .update(context.node.id) + .digest("hex") + .slice(0, 32)}`; + const leases: string[] = []; + try { + const references: ImageGenerationReference[] = []; + for (const assetId of referenceIds) { + const lease = await this.options.assets.acquirePreviewLease(assetId, ownerId, 60_000); + leases.push(lease.token); + const preview = await this.options.assets.readPreview(lease.token, ownerId); + if (!["image/png", "image/jpeg", "image/webp"].includes(preview.asset.mediaType)) { + return { + kind: "failure" as const, + error: "A reference image has an unsupported media type.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + references.push({ + assetId, + bytes: preview.bytes, + mimeType: preview.asset.mediaType as "image/png" | "image/jpeg" | "image/webp", + }); + } + if (promptListOutputs.length === 1) { + const itemIds = promptItems.map((_, index) => + `batch-${createHash("sha256") + .update(active.runId) + .update("\0") + .update(context.node.id) + .update("\0") + .update(String(index)) + .digest("hex") + .slice(0, 40)}`, + ); + for (const [index, itemId] of itemIds.entries()) { + await this.appendBatchItemState(active, context.node.id, itemId, index, "queued"); + } + const outputAssetIds: string[] = []; + const interactionIds: string[] = []; + for (const [index, prompt] of promptItems.entries()) { + const itemId = itemIds[index]!; + if (context.signal.aborted) { + for (let pendingIndex = index; pendingIndex < itemIds.length; pendingIndex += 1) { + await this.appendBatchItemState( + active, + context.node.id, + itemIds[pendingIndex]!, + pendingIndex, + "cancelled", + ); + } + return { kind: "cancelled" as const }; + } + await this.appendBatchItemState( + active, + context.node.id, + itemId, + index, + "submission_prepared", + ); + const request: ValidatedImageGenerationRequest = { + providerId: "gemini", + modelId: context.node.data.modelId ?? "", + prompt, + aspectRatio: context.node.data.aspectRatio, + imageSize: context.node.data.imageSize, + outputMime: context.node.data.outputMime, + count: context.node.data.count, + references, + }; + const gate = this.providerAdmissionGate.tryAcquire("gemini", this.now()); + if (gate.status === "deferred") { + await this.appendBatchItemState(active, context.node.id, itemId, index, "failed", { + errorCode: "rate-limited", + cost: { kind: "unknown" }, + }); + for (let pendingIndex = index + 1; pendingIndex < itemIds.length; pendingIndex += 1) { + await this.appendBatchItemState( + active, + context.node.id, + itemIds[pendingIndex]!, + pendingIndex, + "blocked", + ); + } + return { + kind: "failure" as const, + error: "Gemini request admission is temporarily busy.", + retrySafety: "never" as const, + errorCode: "rate-limited" as const, + }; + } + try { + const result = await provider.execute(currentAuth, request, { + runId: active.runId, + nodeId: `${context.node.id}-${index + 1}`, + signal: context.signal, + }); + if (result.kind === "cancelled") { + await this.appendBatchItemState(active, context.node.id, itemId, index, "cancelled"); + for (let pendingIndex = index + 1; pendingIndex < itemIds.length; pendingIndex += 1) { + await this.appendBatchItemState( + active, + context.node.id, + itemIds[pendingIndex]!, + pendingIndex, + "cancelled", + ); + } + return result; + } + if (result.kind !== "success") { + const errorCode = geminiCoordinatorErrorCode(result.providerErrorCode); + await this.appendBatchItemState(active, context.node.id, itemId, index, "submitted"); + await this.appendBatchItemState(active, context.node.id, itemId, index, "failed", { + errorCode, + cost: { kind: "unknown" }, + }); + for (let pendingIndex = index + 1; pendingIndex < itemIds.length; pendingIndex += 1) { + await this.appendBatchItemState( + active, + context.node.id, + itemIds[pendingIndex]!, + pendingIndex, + "blocked", + ); + } + return { + ...result, + kind: "failure" as const, + retrySafety: "never" as const, + errorCode, + }; + } + await this.appendBatchItemState(active, context.node.id, itemId, index, "submitted"); + const durable = await this.publishNodeOutput( + active, + `${context.node.id}-batch-${index + 1}`, + result.output, + ); + active.publishedOutputs.set(`${context.node.id}-batch-${index + 1}`, durable); + await this.commitBatchReservation(active, `${context.node.id}-batch-${index + 1}`); + outputAssetIds.push(...durable.assetIds); + if (result.output.metadata.interactionId) { + interactionIds.push(result.output.metadata.interactionId); + } + const usage = result.output.metadata.usage; + await this.appendBatchItemState(active, context.node.id, itemId, index, "succeeded", { + outputAssetIds: durable.assetIds, + ...(usage + ? { + usage: { + ...(usage.totalInputTokens === undefined + ? {} + : { inputTokens: usage.totalInputTokens }), + ...(usage.totalOutputTokens === undefined + ? {} + : { outputTokens: usage.totalOutputTokens }), + ...(usage.totalTokens === undefined ? {} : { totalTokens: usage.totalTokens }), + }, + } + : {}), + cost: { kind: "unknown" }, + }); + } finally { + this.providerAdmissionGate.release(gate.lease); + } + } + const acceptanceId = `gemini-batch-${createHash("sha256") + .update(interactionIds.join("\0") || "no-interaction-ids") + .update("\0") + .update(active.runId) + .update("\0") + .update(context.node.id) + .digest("hex")}`; + try { + await context.recordRemoteJobId(acceptanceId); + } catch { + return { + kind: "ambiguous-submit" as const, + error: "Gemini batch completed, but Aiden could not durably bind the responses.", + }; + } + return { + kind: "success" as const, + output: { kind: "images", assetIds: outputAssetIds } satisfies DurableNodeOutput, + }; + } + const request: ValidatedImageGenerationRequest = { + providerId: "gemini", + modelId: context.node.data.modelId ?? "", + prompt: promptItems[0]!, + aspectRatio: context.node.data.aspectRatio, + imageSize: context.node.data.imageSize, + outputMime: context.node.data.outputMime, + count: context.node.data.count, + references, + }; + const gate = this.providerAdmissionGate.tryAcquire("gemini", this.now()); + if (gate.status === "deferred") { + return { + kind: "rate-limited" as const, + providerErrorCode: "rate-limited" as const, + error: "Gemini request admission is temporarily busy. Review and start a new run later.", + retrySafety: "never" as const, + retryAfterMs: gate.retryAfterMs, + }; + } + try { + const result = await provider.execute(currentAuth, request, { + runId: active.runId, + nodeId: context.node.id, + signal: context.signal, + }); + if (result.kind === "success") { + const acceptanceId = `gemini-sync-${createHash("sha256") + .update(result.output.metadata.interactionId ?? "no-interaction-id") + .update("\0") + .update(active.runId) + .update("\0") + .update(context.node.id) + .digest("hex")}`; + try { + await context.recordRemoteJobId(acceptanceId); + } catch { + return { + kind: "ambiguous-submit" as const, + providerErrorCode: "submission-ambiguous" as const, + error: "Gemini completed, but Aiden could not durably bind the response.", + }; + } + return result; + } + return result.kind === "failure" || result.kind === "rate-limited" + ? { ...result, errorCode: geminiCoordinatorErrorCode(result.providerErrorCode) } + : result; + } finally { + this.providerAdmissionGate.release(gate.lease); + } + } catch { + return { + kind: "failure" as const, + error: "Aiden could not prepare the bounded Gemini image request.", + retrySafety: "confirmed-not-submitted" as const, + }; + } finally { + await Promise.all( + leases.map((token) => + this.options.assets.releasePreviewLease(token, ownerId).catch(() => false), + ), + ); + } + } + + private appendBatchItemState( + active: ActiveRun, + nodeId: string, + itemId: string, + itemIndex: number, + state: CreateImagesBatchItemState, + details: { + outputAssetIds?: string[]; + errorCode?: string; + usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; + cost?: { kind: "unknown" } | { kind: "actual"; amountMicros: number; currency: string }; + } = {}, + ): Promise { + return this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, { + ...createEventBase(journal, this.now()), + type: "batch-item-state", + nodeId, + itemId, + itemIndex, + state, + ...details, + }), + ); + } + + private async commitBatchReservation(active: ActiveRun, reservationId: string): Promise { + const reservation = active.reservations.get(reservationId); + if (!reservation) return; + try { + await this.options.references.commitRun(reservation); + } catch (error) { + if (!(await this.options.references.reconcileRuns(this.journals))) throw error; + } + active.reservations.delete(reservationId); + await this.options.assets + .replaceReferences( + { kind: "run", id: active.runId }, + this.cumulativeRunAssetIds(active, []).sort(), + ) + .catch(() => undefined); + } + + private async resumeOutputs( + journal: CreateImagesRunJournalV1, + ): Promise> { + const projection = projectCreateImagesRun(journal); + const nodes = new Map(journal.workflowSnapshot.nodes.map((node) => [node.id, node])); + const outputs = new Map(); + for (const nodeId of journal.plan.orderedNodeIds) { + const nodeRun = projection.nodes[nodeId]; + if (nodeRun?.status !== "succeeded") continue; + const node = nodes.get(nodeId); + if (!node) throw new Error("A paused run references a missing immutable node."); + if (node.type === "prompt") { + const valuesById: Record = {}; + for (const variable of node.data.variables ?? []) { + const edge = journal.workflowSnapshot.edges.find( + (candidate) => + candidate.target === node.id && + candidate.targetPort === `variable-${variable.id}`, + ); + const dependency = edge ? outputs.get(edge.source) : undefined; + valuesById[variable.id] = dependency?.kind === "text" ? dependency.text : undefined; + } + const resolved = resolveCreateImagesPromptVariables( + node.data.text, + node.data.variables ?? [], + valuesById, + ); + if (resolved.status === "invalid") throw new Error(resolved.message); + outputs.set(nodeId, { kind: "text", text: resolved.text, assetIds: [] }); + continue; + } + if (node.type === "prompt-list") { + const parsed = parseCreateImagesPromptList(node.data.source, node.data.format); + if (parsed.status === "invalid") throw new Error(parsed.message); + outputs.set(nodeId, { kind: "text-list", items: parsed.items, assetIds: [] }); + continue; + } + const assetIds = nodeRun.durableOutputAssetIds ?? nodeRun.outputAssetIds; + outputs.set(nodeId, { kind: "images", assetIds: [...assetIds] }); + } + return outputs; + } + + private launch( + active: ActiveRun, + nodes: readonly WorkflowNodeV1[], + resume?: { initialOutputs: ReadonlyMap; skipBeforeNodeId: string }, + ): void { + active.settled = Promise.resolve() + .then(async () => { + const remoteNodeIds = nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id); + const clock = realClock(this.now); + const providerEvents = new MockProviderEventCoordinator(); + const provider = new DeterministicMockImageProvider({ + clock, + script: this.mockScript(remoteNodeIds), + onProviderEvent: (event) => { + providerEvents.observe(event); + }, + }); + const plan = createWorkflowCoordinatorPlan( + active.journal.workflowSnapshot, + active.journal.plan.scope, + ); + const geminiProvider = + active.execution.mode === "gemini" + ? (this.options.createGeminiProvider?.() ?? new GeminiImageProvider()) + : undefined; + await runWorkflowCoordinator(plan, { + runId: active.runId, + localConcurrency: 4, + remoteConcurrency: + active.execution.mode === "gemini" + ? Math.min(active.journal.workflowSnapshot.settings.concurrency, 2) + : active.journal.workflowSnapshot.settings.concurrency, + clock, + jitter: { sample: () => 0.5 }, + retryPolicy: + active.execution.mode === "gemini" + ? CREATE_IMAGES_GEMINI_RETRY_POLICY + : CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY, + durability: this.durability(active), + signal: active.controller.signal, + ...(resume + ? { initialSucceededOutputs: resume.initialOutputs, resuming: true } + : {}), + pauseBeforeNode: async ({ nodeId, dependencyNodeIds }) => { + if (resume?.skipBeforeNodeId === nodeId) return false; + const edges = active.journal.workflowSnapshot.edges.filter( + (edge) => + edge.breakpoint === true && + edge.target === nodeId && + dependencyNodeIds.includes(edge.source), + ); + if (edges.length === 0) return false; + active.pendingPause = { + checkpointId: randomUUID(), + beforeNodeId: nodeId, + edgeIds: edges.map((edge) => edge.id).sort(), + }; + return true; + }, + executeNode: (context) => + geminiProvider + ? this.executeGeminiNode(active, geminiProvider, context) + : this.executeNode(active, provider, providerEvents, context), + }); + }) + .catch(async () => { + active.needsReconciliation = true; + await this.reconcileFailedLaunch(active); + }) + .finally(async () => { + await this.releaseActiveOwnershipIfTerminalOrDegraded(active); + }); + } + + private currentGeminiCapability(modelId: string) { + const provider = this.options.createGeminiProvider?.() ?? new GeminiImageProvider(); + const model = provider.listModels().find((candidate) => candidate.id === modelId); + if (!model) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The reviewed Gemini model is no longer in Aiden's release catalog.", + ); + } + return createCreateImagesProviderCapabilitySnapshot({ + catalogRevision: CREATE_IMAGES_GEMINI_CATALOG_REVISION, + observedAt: CREATE_IMAGES_GEMINI_CATALOG_OBSERVED_AT, + model, + transport: { + kind: "synchronous", + supportsIdempotency: false, + supportsReconciliation: false, + }, + }); + } + + private geminiCredentialBinding(auth: AuthResult): CreateImagesMainCredentialBindingV1 { + const apiKey = auth.auth.apiKey; + if (typeof apiKey !== "string" || apiKey.length === 0) { + throw new CreateImagesProviderAdmissionError( + "credential-required", + "A compatible Google Gemini API key is required.", + ); + } + const recordId = `google-${createHmac("sha256", this.providerConsentAuthority.secret) + .update("aiden-create-images-google-api-key-v1\0") + .update(apiKey) + .digest("hex")}`; + return createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId, + revision: 1, + authKind: "api-key", + }); + } + + private async geminiInvocations(plan: ReturnType): Promise<{ + capability: ReturnType; + invocations: CreateImagesProviderInvocationFactsV1[]; + }> { + const nodes = new Map(plan.snapshot.nodes.map((node) => [node.id, node])); + const generationNodes = plan.orderedNodeIds + .map((nodeId) => nodes.get(nodeId)) + .filter( + (node): node is Extract => + node?.type === "generate-image", + ); + if (generationNodes.length === 0) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "This run scope contains no Gemini generation request.", + ); + } + const modelIds = new Set(generationNodes.map((node) => node.data.modelId)); + if (modelIds.size !== 1 || generationNodes[0]?.data.modelId === undefined) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "One reviewed Gemini run must use exactly one curated model.", + ); + } + const capability = this.currentGeminiCapability(generationNodes[0].data.modelId); + const invocations: CreateImagesProviderInvocationFactsV1[] = []; + for (const node of generationNodes) { + const dependencies = (plan.dependencies[node.id] ?? []) + .map((nodeId) => nodes.get(nodeId)) + .filter((candidate): candidate is WorkflowNodeV1 => candidate !== undefined); + const prompts = dependencies.filter( + (candidate): candidate is Extract => + candidate.type === "prompt", + ); + const promptLists = dependencies.filter( + (candidate): candidate is Extract => + candidate.type === "prompt-list", + ); + if (prompts.length + promptLists.length !== 1) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Each Gemini generation requires exactly one prompt or prompt-list input.", + ); + } + let promptTexts: string[]; + const promptNode = prompts[0]; + if (promptNode) { + const promptValues: Record = {}; + for (const variable of promptNode.data.variables ?? []) { + const edge = plan.snapshot.edges.find( + (candidate) => + candidate.target === promptNode.id && + candidate.targetPort === `variable-${variable.id}`, + ); + const source = edge ? nodes.get(edge.source) : undefined; + if (source?.type === "prompt" && (source.data.variables?.length ?? 0) === 0) { + promptValues[variable.id] = source.data.text; + } + } + const resolvedPrompt = resolveCreateImagesPromptVariables( + promptNode.data.text, + promptNode.data.variables ?? [], + promptValues, + ); + if (resolvedPrompt.status === "invalid" || !resolvedPrompt.text.trim()) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + resolvedPrompt.status === "invalid" + ? resolvedPrompt.message + : "Each Gemini request requires a non-empty resolved prompt.", + ); + } + promptTexts = [resolvedPrompt.text]; + } else { + const parsed = parseCreateImagesPromptList(promptLists[0]!.data.source, promptLists[0]!.data.format); + if (parsed.status === "invalid") { + throw new CreateImagesProviderAdmissionError("invalid-input", parsed.message); + } + promptTexts = parsed.items; + } + if (promptTexts.length * node.data.count > 8) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A confirmed batch is limited to eight provider invocations including output count.", + ); + } + if (dependencies.some((candidate) => candidate.type === "generate-image")) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Chained cloud generations require a separate reviewed run in this release.", + ); + } + const referenceNodes = dependencies.filter( + (candidate): candidate is Extract => + candidate.type === "image-input" && candidate.data.assetId !== undefined, + ); + const referenceAssets = await Promise.all( + referenceNodes.map((candidate) => + this.options.assets.getAvailable(candidate.data.assetId!), + ), + ); + if (referenceAssets.some((asset) => asset === undefined)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A reviewed Gemini reference image is unavailable.", + ); + } + promptTexts.forEach((promptText, index) => { + invocations.push({ + nodeId: promptTexts.length === 1 ? node.id : `${node.id}-batch-${index + 1}`, + promptBytes: Buffer.byteLength(promptText, "utf8"), + referenceImageCount: referenceAssets.length, + referenceImageBytes: referenceAssets.reduce( + (total, asset) => total + (asset?.byteLength ?? 0), + 0, + ), + requestedOutputs: node.data.count, + aspectRatio: node.data.aspectRatio, + imageSize: node.data.imageSize, + outputMime: node.data.outputMime, + }); + }); + } + return { capability, invocations }; + } + + private purgeExpiredGeminiConsents(): void { + const now = this.now(); + for (const [authorizationId, pending] of this.pendingGeminiConsents) { + if (Date.parse(pending.mainPlan.expiresAt) < now) { + this.pendingGeminiConsents.delete(authorizationId); + } + } + while (this.pendingGeminiConsents.size >= CREATE_IMAGES_MAX_PENDING_GEMINI_CONSENTS) { + const oldest = this.pendingGeminiConsents.keys().next().value as string | undefined; + if (!oldest) break; + this.pendingGeminiConsents.delete(oldest); + } + } + + private async prepareGeminiInternal( + input: CreateImagesPrepareGeminiRunRequest, + ): Promise { + await this.initialize(); + const workflow = await this.options.workflows.get(input.workflowId); + if (!workflow) return { status: "not-found", message: "The workflow no longer exists." }; + if (workflow.revision !== input.expectedRevision) { + return { + status: "conflict", + expectedRevision: input.expectedRevision, + currentRevision: workflow.revision, + }; + } + const audit = await this.journals.auditWorkflowAdmission(workflow.id); + if ( + audit.hasDegradedAuthority || + audit.hasNonterminalRun || + audit.hasUnresolvedAmbiguity || + this.activeByWorkflow.has(workflow.id) + ) { + return { + status: "unavailable", + message: "Resolve the workflow's retained run state before reviewing a new cloud run.", + }; + } + if (!this.options.resolveGeminiAuth) { + return { + status: "unavailable", + message: "Google Gemini image execution is unavailable in this Aiden runtime.", + }; + } + try { + const plan = createWorkflowCoordinatorPlan(workflow, input.scope); + const { capability, invocations } = await this.geminiInvocations(plan); + const auth = await this.options.resolveGeminiAuth(); + const credentialBinding = this.geminiCredentialBinding(auth); + const createdAtMs = this.now(); + const prepared = prepareCreateImagesProviderExecutionConsent( + { + authorizationId: randomUUID(), + workflowId: workflow.id, + workflowRevision: workflow.revision, + executionMode: "gemini", + capability, + credentialBinding, + invocations, + maximumAttempts: invocations.length, + estimate: { + kind: "unavailable", + estimatedAt: iso(createdAtMs), + sourceFingerprint: CREATE_IMAGES_GEMINI_ESTIMATE_SOURCE_FINGERPRINT, + }, + createdAt: iso(createdAtMs), + expiresAt: iso(createdAtMs + CREATE_IMAGES_GEMINI_CONSENT_LIFETIME_MS), + }, + this.providerConsentAuthority, + ); + this.purgeExpiredGeminiConsents(); + this.pendingGeminiConsents.set(prepared.mainPlan.authorizationId, { + mainPlan: prepared.mainPlan, + scope: structuredClone(plan.scope), + }); + return { + status: "ready", + plan: prepared.rendererPlan as CreateImagesProviderConsentPlanView, + }; + } catch (error) { + return { + status: error instanceof CreateImagesProviderAdmissionError ? "invalid" : "unavailable", + message: + error instanceof Error + ? error.message + : "Aiden could not prepare a bounded Gemini consent plan.", + }; + } + } + + async prepareGeminiRun( + input: CreateImagesPrepareGeminiRunRequest, + ): Promise { + const previous = this.startAdmissionTail; + const operation = previous.then(() => this.prepareGeminiInternal(input)); + this.startAdmissionTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private async startInternal( + input: CreateImagesRunStartRequest, + isRendererCurrent: () => boolean, + ): Promise { + if (this.shutdownAdmissionBarrier) { + return { + status: "unavailable", + message: "Aiden is preparing to quit and is not accepting new image runs.", + }; + } + await this.initialize(); + if (this.shutdownAdmissionBarrier) { + return { + status: "unavailable", + message: "Aiden is preparing to quit and is not accepting new image runs.", + }; + } + const orphaned = this.activeByWorkflow.get(input.workflowId); + if ( + orphaned?.needsReconciliation && + !(await this.reconcileFailedLaunch(orphaned, Date.now() + this.shutdownTimeoutMs)) + ) { + return { + status: "unavailable", + message: "The previous durable run is still being reconciled. No new work was started.", + }; + } + const existing = this.activeByWorkflow.get(input.workflowId); + if (existing) + return projectCreateImagesRun(existing.journal).terminal + ? { + status: "unavailable", + message: "The previous durable run is still releasing its ownership.", + } + : { + status: "already-running", + run: runView(existing.journal), + }; + if (this.activeByRun.size >= CREATE_IMAGES_MAX_ACTIVE_RUNS) { + return { + status: "unavailable", + message: `Create Images supports at most ${CREATE_IMAGES_MAX_ACTIVE_RUNS} active runs at once.`, + }; + } + const workflow = await this.options.workflows.get(input.workflowId); + if (!workflow) return { status: "not-found", message: "The workflow no longer exists." }; + if (workflow.revision !== input.expectedRevision) { + return { + status: "conflict", + expectedRevision: input.expectedRevision, + currentRevision: workflow.revision, + }; + } + let admissionAudit: CreateImagesWorkflowAdmissionAudit; + try { + admissionAudit = await this.journals.auditWorkflowAdmission(workflow.id); + } catch { + return { + status: "unavailable", + message: "Run authority could not be revalidated safely. No new image run was started.", + }; + } + if (admissionAudit.hasDegradedAuthority) { + return { + status: "unavailable", + message: + "Resolve the workflow's damaged or unsupported run records before starting another run.", + }; + } + if (admissionAudit.hasNonterminalRun) { + return { + status: "unavailable", + message: "A previous durable run must be reconciled before another image run can start.", + }; + } + if (admissionAudit.hasUnresolvedAmbiguity) { + return { + status: "unavailable", + message: + "A previous submission is unresolved. Acknowledge its duplicate-generation risk before starting another run.", + }; + } + let plan; + try { + plan = createWorkflowCoordinatorPlan(workflow, input.scope); + } catch (error) { + return { + status: "invalid", + message: error instanceof Error ? error.message : "The workflow cannot run.", + }; + } + if (this.options.workspaceStatus) { + const workspace = await this.options.workspaceStatus(); + if ( + (this.options.workspaceRequired && !workspace.configured) || + (workspace.configured && workspace.state !== "ready") + ) { + return { + status: "unavailable", + message: + "The configured Create Images workspace is not ready. Reconnect it before starting this run.", + }; + } + } + if ( + input.executionMode !== undefined && + input.executionMode !== "local-mock" && + input.executionMode !== "gemini" + ) { + return { status: "invalid", message: "The execution mode is unsupported." }; + } + let execution: CreateImagesRunExecution = { mode: "local-mock" }; + let providerAuthorization: CreateImagesRunProviderAuthorizationV1 | undefined; + let consumedAuthorizationId: string | undefined; + if (input.executionMode === "gemini") { + const claim = input.providerConsent; + if (!claim) { + return { + status: "invalid", + message: "Review the current Gemini consent plan before starting this run.", + }; + } + this.purgeExpiredGeminiConsents(); + const pending = this.pendingGeminiConsents.get(claim.authorizationId); + if ( + !pending || + pending.mainPlan.workflowId !== workflow.id || + pending.mainPlan.workflowRevision !== workflow.revision || + JSON.stringify(pending.scope) !== JSON.stringify(plan.scope) + ) { + return { + status: "invalid", + message: "The reviewed Gemini consent no longer matches this exact saved run scope.", + }; + } + if (!this.options.resolveGeminiAuth) { + return { + status: "unavailable", + message: "Google Gemini image execution is unavailable in this Aiden runtime.", + }; + } + try { + const auth = await this.options.resolveGeminiAuth(); + const credentialBinding = this.geminiCredentialBinding(auth); + const capability = this.currentGeminiCapability(pending.mainPlan.capability.model.id); + const authorization = admitCreateImagesProviderExecution({ + mainPlan: pending.mainPlan, + claim, + authority: this.providerConsentAuthority, + currentCapability: capability, + currentCredential: credentialBinding, + now: iso(this.now()), + }); + const generationNodeIds = plan.orderedNodeIds.filter( + (nodeId) => + plan.snapshot.nodes.find((candidate) => candidate.id === nodeId)?.type === + "generate-image", + ); + if ( + authorization.invocations.some( + (invocation) => + !generationNodeIds.some( + (nodeId) => + invocation.nodeId === nodeId || + invocation.nodeId.startsWith(`${nodeId}-batch-`), + ), + ) || + generationNodeIds.some( + (nodeId) => + !authorization.invocations.some( + (invocation) => + invocation.nodeId === nodeId || + invocation.nodeId.startsWith(`${nodeId}-batch-`), + ), + ) + ) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "The reviewed Gemini requests no longer match the immutable run plan.", + ); + } + execution = { mode: "gemini", auth }; + providerAuthorization = { + version: 1, + executionMode: "gemini", + authorizationId: authorization.authorizationId, + consentFingerprint: authorization.consentFingerprint, + capabilityFingerprint: authorization.capability.fingerprint, + credentialRecordId: authorization.credentialBinding!.recordId, + credentialRevision: authorization.credentialBinding!.revision, + initialRequestCount: authorization.accounting.initialRequestCount, + expectedOutputCount: authorization.accounting.expectedOutputCount, + maximumAttempts: authorization.accounting.maximumAttempts, + createdAt: pending.mainPlan.createdAt, + expiresAt: authorization.expiresAt, + }; + consumedAuthorizationId = authorization.authorizationId; + } catch (error) { + return { + status: error instanceof CreateImagesProviderAdmissionError ? "invalid" : "unavailable", + message: + error instanceof Error + ? error.message + : "The reviewed Gemini authorization could not be admitted safely.", + }; + } + } else if (input.providerConsent) { + return { status: "invalid", message: "Local mock runs cannot carry cloud consent." }; + } + const runId = this.createRunId(); + const createdAt = iso(this.now()); + const start: CreateImagesRunStartInput = { + runId, + workflowSnapshot: plan.snapshot, + plan: { + scope: structuredClone(plan.scope), + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + ...(providerAuthorization ? { providerAuthorization } : {}), + createdAt, + }; + const inputReservation = await this.options.references.reserveRun( + runId, + plan.snapshot.assetRefs, + ); + let journal: CreateImagesRunJournalV1; + try { + if (consumedAuthorizationId) { + this.pendingGeminiConsents.delete(consumedAuthorizationId); + } + journal = await this.journals.start(start, isRendererCurrent); + } catch (error) { + const authoritative = await this.journals.get(runId).catch(() => undefined); + if (authoritative && !projectCreateImagesRun(authoritative).terminal) { + await this.reconcileAfterRestart(authoritative).catch(() => undefined); + } + await this.options.references.releaseRunReservations(runId).catch(() => undefined); + await this.options.references.reconcileRuns(this.journals).catch(() => undefined); + throw error; + } + try { + await this.options.references.commitRun(inputReservation); + } catch (error) { + if (!(await this.options.references.reconcileRuns(this.journals))) throw error; + } + await this.options.assets + .replaceReferences({ kind: "run", id: runId }, [...new Set(plan.snapshot.assetRefs)].sort()) + .catch(() => undefined); + await this.options.references.releaseRunReservations(runId).catch(() => undefined); + let resolveCancelDurable: () => void = () => undefined; + const cancelDurable = new Promise((resolve) => { + resolveCancelDurable = resolve; + }); + const active: ActiveRun = { + runId, + workflowId: workflow.id, + journal, + execution, + controller: new AbortController(), + mutationTail: Promise.resolve(), + publicationTail: Promise.resolve(), + publishedOutputs: new Map(), + reservations: new Map(), + cancelDurable, + resolveCancelDurable, + settled: Promise.resolve(), + }; + this.activeByRun.set(runId, active); + this.activeByWorkflow.set(workflow.id, active); + this.notify(workflow.id); + this.launch(active, plan.snapshot.nodes); + return { status: "started", run: runView(journal) }; + } + + async start( + input: CreateImagesRunStartRequest, + isRendererCurrent: () => boolean, + ): Promise { + const previous = this.startAdmissionTail; + const operation = previous.then(() => this.startInternal(input, isRendererCurrent)); + const tail = operation.then( + () => undefined, + () => undefined, + ); + this.startAdmissionTail = tail; + return operation; + } + + async resume(input: CreateImagesResumeRunRequest): Promise { + await this.initialize(); + const active = this.activeByRun.get(input.runId); + if (!active || active.workflowId !== input.workflowId) { + return { status: "not-found", message: "The paused workflow run no longer exists." }; + } + let projection = projectCreateImagesRun(active.journal); + if (active.journal.journalRevision !== input.expectedJournalRevision) { + return { + status: "unavailable", + message: "The paused run changed. Review its current state before resuming.", + }; + } + if (projection.status !== "paused" || !projection.pause || projection.pause.resumedAt) { + return { status: "unavailable", message: "This workflow run is not paused." }; + } + if (projection.cancellation) { + return { status: "unavailable", message: "A cancelled workflow run cannot resume." }; + } + const outputs = new Map(await this.resumeOutputs(active.journal)); + const assetIds = [...new Set([...outputs.values()].flatMap((output) => output.assetIds))]; + const availability = await Promise.all( + assetIds.map((assetId) => this.options.assets.getAvailable(assetId)), + ); + if (availability.some((asset) => asset === undefined)) { + return { + status: "unavailable", + message: "A durable upstream image is no longer available. The run remains paused.", + }; + } + if (active.journal.providerAuthorization) { + if ( + !this.options.resolveGeminiAuth || + Date.parse(active.journal.providerAuthorization.expiresAt) < this.now() + ) { + return { + status: "unavailable", + message: "Gemini consent expired while paused. Review a new run before any paid request.", + }; + } + try { + const auth = await this.options.resolveGeminiAuth(); + const binding = this.geminiCredentialBinding(auth); + if ( + binding.recordId !== active.journal.providerAuthorization.credentialRecordId || + binding.revision !== active.journal.providerAuthorization.credentialRevision + ) { + throw new Error("credential-drift"); + } + const modelIds = [ + ...new Set( + active.journal.workflowSnapshot.nodes.flatMap((node) => + node.type === "generate-image" && node.data.modelId ? [node.data.modelId] : [], + ), + ), + ]; + if ( + modelIds.length !== 1 || + this.currentGeminiCapability(modelIds[0]!).fingerprint !== + active.journal.providerAuthorization.capabilityFingerprint + ) { + throw new Error("capability-drift"); + } + active.execution = { mode: "gemini", auth }; + } catch { + return { + status: "unavailable", + message: + "Gemini credentials or capabilities changed while paused. No provider request was sent.", + }; + } + } else { + active.execution = { mode: "local-mock" }; + } + const checkpoint = projection.pause; + await this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, { + ...createEventBase(journal, this.now()), + type: "run-resumed", + checkpointId: checkpoint.checkpointId, + }), + ); + projection = projectCreateImagesRun(active.journal); + if (projection.status !== "running") { + return { status: "unavailable", message: "The durable resume transition was rejected." }; + } + active.controller = new AbortController(); + active.publishedOutputs = outputs; + this.launch(active, active.journal.workflowSnapshot.nodes, { + initialOutputs: outputs, + skipBeforeNodeId: checkpoint.beforeNodeId, + }); + return { status: "resumed", run: runView(active.journal) }; + } + + async deleteWorkflowIfRunLifecycleEmpty( + workflowId: string, + deleteWorkflow: () => Promise, + ): Promise< + | { status: "allowed"; value: Result } + | Exclude + > { + const previous = this.startAdmissionTail; + const operation = previous.then(async () => { + let snapshot: CreateImagesRunListResult; + let authority: CreateImagesWorkflowAdmissionAudit; + try { + authority = await this.journals.auditWorkflowAdmission(workflowId); + snapshot = await this.list(workflowId); + } catch { + return { + status: "unavailable" as const, + message: "Run history could not be verified safely. No workflow was deleted.", + }; + } + const decision = evaluateCreateImagesWorkflowDeletion(snapshot); + if (decision.status !== "allowed") return decision; + if (authority.hasDegradedAuthority) { + return { + status: "unavailable" as const, + message: + "Damaged or unassociated run recovery authority prevents Aiden from proving this workflow is safe to delete. No workflow was deleted.", + }; + } + if (authority.hasNonterminalRun) { + return { + status: "unavailable" as const, + message: + "A durable nonterminal image run must be reconciled before deleting this workflow. No workflow was deleted.", + }; + } + if (authority.hasUnresolvedAmbiguity) { + return { + status: "unavailable" as const, + message: + "An unresolved image submission must remain reviewable before deleting this workflow. No workflow was deleted.", + }; + } + return { status: "allowed" as const, value: await deleteWorkflow() }; + }); + this.startAdmissionTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + async stop( + workflowId: string, + runId: string, + reason: CreateImagesCancellationReason, + ): Promise { + await this.initialize(); + const active = this.activeByRun.get(runId); + if (!active || active.workflowId !== workflowId) { + const journal = await this.journals.get(runId); + if (!journal || journal.workflowId !== workflowId) { + return { + status: "not-found", + message: "The workflow run no longer exists.", + }; + } + return TERMINAL_RUN_STATUSES.has(projectCreateImagesRun(journal).status) + ? { + status: "unavailable", + message: "This workflow run is already finished.", + } + : { + status: "unavailable", + message: "This run must be reconciled before it can stop.", + }; + } + if (!active.controller.signal.aborted) { + active.controller.abort(new CoordinatorCancellationRequest(reason)); + } + if (projectCreateImagesRun(active.journal).status === "paused") { + this.beginActiveCancellation(active, reason); + await active.mutationTail; + for (const nodeId of active.journal.plan.orderedNodeIds) { + if (projectCreateImagesRun(active.journal).nodes[nodeId]?.status !== "queued") continue; + await this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, { + ...createEventBase(journal, this.now()), + type: "node-cancelled", + nodeId, + }), + ); + } + await this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, { + ...createEventBase(journal, this.now()), + type: "run-terminal", + status: "cancelled", + }), + ); + await this.releaseActiveOwnershipIfTerminalOrDegraded(active); + return { status: "stopping", run: runView(active.journal) }; + } + this.beginActiveCancellation(active, reason); + const outcome = await this.waitForActiveStop(active, Date.now() + this.shutdownTimeoutMs); + if (outcome === "blocked") { + return { + status: "unavailable", + message: "The cancellation request was not durably saved. Aiden will keep this run open.", + }; + } + if (outcome === "terminal") { + return { + status: "unavailable", + message: "This workflow run is already finished.", + }; + } + return { + status: "stopping", + // Avoid a post-deadline store read: an interrupted pending publication + // may still own the store's serialization lock. + run: runView(active.journal), + }; + } + + private beginActiveCancellation(active: ActiveRun, reason: CreateImagesCancellationReason): void { + if (!active.controller.signal.aborted) { + active.controller.abort(new CoordinatorCancellationRequest(reason)); + } + if (projectCreateImagesRun(active.journal).cancellation) { + active.resolveCancelDurable(); + return; + } + if (active.cancellationRequest) return; + const request = this.mutateJournal(active, (journal) => + projectCreateImagesRun(journal).cancellation + ? Promise.resolve(journal) + : this.journals.requestCancellation(journal.runId, journal.journalRevision, { + at: createEventBase(journal, this.now()).at, + reason, + }), + ); + active.cancellationRequest = request; + void request.then( + () => { + active.resolveCancelDurable(); + if (active.cancellationRequest === request) active.cancellationRequest = undefined; + }, + () => { + // The bounded waiter below turns this into an observable failure. The + // reference is cleared so a later explicit stop may safely retry. + if (active.cancellationRequest === request) active.cancellationRequest = undefined; + }, + ); + } + + private async waitForActiveStop( + active: ActiveRun, + deadline: number, + ): Promise<"durable" | "terminal" | "blocked"> { + const projection = projectCreateImagesRun(active.journal); + if (projection.cancellation) return "durable"; + if (projection.terminal) return "terminal"; + const remaining = deadline - Date.now(); + if (remaining <= 0) return "blocked"; + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + active.cancelDurable.then(() => "durable" as const), + active.settled.then(() => { + const settled = projectCreateImagesRun(active.journal); + return settled.cancellation + ? ("durable" as const) + : settled.terminal + ? ("terminal" as const) + : ("blocked" as const); + }), + new Promise<"blocked">((resolve) => { + timeout = setTimeout(() => resolve("blocked"), remaining); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + private async waitUntilDeadline(promise: Promise, deadline: number): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), remaining); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + async stopAll(reason: "app-quit"): Promise { + this.shutdownAdmissionBarrier = true; + const deadline = Date.now() + this.shutdownTimeoutMs; + const admittedBeforeShutdown = this.startAdmissionTail; + if (!(await this.waitUntilDeadline(admittedBeforeShutdown, deadline))) { + return { status: "blocked", failedRunIds: [] }; + } + const activeRuns = [...this.activeByRun.values()]; + const alreadyDurable = activeRuns.every((active) => { + const projection = projectCreateImagesRun(active.journal); + return projection.cancellation !== undefined || projection.terminal !== undefined; + }); + for (const active of activeRuns) this.beginActiveCancellation(active, reason); + const outcomes = await Promise.all( + activeRuns.map((active) => this.waitForActiveStop(active, deadline)), + ); + const failedRunIds = activeRuns + .filter((_active, index) => outcomes[index] === "blocked") + .map((active) => active.runId) + .sort(); + if (failedRunIds.length > 0) return { status: "blocked", failedRunIds }; + if (alreadyDurable) { + return { + status: "safe-to-quit", + unsettledRunIds: activeRuns + .filter((active) => this.activeByRun.has(active.runId)) + .map((active) => active.runId) + .sort(), + }; + } + const remaining = deadline - Date.now(); + if (remaining > 0) { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled(activeRuns.map((active) => active.settled)).then(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(resolve, remaining); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + return { + status: "safe-to-quit", + unsettledRunIds: activeRuns + .filter((active) => this.activeByRun.has(active.runId)) + .map((active) => active.runId) + .sort(), + }; + } + + /** Reopens admission only after main has explicitly abandoned a quit attempt. */ + resumeRunAdmissionsAfterCancelledShutdown(): void { + this.shutdownAdmissionBarrier = false; + } + + async activeRuns(): Promise { + await this.initialize(); + const views = [...this.activeByRun.values()].map((active) => runView(active.journal)); + return views.sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.runId.localeCompare(right.runId), + ); + } + + async listRecentOutputs(limit: number): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) { + throw new Error("Invalid Create Images recent output limit."); + } + await this.initialize(); + const items: CreateImagesRecentOutputView[] = []; + for (const summary of await this.journals.terminalHistory()) { + if (items.length >= limit) break; + const journal = await this.journals.get(summary.runId); + if (!journal) continue; + const projection = projectCreateImagesRun(journal); + const nodes = new Map(journal.workflowSnapshot.nodes.map((node) => [node.id, node])); + const incoming = new Map(); + for (const edge of journal.workflowSnapshot.edges) { + const sources = incoming.get(edge.target) ?? []; + sources.push(edge.source); + incoming.set(edge.target, sources); + } + for (const nodeId of journal.plan.orderedNodeIds) { + if (items.length >= limit) break; + const node = nodes.get(nodeId); + if (node?.type !== "generate-image") continue; + const promptNode = (incoming.get(node.id) ?? []) + .map((sourceId) => nodes.get(sourceId)) + .find((candidate) => candidate?.type === "prompt"); + const prompt = + promptNode?.type === "prompt" ? promptNode.data.text.trim().slice(0, 280) : ""; + for (const assetId of projection.nodes[nodeId]?.outputAssetIds ?? []) { + if (items.length >= limit) break; + if (!this.options.references.isRunAssetReferenced(journal.runId, assetId)) continue; + const asset = await this.options.assets.getAvailable(assetId); + if (!asset) continue; + items.push({ + assetId, + runId: journal.runId, + workflowId: journal.workflowId, + nodeId, + prompt, + modelLabel: node.data.modelId ?? "Gemini image model", + createdAt: journal.updatedAt, + width: asset.width, + height: asset.height, + mediaType: asset.mediaType, + }); + } + } + } + return items; + } + + async list(workflowId: string): Promise { + await this.initialize(); + if (!(await this.options.workflows.get(workflowId))) return { status: "not-found" }; + const orphaned = this.activeByWorkflow.get(workflowId); + if ( + orphaned?.needsReconciliation && + !(await this.reconcileFailedLaunch(orphaned, Date.now() + this.shutdownTimeoutMs)) + ) { + return { + status: "unavailable", + message: "The durable run is still being reconciled. Try again shortly.", + retryAfterMs: this.shutdownTimeoutMs, + }; + } + const active = this.activeByWorkflow.get(workflowId); + let cached = this.terminalCache.get(workflowId); + let refreshedRecoveries: CreateImagesRunRecoveryView[] | undefined; + if (!cached) { + const summaries = (await this.journals.terminalHistory()) + .filter((summary) => summary.workflowId === workflowId) + .slice(0, 100); + refreshedRecoveries = this.recoveryViews( + await this.journals.refreshWorkflowDegradedMetadata( + workflowId, + summaries.map((summary) => summary.runId), + ), + ); + const refreshedRecoveryIds = new Set(refreshedRecoveries.map((recovery) => recovery.runId)); + const history: CreateImagesTerminalRunView[] = []; + let latestTerminalRun: CreateImagesRunView | undefined; + for (const summary of summaries) { + if (refreshedRecoveryIds.has(summary.runId)) continue; + const journal = await this.journals.get(summary.runId); + if (!journal) continue; + const view = terminalView(journal); + if (view) { + history.push(view); + latestTerminalRun ??= runView(journal); + } + } + cached = { history, ...(latestTerminalRun ? { latestTerminalRun } : {}) }; + this.cacheTerminalHistory(workflowId, cached); + } else { + this.cacheTerminalHistory(workflowId, cached); + } + const recoveries = refreshedRecoveries ?? (await this.recoveryViewsForWorkflow(workflowId)); + const recoveryRunIds = new Set(recoveries.map((recovery) => recovery.runId)); + const history = cached.history.filter((entry) => !recoveryRunIds.has(entry.runId)); + const latestTerminalRun = + cached.latestTerminalRun && !recoveryRunIds.has(cached.latestTerminalRun.runId) + ? cached.latestTerminalRun + : undefined; + return { + status: "ready", + authoritative: true, + ...(active ? { activeRun: runView(active.journal) } : {}), + ...(!active && latestTerminalRun + ? { latestTerminalRun: structuredClone(latestTerminalRun) } + : {}), + history: structuredClone(history), + recoveries, + }; + } + + async get(workflowId: string, runId: string): Promise { + await this.initialize(); + if (!(await this.options.workflows.get(workflowId))) return { status: "not-found" }; + const health = await this.journals.health(runId); + if (health.status === "missing") return { status: "not-found" }; + if (health.status === "unsafe") { + const recovery = unsafeRecoveryView(health); + return recovery?.workflowId === workflowId + ? { + status: "unsafe", + recovery, + message: "This run uses an unsupported schema or unsafe storage and is read-only.", + } + : { status: "not-found" }; + } + if (health.status === "recovery-required") { + const recovery = recoveryRequiredView(health); + return recovery?.workflowId === workflowId + ? { status: "recovery-required", recovery } + : { status: "not-found" }; + } + const journal = await this.journals.get(runId); + return journal?.workflowId === workflowId + ? { status: "ready", run: runView(journal) } + : { status: "not-found" }; + } + + async resolveRunAmbiguity( + input: CreateImagesResolveRunAmbiguityRequest, + ): Promise { + await this.initialize(); + if ( + input.resolution !== "acknowledge-unresolved-submission" || + !(await this.options.workflows.get(input.workflowId)) + ) { + return { status: "not-found" }; + } + const health = await this.journals.health(input.runId); + if (health.status === "missing") return { status: "not-found" }; + if (health.status !== "healthy") { + return { + status: "unavailable", + message: "This run must be recovered before its unresolved submission can be acknowledged.", + }; + } + let journal = await this.journals.get(input.runId); + if (!journal || journal.workflowId !== input.workflowId) { + return { status: "not-found" }; + } + if (journal.journalRevision !== input.expectedJournalRevision) { + return { + status: "conflict", + expectedJournalRevision: input.expectedJournalRevision, + currentJournalRevision: journal.journalRevision, + }; + } + let projection = projectCreateImagesRun(journal); + const wasAlreadyResolved = projection.ambiguityResolution !== undefined; + if (!wasAlreadyResolved && !hasUnresolvedCreateImagesRunAmbiguity(projection)) { + return { status: "not-ambiguous" }; + } + if (!wasAlreadyResolved) { + try { + journal = await this.journals.append(journal.runId, input.expectedJournalRevision, { + ...createEventBase(journal, this.now()), + type: "run-ambiguity-acknowledged", + expectedNeedsAttentionJournalRevision: input.expectedJournalRevision, + }); + } catch (error) { + if (error instanceof CreateImagesRunJournalRevisionConflictError) { + if (error.actualJournalRevision === null) return { status: "not-found" }; + return { + status: "conflict", + expectedJournalRevision: input.expectedJournalRevision, + currentJournalRevision: error.actualJournalRevision, + }; + } + if (error instanceof CreateImagesRunJournalLoadError) { + return { + status: "unavailable", + message: "The unresolved submission acknowledgement could not be saved safely.", + }; + } + throw error; + } + projection = projectCreateImagesRun(journal); + if (!projection.ambiguityResolution) { + return { + status: "unavailable", + message: "The unresolved submission acknowledgement was not durably projected.", + }; + } + this.terminalCache.delete(input.workflowId); + this.notify(input.workflowId); + } + const authoritativeList = await this.list(input.workflowId); + if (authoritativeList.status !== "ready") { + return { + status: "unavailable", + message: "The updated run history could not be loaded safely.", + }; + } + return { + status: wasAlreadyResolved ? "already-resolved" : "resolved", + run: runView(journal), + authoritativeList, + }; + } + + async planDegradedRunDiscard(runId: string): Promise { + await this.initialize(); + if (this.activeByRun.has(runId)) { + return { + status: "unavailable", + message: "An active run cannot be discarded.", + }; + } + try { + const planned = await this.journals.planDegradedRunDiscard(runId); + if (planned.status !== "ready") return planned; + const { recordFingerprint: _recordFingerprint, version: _version, ...safe } = planned.plan; + return { + status: "ready", + ...safe, + mayLoseOutputs: true, + mayDuplicateProviderWork: true, + }; + } catch { + return { + status: "unavailable", + message: "The damaged run record could not be authorized for discard safely.", + }; + } + } + + async discardDegradedRun( + input: CreateImagesDiscardDegradedRunRequest, + ): Promise { + await this.initialize(); + if (this.activeByRun.has(input.runId)) { + return { + status: "unavailable", + message: "An active run cannot be discarded.", + }; + } + const referencedAssetCount = (await this.options.assets.list()).filter((asset) => + this.options.references.isRunAssetReferenced(input.runId, asset.assetId), + ).length; + try { + const discarded = await this.journals.discardDegradedRun({ + runId: input.runId, + authorizationToken: input.authorizationToken, + ...(input.expectedCurrentJournalRevision === undefined + ? {} + : { + expectedCurrentJournalRevision: input.expectedCurrentJournalRevision, + }), + ...(input.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { + expectedLastKnownGoodJournalRevision: input.expectedLastKnownGoodJournalRevision, + }), + }); + if (discarded.status !== "discarded") return discarded; + const assetReferencesReleased = await this.options.assets + .replaceReferences({ kind: "run", id: input.runId }, []) + .then( + () => true, + () => false, + ); + const releasedAssetCount = assetReferencesReleased ? referencedAssetCount : 0; + await this.options.references.reconcileRuns(this.journals).catch(() => false); + const workflowId = discarded.result.workflowId; + if (!workflowId) { + return { status: "discarded", runId: input.runId, releasedAssetCount }; + } + this.terminalCache.delete(workflowId); + this.notify(workflowId); + const authoritativeList = await this.list(workflowId); + return { + status: "discarded", + runId: input.runId, + releasedAssetCount, + authoritativeList, + }; + } catch { + return { + status: "unavailable", + message: "The damaged run record was preserved because discard could not finish safely.", + }; + } + } + + async planHistoryPrune(keepLatest: number): Promise { + await this.initialize(); + const candidates = await this.journals.terminalRetentionCandidates({ + keepLatest, + limit: 100, + }); + if (candidates.length === 0) return { status: "nothing-to-prune" }; + const plan = await this.journals.planTerminalPrune(candidates); + return { + status: "ready", + scope: "all-workflows", + mayReleaseUniqueOutputs: true, + authorizationToken: plan.token, + keepLatest, + candidateRunCount: plan.candidates.length, + releasedAssetCount: plan.assetIds.length, + }; + } + + async pruneHistory( + keepLatest: number, + authorizationToken: string, + ): Promise { + await this.initialize(); + const candidates = await this.journals.terminalRetentionCandidates({ + keepLatest, + limit: 100, + }); + if (candidates.length === 0) return { status: "nothing-to-prune" }; + const affectedWorkflowIds = new Set(candidates.map((candidate) => candidate.workflowId)); + const plan = await this.journals.planTerminalPrune(candidates); + if (plan.token !== authorizationToken) { + return { + status: "conflict", + message: "Run history changed after confirmation. Review the updated cleanup plan.", + }; + } + const result = await this.journals.pruneTerminalRuns(plan); + for (const runId of result.removedRunIds) { + await this.options.assets + .replaceReferences({ kind: "run", id: runId }, []) + .catch(() => undefined); + } + await this.options.references.reconcileRuns(this.journals); + for (const workflowId of affectedWorkflowIds) { + this.terminalCache.delete(workflowId); + this.notify(workflowId); + } + return { + status: "pruned", + removedRunCount: result.removedRunIds.length, + releasedAssetCount: result.releasedAssetIds.length, + }; + } + + async recover( + workflowId: string, + runId: string, + source: "last-known-good" | "current", + expectedCandidateJournalRevision: number, + ): Promise { + await this.initialize(); + if (!(await this.options.workflows.get(workflowId))) return { status: "not-found" }; + if (this.activeByRun.has(runId)) { + return { + status: "unavailable", + message: "An active run cannot be recovered.", + }; + } + const health = await this.journals.health(runId); + if (health.status === "missing") return { status: "not-found" }; + if (health.status === "unsafe") { + const recovery = unsafeRecoveryView(health); + return recovery?.workflowId === workflowId + ? { + status: "unsafe", + recovery, + message: + "This run uses an unsupported schema or unsafe storage and cannot be recovered by this version.", + } + : { status: "not-found" }; + } + if (health.status === "healthy") { + const journal = await this.journals.get(runId); + return journal?.workflowId === workflowId + ? { status: "recovered", run: runView(journal) } + : { status: "not-found" }; + } + const recovery = recoveryRequiredView(health); + if (!recovery || recovery.workflowId !== workflowId) return { status: "not-found" }; + const expectedSource = + health.canRecover === "from-last-known-good" + ? "last-known-good" + : health.canRecover === "from-current" + ? "current" + : undefined; + const currentCandidateJournalRevision = + expectedSource === "last-known-good" + ? health.lastKnownGoodJournalRevision + : expectedSource === "current" + ? health.currentJournalRevision + : undefined; + if (!expectedSource || currentCandidateJournalRevision === undefined) { + return { status: "recovery-required", recovery }; + } + if ( + expectedSource !== source || + currentCandidateJournalRevision !== expectedCandidateJournalRevision + ) { + return { + status: "conflict", + source, + expectedCandidateJournalRevision, + currentCandidateJournalRevision, + }; + } + let journal: CreateImagesRunJournalV1; + try { + journal = + source === "last-known-good" + ? await this.journals.recoverFromLastKnownGood(runId, expectedCandidateJournalRevision) + : await this.journals.recoverLastKnownGoodFromCurrent( + runId, + expectedCandidateJournalRevision, + ); + } catch (error) { + if (error instanceof CreateImagesRunJournalRevisionConflictError) { + return { + status: "conflict", + source, + expectedCandidateJournalRevision, + ...(error.actualJournalRevision === null + ? {} + : { + currentCandidateJournalRevision: error.actualJournalRevision, + }), + }; + } + if (error instanceof CreateImagesRunJournalLoadError) { + const latest = await this.journals.health(runId); + if (latest.status === "unsafe") { + const latestRecovery = unsafeRecoveryView(latest); + if (latestRecovery?.workflowId === workflowId) { + return { + status: "unsafe", + recovery: latestRecovery, + message: + "This run uses an unsupported schema or unsafe storage and cannot be recovered by this version.", + }; + } + } + if (latest.status === "recovery-required") { + const latestRecovery = recoveryRequiredView(latest); + if (latestRecovery?.workflowId === workflowId) { + return { status: "recovery-required", recovery: latestRecovery }; + } + } + return { + status: "unavailable", + message: "The run record could not be recovered safely.", + }; + } + throw error; + } + if (journal.workflowId !== workflowId) return { status: "not-found" }; + if (!projectCreateImagesRun(journal).terminal) { + await this.reconcileAfterRestart(journal); + journal = (await this.journals.get(runId)) ?? journal; + } + this.terminalCache.delete(workflowId); + await this.options.references.reconcileRuns(this.journals); + this.notify(workflowId); + return { status: "recovered", run: runView(journal) }; + } + + async isRunAssetReferenced(workflowId: string, runId: string, assetId: string): Promise { + const journal = await this.journals.get(runId); + return ( + journal?.workflowId === workflowId && + Object.values(projectCreateImagesRun(journal).nodes).some((node) => + node.outputAssetIds.includes(assetId), + ) + ); + } +} diff --git a/main/services/create-images/scheduler-core.test.ts b/main/services/create-images/scheduler-core.test.ts new file mode 100644 index 00000000..e9f9c7a1 --- /dev/null +++ b/main/services/create-images/scheduler-core.test.ts @@ -0,0 +1,1030 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { WorkflowExecutionPlan } from "../../../renderer/shared/create-images/execution.js"; +import type { + GenerateImageNodeV1, + PromptNodeV1, + WorkflowDocumentV1, + WorkflowNodeV1, +} from "../../../renderer/shared/create-images/schema.js"; +import { + CoordinatorCancellationRequest, + createCoordinatorEventCursor, + createWorkflowCoordinatorPlan, + reconcileRestartNode, + reduceCoordinatorEvent, + rendererDisconnectDecision, + runWorkflowCoordinator, + type CoordinatorClock, + type CoordinatorDurability, + type CoordinatorEvent, + type CoordinatorEventPayload, + type CoordinatorEventReduction, + type CoordinatorRetryPolicy, +} from "./scheduler-core.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; + +function prompt(id: string): PromptNodeV1 { + return { id, type: "prompt", position: { x: 0, y: 0 }, data: { text: id } }; +} + +function generate(id: string): GenerateImageNodeV1 { + return { + id, + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }; +} + +function documentWith( + nodes: WorkflowNodeV1[], + edges: WorkflowDocumentV1["edges"] = [], +): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Coordinator test", + revision: 7, + createdAt: NOW, + updatedAt: NOW, + nodes, + edges, + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function edge(id: string, source: string, sourcePort: string, target: string, targetPort: string) { + return { id, source, sourcePort, target, targetPort }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function settleUntil(predicate: () => boolean): Promise { + for (let index = 0; index < 50 && !predicate(); index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal( + predicate(), + true, + "The deterministic coordinator did not reach the expected point.", + ); +} + +class TestClock implements CoordinatorClock { + time = 1_000; + readonly sleeps: number[] = []; + + now(): number { + this.time += 1; + return this.time; + } + + async sleep(delayMs: number, signal: AbortSignal): Promise { + this.sleeps.push(delayMs); + if (signal.aborted) throw signal.reason; + this.time += delayMs; + } +} + +const POLICY: CoordinatorRetryPolicy = { + maxRetriesPerNode: 2, + baseDelayMs: 100, + maxDelayMs: 1_000, + maxTotalDelayMs: 5_000, + jitterRatio: 0, + retryRemoteNotSubmitted: true, + retryRemoteIdempotent: true, +}; + +function harness() { + const log: string[] = []; + const events: CoordinatorEvent[] = []; + const durability: CoordinatorDurability = { + async persistPlan() { + log.push("plan"); + }, + async appendEvent(event) { + log.push(`event:${event.kind}:${"status" in event ? event.status : "job"}`); + events.push(event); + }, + async persistCancelIntent() { + log.push("cancel-intent"); + }, + async persistSubmissionPrepared(record) { + log.push(`prepared:${record.nodeId}:${record.attempt}:${record.idempotencyKey}`); + }, + async persistRemoteJob(record) { + log.push(`remote-job:${record.nodeId}`); + }, + async publishOutput(record) { + log.push(`publish:${record.nodeId}`); + return record.output; + }, + }; + return { log, events, durability }; +} + +function options(durability: CoordinatorDurability, clock = new TestClock()) { + return { + runId: "run-1", + localConcurrency: 1, + remoteConcurrency: 1, + clock, + jitter: { sample: () => 0.5 }, + retryPolicy: POLICY, + durability, + } as const; +} + +test("coordinator uses stable plan order with independent local and remote gates", async () => { + const document = documentWith( + [prompt("prompt-a"), prompt("prompt-b"), generate("generate-a"), generate("generate-b")], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "prompt-b", "text", "generate-b", "prompt"), + ], + ); + const plan = createWorkflowCoordinatorPlan(document, { kind: "all" }); + const gates = new Map(plan.orderedNodeIds.map((nodeId) => [nodeId, deferred()])); + const started: string[] = []; + const idempotencyKeys = new Map(); + const active = { local: 0, remote: 0 }; + const maximum = { local: 0, remote: 0 }; + const { durability, log } = harness(); + const run = runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ node, lane, idempotencyKey, recordRemoteJobId }) => { + log.push(`execute:${node.id}`); + active[lane] += 1; + maximum[lane] = Math.max(maximum[lane], active[lane]); + started.push(node.id); + if (lane === "remote") { + idempotencyKeys.set(node.id, idempotencyKey ?? ""); + await recordRemoteJobId(`job-${node.id}`); + } + await gates.get(node.id)?.promise; + active[lane] -= 1; + return { kind: "success", output: node.id }; + }, + }); + await settleUntil(() => started.length >= 1); + assert.deepEqual(started, ["prompt-a"]); + gates.get("prompt-a")?.resolve(); + await settleUntil(() => started.length >= 3); + assert.deepEqual(started, ["prompt-a", "prompt-b", "generate-a"]); + gates.get("prompt-b")?.resolve(); + gates.get("generate-a")?.resolve(); + await settleUntil(() => started.length >= 4); + assert.deepEqual(started, ["prompt-a", "prompt-b", "generate-a", "generate-b"]); + gates.get("generate-b")?.resolve(); + const result = await run; + assert.deepEqual(maximum, { local: 1, remote: 1 }); + assert.notEqual(idempotencyKeys.get("generate-a"), idempotencyKeys.get("generate-b")); + assert.match(idempotencyKeys.get("generate-a") ?? "", /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u); + assert.ok( + log.findIndex((entry) => entry.startsWith("prepared:generate-a:1:")) < + log.indexOf("execute:generate-a"), + ); + assert.equal(result.status, "succeeded"); + assert.deepEqual(result.nodeStatuses, { + "prompt-a": "succeeded", + "prompt-b": "succeeded", + "generate-a": "succeeded", + "generate-b": "succeeded", + }); + let cursor = createCoordinatorEventCursor( + { + workflowId: result.workflowId, + workflowRevision: result.workflowRevision, + runId: result.runId, + }, + plan.orderedNodeIds, + ); + for (const event of result.events) { + const reduced = reduceCoordinatorEvent(cursor, event); + assert.equal(reduced.accepted, true); + if (reduced.accepted) cursor = reduced.cursor; + } + assert.equal(cursor.runStatus, "succeeded"); +}); + +test("durable checkpoints pause before downstream admission and resume without rerunning upstream", async () => { + const document = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const plan = createWorkflowCoordinatorPlan(document, { kind: "all" }); + const firstHarness = harness(); + const executed: string[] = []; + const paused = await runWorkflowCoordinator(plan, { + ...options(firstHarness.durability), + pauseBeforeNode: async ({ nodeId }) => nodeId === "generate-a", + executeNode: async ({ node }) => { + executed.push(node.id); + return { kind: "success", output: node.id }; + }, + }); + assert.equal(paused.status, "paused"); + assert.deepEqual(executed, ["prompt-a"]); + assert.equal(paused.nodeStatuses["prompt-a"], "succeeded"); + assert.equal(paused.nodeStatuses["generate-a"], "queued"); + + const resumedHarness = harness(); + const resumed = await runWorkflowCoordinator(plan, { + ...options(resumedHarness.durability), + resuming: true, + initialSucceededOutputs: new Map([["prompt-a", "prompt-a"]]), + pauseBeforeNode: async () => false, + executeNode: async ({ node, recordRemoteJobId }) => { + executed.push(node.id); + await recordRemoteJobId("job-resumed"); + return { kind: "success", output: node.id }; + }, + }); + assert.equal(resumed.status, "succeeded"); + assert.deepEqual(executed, ["prompt-a", "generate-a"]); + assert.equal( + resumedHarness.events.some((candidate) => candidate.kind === "run" && candidate.status === "running"), + false, + ); +}); + +test("coordinator snapshots plan inputs and publishes output before durable success", async () => { + const document = documentWith([prompt("prompt-a")]); + const plan = createWorkflowCoordinatorPlan(document, { kind: "all" }); + document.nodes[0] = prompt("mutated-node"); + const observed: string[] = []; + const { durability, log } = harness(); + const result = await runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ node }) => { + observed.push(node.id); + return { kind: "success", output: "durable-output" }; + }, + }); + assert.deepEqual(observed, ["prompt-a"]); + assert.ok(log.indexOf("plan") < log.indexOf("event:run:running")); + assert.ok(log.indexOf("publish:prompt-a") < log.lastIndexOf("event:node:succeeded")); + assert.equal(result.outputs.get("prompt-a"), "durable-output"); + assert.equal(Object.isFrozen(plan.snapshot), true); +}); + +test("only durable publication projections enter outputs and downstream dependencies", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const rawPrompt = { bytes: Uint8Array.from([1, 2, 3]) }; + const rawImage = { bytes: Uint8Array.from([4, 5, 6]) }; + const durablePrompt = { kind: "text", value: "durable prompt" }; + const durableImage = { kind: "assets", assetIds: ["a".repeat(64)] }; + const observedDependencies: unknown[] = []; + const { durability } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(graph, { kind: "all" }), + { + ...options({ + ...durability, + async publishOutput(record) { + return record.nodeId === "prompt-a" ? durablePrompt : durableImage; + }, + }), + executeNode: async ({ lane, dependencyOutputs }) => { + if (lane === "local") return { kind: "success", output: rawPrompt }; + observedDependencies.push(dependencyOutputs.get("prompt-a")); + return { kind: "success", output: rawImage }; + }, + }, + ); + assert.deepEqual(observedDependencies, [durablePrompt]); + assert.deepEqual(result.outputs.get("prompt-a"), durablePrompt); + assert.deepEqual(result.outputs.get("generate-a"), durableImage); + assert.notEqual(result.outputs.get("prompt-a"), rawPrompt); + assert.notEqual(result.outputs.get("generate-a"), rawImage); +}); + +test("failure and node cancellation block required descendants while independent work succeeds", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + prompt("independent"), + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const { durability } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(graph, { kind: "all" }), + { + ...options(durability), + localConcurrency: 2, + executeNode: async ({ node }) => + node.id === "generate-a" + ? { kind: "failure", error: "mock refused", retrySafety: "never" } + : { kind: "success", output: node.id }, + }, + ); + assert.equal(result.status, "failed"); + assert.equal(result.nodeStatuses["generate-a"], "failed"); + assert.equal(result.nodeStatuses["output-a"], "blocked"); + assert.equal(result.nodeStatuses.independent, "succeeded"); +}); + +test("run-from-here executes required ancestors and only the explicitly selected downstream path", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + prompt("independent"), + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const runScope = async (plan: WorkflowExecutionPlan) => { + const { durability } = harness(); + const executed: string[] = []; + const result = await runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ node }) => { + executed.push(node.id); + return { kind: "success", output: node.id }; + }, + }); + return { executed, result }; + }; + const selected = await runScope( + createWorkflowCoordinatorPlan(graph, { + kind: "from-node", + nodeId: "generate-a", + }), + ); + assert.deepEqual(selected.executed, ["prompt-a", "generate-a"]); + assert.deepEqual(Object.keys(selected.result.nodeStatuses), ["prompt-a", "generate-a"]); + + const downstream = await runScope( + createWorkflowCoordinatorPlan(graph, { + kind: "from-node", + nodeId: "generate-a", + downstreamPath: ["output-a"], + }), + ); + assert.deepEqual(downstream.executed, ["prompt-a", "generate-a", "output-a"]); +}); + +test("main coordinator planning rejects a forged path whose rejoin hides sibling work", () => { + const graph = documentWith( + [ + prompt("start"), + generate("generate-a"), + generate("generate-b"), + { + id: "gallery", + type: "output-gallery", + position: { x: 200, y: 0 }, + data: {}, + }, + ], + [ + edge("prompt-a", "start", "text", "generate-a", "prompt"), + edge("prompt-b", "start", "text", "generate-b", "prompt"), + edge("image-a", "generate-a", "images", "gallery", "images"), + edge("image-b", "generate-b", "images", "gallery", "images"), + ], + ); + assert.throws( + () => + createWorkflowCoordinatorPlan(graph, { + kind: "from-node", + nodeId: "start", + downstreamPath: ["generate-a", "gallery"], + }), + /additional branch work/u, + ); +}); + +test("bounded retry uses injected clock and explicit safety classifications", async () => { + const clock = new TestClock(); + const { durability } = harness(); + let attempts = 0; + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { + kind: "all", + }), + { + ...options(durability, clock), + executeNode: async () => { + attempts += 1; + return attempts < 3 + ? { + kind: "rate-limited", + error: "slow down", + retrySafety: "local-safe", + } + : { kind: "success", output: "done" }; + }, + }, + ); + assert.equal(attempts, 3); + assert.deepEqual(clock.sleeps, [100, 200]); + assert.equal(result.retryDelayMs, 300); + assert.deepEqual( + result.events + .filter((event) => event.kind === "node" && event.nodeId === "prompt-a") + .map((event) => (event.kind === "node" ? [event.status, event.attempt] : [])), + [ + ["queued", 0], + ["running", 1], + ["retry_wait", 1], + ["running", 2], + ["retry_wait", 2], + ["running", 3], + ["succeeded", 3], + ], + ); +}); + +test("ambiguous remote submission is terminal and can never be auto-retried", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const { durability, log } = harness(); + let remoteAttempts = 0; + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(graph, { kind: "all" }), + { + ...options(durability), + executeNode: async ({ lane }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + remoteAttempts += 1; + return { kind: "ambiguous-submit", error: "accepted, response lost" }; + }, + }, + ); + assert.equal(remoteAttempts, 1); + assert.equal(result.nodeStatuses["generate-a"], "ambiguous"); + assert.equal(result.status, "needs_attention"); + assert.equal(result.retryDelayMs, 0); + assert.ok(log.some((entry) => entry.startsWith("prepared:generate-a:1:"))); + assert.equal(log.includes("remote-job:generate-a"), false); +}); + +test("unknown remote exceptions after durable preparation are ambiguous, while typed non-submission can retry", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const plan = createWorkflowCoordinatorPlan(graph, { kind: "all" }); + const unknownHarness = harness(); + let unknownAttempts = 0; + const unknown = await runWorkflowCoordinator(plan, { + ...options(unknownHarness.durability), + executeNode: async ({ lane }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + unknownAttempts += 1; + throw new Error("socket closed after send"); + }, + }); + assert.equal(unknownAttempts, 1); + assert.equal(unknown.status, "needs_attention"); + assert.equal(unknown.nodeStatuses["generate-a"], "ambiguous"); + assert.equal(unknown.retryDelayMs, 0); + + const safeHarness = harness(); + let safeAttempts = 0; + const safe = await runWorkflowCoordinator(plan, { + ...options(safeHarness.durability), + executeNode: async ({ lane }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + safeAttempts += 1; + return safeAttempts === 1 + ? { + kind: "failure", + error: "failed before transport", + retrySafety: "confirmed-not-submitted", + } + : { kind: "success", output: "image" }; + }, + }); + assert.equal(safeAttempts, 2); + assert.equal(safe.status, "succeeded"); +}); + +test("remote retry requires confirmed non-submission or the exact bounded idempotency class", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const plan = createWorkflowCoordinatorPlan(graph, { kind: "all" }); + const runCase = async ( + firstRemoteResult: + | { + kind: "rate-limited"; + error: string; + retrySafety: "confirmed-not-submitted" | "same-idempotency-key"; + retryAfterMs?: number; + idempotencyKey?: string; + } + | { + kind: "failure"; + error: string; + retrySafety: "same-idempotency-key"; + idempotencyKey?: string; + }, + retryPolicy: CoordinatorRetryPolicy = POLICY, + ) => { + const { durability } = harness(); + const clock = new TestClock(); + let remoteAttempts = 0; + const attemptKeys: string[] = []; + const result = await runWorkflowCoordinator(plan, { + ...options(durability, clock), + retryPolicy, + executeNode: async ({ lane, idempotencyKey }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + remoteAttempts += 1; + attemptKeys.push(idempotencyKey ?? ""); + if (remoteAttempts !== 1) return { kind: "success", output: "image" }; + return firstRemoteResult.idempotencyKey === "USE_CONTEXT_KEY" + ? { ...firstRemoteResult, idempotencyKey } + : firstRemoteResult; + }, + }); + return { result, remoteAttempts, clock, attemptKeys }; + }; + + const disabled = await runCase( + { + kind: "rate-limited", + error: "not submitted", + retrySafety: "confirmed-not-submitted", + }, + { ...POLICY, retryRemoteNotSubmitted: false }, + ); + assert.equal(disabled.remoteAttempts, 1); + assert.equal(disabled.result.status, "failed"); + + const missingKey = await runCase({ + kind: "failure", + error: "accepted under a key", + retrySafety: "same-idempotency-key", + }); + assert.equal(missingKey.remoteAttempts, 1); + + const sameKey = await runCase({ + kind: "rate-limited", + error: "retry same request", + retrySafety: "same-idempotency-key", + idempotencyKey: "USE_CONTEXT_KEY", + retryAfterMs: 250, + }); + assert.equal(sameKey.remoteAttempts, 2); + assert.equal(sameKey.result.status, "succeeded"); + assert.deepEqual(sameKey.clock.sleeps, [250]); + assert.equal(sameKey.attemptKeys.length, 2); + assert.equal(sameKey.attemptKeys[0], sameKey.attemptKeys[1]); + + const freshKey = await runCase({ + kind: "rate-limited", + error: "definitely not submitted", + retrySafety: "confirmed-not-submitted", + }); + assert.equal(freshKey.remoteAttempts, 2); + assert.notEqual(freshKey.attemptKeys[0], freshKey.attemptKeys[1]); + + const excessiveRetryAfter = await runCase({ + kind: "rate-limited", + error: "retry much later", + retrySafety: "confirmed-not-submitted", + retryAfterMs: 1_001, + }); + assert.equal(excessiveRetryAfter.remoteAttempts, 1); + assert.deepEqual(excessiveRetryAfter.clock.sleeps, []); + + const { durability } = harness(); + let contradictoryAttempts = 0; + const contradictory = await runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ lane, recordRemoteJobId }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + contradictoryAttempts += 1; + await recordRemoteJobId("accepted-job-1"); + return { + kind: "rate-limited", + error: "incorrect classification", + retrySafety: "confirmed-not-submitted", + }; + }, + }); + assert.equal(contradictoryAttempts, 1); + assert.equal(contradictory.status, "needs_attention"); + assert.equal(contradictory.nodeStatuses["generate-a"], "ambiguous"); + + for (const acceptedResult of [ + { + kind: "rate-limited" as const, + error: "polling was rate limited", + retrySafety: "same-idempotency-key" as const, + }, + { + kind: "failure" as const, + error: "accepted job lookup failed", + retrySafety: "same-idempotency-key" as const, + }, + ]) { + const acceptedHarness = harness(); + let acceptedAttempts = 0; + const accepted = await runWorkflowCoordinator(plan, { + ...options(acceptedHarness.durability), + executeNode: async ({ lane, idempotencyKey, recordRemoteJobId }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + acceptedAttempts += 1; + await recordRemoteJobId("accepted-same-key-job"); + return { ...acceptedResult, idempotencyKey }; + }, + }); + assert.equal(acceptedAttempts, 1); + assert.equal(accepted.status, "needs_attention"); + assert.equal(accepted.nodeStatuses["generate-a"], "ambiguous"); + assert.deepEqual( + accepted.events + .filter((event) => event.kind === "node" && event.nodeId === "generate-a") + .map((event) => (event.kind === "node" ? event.status : undefined)), + ["queued", "running", "ambiguous"], + ); + assert.equal( + acceptedHarness.log.some((entry) => entry.includes("retry_wait")), + false, + ); + } +}); + +test("durable cancellation precedes abort/provider cancel and suppresses late completion", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const controller = new AbortController(); + const remoteStarted = deferred(); + const late = deferred(); + const { durability, log } = harness(); + const run = runWorkflowCoordinator(createWorkflowCoordinatorPlan(graph, { kind: "all" }), { + ...options(durability), + signal: controller.signal, + cancelRemoteJob: async (record) => { + log.push(`cancel-remote:${record.nodeId}`); + }, + executeNode: async ({ lane, signal, recordRemoteJobId }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + await recordRemoteJobId("remote-job-1"); + signal.addEventListener("abort", () => log.push("executor-abort"), { + once: true, + }); + remoteStarted.resolve(); + return late.promise; + }, + }); + await remoteStarted.promise; + controller.abort(new CoordinatorCancellationRequest("renderer-disconnected")); + const result = await Promise.race([ + run, + new Promise((_, reject) => + setTimeout(() => reject(new Error("non-cooperative cancel did not terminalize")), 100), + ), + ]); + const intentIndex = log.indexOf("cancel-intent"); + assert.ok(intentIndex >= 0); + assert.ok(intentIndex < log.indexOf("executor-abort")); + assert.ok(intentIndex < log.indexOf("cancel-remote:generate-a")); + assert.equal(result.nodeStatuses["generate-a"], "cancelled"); + assert.equal(result.nodeStatuses["output-a"], "blocked"); + assert.equal(result.outputs.has("generate-a"), false); +}); + +test("a prepared remote submission remains ambiguous when cancellation becomes durable", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const controller = new AbortController(); + const remoteStarted = deferred(); + const { durability, log } = harness(); + const run = runWorkflowCoordinator(createWorkflowCoordinatorPlan(graph, { kind: "all" }), { + ...options(durability), + signal: controller.signal, + executeNode: async ({ lane, signal }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + remoteStarted.resolve(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + return { kind: "cancelled" }; + }, + }); + await remoteStarted.promise; + controller.abort(new CoordinatorCancellationRequest("app-quit")); + const result = await run; + + assert.equal(result.status, "needs_attention"); + assert.equal(result.nodeStatuses["generate-a"], "ambiguous"); + assert.equal(result.nodeStatuses["output-a"], "blocked"); + assert.ok( + log.findIndex((entry) => entry.startsWith("prepared:generate-a:1:")) < + log.indexOf("cancel-intent"), + ); + assert.equal( + result.events.some( + (event) => + event.kind === "node" && event.nodeId === "generate-a" && event.status === "cancelled", + ), + false, + ); +}); + +test("durable cancellation during output publication keeps the output on a cancelled run", async () => { + const controller = new AbortController(); + const { durability, log, events } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { kind: "all" }), + { + ...options({ + ...durability, + async persistCancelIntent() { + log.push("cancel-intent"); + }, + async publishOutput(record) { + log.push("publish-start"); + controller.abort(new CoordinatorCancellationRequest("app-quit")); + await new Promise((resolve) => setImmediate(resolve)); + log.push("publish-end"); + return record.output; + }, + }), + signal: controller.signal, + executeNode: async () => ({ kind: "success", output: "durable prompt" }), + }, + ); + + assert.deepEqual(log.slice(log.indexOf("publish-start"), log.indexOf("publish-end") + 1), [ + "publish-start", + "cancel-intent", + "publish-end", + ]); + assert.equal(result.status, "cancelled"); + assert.equal(result.nodeStatuses["prompt-a"], "succeeded"); + assert.equal(result.outputs.get("prompt-a"), "durable prompt"); + const terminalEvent = events[events.length - 1]; + assert.equal(terminalEvent?.kind, "run"); + assert.equal(terminalEvent?.kind === "run" ? terminalEvent.status : undefined, "cancelled"); +}); + +test("typed abort reasons durably distinguish user, renderer disconnect, and app quit", async () => { + for (const [expected, reason] of [ + ["user", new Error("untrusted renderer text")], + ["renderer-disconnected", new CoordinatorCancellationRequest("renderer-disconnected")], + ["app-quit", new CoordinatorCancellationRequest("app-quit")], + ] as const) { + const controller = new AbortController(); + controller.abort(reason); + const persisted: string[] = []; + const { durability } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { + kind: "all", + }), + { + ...options({ + ...durability, + async persistCancelIntent(intent) { + persisted.push(intent.reason); + }, + }), + signal: controller.signal, + executeNode: async () => { + throw new Error("A pre-cancelled run must not execute nodes."); + }, + }, + ); + assert.deepEqual(persisted, [expected]); + assert.equal(result.status, "cancelled"); + } +}); + +test("event reducer rejects cross-run, duplicate, out-of-order, invalid, and late events", () => { + const identity = { + workflowId: "workflow-1", + workflowRevision: 7, + runId: "run-1", + }; + let cursor = createCoordinatorEventCursor(identity, ["node-a"]); + const event = (sequence: number, value: CoordinatorEventPayload): CoordinatorEvent => + ({ ...identity, sequence, atMs: sequence, ...value }) as CoordinatorEvent; + const rejectionReason = (reduction: CoordinatorEventReduction) => { + assert.equal(reduction.accepted, false); + return reduction.accepted ? undefined : reduction.reason; + }; + const started = reduceCoordinatorEvent(cursor, event(1, { kind: "run", status: "running" })); + assert.equal(started.accepted, true); + if (!started.accepted) return; + cursor = started.cursor; + assert.equal( + rejectionReason(reduceCoordinatorEvent(cursor, event(2, { kind: "run", status: "failed" }))), + "invalid-transition", + ); + assert.equal( + rejectionReason(reduceCoordinatorEvent(cursor, event(1, { kind: "run", status: "running" }))), + "duplicate-or-stale", + ); + assert.equal( + rejectionReason( + reduceCoordinatorEvent( + cursor, + event(3, { + kind: "node", + nodeId: "node-a", + status: "queued", + attempt: 0, + }), + ), + ), + "out-of-order", + ); + assert.equal( + rejectionReason( + reduceCoordinatorEvent(cursor, { + ...event(2, { + kind: "node", + nodeId: "node-a", + status: "queued", + attempt: 0, + }), + runId: "other", + }), + ), + "wrong-run", + ); + for (const next of [ + event(2, { kind: "node", nodeId: "node-a", status: "queued", attempt: 0 }), + event(3, { kind: "node", nodeId: "node-a", status: "running", attempt: 1 }), + event(4, { + kind: "node", + nodeId: "node-a", + status: "succeeded", + attempt: 1, + }), + event(5, { kind: "run", status: "succeeded" }), + ]) { + const reduced = reduceCoordinatorEvent(cursor, next); + assert.equal(reduced.accepted, true); + if (reduced.accepted) cursor = reduced.cursor; + } + assert.equal( + rejectionReason( + reduceCoordinatorEvent( + cursor, + event(6, { + kind: "node", + nodeId: "node-a", + status: "failed", + attempt: 1, + }), + ), + ), + "late-after-terminal", + ); +}); + +test("renderer lifecycle and every restart phase have explicit no-auto-submit decisions", () => { + assert.equal( + rendererDisconnectDecision("document-1", { + kind: "route-change", + documentId: "document-1", + }), + "continue-and-resubscribe", + ); + assert.equal( + rendererDisconnectDecision("document-1", { + kind: "document-destroyed", + documentId: "document-1", + }), + "request-best-effort-cancel", + ); + assert.equal( + rendererDisconnectDecision("document-1", { + kind: "document-destroyed", + documentId: "document-2", + }), + "ignore", + ); + const decisions = [ + reconcileRestartNode({ phase: "never-started", lane: "local" }), + reconcileRestartNode({ phase: "local-running", lane: "local" }), + reconcileRestartNode({ phase: "remote-submitting", lane: "remote" }), + reconcileRestartNode({ + phase: "remote-submitting", + lane: "remote", + remoteJobId: "job-1", + }), + reconcileRestartNode({ phase: "remote-submitted", lane: "remote" }), + reconcileRestartNode({ + phase: "remote-submitted", + lane: "remote", + remoteJobId: "job-1", + }), + reconcileRestartNode({ + phase: "output-publishing", + lane: "remote", + durableOutputAvailable: true, + }), + reconcileRestartNode({ + phase: "cancel-requested", + lane: "remote", + remoteJobId: "job-1", + }), + reconcileRestartNode({ phase: "terminal", lane: "remote" }), + ]; + assert.ok(decisions.every((decision) => decision.autoSubmit === false)); + assert.deepEqual( + decisions.map((decision) => decision.category), + [ + "await-explicit-resume", + "mark-interrupted", + "ambiguous-submit", + "reconcile-remote-job", + "ambiguous-submit", + "reconcile-remote-job", + "resume-output-publication", + "reconcile-cancel", + "terminal", + ], + ); +}); + +test("altered and invalid plans, retry policies, and concurrency fail before execution", async () => { + const plan = createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { kind: "all" }); + const forged = { + ...plan, + orderedNodeIds: ["missing"], + } as WorkflowExecutionPlan; + const { durability } = harness(); + await assert.rejects( + runWorkflowCoordinator(forged, { + ...options(durability), + executeNode: async () => ({ kind: "success", output: undefined }), + }), + /altered/u, + ); + await assert.rejects( + runWorkflowCoordinator(plan, { + ...options(durability), + remoteConcurrency: 5, + executeNode: async () => ({ kind: "success", output: undefined }), + }), + /between 1 and 4/u, + ); + await assert.rejects( + runWorkflowCoordinator(plan, { + ...options(durability), + retryPolicy: { ...POLICY, maxRetriesPerNode: 6 }, + executeNode: async () => ({ kind: "success", output: undefined }), + }), + /Retry count/u, + ); +}); diff --git a/main/services/create-images/scheduler-core.ts b/main/services/create-images/scheduler-core.ts new file mode 100644 index 00000000..c7012fea --- /dev/null +++ b/main/services/create-images/scheduler-core.ts @@ -0,0 +1,1219 @@ +import { createHash } from "node:crypto"; +import { + planWorkflowExecution, + type WorkflowExecutionPlan, + type WorkflowRunScope, +} from "../../../renderer/shared/create-images/execution.js"; +import { CREATE_IMAGES_NODE_DEFINITIONS } from "../../../renderer/shared/create-images/ports.js"; +import type { + WorkflowDocumentV1, + WorkflowNodeV1, +} from "../../../renderer/shared/create-images/schema.js"; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; +const MAX_RETRIES = 5; +const MAX_RETRY_DELAY_MS = 5 * 60_000; +const MAX_TOTAL_RETRY_DELAY_MS = 10 * 60_000; + +export type CoordinatorExecutionLane = "local" | "remote"; + +export type CoordinatorNodeStatus = + | "queued" + | "running" + | "retry_wait" + | "succeeded" + | "failed" + | "cancelled" + | "blocked" + | "ambiguous"; + +export type CoordinatorRunStatus = + | "pending" + | "running" + | "paused" + | "succeeded" + | "failed" + | "cancelled" + | "needs_attention"; + +export type CoordinatorErrorCode = + | "authentication-required" + | "cancelled" + | "dependency-unschedulable" + | "execution-failed" + | "interrupted" + | "output-invalid" + | "output-publication-failed" + | "permission-denied" + | "provider-refused" + | "provider-unavailable" + | "rate-limited" + | "request-rejected" + | "submission-ambiguous" + | "upstream-blocked"; + +export type CoordinatorRetrySafety = + | "never" + | "local-safe" + | "confirmed-not-submitted" + | "same-idempotency-key"; + +export type CoordinatorAttemptResult = + | { kind: "success"; output: unknown } + | { + kind: "failure"; + error: string; + retrySafety: CoordinatorRetrySafety; + idempotencyKey?: string; + errorCode?: CoordinatorErrorCode; + } + | { + kind: "rate-limited"; + error: string; + retrySafety: CoordinatorRetrySafety; + retryAfterMs?: number; + idempotencyKey?: string; + errorCode?: CoordinatorErrorCode; + } + | { kind: "cancelled"; error?: string } + | { kind: "ambiguous-submit"; error: string }; + +export interface CoordinatorRunIdentity { + workflowId: string; + workflowRevision: number; + runId: string; +} + +interface CoordinatorEventBase extends CoordinatorRunIdentity { + sequence: number; + atMs: number; +} + +export type CoordinatorEvent = + | (CoordinatorEventBase & { + kind: "run"; + status: Exclude; + }) + | (CoordinatorEventBase & { + kind: "node"; + nodeId: string; + status: CoordinatorNodeStatus; + attempt: number; + errorCode?: CoordinatorErrorCode; + retryDelayMs?: number; + retrySafety?: CoordinatorRetrySafety; + }) + | (CoordinatorEventBase & { + kind: "remote-job"; + nodeId: string; + attempt: number; + remoteJobId: string; + }); + +export type CoordinatorEventPayload = + | { + kind: "run"; + status: Exclude; + } + | { + kind: "node"; + nodeId: string; + status: CoordinatorNodeStatus; + attempt: number; + errorCode?: CoordinatorErrorCode; + retryDelayMs?: number; + retrySafety?: CoordinatorRetrySafety; + } + | { + kind: "remote-job"; + nodeId: string; + attempt: number; + remoteJobId: string; + }; + +export interface CoordinatorClock { + now(): number; + sleep(delayMs: number, signal: AbortSignal): Promise; +} + +export interface CoordinatorJitter { + /** A deterministic sample in the inclusive range 0 through 1. */ + sample(): number; +} + +export interface CoordinatorRetryPolicy { + maxRetriesPerNode: number; + baseDelayMs: number; + maxDelayMs: number; + maxTotalDelayMs: number; + jitterRatio: number; + retryRemoteNotSubmitted: boolean; + retryRemoteIdempotent: boolean; +} + +export interface CoordinatorPlanRecord extends CoordinatorRunIdentity { + plan: WorkflowExecutionPlan; + localConcurrency: number; + remoteConcurrency: number; +} + +export interface CoordinatorCancelIntent extends CoordinatorRunIdentity { + reason: "user" | "renderer-disconnected" | "app-quit"; + remoteJobs: Readonly>; +} + +export class CoordinatorCancellationRequest extends Error { + readonly cancellationReason: CoordinatorCancelIntent["reason"]; + + constructor(cancellationReason: CoordinatorCancelIntent["reason"]) { + if (!["user", "renderer-disconnected", "app-quit"].includes(cancellationReason)) { + throw new Error("The Create Images cancellation reason is invalid."); + } + super(`Create Images cancellation requested: ${cancellationReason}.`); + this.name = "CoordinatorCancellationRequest"; + this.cancellationReason = cancellationReason; + } +} + +export interface CoordinatorRemoteJobRecord extends CoordinatorRunIdentity { + nodeId: string; + attempt: number; + /** Stable for same-idempotency-key retries; fresh after confirmed non-submission. */ + idempotencyKey: string; + remoteJobId: string; +} + +export interface CoordinatorSubmissionPreparedRecord extends CoordinatorRunIdentity { + nodeId: string; + attempt: number; + idempotencyKey: string; +} + +export interface CoordinatorDurability { + persistPlan(record: CoordinatorPlanRecord): Promise; + appendEvent(event: CoordinatorEvent): Promise; + persistCancelIntent(intent: CoordinatorCancelIntent): Promise; + /** Must journal the idempotency key before executeNode may submit remote work. */ + persistSubmissionPrepared(record: CoordinatorSubmissionPreparedRecord): Promise; + persistRemoteJob(record: CoordinatorRemoteJobRecord): Promise; + publishOutput( + record: CoordinatorRunIdentity & { nodeId: string; output: unknown }, + ): Promise; +} + +export interface CoordinatorNodeExecutionContext extends CoordinatorRunIdentity { + node: WorkflowNodeV1; + lane: CoordinatorExecutionLane; + attempt: number; + /** Stable for same-idempotency-key retries; fresh after confirmed non-submission. */ + idempotencyKey?: string; + signal: AbortSignal; + dependencyOutputs: ReadonlyMap; + recordRemoteJobId(remoteJobId: string): Promise; +} + +export interface RunWorkflowCoordinatorOptions { + runId: string; + localConcurrency: number; + remoteConcurrency: number; + clock: CoordinatorClock; + jitter: CoordinatorJitter; + retryPolicy: CoordinatorRetryPolicy; + durability: CoordinatorDurability; + signal?: AbortSignal; + executeNode(context: CoordinatorNodeExecutionContext): Promise; + initialSucceededOutputs?: ReadonlyMap; + resuming?: boolean; + pauseBeforeNode?(input: { + nodeId: string; + dependencyNodeIds: readonly string[]; + }): Promise; + cancelRemoteJob?(record: CoordinatorRemoteJobRecord): Promise; + onEvent?(event: CoordinatorEvent): void; +} + +export interface WorkflowCoordinatorResult extends CoordinatorRunIdentity { + status: Exclude; + nodeStatuses: Readonly>; + outputs: ReadonlyMap; + events: readonly CoordinatorEvent[]; + retryDelayMs: number; +} + +type SettledNode = { + nodeId: string; + lane: CoordinatorExecutionLane; + result: CoordinatorAttemptResult; +}; + +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function assertOpaqueId(value: string, label: string): void { + if (!OPAQUE_ID_PATTERN.test(value)) throw new Error(`${label} must be an opaque identifier.`); +} + +function assertProviderJobId(value: string): void { + if (!PROVIDER_JOB_ID_PATTERN.test(value)) { + throw new Error("Remote job ID must be a bounded provider identifier."); + } +} + +function assertConcurrency(value: number, label: string): void { + if (!Number.isInteger(value) || value < 1 || value > 4) { + throw new Error(`${label} concurrency must be between 1 and 4.`); + } +} + +function validatedPolicy(policy: CoordinatorRetryPolicy): CoordinatorRetryPolicy { + if ( + !Number.isInteger(policy.maxRetriesPerNode) || + policy.maxRetriesPerNode < 0 || + policy.maxRetriesPerNode > MAX_RETRIES + ) { + throw new Error(`Retry count must be between 0 and ${MAX_RETRIES}.`); + } + for (const [label, value, maximum] of [ + ["base delay", policy.baseDelayMs, MAX_RETRY_DELAY_MS], + ["maximum delay", policy.maxDelayMs, MAX_RETRY_DELAY_MS], + ["total delay", policy.maxTotalDelayMs, MAX_TOTAL_RETRY_DELAY_MS], + ] as const) { + if (!Number.isInteger(value) || value < 0 || value > maximum) { + throw new Error(`Retry ${label} must be an integer between 0 and ${maximum}.`); + } + } + if (policy.maxDelayMs < policy.baseDelayMs) { + throw new Error("Retry maximum delay cannot be smaller than the base delay."); + } + if (!Number.isFinite(policy.jitterRatio) || policy.jitterRatio < 0 || policy.jitterRatio > 1) { + throw new Error("Retry jitter ratio must be between 0 and 1."); + } + return deepFreeze({ ...policy }); +} + +function immutableVerifiedPlan(plan: WorkflowExecutionPlan): WorkflowExecutionPlan { + const rebuilt = planWorkflowExecution(plan.snapshot, plan.scope); + if ( + rebuilt.workflowId !== plan.workflowId || + rebuilt.workflowRevision !== plan.workflowRevision || + JSON.stringify(rebuilt.orderedNodeIds) !== JSON.stringify(plan.orderedNodeIds) || + JSON.stringify(rebuilt.dependencies) !== JSON.stringify(plan.dependencies) + ) { + throw new Error("The Create Images execution plan is stale or has been altered."); + } + return rebuilt; +} + +export function createWorkflowCoordinatorPlan( + document: WorkflowDocumentV1, + scope: WorkflowRunScope, +): WorkflowExecutionPlan { + return planWorkflowExecution(document, scope); +} + +function laneFor(node: WorkflowNodeV1): CoordinatorExecutionLane { + return CREATE_IMAGES_NODE_DEFINITIONS[node.type].execution; +} + +function terminalNode(status: CoordinatorNodeStatus): boolean { + return ["succeeded", "failed", "cancelled", "blocked", "ambiguous"].includes(status); +} + +function dependencyBlocks(status: CoordinatorNodeStatus | undefined): boolean { + return ( + status === "failed" || status === "cancelled" || status === "blocked" || status === "ambiguous" + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "The node executor failed without a usable error."; +} + +function retryIsAllowed( + lane: CoordinatorExecutionLane, + result: Extract, + policy: CoordinatorRetryPolicy, + expectedIdempotencyKey: string | undefined, +): boolean { + if (lane === "local") return result.retrySafety === "local-safe"; + if (result.retrySafety === "confirmed-not-submitted") return policy.retryRemoteNotSubmitted; + return ( + result.retrySafety === "same-idempotency-key" && + policy.retryRemoteIdempotent && + typeof result.idempotencyKey === "string" && + IDEMPOTENCY_KEY_PATTERN.test(result.idempotencyKey) && + result.idempotencyKey === expectedIdempotencyKey + ); +} + +function idempotencyKeyFor( + identity: CoordinatorRunIdentity, + nodeId: string, + generation: number, +): string { + const digest = createHash("sha256") + .update(identity.workflowId) + .update("\0") + .update(String(identity.workflowRevision)) + .update("\0") + .update(identity.runId) + .update("\0") + .update(nodeId) + .update("\0") + .update(String(generation)) + .digest("hex"); + return `aiden-ci-${digest}`; +} + +function retryDelay( + retryIndex: number, + result: Extract, + policy: CoordinatorRetryPolicy, + jitter: CoordinatorJitter, +): number { + const exponential = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** retryIndex); + const retryAfter = result.kind === "rate-limited" ? (result.retryAfterMs ?? 0) : 0; + if (!Number.isInteger(retryAfter) || retryAfter < 0 || retryAfter > policy.maxDelayMs) return -1; + const floor = Math.max(exponential, retryAfter); + const sample = jitter.sample(); + if (!Number.isFinite(sample) || sample < 0 || sample > 1) { + throw new Error("The injected retry jitter sample must be between 0 and 1."); + } + const factor = 1 + policy.jitterRatio * (sample * 2 - 1); + return Math.min(policy.maxDelayMs, Math.max(retryAfter, Math.max(0, Math.round(floor * factor)))); +} + +function finalRunStatus( + statuses: ReadonlyMap, +): WorkflowCoordinatorResult["status"] { + const values = [...statuses.values()]; + if (values.some((status) => status === "ambiguous")) return "needs_attention"; + if (values.some((status) => status === "failed")) return "failed"; + if (values.some((status) => status === "cancelled")) return "cancelled"; + return "succeeded"; +} + +export async function runWorkflowCoordinator( + inputPlan: WorkflowExecutionPlan, + options: RunWorkflowCoordinatorOptions, +): Promise { + assertOpaqueId(options.runId, "Run ID"); + assertConcurrency(options.localConcurrency, "Local"); + assertConcurrency(options.remoteConcurrency, "Remote"); + const policy = validatedPolicy(options.retryPolicy); + const plan = immutableVerifiedPlan(inputPlan); + const identity = deepFreeze({ + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + runId: options.runId, + }); + const nodes = new Map(plan.snapshot.nodes.map((node) => [node.id, node])); + const statuses = new Map(); + const outputs = new Map(); + const nodeAttempts = new Map(); + const events: CoordinatorEvent[] = []; + const active = new Map>(); + const controllers = new Map(); + const remoteJobs = new Map(); + const uncertainRemoteSubmissions = new Set(); + const remoteCancelIssued = new Set(); + let sequence = 0; + let lastAtMs = 0; + let totalRetryDelayMs = 0; + let acceptSettlements = true; + let admissionStopped = false; + let pauseRequestedNodeId: string | undefined; + let cancelRequested = false; + let planPersisted = false; + let cancelPersisted = false; + let cancelFailure: unknown; + let cancelWakeResolve!: () => void; + const cancelWake = new Promise((resolve) => { + cancelWakeResolve = resolve; + }); + let cancelTask: Promise | undefined; + let eventQueue: Promise = Promise.resolve(); + + const emit = async (event: CoordinatorEventPayload): Promise => { + const now = options.clock.now(); + if (!Number.isFinite(now) || now < 0) + throw new Error("The coordinator clock returned an invalid time."); + lastAtMs = Math.max(lastAtMs, now); + sequence += 1; + const durableEvent = deepFreeze({ + ...identity, + ...event, + sequence, + atMs: lastAtMs, + } as CoordinatorEvent); + const append = eventQueue.then(() => options.durability.appendEvent(durableEvent)); + eventQueue = append.catch(() => undefined); + await append; + events.push(durableEvent); + options.onEvent?.(durableEvent); + }; + + const cancelRemote = async (record: CoordinatorRemoteJobRecord): Promise => { + if (!options.cancelRemoteJob || remoteCancelIssued.has(record.nodeId)) return; + remoteCancelIssued.add(record.nodeId); + await options.cancelRemoteJob(record); + }; + + const cancellationReason = (): CoordinatorCancelIntent["reason"] => + options.signal?.reason instanceof CoordinatorCancellationRequest + ? options.signal.reason.cancellationReason + : "user"; + + const beginCancel = (): void => { + if (cancelTask) return; + cancelRequested = true; + admissionStopped = true; + if (!planPersisted) return; + cancelTask = (async () => { + try { + const remoteJobSnapshot = Object.fromEntries( + [...remoteJobs.entries()].map(([nodeId, record]) => [nodeId, record.remoteJobId]), + ); + await options.durability.persistCancelIntent( + deepFreeze({ + ...identity, + reason: cancellationReason(), + remoteJobs: remoteJobSnapshot, + }), + ); + cancelPersisted = true; + acceptSettlements = false; + for (const controller of controllers.values()) controller.abort(options.signal?.reason); + await Promise.allSettled([...remoteJobs.values()].map(cancelRemote)); + } catch (error) { + cancelFailure = error; + } finally { + cancelWakeResolve(); + } + })(); + }; + + const abortListener = (): void => beginCancel(); + if (options.signal?.aborted) { + cancelRequested = true; + admissionStopped = true; + } else options.signal?.addEventListener("abort", abortListener, { once: true }); + + try { + await options.durability.persistPlan( + deepFreeze({ + ...identity, + plan, + localConcurrency: options.localConcurrency, + remoteConcurrency: options.remoteConcurrency, + }), + ); + planPersisted = true; + if (cancelRequested) beginCancel(); + if (!options.resuming) await emit({ kind: "run", status: "running" }); + for (const nodeId of plan.orderedNodeIds) { + if (options.initialSucceededOutputs?.has(nodeId)) { + statuses.set(nodeId, "succeeded"); + outputs.set(nodeId, options.initialSucceededOutputs.get(nodeId)); + } else { + statuses.set(nodeId, "queued"); + await emit({ kind: "node", nodeId, status: "queued", attempt: 0 }); + } + } + + const execute = async ( + nodeId: string, + lane: CoordinatorExecutionLane, + ): Promise => { + const node = nodes.get(nodeId); + if (!node) { + return { + nodeId, + lane, + result: { + kind: "failure", + error: "The planned node no longer exists.", + retrySafety: "never", + }, + }; + } + const controller = new AbortController(); + controllers.set(nodeId, controller); + let attempt = 1; + let idempotencyGeneration = 1; + let idempotencyKey = + lane === "remote" ? idempotencyKeyFor(identity, nodeId, idempotencyGeneration) : undefined; + let nodeRetryDelayMs = 0; + const dependencies = plan.dependencies[nodeId] ?? []; + const dependencyOutputs = new Map(); + for (const dependency of dependencies) { + if (outputs.has(dependency)) dependencyOutputs.set(dependency, outputs.get(dependency)); + } + + while (true) { + if (controller.signal.aborted) return { nodeId, lane, result: { kind: "cancelled" } }; + nodeAttempts.set(nodeId, attempt); + await emit({ kind: "node", nodeId, status: "running", attempt }); + if (lane === "remote" && idempotencyKey) { + await options.durability.persistSubmissionPrepared( + deepFreeze({ ...identity, nodeId, attempt, idempotencyKey }), + ); + uncertainRemoteSubmissions.add(nodeId); + } + let result: CoordinatorAttemptResult; + try { + result = await options.executeNode({ + ...identity, + node, + lane, + attempt, + ...(idempotencyKey ? { idempotencyKey } : {}), + signal: controller.signal, + dependencyOutputs, + recordRemoteJobId: async (remoteJobId) => { + if (lane !== "remote") throw new Error("Only remote nodes can record provider jobs."); + if (!idempotencyKey) + throw new Error("Remote jobs require a prepared idempotency key."); + assertProviderJobId(remoteJobId); + const existing = remoteJobs.get(nodeId); + if (existing && existing.remoteJobId !== remoteJobId) { + throw new Error("A node attempt cannot replace its durable remote job ID."); + } + if (existing) return; + const record = deepFreeze({ + ...identity, + nodeId, + attempt, + idempotencyKey, + remoteJobId, + }); + await options.durability.persistRemoteJob(record); + remoteJobs.set(nodeId, record); + uncertainRemoteSubmissions.delete(nodeId); + await emit({ kind: "remote-job", nodeId, attempt, remoteJobId }); + if (cancelPersisted) await cancelRemote(record); + }, + }); + } catch (error) { + result = + lane === "remote" + ? { + kind: "ambiguous-submit", + error: errorMessage(error), + } + : { + kind: "failure", + error: errorMessage(error), + retrySafety: "never", + }; + } + if (controller.signal.aborted) return { nodeId, lane, result: { kind: "cancelled" } }; + if (result.kind !== "failure" && result.kind !== "rate-limited") { + return { nodeId, lane, result }; + } + if ( + remoteJobs.has(nodeId) && + (result.retrySafety === "confirmed-not-submitted" || + result.retrySafety === "same-idempotency-key") + ) { + return { + nodeId, + lane, + result: { + kind: "ambiguous-submit", + error: + "A durable remote job must be reconciled and cannot enter the submission retry path.", + }, + }; + } + const retriesUsed = attempt - 1; + if ( + retriesUsed >= policy.maxRetriesPerNode || + !retryIsAllowed(lane, result, policy, idempotencyKey) + ) { + return { nodeId, lane, result }; + } + const delayMs = retryDelay(retriesUsed, result, policy, options.jitter); + if ( + delayMs < 0 || + totalRetryDelayMs + delayMs > policy.maxTotalDelayMs || + nodeRetryDelayMs + delayMs > policy.maxTotalDelayMs + ) { + return { nodeId, lane, result }; + } + totalRetryDelayMs += delayMs; + nodeRetryDelayMs += delayMs; + // A retry-scheduled event durably seals the preceding prepared + // submission with an explicit safe-retry classification. The next + // attempt becomes uncertain only after its own prepared record lands. + uncertainRemoteSubmissions.delete(nodeId); + await emit({ + kind: "node", + nodeId, + status: "retry_wait", + attempt, + errorCode: + result.errorCode ?? + (result.kind === "rate-limited" ? "rate-limited" : "execution-failed"), + retryDelayMs: delayMs, + retrySafety: result.retrySafety, + }); + try { + await options.clock.sleep(delayMs, controller.signal); + } catch (error) { + if (controller.signal.aborted) return { nodeId, lane, result: { kind: "cancelled" } }; + return { + nodeId, + lane, + result: { + kind: "failure", + error: errorMessage(error), + retrySafety: "never", + }, + }; + } + attempt += 1; + if (lane === "remote" && result.retrySafety === "confirmed-not-submitted") { + idempotencyGeneration += 1; + idempotencyKey = idempotencyKeyFor(identity, nodeId, idempotencyGeneration); + } + } + }; + + while ([...statuses.values()].some((status) => !terminalNode(status))) { + if (cancelFailure) throw cancelFailure; + if (cancelPersisted) { + for (const nodeId of plan.orderedNodeIds) { + const status = statuses.get(nodeId); + if (status === "running" || status === "retry_wait") { + const submissionIsUncertain = uncertainRemoteSubmissions.has(nodeId); + statuses.set(nodeId, submissionIsUncertain ? "ambiguous" : "cancelled"); + await emit({ + kind: "node", + nodeId, + status: submissionIsUncertain ? "ambiguous" : "cancelled", + attempt: nodeAttempts.get(nodeId) ?? 0, + errorCode: submissionIsUncertain ? "submission-ambiguous" : "cancelled", + }); + } + } + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + const blocked = dependencies.some((dependency) => + dependencyBlocks(statuses.get(dependency)), + ); + statuses.set(nodeId, blocked ? "blocked" : "cancelled"); + await emit({ + kind: "node", + nodeId, + status: blocked ? "blocked" : "cancelled", + attempt: 0, + errorCode: blocked ? "upstream-blocked" : "cancelled", + }); + } + active.clear(); + break; + } + + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (dependencies.some((dependency) => dependencyBlocks(statuses.get(dependency)))) { + statuses.set(nodeId, "blocked"); + await emit({ + kind: "node", + nodeId, + status: "blocked", + attempt: 0, + errorCode: "upstream-blocked", + }); + } + } + + if (!admissionStopped && options.pauseBeforeNode) { + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (!dependencies.every((dependency) => statuses.get(dependency) === "succeeded")) { + continue; + } + if (await options.pauseBeforeNode({ nodeId, dependencyNodeIds: dependencies })) { + admissionStopped = true; + pauseRequestedNodeId = nodeId; + break; + } + } + } + + if (!admissionStopped) { + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (!dependencies.every((dependency) => statuses.get(dependency) === "succeeded")) + continue; + const node = nodes.get(nodeId); + if (!node) continue; + const lane = laneFor(node); + const activeInLane = [...active.keys()].filter((activeId) => { + const activeNode = nodes.get(activeId); + return activeNode ? laneFor(activeNode) === lane : false; + }).length; + const limit = lane === "local" ? options.localConcurrency : options.remoteConcurrency; + if (activeInLane >= limit) continue; + statuses.set(nodeId, "running"); + const task = execute(nodeId, lane).catch( + (error): SettledNode => ({ + nodeId, + lane, + result: { + kind: "failure", + error: errorMessage(error), + retrySafety: "never", + }, + }), + ); + active.set(nodeId, task); + } + } + + if (active.size === 0) { + if (pauseRequestedNodeId) { + await emit({ kind: "run", status: "paused" }); + await eventQueue; + return deepFreeze({ + ...identity, + status: "paused" as const, + nodeStatuses: Object.fromEntries(statuses), + outputs, + events, + retryDelayMs: totalRetryDelayMs, + }); + } + if (admissionStopped && cancelTask) { + await cancelWake; + continue; + } + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) === "queued") { + statuses.set(nodeId, "blocked"); + await emit({ + kind: "node", + nodeId, + status: "blocked", + attempt: 0, + errorCode: "dependency-unschedulable", + }); + } + } + continue; + } + + const settled = await Promise.race([...active.values(), cancelWake.then(() => undefined)]); + if (!settled) continue; + active.delete(settled.nodeId); + controllers.delete(settled.nodeId); + if (!acceptSettlements) continue; + const result = settled.result; + if (result.kind === "success") { + try { + const durableOutput = await options.durability.publishOutput({ + ...identity, + nodeId: settled.nodeId, + output: result.output, + }); + outputs.set(settled.nodeId, durableOutput); + statuses.set(settled.nodeId, "succeeded"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "succeeded", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + }); + } catch { + statuses.set(settled.nodeId, "failed"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "failed", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: "output-publication-failed", + }); + } + } else if (result.kind === "ambiguous-submit") { + statuses.set(settled.nodeId, "ambiguous"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "ambiguous", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: "submission-ambiguous", + }); + } else if (result.kind === "cancelled") { + statuses.set(settled.nodeId, "cancelled"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "cancelled", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: "cancelled", + }); + } else { + statuses.set(settled.nodeId, "failed"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "failed", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: + result.errorCode ?? + (result.kind === "rate-limited" ? "rate-limited" : "execution-failed"), + }); + } + } + + if (cancelTask) { + await cancelTask; + if (cancelFailure) throw cancelFailure; + } + const completedStatus = finalRunStatus(statuses); + const status = + completedStatus === "needs_attention" + ? completedStatus + : cancelPersisted + ? "cancelled" + : completedStatus; + await emit({ kind: "run", status }); + return deepFreeze({ + ...identity, + status, + nodeStatuses: Object.fromEntries(statuses), + outputs, + events, + retryDelayMs: totalRetryDelayMs, + }); + } finally { + options.signal?.removeEventListener("abort", abortListener); + } +} + +export interface CoordinatorEventCursor extends CoordinatorRunIdentity { + lastSequence: number; + runStatus: CoordinatorRunStatus; + nodeStatuses: Readonly>; + nodeAttempts: Readonly>; +} + +export type CoordinatorEventRejection = + | "wrong-run" + | "duplicate-or-stale" + | "out-of-order" + | "late-after-terminal" + | "unknown-node" + | "invalid-transition" + | "attempt-regression"; + +export type CoordinatorEventReduction = + | { accepted: true; cursor: CoordinatorEventCursor } + | { + accepted: false; + cursor: CoordinatorEventCursor; + reason: CoordinatorEventRejection; + }; + +export function createCoordinatorEventCursor( + identity: CoordinatorRunIdentity, + nodeIds: readonly string[], +): CoordinatorEventCursor { + assertOpaqueId(identity.workflowId, "Workflow ID"); + assertOpaqueId(identity.runId, "Run ID"); + if (!Number.isInteger(identity.workflowRevision) || identity.workflowRevision < 0) { + throw new Error("Workflow revision must be a non-negative integer."); + } + const unique = new Set(nodeIds); + if (unique.size !== nodeIds.length || nodeIds.some((nodeId) => !OPAQUE_ID_PATTERN.test(nodeId))) { + throw new Error("Event cursors require unique opaque node IDs."); + } + return deepFreeze({ + ...identity, + lastSequence: 0, + runStatus: "pending", + nodeStatuses: Object.fromEntries(nodeIds.map((nodeId) => [nodeId, undefined])), + nodeAttempts: Object.fromEntries(nodeIds.map((nodeId) => [nodeId, 0])), + }); +} + +const NODE_TRANSITIONS: Readonly> = { + unseen: ["queued"], + queued: ["running", "cancelled", "blocked"], + running: ["retry_wait", "succeeded", "failed", "cancelled", "ambiguous"], + retry_wait: ["running", "cancelled"], + succeeded: [], + failed: [], + cancelled: [], + blocked: [], + ambiguous: [], +}; + +export function reduceCoordinatorEvent( + cursor: CoordinatorEventCursor, + event: CoordinatorEvent, +): CoordinatorEventReduction { + if ( + cursor.workflowId !== event.workflowId || + cursor.workflowRevision !== event.workflowRevision || + cursor.runId !== event.runId + ) { + return { accepted: false, cursor, reason: "wrong-run" }; + } + if (event.sequence <= cursor.lastSequence) { + return { accepted: false, cursor, reason: "duplicate-or-stale" }; + } + if (event.sequence !== cursor.lastSequence + 1) { + return { accepted: false, cursor, reason: "out-of-order" }; + } + if (!["pending", "running"].includes(cursor.runStatus)) { + return { accepted: false, cursor, reason: "late-after-terminal" }; + } + if (event.kind === "run") { + if (cursor.runStatus === "pending" && event.status !== "running") { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (cursor.runStatus === "running" && event.status === "running") { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (cursor.runStatus === "running") { + const entries = Object.entries(cursor.nodeStatuses); + if ( + entries.some(([, status]) => status === undefined || !terminalNode(status)) || + finalRunStatus(new Map(entries as Array<[string, CoordinatorNodeStatus]>)) !== event.status + ) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + } + return { + accepted: true, + cursor: deepFreeze({ + ...cursor, + lastSequence: event.sequence, + runStatus: event.status, + }), + }; + } + if (!Object.prototype.hasOwnProperty.call(cursor.nodeStatuses, event.nodeId)) { + return { accepted: false, cursor, reason: "unknown-node" }; + } + const previousAttempt = cursor.nodeAttempts[event.nodeId] ?? 0; + if (!Number.isInteger(event.attempt) || event.attempt < 0 || event.attempt < previousAttempt) { + return { accepted: false, cursor, reason: "attempt-regression" }; + } + if (event.kind === "remote-job") { + if ( + !PROVIDER_JOB_ID_PATTERN.test(event.remoteJobId) || + cursor.nodeStatuses[event.nodeId] !== "running" || + event.attempt !== previousAttempt + ) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + return { + accepted: true, + cursor: deepFreeze({ ...cursor, lastSequence: event.sequence }), + }; + } + const previous = cursor.nodeStatuses[event.nodeId]; + const retryContractValid = + event.status !== "retry_wait" || + (Number.isInteger(event.retryDelayMs) && + (event.retryDelayMs ?? -1) >= 0 && + (event.retryDelayMs ?? MAX_RETRY_DELAY_MS + 1) <= MAX_RETRY_DELAY_MS && + event.retrySafety !== undefined && + event.retrySafety !== "never" && + event.errorCode !== undefined); + const nonRetryContractValid = + event.status === "retry_wait" || + (event.retryDelayMs === undefined && event.retrySafety === undefined); + const failureCodeRequired = ["failed", "cancelled", "blocked", "ambiguous"].includes( + event.status, + ); + if ( + !retryContractValid || + !nonRetryContractValid || + (failureCodeRequired && event.errorCode === undefined) || + (!failureCodeRequired && event.status !== "retry_wait" && event.errorCode !== undefined) + ) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (!(NODE_TRANSITIONS[previous ?? "unseen"] ?? []).includes(event.status)) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (event.status === "running" && event.attempt !== previousAttempt + 1) { + return { accepted: false, cursor, reason: "attempt-regression" }; + } + if ( + event.status !== "running" && + event.status !== "queued" && + event.attempt !== previousAttempt + ) { + return { accepted: false, cursor, reason: "attempt-regression" }; + } + return { + accepted: true, + cursor: deepFreeze({ + ...cursor, + lastSequence: event.sequence, + nodeStatuses: { ...cursor.nodeStatuses, [event.nodeId]: event.status }, + nodeAttempts: { + ...cursor.nodeAttempts, + [event.nodeId]: event.status === "running" ? event.attempt : previousAttempt, + }, + }), + }; +} + +export type RendererLifecycleEvent = + | { kind: "route-change"; documentId: string } + | { kind: "document-destroyed"; documentId: string }; + +export type RendererDisconnectDecision = + | "continue-and-resubscribe" + | "request-best-effort-cancel" + | "ignore"; + +export function rendererDisconnectDecision( + runOwnerDocumentId: string, + event: RendererLifecycleEvent, +): RendererDisconnectDecision { + assertOpaqueId(runOwnerDocumentId, "Run owner document ID"); + assertOpaqueId(event.documentId, "Renderer document ID"); + if (event.documentId !== runOwnerDocumentId) return "ignore"; + return event.kind === "route-change" ? "continue-and-resubscribe" : "request-best-effort-cancel"; +} + +export type RestartNodePhase = + | "never-started" + | "local-running" + | "remote-submitting" + | "remote-submitted" + | "output-publishing" + | "cancel-requested" + | "terminal"; + +export interface RestartNodeRecord { + phase: RestartNodePhase; + lane: CoordinatorExecutionLane; + remoteJobId?: string; + durableOutputAvailable?: boolean; +} + +export type RestartReconciliationCategory = + | "await-explicit-resume" + | "mark-interrupted" + | "ambiguous-submit" + | "reconcile-remote-job" + | "resume-output-publication" + | "reconcile-cancel" + | "finalize-cancel" + | "terminal"; + +export interface RestartReconciliationDecision { + category: RestartReconciliationCategory; + autoSubmit: false; + maySubmitAfterExplicitApproval: boolean; + remoteJobId?: string; +} + +export function reconcileRestartNode(record: RestartNodeRecord): RestartReconciliationDecision { + if (record.remoteJobId !== undefined) assertProviderJobId(record.remoteJobId); + if (record.remoteJobId !== undefined && record.lane !== "remote") { + throw new Error("Only remote restart records can contain provider job IDs."); + } + if (record.phase === "local-running" && record.lane !== "local") { + throw new Error("A local-running restart record must use the local lane."); + } + if ( + (record.phase === "remote-submitting" || record.phase === "remote-submitted") && + record.lane !== "remote" + ) { + throw new Error("Remote submission restart records must use the remote lane."); + } + switch (record.phase) { + case "never-started": + return { + category: "await-explicit-resume", + autoSubmit: false, + maySubmitAfterExplicitApproval: true, + }; + case "local-running": + return { + category: "mark-interrupted", + autoSubmit: false, + maySubmitAfterExplicitApproval: true, + }; + case "remote-submitting": + if (record.remoteJobId) { + return { + category: "reconcile-remote-job", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + remoteJobId: record.remoteJobId, + }; + } + return { + category: "ambiguous-submit", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + case "remote-submitted": + if (!record.remoteJobId) { + return { + category: "ambiguous-submit", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + } + return { + category: "reconcile-remote-job", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + remoteJobId: record.remoteJobId, + }; + case "output-publishing": + return record.durableOutputAvailable + ? { + category: "resume-output-publication", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + } + : { + category: "mark-interrupted", + autoSubmit: false, + maySubmitAfterExplicitApproval: true, + }; + case "cancel-requested": + return record.remoteJobId + ? { + category: "reconcile-cancel", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + remoteJobId: record.remoteJobId, + } + : { + category: "finalize-cancel", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + case "terminal": + return { + category: "terminal", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + } +} diff --git a/main/services/create-images/workflow-manifest-store.test.ts b/main/services/create-images/workflow-manifest-store.test.ts new file mode 100644 index 00000000..e7b971c7 --- /dev/null +++ b/main/services/create-images/workflow-manifest-store.test.ts @@ -0,0 +1,803 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import { + CREATE_IMAGES_SCHEMA_VERSION, + createStarterWorkflow, +} from "../../../renderer/shared/create-images/schema.js"; +import { + WorkflowManifestLoadError, + WorkflowManifestStore, + WorkflowRevisionConflictError, +} from "./workflow-manifest-store.js"; + +async function harness(t: TestContext) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + return { directory, store: new WorkflowManifestStore(() => directory) }; +} + +function workflow(workflowId = "workflow-1", now = "2026-08-11T12:00:00.000Z") { + return createStarterWorkflow({ + workflowId, + promptNodeId: `${workflowId}-prompt`, + generationNodeId: `${workflowId}-generate`, + outputNodeId: `${workflowId}-output`, + promptEdgeId: `${workflowId}-edge-1`, + outputEdgeId: `${workflowId}-edge-2`, + now, + }); +} + +function nextRevision( + current: ReturnType, + title: string, + updatedAt = "2026-08-11T12:01:00.000Z", +) { + return { ...structuredClone(current), title, revision: current.revision + 1, updatedAt }; +} + +test("workflow manifests persist independently with a rebuildable metadata-only index", async (t) => { + const { directory, store } = await harness(t); + assert.deepEqual(await store.health(), { + status: "healthy", + source: "missing", + path: path.join(directory, "index.json"), + }); + const first = workflow(); + await store.create(first); + assert.deepEqual(await store.get(first.id), first); + assert.deepEqual(await store.list(), [ + { + id: first.id, + title: first.title, + revision: 1, + createdAt: first.createdAt, + updatedAt: first.updatedAt, + nodeCount: 3, + edgeCount: 2, + assetCount: 0, + health: "healthy", + recoveryAvailable: true, + }, + ]); + assert.deepEqual(await store.health(), { + status: "healthy", + source: "disk", + path: path.join(directory, "index.json"), + }); + assert.deepEqual((await fs.readdir(path.join(directory, "workflows", first.id))).sort(), [ + "workflow.json", + "workflow.last-known-good.json", + ]); + const indexText = await fs.readFile(path.join(directory, "index.json"), "utf8"); + assert.equal(indexText.includes("nodes"), false); + assert.equal(indexText.includes("prompt"), false); +}); + +test("save, rename, duplicate, and recoverable delete enforce exact revisions", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + await assert.rejects( + () => store.put(nextRevision(first, "stale create"), null), + WorkflowRevisionConflictError, + ); + const saved = nextRevision(first, "Saved revision"); + await store.put(saved, 1); + await assert.rejects(() => store.delete(first.id, 1), WorkflowRevisionConflictError); + const renamed = await store.rename(first.id, "Renamed", 2, "2026-08-11T12:02:00.000Z"); + assert.equal(renamed.revision, 3); + assert.equal(renamed.title, "Renamed"); + await assert.rejects( + () => + store.duplicate(first.id, { + workflowId: "stale-copy", + expectedRevision: 2, + now: "2026-08-11T12:03:00.000Z", + }), + WorkflowRevisionConflictError, + ); + const duplicate = await store.duplicate(first.id, { + workflowId: "workflow-copy", + expectedRevision: 3, + title: "A durable copy", + now: "2026-08-11T12:03:00.000Z", + }); + assert.equal(duplicate.id, "workflow-copy"); + assert.equal(duplicate.revision, 1); + assert.equal(duplicate.nodes.length, renamed.nodes.length); + assert.deepEqual(await store.delete(first.id, 3), renamed); + assert.equal(await store.get(first.id), undefined); + assert.deepEqual( + (await store.list()).map((item) => item.id), + ["workflow-copy"], + ); + const deletedQuarantine = path.join(directory, "quarantine", "deleted-workflows"); + const quarantined = await fs.readdir(deletedQuarantine); + assert.equal(quarantined.length, 1); + const quarantineEntry = quarantined[0]; + assert.ok(quarantineEntry?.startsWith("deleted-workflow-1-")); + const quarantinePath = path.join(deletedQuarantine, quarantineEntry); + assert.equal((await fs.lstat(quarantinePath)).isDirectory(), true); + assert.deepEqual((await fs.readdir(quarantinePath)).sort(), [ + "workflow.json", + "workflow.last-known-good.json", + ]); +}); + +test("concurrent stores serialize mutations and reject the stale autosave", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const left = new WorkflowManifestStore(() => directory); + const right = new WorkflowManifestStore(() => directory); + const results = await Promise.allSettled([ + left.put(nextRevision(first, "Left"), 1), + right.put(nextRevision(first, "Right"), 1), + ]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + const rejected = results.find((result) => result.status === "rejected"); + assert.equal(rejected?.status, "rejected"); + if (rejected?.status === "rejected") { + assert.equal(rejected.reason instanceof WorkflowRevisionConflictError, true); + } + const current = await new WorkflowManifestStore(() => directory).get(first.id); + assert.equal(current?.revision, 2); + assert.equal(["Left", "Right"].includes(current?.title ?? ""), true); + assert.equal((await store.list())[0]?.revision, 2); +}); + +test("autosave can be staged, observed, flushed, and discarded with CAS", async (t) => { + const { store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const second = nextRevision(first, "Pending autosave"); + await store.stageAutosave(second, 1); + assert.deepEqual(await store.autosaveStatus(first.id), { + workflowId: first.id, + state: "pending", + baseRevision: 1, + targetRevision: 2, + stagedAt: second.updatedAt, + }); + const pendingHealth = await store.inspect(first.id); + assert.equal(pendingHealth.status, "recovery-required"); + if (pendingHealth.status === "recovery-required") { + assert.equal(pendingHealth.reason, "journal-pending"); + } + await assert.rejects(() => store.get(first.id), WorkflowManifestLoadError); + await assert.rejects(() => store.flushAutosave(first.id, null), WorkflowRevisionConflictError); + assert.deepEqual(await store.flushAutosave(first.id, 1), second); + assert.deepEqual(await store.autosaveStatus(first.id), { + workflowId: first.id, + state: "none", + }); + + const third = nextRevision(second, "Discard me", "2026-08-11T12:02:00.000Z"); + await store.stageAutosave(third, 2); + await assert.rejects(() => store.discardAutosave(first.id, 4), WorkflowRevisionConflictError); + await store.discardAutosave(first.id, 3); + assert.equal((await store.get(first.id))?.revision, 2); +}); + +test("a crash-survived newer journal requires explicit autosave recovery", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const second = nextRevision(first, "Journal survived"); + const interrupted = new WorkflowManifestStore(() => directory, { + afterJournalPublished: async () => { + throw new Error("simulated journal crash"); + }, + }); + await assert.rejects(() => interrupted.put(second, 1), /simulated journal crash/u); + const reopened = new WorkflowManifestStore(() => directory); + const journalPath = path.join(directory, "workflows", first.id, "autosave.journal"); + const durableJournal = await fs.readFile(journalPath, "utf8"); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") assert.equal(health.reason, "journal-pending"); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + await assert.rejects( + () => reopened.stageAutosave(nextRevision(first, "Must not replace it"), 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(journalPath, "utf8"), durableJournal); + assert.equal((await reopened.autosaveStatus(first.id)).state, "pending"); + const recovered = await reopened.recover(first.id, "autosave", 2, "2026-08-11T12:03:00.000Z"); + assert.equal(recovered.title, second.title); + assert.equal(recovered.revision, 3); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); +}); + +test("a crash after current publication is idempotently reconciled on restart", async (t) => { + for (const entrypoint of ["initialize", "get"] as const) { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), `aiden-create-images-${entrypoint}-reconcile-`), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const first = workflow(`workflow-${entrypoint}`); + await new WorkflowManifestStore(() => directory).put(first, null); + const second = nextRevision(first, "Current survived"); + const interrupted = new WorkflowManifestStore(() => directory, { + afterCurrentPublished: async () => { + throw new Error("simulated post-current crash"); + }, + }); + await assert.rejects(() => interrupted.put(second, 1), /simulated post-current crash/u); + + const workflowDirectory = path.join(directory, "workflows", first.id); + const lastKnownGoodPath = path.join(workflowDirectory, "workflow.last-known-good.json"); + const journalPath = path.join(workflowDirectory, "autosave.journal"); + assert.deepEqual(JSON.parse(await fs.readFile(lastKnownGoodPath, "utf8")), first); + await fs.access(journalPath); + + const reopened = new WorkflowManifestStore(() => directory); + if (entrypoint === "initialize") { + assert.equal((await reopened.initialize())[0]?.revision, second.revision); + } else { + assert.deepEqual(await reopened.get(first.id), second); + } + assert.deepEqual(JSON.parse(await fs.readFile(lastKnownGoodPath, "utf8")), second); + await assert.rejects(() => fs.lstat(journalPath), { code: "ENOENT" }); + assert.deepEqual(await reopened.get(first.id), second); + assert.equal((await reopened.autosaveStatus(first.id)).state, "none"); + assert.equal((await reopened.initialize())[0]?.health, "healthy"); + } +}); + +test("a divergent autosave conflict can be recovered without overwriting either revision", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const autosave = nextRevision(first, "Autosaved branch"); + await store.stageAutosave(autosave, 1); + const independentlySaved = nextRevision(first, "Saved elsewhere"); + await fs.writeFile( + path.join(directory, "workflows", first.id, "workflow.json"), + `${JSON.stringify(independentlySaved)}\n`, + "utf8", + ); + + const reopened = new WorkflowManifestStore(() => directory); + assert.deepEqual(await reopened.inspect(first.id), { + status: "recovery-required", + workflowId: first.id, + currentPath: path.join(directory, "workflows", first.id, "workflow.json"), + reason: "journal-conflict", + currentRevision: 2, + lastKnownGoodAvailable: true, + lastKnownGoodRevision: 1, + autosave: "pending", + autosaveTargetRevision: 2, + }); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + const recovered = await reopened.recover(first.id, "autosave", 2, "2026-08-11T12:04:00.000Z"); + assert.equal(recovered.title, "Autosaved branch"); + assert.equal(recovered.revision, 3); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); + assert.deepEqual(await reopened.autosaveStatus(first.id), { + workflowId: first.id, + state: "none", + }); +}); + +test("an autosave can recover when last-known-good metadata is also corrupt", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const pending = nextRevision(first, "Pending survives damaged metadata"); + await store.stageAutosave(pending, 1); + const lastGoodPath = path.join(directory, "workflows", first.id, "workflow.last-known-good.json"); + await fs.writeFile(lastGoodPath, "{broken", "utf8"); + + const reopened = new WorkflowManifestStore(() => directory); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") { + assert.equal(health.reason, "last-known-good-corrupt"); + assert.equal(health.autosave, "pending"); + assert.equal(health.autosaveTargetRevision, 2); + } + await assert.rejects( + () => reopened.repairRecoveryMetadata(first.id, 1), + /Flush or discard the pending autosave/u, + ); + await assert.rejects( + () => reopened.recover(first.id, "last-known-good", 1, "2026-08-11T12:04:00.000Z"), + /healthy workflow does not require recovery/u, + ); + const recovered = await reopened.recover(first.id, "autosave", 2, "2026-08-11T12:04:00.000Z"); + assert.equal(recovered.title, pending.title); + assert.equal(recovered.revision, 3); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); +}); + +test("corrupt current data opens recovery state and restores an incremented last-known-good", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const currentPath = path.join(directory, "workflows", first.id, "workflow.json"); + await fs.writeFile(currentPath, "{broken", "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + assert.deepEqual(await reopened.inspect(first.id), { + status: "recovery-required", + workflowId: first.id, + currentPath, + reason: "current-corrupt", + lastKnownGoodAvailable: true, + lastKnownGoodRevision: 1, + autosave: "none", + }); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + await assert.rejects( + () => reopened.put(nextRevision(first, "Never overwrite"), 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(currentPath, "utf8"), "{broken"); + const recovered = await reopened.recover( + first.id, + "last-known-good", + 1, + "2026-08-11T12:05:00.000Z", + ); + assert.equal(recovered.revision, 2); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); + assert.equal( + (await fs.readdir(path.join(directory, "quarantine"))).some((name) => + name.startsWith(`${first.id}-current-corrupt-`), + ), + true, + ); +}); + +test("recovery advances past every durable candidate revision", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const pending = nextRevision(first, "Newer pending edit"); + await store.stageAutosave(pending, 1); + await fs.writeFile( + path.join(directory, "workflows", first.id, "workflow.json"), + "{broken", + "utf8", + ); + const reopened = new WorkflowManifestStore(() => directory); + const recovered = await reopened.recover( + first.id, + "last-known-good", + 1, + "2026-08-11T12:05:00.000Z", + ); + assert.equal(recovered.revision, 3); + assert.equal(recovered.title, first.title); + assert.equal((await reopened.autosaveStatus(first.id)).state, "none"); +}); + +test("missing current and corrupt journal states remain explicit and non-destructive", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const paths = path.join(directory, "workflows", first.id); + await fs.rm(path.join(paths, "workflow.json")); + const missing = new WorkflowManifestStore(() => directory); + const missingHealth = await missing.inspect(first.id); + assert.equal(missingHealth.status, "recovery-required"); + if (missingHealth.status === "recovery-required") { + assert.equal(missingHealth.reason, "current-missing"); + } + await assert.rejects(() => missing.get(first.id), WorkflowManifestLoadError); + const restored = await missing.recover( + first.id, + "last-known-good", + 1, + "2026-08-11T12:06:00.000Z", + ); + assert.equal(restored.revision, 2); + + await fs.writeFile(path.join(paths, "autosave.journal"), "{bad", "utf8"); + const corruptJournal = new WorkflowManifestStore(() => directory); + assert.deepEqual(await corruptJournal.autosaveStatus(first.id), { + workflowId: first.id, + state: "corrupt", + }); + await assert.rejects( + () => corruptJournal.stageAutosave(nextRevision(restored, "blocked"), 2), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(path.join(paths, "autosave.journal"), "utf8"), "{bad"); + assert.equal((await corruptJournal.inspect(first.id)).status, "recovery-required"); + await corruptJournal.repairRecoveryMetadata(first.id, 2); + assert.deepEqual(await corruptJournal.autosaveStatus(first.id), { + workflowId: first.id, + state: "none", + }); + assert.equal((await corruptJournal.inspect(first.id)).status, "healthy"); +}); + +test("future workflow and journal schemas are read-only and never quarantined implicitly", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const workflowPath = path.join(directory, "workflows", first.id, "workflow.json"); + const future = `${JSON.stringify({ ...first, schemaVersion: CREATE_IMAGES_SCHEMA_VERSION + 1 })}\n`; + await fs.writeFile(workflowPath, future, "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "unsafe"); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + await assert.rejects( + () => reopened.recover(first.id, "last-known-good", 1, "2026-08-11T12:07:00.000Z"), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(workflowPath, "utf8"), future); + + await fs.writeFile(workflowPath, `${JSON.stringify(first)}\n`, "utf8"); + const journalPath = path.join(directory, "workflows", first.id, "autosave.journal"); + const futureJournal = `${JSON.stringify({ version: 2 })}\n`; + await fs.writeFile(journalPath, futureJournal, "utf8"); + const journalStore = new WorkflowManifestStore(() => directory); + assert.equal((await journalStore.inspect(first.id)).status, "unsafe"); + await assert.rejects( + () => journalStore.stageAutosave(nextRevision(first, "blocked"), 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(journalPath, "utf8"), futureJournal); +}); + +test("non-SHA asset references are isolated as workflow recovery instead of poisoning inventory", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const malformed = structuredClone(first); + malformed.nodes.push({ + id: "bad-image", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId: "asset-1" }, + }); + malformed.assetRefs = ["asset-1"]; + const workflowDirectory = path.join(directory, "workflows", first.id); + await Promise.all([ + fs.writeFile(path.join(workflowDirectory, "workflow.json"), `${JSON.stringify(malformed)}\n`), + fs.writeFile( + path.join(workflowDirectory, "workflow.last-known-good.json"), + `${JSON.stringify(malformed)}\n`, + ), + ]); + + const reopened = new WorkflowManifestStore(() => directory); + const summaries = await reopened.initialize(); + assert.equal(summaries.length, 1); + assert.equal(summaries[0]?.health, "recovery-required"); + assert.equal((await reopened.inspect(first.id)).status, "recovery-required"); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); +}); + +test("future last-known-good metadata is explicit and cannot be replaced by save or repair", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const lastGoodPath = path.join(directory, "workflows", first.id, "workflow.last-known-good.json"); + const future = `${JSON.stringify({ ...first, schemaVersion: CREATE_IMAGES_SCHEMA_VERSION + 1 })}\n`; + await fs.writeFile(lastGoodPath, future, "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "unsafe"); + if (health.status === "unsafe") { + assert.equal(health.reason, "last-known-good-future-schema"); + } + await assert.rejects( + () => reopened.put(nextRevision(first, "blocked"), 1), + WorkflowManifestLoadError, + ); + await assert.rejects( + () => reopened.repairRecoveryMetadata(first.id, 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(lastGoodPath, "utf8"), future); +}); + +test("Phase 0 aggregate storage migrates without changing workflow revisions", async (t) => { + const { directory } = await harness(t); + const first = workflow(); + await fs.writeFile( + path.join(directory, "workflows.json"), + `${JSON.stringify({ version: 1, workflows: { [first.id]: first } })}\n`, + "utf8", + ); + const store = new WorkflowManifestStore(() => directory); + assert.deepEqual(await store.get(first.id), first); + assert.equal((await store.list())[0]?.revision, 1); + await assert.rejects(() => fs.readFile(path.join(directory, "workflows.json")), { + code: "ENOENT", + }); + assert.equal( + (await fs.readdir(directory)).some((name) => name.startsWith("workflows.phase-0-migrated-")), + true, + ); +}); + +test("corrupt or future Phase 0 aggregate data is preserved and blocks migration", async (t) => { + const { directory } = await harness(t); + const legacyPath = path.join(directory, "workflows.json"); + await fs.writeFile(legacyPath, "{broken", "utf8"); + const corrupt = new WorkflowManifestStore(() => directory); + assert.deepEqual(await corrupt.health(), { status: "corrupt", path: legacyPath }); + await assert.rejects(() => corrupt.list(), WorkflowManifestLoadError); + await assert.rejects(() => corrupt.put(workflow(), null), WorkflowManifestLoadError); + assert.equal(await fs.readFile(legacyPath, "utf8"), "{broken"); + + await fs.writeFile(legacyPath, JSON.stringify({ version: 2, workflows: {} }), "utf8"); + const future = new WorkflowManifestStore(() => directory); + assert.deepEqual(await future.health(), { status: "unsafe", path: legacyPath }); + await assert.rejects(() => future.get("workflow-1"), WorkflowManifestLoadError); + assert.deepEqual(JSON.parse(await fs.readFile(legacyPath, "utf8")), { + version: 2, + workflows: {}, + }); +}); + +test("corrupt index is rebuilt from manifests while a future index is preserved", async (t) => { + const { directory, store } = await harness(t); + await store.put(workflow(), null); + const indexPath = path.join(directory, "index.json"); + await fs.writeFile(indexPath, "{broken", "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + assert.deepEqual(await reopened.health(), { status: "corrupt", path: indexPath }); + assert.equal((await reopened.list()).length, 1); + assert.equal(JSON.parse(await fs.readFile(indexPath, "utf8")).version, 1); + assert.equal( + (await fs.readdir(path.join(directory, "quarantine"))).some((name) => + name.startsWith("index-corrupt-"), + ), + true, + ); + + const future = `${JSON.stringify({ version: 2, workflows: [] })}\n`; + await fs.writeFile(indexPath, future, "utf8"); + const futureStore = new WorkflowManifestStore(() => directory); + assert.deepEqual(await futureStore.health(), { status: "unsafe", path: indexPath }); + assert.equal((await futureStore.list()).length, 1); + assert.equal(await fs.readFile(indexPath, "utf8"), future); +}); + +test("workflow IDs are path-bounded and object-prototype names remain safe", async (t) => { + const { store } = await harness(t); + const document = workflow("constructor"); + await store.put(document, null); + assert.deepEqual(await store.get("constructor"), document); + assert.equal(await store.get("toString"), undefined); + await assert.rejects(() => store.get("../escape"), /Invalid Create Images workflow ID/u); + await assert.rejects( + () => + store.duplicate("constructor", { + workflowId: "../copy", + expectedRevision: 1, + now: document.updatedAt, + }), + /Invalid Create Images workflow ID/u, + ); +}); + +test("workflow inventory fails closed on a same-name directory symlink", async (t) => { + const { directory, store } = await harness(t); + await store.initialize(); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-outside-")); + t.after(() => fs.rm(outside, { recursive: true, force: true })); + const document = workflow("redirected"); + await fs.writeFile(path.join(outside, "workflow.json"), `${JSON.stringify(document)}\n`, "utf8"); + await fs.symlink(outside, path.join(directory, "workflows", document.id)); + await assert.rejects(() => store.get(document.id), WorkflowManifestLoadError); + await assert.rejects(() => store.put(document, null), WorkflowManifestLoadError); + await assert.rejects(() => store.list(), WorkflowManifestLoadError); +}); + +test("workflow inventory fails closed when a workflow directory is renamed to an invalid ID", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const original = path.join(directory, "workflows", first.id); + const invalid = path.join(directory, "workflows", `${first.id}.renamed`); + await fs.rename(original, invalid); + + await assert.rejects(() => store.list(), WorkflowManifestLoadError); + await assert.rejects(() => store.put(workflow("workflow-2"), null), WorkflowManifestLoadError); + assert.equal((await fs.readdir(invalid)).includes("workflow.json"), true); + await assert.rejects(() => fs.lstat(path.join(directory, "workflows", "workflow-2")), { + code: "ENOENT", + }); +}); + +test("workflow inventory fails closed on unknown workflow files", async (t) => { + const { directory, store } = await harness(t); + await store.create(workflow()); + const unknown = path.join(directory, "workflows", "workflow-1", "unexpected.bin"); + await fs.writeFile(unknown, "not workflow metadata", "utf8"); + + await assert.rejects( + () => store.initialize(), + (error: unknown) => { + assert.equal(error instanceof WorkflowManifestLoadError, true); + assert.equal((error as WorkflowManifestLoadError).status, "unsafe"); + return true; + }, + ); +}); + +test("workflow count and aggregate byte preflights prevent durable unindexed growth", async (t) => { + const { directory } = await harness(t); + const countLimited = new WorkflowManifestStore(() => directory, {}, { maxWorkflowCount: 1 }); + const first = workflow(); + await countLimited.put(first, null); + await assert.rejects( + () => countLimited.put(workflow("workflow-2"), null), + /workflow count limit/u, + ); + await assert.rejects(() => fs.lstat(path.join(directory, "workflows", "workflow-2")), { + code: "ENOENT", + }); + + const firstDirectory = path.join(directory, "workflows", first.id); + const existingBytes = ( + await Promise.all( + ( + await fs.readdir(firstDirectory) + ).map(async (name) => (await fs.lstat(path.join(firstDirectory, name))).size), + ) + ).reduce((sum, size) => sum + size, 0); + const byteLimited = new WorkflowManifestStore( + () => directory, + {}, + { maxAggregateWorkflowBytes: existingBytes + 16 }, + ); + await assert.rejects( + () => byteLimited.stageAutosave(nextRevision(first, "This cannot fit"), 1), + /aggregate byte limit/u, + ); + await assert.rejects(() => fs.lstat(path.join(firstDirectory, "autosave.journal")), { + code: "ENOENT", + }); + assert.deepEqual(await byteLimited.get(first.id), first); +}); + +test("hostile prepopulated inventory hits the aggregate limit before manifest parsing", async (t) => { + const { directory } = await harness(t); + const workflowDirectory = path.join(directory, "workflows", "hostile-workflow"); + await fs.mkdir(workflowDirectory, { recursive: true }); + const hostileBody = "{".repeat(128); + const manifestPath = path.join(workflowDirectory, "workflow.json"); + await fs.writeFile(manifestPath, hostileBody, "utf8"); + const store = new WorkflowManifestStore(() => directory, {}, { maxAggregateWorkflowBytes: 64 }); + + await assert.rejects(() => store.initialize(), /aggregate byte limit/u); + assert.equal(await fs.readFile(manifestPath, "utf8"), hostileBody); + await assert.rejects(() => fs.lstat(path.join(directory, "index.json")), { code: "ENOENT" }); +}); + +test("deleted workflow quarantine is bounded without pruning recovery evidence", async (t) => { + const { directory } = await harness(t); + const store = new WorkflowManifestStore(() => directory, {}, { maxDeletedQuarantineEntries: 1 }); + const recoveryEvidence = path.join(directory, "quarantine", "workflow-corrupt-evidence.json"); + await store.initialize(); + await fs.writeFile(recoveryEvidence, "{broken", "utf8"); + + const first = workflow("workflow-1"); + await store.put(first, null); + await store.delete(first.id, 1); + const second = workflow("workflow-2", "2026-08-11T12:01:00.000Z"); + await store.put(second, null); + await store.delete(second.id, 1); + + const quarantine = await fs.readdir(path.join(directory, "quarantine", "deleted-workflows")); + assert.equal(quarantine.filter((name) => name.startsWith("deleted-")).length, 1); + assert.equal(await fs.readFile(recoveryEvidence, "utf8"), "{broken"); +}); + +test("recovery evidence cannot exhaust the deleted-workflow quarantine scan", async (t) => { + const { directory, store } = await harness(t); + await store.initialize(); + const recoveryPath = path.join(directory, "quarantine"); + for (let offset = 0; offset < 4_100; offset += 100) { + await Promise.all( + Array.from({ length: 100 }, (_, index) => + fs.writeFile(path.join(recoveryPath, `recovery-${offset + index}.json`), "{}"), + ), + ); + } + const first = workflow("workflow-separated-quarantine"); + await store.put(first, null); + await store.delete(first.id, 1); + assert.equal( + (await fs.readdir(path.join(directory, "quarantine", "deleted-workflows"))).filter((name) => + name.startsWith("deleted-"), + ).length, + 1, + ); +}); + +test("deleted workflow quarantine also enforces its aggregate byte budget", async (t) => { + const { directory, store } = await harness(t); + const first = workflow("workflow-1"); + await store.put(first, null); + await store.delete(first.id, 1); + const quarantinePath = path.join(directory, "quarantine", "deleted-workflows"); + const firstDeleted = (await fs.readdir(quarantinePath)).find((name) => + name.startsWith("deleted-workflow-1-"), + ); + assert.ok(firstDeleted); + const firstDeletedPath = path.join(quarantinePath, firstDeleted); + const firstBytes = ( + await Promise.all( + ( + await fs.readdir(firstDeletedPath) + ).map(async (name) => (await fs.lstat(path.join(firstDeletedPath, name))).size), + ) + ).reduce((sum, size) => sum + size, 0); + + const byteBounded = new WorkflowManifestStore( + () => directory, + {}, + { maxDeletedQuarantineBytes: firstBytes + 16 }, + ); + const second = workflow("workflow-2", "2026-08-11T12:01:00.000Z"); + await byteBounded.put(second, null); + await byteBounded.delete(second.id, 1); + assert.equal( + (await fs.readdir(quarantinePath)).filter((name) => name.startsWith("deleted-")).length, + 1, + ); +}); + +test("a projection rebuild failure after publication does not misreport a durable create", async (t) => { + const { directory } = await harness(t); + const unexpected = path.join(directory, "workflows", "invalid.workflow"); + const store = new WorkflowManifestStore(() => directory, { + afterCurrentPublished: async () => { + await fs.mkdir(unexpected); + }, + }); + const first = workflow(); + assert.deepEqual(await store.put(first, null), first); + assert.deepEqual(await store.get(first.id), first); + await assert.rejects(() => store.list(), WorkflowManifestLoadError); + await fs.rmdir(unexpected); + assert.equal((await store.list()).length, 1); +}); + +test("every renderer-owned publication checks document liveness", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await assert.rejects( + () => store.put(first, null, () => false), + /renderer document is no longer active/u, + ); + await assert.rejects( + () => fs.readFile(path.join(directory, "workflows", first.id, "workflow.json")), + { code: "ENOENT" }, + ); + await assert.rejects( + () => fs.readFile(path.join(directory, "workflows", first.id, "autosave.journal")), + { code: "ENOENT" }, + ); + await assert.rejects(() => fs.lstat(path.join(directory, "workflows", first.id)), { + code: "ENOENT", + }); + assert.deepEqual(await store.list(), []); + + await store.put(first, null); + const second = nextRevision(first, "not current"); + await assert.rejects( + () => store.stageAutosave(second, 1, () => false), + /renderer document is no longer active/u, + ); + assert.equal((await store.get(first.id))?.revision, 1); + await assert.rejects( + () => store.delete(first.id, 1, () => false), + /renderer document is no longer active/u, + ); + assert.equal((await store.get(first.id))?.revision, 1); +}); diff --git a/main/services/create-images/workflow-manifest-store.ts b/main/services/create-images/workflow-manifest-store.ts new file mode 100644 index 00000000..4199e00a --- /dev/null +++ b/main/services/create-images/workflow-manifest-store.ts @@ -0,0 +1,1688 @@ +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + CREATE_IMAGES_SCHEMA_VERSION, + parseWorkflowDocument, +} from "../../../renderer/shared/create-images/schema.js"; +import { decodeUtf8, readRegularFile } from "../regular-file-read.js"; + +const INDEX_VERSION = 1 as const; +const JOURNAL_VERSION = 1 as const; +const MAX_WORKFLOW_BYTES = CREATE_IMAGES_MAX_WORKFLOW_BYTES; +const MAX_JOURNAL_BYTES = CREATE_IMAGES_MAX_WORKFLOW_BYTES + 64 * 1024; +const MAX_INDEX_BYTES = 4 * 1024 * 1024; +const DEFAULT_MAX_WORKFLOW_COUNT = 1_000; +const DEFAULT_MAX_AGGREGATE_WORKFLOW_BYTES = 512 * 1024 * 1024; +const DEFAULT_MAX_DELETED_QUARANTINE_ENTRIES = 32; +const DEFAULT_MAX_DELETED_QUARANTINE_BYTES = 128 * 1024 * 1024; +const MAX_QUARANTINE_SCAN_ENTRIES = 4_096; +const WORKFLOW_FILE_NAMES = new Set([ + "autosave.journal", + "workflow.json", + "workflow.last-known-good.json", +]); +const WORKFLOW_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const CURRENT_FILE = "workflow.json"; +const LAST_KNOWN_GOOD_FILE = "workflow.last-known-good.json"; +const AUTOSAVE_FILE = "autosave.journal"; + +interface WorkflowIndexV1 { + version: typeof INDEX_VERSION; + workflows: WorkflowManifestSummary[]; +} + +interface AutosaveJournalV1 { + version: typeof JOURNAL_VERSION; + workflowId: string; + baseRevision: number | null; + targetRevision: number; + stagedAt: string; + snapshot: WorkflowDocumentV1; +} + +type FileInspection = + | { status: "missing" } + | { status: "healthy"; value: T } + | { status: "corrupt" } + | { status: "unsafe" }; + +export interface WorkflowManifestSummary { + id: string; + title: string; + revision: number; + createdAt: string; + updatedAt: string; + nodeCount: number; + edgeCount: number; + assetCount: number; + health: "healthy" | "recovery-required" | "unsafe"; + recoveryAvailable: boolean; +} + +export type WorkflowManifestHealth = + | { status: "healthy"; source: "missing" | "disk"; path: string } + | { status: "corrupt"; path: string } + | { status: "unsafe"; path: string }; + +export type WorkflowRecoveryReason = + | "current-corrupt" + | "current-missing" + | "last-known-good-corrupt" + | "journal-corrupt" + | "journal-pending" + | "journal-conflict"; + +export type WorkflowRecoveryHealth = + | { + status: "missing"; + workflowId: string; + currentPath: string; + lastKnownGoodAvailable: false; + autosave: "none"; + } + | { + status: "healthy"; + workflowId: string; + currentPath: string; + revision: number; + lastKnownGoodAvailable: boolean; + autosave: "none" | "pending"; + autosaveTargetRevision?: number; + } + | { + status: "recovery-required"; + workflowId: string; + currentPath: string; + reason: WorkflowRecoveryReason; + currentRevision?: number; + lastKnownGoodAvailable: boolean; + lastKnownGoodRevision?: number; + autosave: "none" | "pending" | "corrupt"; + autosaveTargetRevision?: number; + } + | { + status: "unsafe"; + workflowId: string; + currentPath: string; + reason: "current-future-schema" | "last-known-good-future-schema" | "journal-future-schema"; + lastKnownGoodAvailable: boolean; + autosave: "none" | "pending" | "unsafe"; + }; + +export interface WorkflowAutosaveStatus { + workflowId: string; + state: "none" | "pending" | "corrupt" | "unsafe"; + baseRevision?: number | null; + targetRevision?: number; + stagedAt?: string; +} + +export interface WorkflowManifestDurability { + /** Test seam representing a crash after the journal is durable. */ + afterJournalPublished?: (workflowId: string) => Promise; + /** Test seam representing a crash after current is durable but before cleanup. */ + afterCurrentPublished?: (workflowId: string) => Promise; +} + +export interface WorkflowManifestStoreLimits { + maxWorkflowCount?: number; + maxAggregateWorkflowBytes?: number; + maxDeletedQuarantineEntries?: number; + maxDeletedQuarantineBytes?: number; +} + +export interface WorkflowReferenceInventory { + complete: boolean; + records: Array<{ + workflowId: string; + assetIds: string[]; + }>; +} + +export class WorkflowManifestLoadError extends Error { + constructor( + readonly status: "corrupt" | "unsafe", + readonly filePath: string, + ) { + super( + status === "corrupt" + ? "The Create Images workflow is damaged and has been kept for recovery." + : "The Create Images workflow belongs to an unsupported future schema and is read-only.", + ); + this.name = "WorkflowManifestLoadError"; + } +} + +export class WorkflowRevisionConflictError extends Error { + constructor( + readonly workflowId: string, + readonly expectedRevision: number | null, + readonly actualRevision: number | null, + ) { + super( + `Workflow "${workflowId}" changed: expected revision ${expectedRevision ?? "absent"}, found ${actualRevision ?? "absent"}.`, + ); + this.name = "WorkflowRevisionConflictError"; + } +} + +const rootMutationTails = new Map>(); + +function serializedAtRoot(root: string, operation: () => Promise): Promise { + const key = path.resolve(root); + const tail = rootMutationTails.get(key) ?? Promise.resolve(); + const result = tail.then(operation, operation); + rootMutationTails.set( + key, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +function validateWorkflowId(workflowId: string): string { + if (!WORKFLOW_ID.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return workflowId; +} + +function parseSnapshot(value: unknown): WorkflowDocumentV1 { + const parsed = parseWorkflowDocument(value); + if (!parsed.success) { + throw new Error(parsed.issues[0]?.message ?? "Invalid Create Images workflow."); + } + return parsed.value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFutureVersion(value: unknown, field: "schemaVersion" | "version"): boolean { + return ( + isRecord(value) && + typeof value[field] === "number" && + value[field] > (field === "schemaVersion" ? CREATE_IMAGES_SCHEMA_VERSION : 1) + ); +} + +function parseJournal(value: unknown): AutosaveJournalV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => + ![ + "version", + "workflowId", + "baseRevision", + "targetRevision", + "stagedAt", + "snapshot", + ].includes(key), + ) + ) { + return undefined; + } + const snapshot = parseWorkflowDocument(value.snapshot); + const baseRevision = + value.baseRevision === null + ? null + : typeof value.baseRevision === "number" && Number.isSafeInteger(value.baseRevision) + ? value.baseRevision + : undefined; + const targetRevision = + typeof value.targetRevision === "number" && Number.isSafeInteger(value.targetRevision) + ? value.targetRevision + : undefined; + if ( + value.version !== JOURNAL_VERSION || + typeof value.workflowId !== "string" || + !WORKFLOW_ID.test(value.workflowId) || + !snapshot.success || + snapshot.value.id !== value.workflowId || + baseRevision === undefined || + (baseRevision !== null && baseRevision < 1) || + targetRevision === undefined || + targetRevision < 1 || + snapshot.value.revision !== targetRevision || + targetRevision !== (baseRevision === null ? 1 : baseRevision + 1) || + typeof value.stagedAt !== "string" || + !Number.isFinite(Date.parse(value.stagedAt)) + ) { + return undefined; + } + return { + version: JOURNAL_VERSION, + workflowId: value.workflowId, + baseRevision, + targetRevision, + stagedAt: value.stagedAt, + snapshot: snapshot.value, + }; +} + +function summaryOf( + workflow: WorkflowDocumentV1, + health: WorkflowManifestSummary["health"], + recoveryAvailable: boolean, +): WorkflowManifestSummary { + return { + id: workflow.id, + title: workflow.title, + revision: workflow.revision, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + nodeCount: workflow.nodes.length, + edgeCount: workflow.edges.length, + assetCount: workflow.assetRefs.length, + health, + recoveryAvailable, + }; +} + +function parseLegacyDatabase(value: unknown): Record | undefined { + if ( + !isRecord(value) || + Object.keys(value).some((key) => key !== "version" && key !== "workflows") || + value.version !== 1 || + !isRecord(value.workflows) + ) { + return undefined; + } + const workflows = Object.create(null) as Record; + for (const [id, candidate] of Object.entries(value.workflows)) { + const parsed = parseWorkflowDocument(candidate); + if (!parsed.success || parsed.value.id !== id) return undefined; + workflows[id] = parsed.value; + } + return workflows; +} + +/** + * Device-local durable Create Images workflow authority. + * + * Each workflow is independently bounded and published. `index.json` is a + * rebuildable projection; workflow manifests, last-known-good snapshots, and + * autosave journals are the authority. Binary assets and run journals remain + * outside this store. + */ +export class WorkflowManifestStore { + private readonly limits: Required; + + constructor( + private readonly rootResolver: () => string, + private readonly durability: WorkflowManifestDurability = {}, + limits: WorkflowManifestStoreLimits = {}, + ) { + this.limits = { + maxWorkflowCount: limits.maxWorkflowCount ?? DEFAULT_MAX_WORKFLOW_COUNT, + maxAggregateWorkflowBytes: + limits.maxAggregateWorkflowBytes ?? DEFAULT_MAX_AGGREGATE_WORKFLOW_BYTES, + maxDeletedQuarantineEntries: + limits.maxDeletedQuarantineEntries ?? DEFAULT_MAX_DELETED_QUARANTINE_ENTRIES, + maxDeletedQuarantineBytes: + limits.maxDeletedQuarantineBytes ?? DEFAULT_MAX_DELETED_QUARANTINE_BYTES, + }; + for (const [name, value] of Object.entries(this.limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Invalid Create Images storage limit: ${name}.`); + } + } + } + + private root(): string { + return path.resolve(this.rootResolver()); + } + + private indexPath(): string { + return path.join(this.root(), "index.json"); + } + + private legacyPath(): string { + return path.join(this.root(), "workflows.json"); + } + + private workflowsPath(): string { + return path.join(this.root(), "workflows"); + } + + private workflowDirectory(workflowId: string): string { + return path.join(this.workflowsPath(), validateWorkflowId(workflowId)); + } + + private deletedWorkflowQuarantinePath(): string { + return path.join(this.root(), "quarantine", "deleted-workflows"); + } + + private workflowPaths(workflowId: string) { + const directory = this.workflowDirectory(workflowId); + return { + directory, + current: path.join(directory, CURRENT_FILE), + lastKnownGood: path.join(directory, LAST_KNOWN_GOOD_FILE), + autosave: path.join(directory, AUTOSAVE_FILE), + }; + } + + private async ensureDirectory(target: string): Promise { + const created = await fs.mkdir(target, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("Create Images storage contains an unsafe directory."); + } + if (created !== undefined) await this.syncDirectory(path.dirname(target)); + return created !== undefined; + } + + private async prepareDirectories(): Promise { + await this.ensureDirectory(this.root()); + await this.ensureDirectory(this.workflowsPath()); + await this.ensureDirectory(path.join(this.root(), "quarantine")); + await this.ensureDirectory(this.deletedWorkflowQuarantinePath()); + } + + private async readJson(target: string, maxBytes: number): Promise> { + let bytes: Buffer; + try { + bytes = await readRegularFile(target, maxBytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "corrupt" }; + } + try { + return { status: "healthy", value: JSON.parse(decodeUtf8(bytes)) as unknown }; + } catch { + return { status: "corrupt" }; + } + } + + private async inspectWorkflowFile(target: string): Promise> { + const raw = await this.readJson(target, MAX_WORKFLOW_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureVersion(raw.value, "schemaVersion")) return { status: "unsafe" }; + const parsed = parseWorkflowDocument(raw.value); + return parsed.success ? { status: "healthy", value: parsed.value } : { status: "corrupt" }; + } + + private async inspectJournalFile(target: string): Promise> { + const raw = await this.readJson(target, MAX_JOURNAL_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureVersion(raw.value, "version")) return { status: "unsafe" }; + const journal = parseJournal(raw.value); + return journal ? { status: "healthy", value: journal } : { status: "corrupt" }; + } + + private async syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } + + private async writeAtomic( + target: string, + value: unknown, + maxBytes: number, + isCurrent: () => boolean, + ): Promise { + const directory = path.dirname(target); + const serialized = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(serialized, "utf8") > maxBytes) { + throw new Error("Create Images workflow metadata exceeds its storage limit."); + } + const createdDirectory = await this.ensureDirectory(directory); + const staged = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`); + let publicationError: unknown; + try { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new Error("Create Images storage contains an unsafe file."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await fs.writeFile(staged, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 }); + const handle = await fs.open(staged, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await fs.rename(staged, target); + await this.syncDirectory(directory); + } catch (error) { + publicationError = error; + } + await fs.rm(staged, { force: true }).catch(() => undefined); + if (createdDirectory) { + try { + await fs.rmdir(directory); + await this.syncDirectory(path.dirname(directory)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + publicationError === undefined && + code !== "ENOENT" && + code !== "ENOTEMPTY" && + code !== "EEXIST" + ) { + publicationError = error; + } + } + } + if (publicationError !== undefined) throw publicationError; + } + + private async removeFileDurably(target: string): Promise { + try { + await fs.rm(target); + await this.syncDirectory(path.dirname(target)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + private async quarantineFile(target: string, label: string): Promise { + const quarantine = path.join( + this.root(), + "quarantine", + `${label}-${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID()}.json`, + ); + try { + await fs.rename(target, quarantine); + await this.syncDirectory(path.dirname(target)); + await this.syncDirectory(path.dirname(quarantine)); + return quarantine; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + private serializedBytes(value: unknown): number { + return Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, "utf8"); + } + + private unsafeStorageEntry(target: string): WorkflowManifestLoadError { + return new WorkflowManifestLoadError("unsafe", target); + } + + private async boundedDirectoryEntries(directory: string, maxEntries: number): Promise { + const entries: Dirent[] = []; + const handle = await fs.opendir(directory); + for await (const entry of handle) { + entries.push(entry); + if (entries.length > maxEntries) throw this.unsafeStorageEntry(directory); + } + return entries; + } + + private async workflowInventory(): Promise<{ + workflowIds: string[]; + workflowCount: number; + aggregateBytes: number; + }> { + const workflowIds: string[] = []; + let aggregateBytes = 0; + const entries = await this.boundedDirectoryEntries( + this.workflowsPath(), + this.limits.maxWorkflowCount, + ); + for (const entry of entries) { + const entryPath = path.join(this.workflowsPath(), entry.name); + const info = await fs.lstat(entryPath); + if ( + !WORKFLOW_ID.test(entry.name) || + !entry.isDirectory() || + entry.isSymbolicLink() || + !info.isDirectory() || + info.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(entryPath); + } + workflowIds.push(entry.name); + for (const child of await this.boundedDirectoryEntries(entryPath, WORKFLOW_FILE_NAMES.size)) { + const childPath = path.join(entryPath, child.name); + const childInfo = await fs.lstat(childPath); + if ( + !WORKFLOW_FILE_NAMES.has(child.name) || + !child.isFile() || + child.isSymbolicLink() || + !childInfo.isFile() || + childInfo.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(childPath); + } + aggregateBytes += childInfo.size; + if ( + !Number.isSafeInteger(aggregateBytes) || + aggregateBytes > this.limits.maxAggregateWorkflowBytes + ) { + throw new Error("Create Images workflow storage has reached its aggregate byte limit."); + } + } + } + return { workflowIds, workflowCount: workflowIds.length, aggregateBytes }; + } + + async referenceInventory(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const inventory = await this.workflowInventory(); + const records: WorkflowReferenceInventory["records"] = []; + let complete = true; + for (const workflowId of inventory.workflowIds) { + const state = await this.inspected(workflowId); + if ( + state.current.status === "corrupt" || + state.current.status === "unsafe" || + state.lastKnownGood.status === "corrupt" || + state.lastKnownGood.status === "unsafe" || + state.journal.status === "corrupt" || + state.journal.status === "unsafe" + ) { + complete = false; + } + const candidates = [ + this.validRecoveryCandidate(state.current, workflowId), + this.validRecoveryCandidate(state.lastKnownGood, workflowId), + this.validRecoveryCandidate(state.journal, workflowId), + ].filter((candidate): candidate is WorkflowDocumentV1 => candidate !== undefined); + if (candidates.length === 0) complete = false; + records.push({ + workflowId, + assetIds: [...new Set(candidates.flatMap((candidate) => candidate.assetRefs))].sort(), + }); + } + return { complete, records }; + }); + } + + private async assertMutationWithinLimits( + workflowId: string, + replacements: ReadonlyMap, + ): Promise { + const inventory = await this.workflowInventory(); + const isNewWorkflow = !inventory.workflowIds.includes(workflowId); + const projectedCount = inventory.workflowCount + (isNewWorkflow ? 1 : 0); + if (projectedCount > this.limits.maxWorkflowCount) { + throw new Error("Create Images workflow storage has reached its workflow count limit."); + } + let projectedBytes = inventory.aggregateBytes; + for (const [target, replacement] of replacements) { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw this.unsafeStorageEntry(target); + } + projectedBytes -= existing.size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (replacement !== undefined) projectedBytes += this.serializedBytes(replacement); + } + if ( + !Number.isSafeInteger(projectedBytes) || + projectedBytes > this.limits.maxAggregateWorkflowBytes + ) { + throw new Error("Create Images workflow storage has reached its aggregate byte limit."); + } + } + + private async deletedWorkflowDirectoryBytes(directory: string): Promise { + let total = 0; + for (const entry of await this.boundedDirectoryEntries(directory, WORKFLOW_FILE_NAMES.size)) { + const entryPath = path.join(directory, entry.name); + const info = await fs.lstat(entryPath); + if ( + !WORKFLOW_FILE_NAMES.has(entry.name) || + !entry.isFile() || + entry.isSymbolicLink() || + !info.isFile() || + info.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(entryPath); + } + total += info.size; + if (!Number.isSafeInteger(total)) { + throw new Error("Create Images quarantine is too large to inventory safely."); + } + } + return total; + } + + private async pruneDeletedQuarantine(): Promise { + const quarantinePath = this.deletedWorkflowQuarantinePath(); + const deleted: Array<{ path: string; mtimeMs: number; bytes: number }> = []; + for (const entry of await this.boundedDirectoryEntries( + quarantinePath, + MAX_QUARANTINE_SCAN_ENTRIES, + )) { + const entryPath = path.join(quarantinePath, entry.name); + const info = await fs.lstat(entryPath); + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + !info.isDirectory() || + info.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(entryPath); + } + deleted.push({ + path: entryPath, + mtimeMs: info.mtimeMs, + bytes: await this.deletedWorkflowDirectoryBytes(entryPath), + }); + } + deleted.sort( + (left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path), + ); + let retainedBytes = 0; + const removals: string[] = []; + for (const [index, entry] of deleted.entries()) { + if ( + index >= this.limits.maxDeletedQuarantineEntries || + retainedBytes + entry.bytes > this.limits.maxDeletedQuarantineBytes + ) { + removals.push(entry.path); + } else { + retainedBytes += entry.bytes; + } + } + for (const target of removals) await fs.rm(target, { recursive: true }); + if (removals.length > 0) await this.syncDirectory(quarantinePath); + } + + private async legacyBlocker(): Promise< + Extract | undefined + > { + const raw = await this.readJson(this.legacyPath(), MAX_WORKFLOW_BYTES); + if (raw.status === "missing") return undefined; + if (raw.status === "corrupt") return { status: "corrupt", path: this.legacyPath() }; + if (raw.status === "unsafe") return { status: "unsafe", path: this.legacyPath() }; + if (isFutureVersion(raw.value, "version")) { + return { status: "unsafe", path: this.legacyPath() }; + } + return parseLegacyDatabase(raw.value) + ? undefined + : { status: "corrupt", path: this.legacyPath() }; + } + + private async migrateLegacy(): Promise { + const raw = await this.readJson(this.legacyPath(), MAX_WORKFLOW_BYTES); + if (raw.status === "missing") return; + if ( + raw.status === "corrupt" || + (raw.status === "healthy" && isFutureVersion(raw.value, "version")) + ) { + throw new WorkflowManifestLoadError( + raw.status === "corrupt" ? "corrupt" : "unsafe", + this.legacyPath(), + ); + } + if (raw.status !== "healthy") return; + const legacy = parseLegacyDatabase(raw.value); + if (!legacy) throw new WorkflowManifestLoadError("corrupt", this.legacyPath()); + for (const workflow of Object.values(legacy)) { + const paths = this.workflowPaths(workflow.id); + await this.assertMutationWithinLimits( + workflow.id, + new Map([ + [paths.current, workflow], + [paths.lastKnownGood, workflow], + ]), + ); + await this.ensureDirectory(paths.directory); + const current = await this.inspectWorkflowFile(paths.current); + if (current.status === "missing") { + await this.writeAtomic(paths.current, workflow, MAX_WORKFLOW_BYTES, () => true); + await this.writeAtomic(paths.lastKnownGood, workflow, MAX_WORKFLOW_BYTES, () => true); + } else if ( + current.status !== "healthy" || + JSON.stringify(current.value) !== JSON.stringify(workflow) + ) { + throw new WorkflowManifestLoadError( + current.status === "unsafe" ? "unsafe" : "corrupt", + paths.current, + ); + } + } + await this.rebuildIndexInternal(); + const migrated = path.join(this.root(), `workflows.phase-0-migrated-${randomUUID()}.json`); + await fs.rename(this.legacyPath(), migrated); + await this.syncDirectory(this.root()); + } + + private async prepare(): Promise { + await this.prepareDirectories(); + const blocker = await this.legacyBlocker(); + if (blocker) throw new WorkflowManifestLoadError(blocker.status, blocker.path); + await this.migrateLegacy(); + } + + private async inspected(workflowId: string): Promise<{ + paths: ReturnType; + current: FileInspection; + lastKnownGood: FileInspection; + journal: FileInspection; + }> { + const paths = this.workflowPaths(workflowId); + try { + const directory = await fs.lstat(paths.directory); + if (!directory.isDirectory() || directory.isSymbolicLink()) { + return { + paths, + current: { status: "corrupt" }, + lastKnownGood: { status: "corrupt" }, + journal: { status: "corrupt" }, + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return { + paths, + current: { status: "missing" }, + lastKnownGood: { status: "missing" }, + journal: { status: "missing" }, + }; + } + const [current, lastKnownGood, journal] = await Promise.all([ + this.inspectWorkflowFile(paths.current), + this.inspectWorkflowFile(paths.lastKnownGood), + this.inspectJournalFile(paths.autosave), + ]); + return { paths, current, lastKnownGood, journal }; + } + + private validRecoveryCandidate( + inspection: FileInspection | FileInspection, + workflowId: string, + ): WorkflowDocumentV1 | undefined { + if (inspection.status !== "healthy") return undefined; + const value = "snapshot" in inspection.value ? inspection.value.snapshot : inspection.value; + return value.id === workflowId ? value : undefined; + } + + private async reconcilePublishedJournal( + workflowId: string, + state: Awaited>, + ): Promise>> { + if ( + state.current.status !== "healthy" || + state.current.value.id !== workflowId || + state.journal.status !== "healthy" || + state.journal.value.workflowId !== workflowId || + state.journal.value.targetRevision !== state.current.value.revision || + JSON.stringify(state.journal.value.snapshot) !== JSON.stringify(state.current.value) || + state.lastKnownGood.status === "corrupt" || + state.lastKnownGood.status === "unsafe" + ) { + return state; + } + // Current is already the exact journal target, so publication completed + // before the crash. Finish main-owned durability cleanup without depending + // on renderer liveness; a repeated restart is safe at either boundary. + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + return { + ...state, + lastKnownGood: { status: "healthy", value: state.current.value }, + journal: { status: "missing" }, + }; + } + + private recoveryHealthOf( + workflowId: string, + state: Awaited>, + ): WorkflowRecoveryHealth { + const lastGood = this.validRecoveryCandidate(state.lastKnownGood, workflowId); + const journal = + state.journal.status === "healthy" && state.journal.value.workflowId === workflowId + ? state.journal.value + : undefined; + const common = { + workflowId, + currentPath: state.paths.current, + lastKnownGoodAvailable: Boolean(lastGood), + }; + if (state.current.status === "unsafe") { + return { + ...common, + status: "unsafe", + reason: "current-future-schema", + autosave: state.journal.status === "unsafe" ? "unsafe" : journal ? "pending" : "none", + }; + } + if (state.lastKnownGood.status === "unsafe") { + return { + ...common, + status: "unsafe", + reason: "last-known-good-future-schema", + autosave: state.journal.status === "unsafe" ? "unsafe" : journal ? "pending" : "none", + }; + } + if (state.journal.status === "unsafe") { + return { ...common, status: "unsafe", reason: "journal-future-schema", autosave: "unsafe" }; + } + if ( + state.current.status === "missing" && + state.lastKnownGood.status === "missing" && + state.journal.status === "missing" + ) { + return { + status: "missing", + workflowId, + currentPath: state.paths.current, + lastKnownGoodAvailable: false, + autosave: "none", + }; + } + if (state.current.status === "corrupt" || state.current.status === "missing") { + return { + ...common, + status: "recovery-required", + reason: state.current.status === "corrupt" ? "current-corrupt" : "current-missing", + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: state.journal.status === "corrupt" ? "corrupt" : journal ? "pending" : "none", + ...(journal ? { autosaveTargetRevision: journal.targetRevision } : {}), + }; + } + if (state.lastKnownGood.status === "corrupt") { + return { + ...common, + status: "recovery-required", + reason: "last-known-good-corrupt", + currentRevision: state.current.value.revision, + autosave: state.journal.status === "corrupt" ? "corrupt" : journal ? "pending" : "none", + ...(journal ? { autosaveTargetRevision: journal.targetRevision } : {}), + }; + } + if (state.journal.status === "corrupt") { + return { + ...common, + status: "recovery-required", + reason: "journal-corrupt", + currentRevision: state.current.value.revision, + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: "corrupt", + }; + } + if (journal && journal.baseRevision === state.current.value.revision) { + return { + ...common, + status: "recovery-required", + reason: "journal-pending", + currentRevision: state.current.value.revision, + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: "pending", + autosaveTargetRevision: journal.targetRevision, + }; + } + if ( + journal && + !( + journal.baseRevision === state.current.value.revision || + (journal.targetRevision === state.current.value.revision && + JSON.stringify(journal.snapshot) === JSON.stringify(state.current.value)) + ) + ) { + return { + ...common, + status: "recovery-required", + reason: "journal-conflict", + currentRevision: state.current.value.revision, + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: "pending", + autosaveTargetRevision: journal.targetRevision, + }; + } + return { + ...common, + status: "healthy", + revision: state.current.value.revision, + autosave: journal ? "pending" : "none", + ...(journal ? { autosaveTargetRevision: journal.targetRevision } : {}), + }; + } + + private async scanSummaries(): Promise { + const summaries: WorkflowManifestSummary[] = []; + const inventory = await this.workflowInventory(); + for (const workflowId of inventory.workflowIds) { + const state = await this.reconcilePublishedJournal( + workflowId, + await this.inspected(workflowId), + ); + const health = this.recoveryHealthOf(workflowId, state); + const current = this.validRecoveryCandidate(state.current, workflowId); + const lastGood = this.validRecoveryCandidate(state.lastKnownGood, workflowId); + const journal = this.validRecoveryCandidate(state.journal, workflowId); + const representative = current ?? lastGood ?? journal; + if (!representative) { + summaries.push({ + id: workflowId, + title: "Workflow needs recovery", + revision: 0, + createdAt: "", + updatedAt: "", + nodeCount: 0, + edgeCount: 0, + assetCount: 0, + health: health.status === "unsafe" ? "unsafe" : "recovery-required", + recoveryAvailable: false, + }); + continue; + } + summaries.push( + summaryOf( + representative, + health.status === "healthy" + ? "healthy" + : health.status === "unsafe" + ? "unsafe" + : "recovery-required", + Boolean(lastGood || journal), + ), + ); + } + return summaries.sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + } + + private parseIndex(value: unknown): WorkflowIndexV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some((key) => key !== "version" && key !== "workflows") || + value.version !== INDEX_VERSION || + !Array.isArray(value.workflows) + ) { + return undefined; + } + const ids = new Set(); + for (const summary of value.workflows) { + if ( + !isRecord(summary) || + Object.keys(summary).some( + (key) => + ![ + "id", + "title", + "revision", + "createdAt", + "updatedAt", + "nodeCount", + "edgeCount", + "assetCount", + "health", + "recoveryAvailable", + ].includes(key), + ) || + typeof summary.id !== "string" || + !WORKFLOW_ID.test(summary.id) || + ids.has(summary.id) || + typeof summary.title !== "string" || + typeof summary.revision !== "number" || + !Number.isSafeInteger(summary.revision) || + typeof summary.createdAt !== "string" || + typeof summary.updatedAt !== "string" || + typeof summary.nodeCount !== "number" || + !Number.isSafeInteger(summary.nodeCount) || + typeof summary.edgeCount !== "number" || + !Number.isSafeInteger(summary.edgeCount) || + typeof summary.assetCount !== "number" || + !Number.isSafeInteger(summary.assetCount) || + !["healthy", "recovery-required", "unsafe"].includes(summary.health as string) || + typeof summary.recoveryAvailable !== "boolean" + ) { + return undefined; + } + ids.add(summary.id); + } + return value as unknown as WorkflowIndexV1; + } + + private async inspectIndex(): Promise> { + const raw = await this.readJson(this.indexPath(), MAX_INDEX_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureVersion(raw.value, "version")) return { status: "unsafe" }; + const parsed = this.parseIndex(raw.value); + return parsed ? { status: "healthy", value: parsed } : { status: "corrupt" }; + } + + private async rebuildIndexInternal(): Promise { + const summaries = await this.scanSummaries(); + const existing = await this.inspectIndex(); + if (existing.status === "unsafe") return summaries; + if (existing.status === "corrupt") await this.quarantineFile(this.indexPath(), "index-corrupt"); + if ( + existing.status !== "healthy" || + JSON.stringify(existing.value.workflows) !== JSON.stringify(summaries) + ) { + await this.writeAtomic( + this.indexPath(), + { version: INDEX_VERSION, workflows: summaries } satisfies WorkflowIndexV1, + MAX_INDEX_BYTES, + () => true, + ); + } + return summaries; + } + + private async refreshIndexAfterAuthoritativeMutation(): Promise { + try { + await this.rebuildIndexInternal(); + } catch { + // index.json is a rebuildable projection. Once a manifest mutation is + // durable, reporting the operation as failed invites a retry that can + // create duplicates. A later list/initialize performs the repair or + // reports the underlying inventory problem explicitly. + } + } + + async path(): Promise { + return this.indexPath(); + } + + async health(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepareDirectories(); + const blocker = await this.legacyBlocker(); + if (blocker) return blocker; + await this.migrateLegacy(); + const index = await this.inspectIndex(); + if (index.status === "corrupt") return { status: "corrupt", path: this.indexPath() }; + if (index.status === "unsafe") return { status: "unsafe", path: this.indexPath() }; + return { + status: "healthy", + source: index.status === "missing" ? "missing" : "disk", + path: this.indexPath(), + }; + }); + } + + async inspect(workflowId: string): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + return this.recoveryHealthOf(workflowId, await this.inspected(workflowId)); + }); + } + + async list(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + return structuredClone(await this.rebuildIndexInternal()); + }); + } + + async initialize(): Promise { + return this.list(); + } + + async get(workflowId: string): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.reconcilePublishedJournal( + workflowId, + await this.inspected(workflowId), + ); + if (state.current.status === "missing") { + if (state.lastKnownGood.status !== "missing" || state.journal.status !== "missing") { + throw new WorkflowManifestLoadError("corrupt", state.paths.current); + } + return undefined; + } + if (state.current.status !== "healthy") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + const health = this.recoveryHealthOf(workflowId, state); + if (health.status === "unsafe") { + const unsafePath = + health.reason === "current-future-schema" + ? state.paths.current + : health.reason === "last-known-good-future-schema" + ? state.paths.lastKnownGood + : state.paths.autosave; + throw new WorkflowManifestLoadError("unsafe", unsafePath); + } + if (health.status === "recovery-required") { + const corruptPath = + health.reason === "last-known-good-corrupt" + ? state.paths.lastKnownGood + : health.reason.startsWith("journal-") + ? state.paths.autosave + : state.paths.current; + throw new WorkflowManifestLoadError("corrupt", corruptPath); + } + return structuredClone(state.current.value); + }); + } + + private async stageAutosaveInternal( + parsed: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean, + ): Promise { + const state = await this.inspected(parsed.id); + if (state.current.status === "corrupt" || state.current.status === "unsafe") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + if (state.journal.status === "corrupt" || state.journal.status === "unsafe") { + throw new WorkflowManifestLoadError(state.journal.status, state.paths.autosave); + } + if (state.lastKnownGood.status === "corrupt" || state.lastKnownGood.status === "unsafe") { + throw new WorkflowManifestLoadError(state.lastKnownGood.status, state.paths.lastKnownGood); + } + if (state.journal.status === "healthy") { + if ( + state.current.status !== "healthy" || + state.journal.value.targetRevision !== state.current.value.revision || + JSON.stringify(state.journal.value.snapshot) !== JSON.stringify(state.current.value) + ) { + throw new WorkflowManifestLoadError("corrupt", state.paths.autosave); + } + // A crash can leave the journal behind after current became authoritative. + // Reconcile that exact snapshot before accepting another stage; never replace + // a distinct crash-survived journal. + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + } + const actualRevision = state.current.status === "healthy" ? state.current.value.revision : null; + if (actualRevision !== expectedRevision) { + throw new WorkflowRevisionConflictError(parsed.id, expectedRevision, actualRevision); + } + const requiredRevision = expectedRevision === null ? 1 : expectedRevision + 1; + if (parsed.revision !== requiredRevision) { + throw new WorkflowRevisionConflictError(parsed.id, requiredRevision, parsed.revision); + } + if (state.current.status === "healthy" && state.current.value.createdAt !== parsed.createdAt) { + throw new Error("A workflow's creation timestamp cannot change."); + } + if (state.current.status === "missing" && state.lastKnownGood.status !== "missing") { + throw new WorkflowManifestLoadError("corrupt", state.paths.current); + } + const journal: AutosaveJournalV1 = { + version: JOURNAL_VERSION, + workflowId: parsed.id, + baseRevision: expectedRevision, + targetRevision: parsed.revision, + stagedAt: parsed.updatedAt, + snapshot: parsed, + }; + await this.assertMutationWithinLimits( + parsed.id, + new Map([ + [state.paths.current, parsed], + [state.paths.lastKnownGood, parsed], + [state.paths.autosave, journal], + ]), + ); + await this.writeAtomic(state.paths.autosave, journal, MAX_JOURNAL_BYTES, isCurrent); + await this.durability.afterJournalPublished?.(parsed.id); + return structuredClone(parsed); + } + + async stageAutosave( + snapshot: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + const parsed = parseSnapshot(snapshot); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + return this.stageAutosaveInternal(parsed, expectedRevision, isCurrent); + }); + } + + private async finishJournalCommit( + state: Awaited>, + journal: AutosaveJournalV1, + expectedRevision: number | null, + isCurrent: () => boolean, + ): Promise { + if (journal.workflowId !== journal.snapshot.id || journal.baseRevision !== expectedRevision) { + throw new WorkflowRevisionConflictError( + journal.workflowId, + expectedRevision, + journal.baseRevision, + ); + } + if (state.current.status === "unsafe" || state.current.status === "corrupt") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + if (state.lastKnownGood.status === "unsafe" || state.lastKnownGood.status === "corrupt") { + throw new WorkflowManifestLoadError(state.lastKnownGood.status, state.paths.lastKnownGood); + } + const actualRevision = state.current.status === "healthy" ? state.current.value.revision : null; + await this.assertMutationWithinLimits( + journal.workflowId, + new Map([ + [state.paths.current, journal.snapshot], + [state.paths.lastKnownGood, journal.snapshot], + [state.paths.autosave, undefined], + ]), + ); + if ( + actualRevision === journal.targetRevision && + state.current.status === "healthy" && + JSON.stringify(state.current.value) === JSON.stringify(journal.snapshot) + ) { + await this.writeAtomic( + state.paths.lastKnownGood, + journal.snapshot, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(journal.snapshot); + } + if (actualRevision !== expectedRevision) { + throw new WorkflowRevisionConflictError(journal.workflowId, expectedRevision, actualRevision); + } + if (state.current.status === "healthy") { + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + } + await this.writeAtomic(state.paths.current, journal.snapshot, MAX_WORKFLOW_BYTES, isCurrent); + await this.durability.afterCurrentPublished?.(journal.workflowId); + // Once current is committed, cleanup is main-owned reconciliation and must + // finish even if the renderer navigates away during these final steps. + await this.writeAtomic( + state.paths.lastKnownGood, + journal.snapshot, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(journal.snapshot); + } + + async flushAutosave( + workflowId: string, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.journal.status === "missing") { + throw new Error("There is no pending autosave to flush."); + } + if (state.journal.status !== "healthy") { + throw new WorkflowManifestLoadError(state.journal.status, state.paths.autosave); + } + return this.finishJournalCommit(state, state.journal.value, expectedRevision, isCurrent); + }); + } + + async autosaveStatus(workflowId: string): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const journal = (await this.inspected(workflowId)).journal; + if (journal.status === "missing") return { workflowId, state: "none" }; + if (journal.status === "corrupt" || journal.status === "unsafe") { + return { workflowId, state: journal.status }; + } + return { + workflowId, + state: "pending", + baseRevision: journal.value.baseRevision, + targetRevision: journal.value.targetRevision, + stagedAt: journal.value.stagedAt, + }; + }); + } + + async discardAutosave( + workflowId: string, + expectedTargetRevision: number, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.current.status !== "healthy") { + throw new WorkflowManifestLoadError( + state.current.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.journal.status !== "healthy") { + if (state.journal.status === "missing") return; + throw new WorkflowManifestLoadError(state.journal.status, state.paths.autosave); + } + if (state.journal.value.targetRevision !== expectedTargetRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedTargetRevision, + state.journal.value.targetRevision, + ); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await this.removeFileDurably(state.paths.autosave); + }); + } + + async put( + snapshot: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + const parsed = parseSnapshot(snapshot); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.stageAutosaveInternal(parsed, expectedRevision, isCurrent); + const state = await this.inspected(parsed.id); + if (state.journal.status !== "healthy") { + throw new WorkflowManifestLoadError( + state.journal.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.autosave, + ); + } + return this.finishJournalCommit(state, state.journal.value, expectedRevision, isCurrent); + }); + } + + async save( + snapshot: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + return this.put(snapshot, expectedRevision, isCurrent); + } + + async create( + snapshot: WorkflowDocumentV1, + isCurrent: () => boolean = () => true, + ): Promise { + return this.put(snapshot, null, isCurrent); + } + + async rename( + workflowId: string, + title: string, + expectedRevision: number, + updatedAt: string, + isCurrent: () => boolean = () => true, + ): Promise { + const current = await this.get(workflowId); + if (!current) throw new WorkflowRevisionConflictError(workflowId, expectedRevision, null); + const next = parseSnapshot({ + ...current, + title, + revision: expectedRevision + 1, + updatedAt, + }); + return this.put(next, expectedRevision, isCurrent); + } + + async duplicate( + sourceWorkflowId: string, + input: { + workflowId: string; + expectedRevision: number; + title?: string; + now: string; + }, + isCurrent: () => boolean = () => true, + ): Promise { + const source = await this.get(sourceWorkflowId); + if (!source) throw new Error("The source workflow does not exist."); + if (source.revision !== input.expectedRevision) { + throw new WorkflowRevisionConflictError( + sourceWorkflowId, + input.expectedRevision, + source.revision, + ); + } + const duplicate = parseSnapshot({ + ...structuredClone(source), + id: validateWorkflowId(input.workflowId), + title: input.title ?? `${source.title} copy`, + revision: 1, + createdAt: input.now, + updatedAt: input.now, + }); + return this.put(duplicate, null, isCurrent); + } + + async delete( + workflowId: string, + expectedRevision: number, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.current.status === "corrupt" || state.current.status === "unsafe") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + const current = state.current.status === "healthy" ? state.current.value : undefined; + if (!current || current.revision !== expectedRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedRevision, + current?.revision ?? null, + ); + } + const health = this.recoveryHealthOf(workflowId, state); + if (health.status === "unsafe" || health.status === "recovery-required") { + throw new WorkflowManifestLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + health.status !== "unsafe" && health.reason.startsWith("journal-") + ? state.paths.autosave + : state.paths.current, + ); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + const deletedBytes = await this.deletedWorkflowDirectoryBytes(state.paths.directory); + if (deletedBytes > this.limits.maxDeletedQuarantineBytes) { + throw new Error( + "Create Images cannot retain this deleted workflow within its recovery limit.", + ); + } + await this.pruneDeletedQuarantine(); + const quarantine = path.join( + this.deletedWorkflowQuarantinePath(), + `deleted-${workflowId}-${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID()}`, + ); + await fs.rename(state.paths.directory, quarantine); + const touchedAt = new Date(); + await fs.utimes(quarantine, touchedAt, touchedAt); + await this.syncDirectory(this.workflowsPath()); + await this.syncDirectory(path.dirname(quarantine)); + await this.pruneDeletedQuarantine(); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(current); + }); + } + + async repairRecoveryMetadata( + workflowId: string, + expectedRevision: number, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.current.status !== "healthy") { + throw new WorkflowManifestLoadError( + state.current.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.current.value.revision !== expectedRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedRevision, + state.current.value.revision, + ); + } + if (state.lastKnownGood.status === "unsafe" || state.journal.status === "unsafe") { + throw new WorkflowManifestLoadError( + "unsafe", + state.lastKnownGood.status === "unsafe" + ? state.paths.lastKnownGood + : state.paths.autosave, + ); + } + if (state.journal.status === "healthy") { + throw new Error( + "Flush or discard the pending autosave before repairing recovery metadata.", + ); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await this.assertMutationWithinLimits( + workflowId, + new Map([ + [state.paths.lastKnownGood, state.current.value], + [state.paths.autosave, undefined], + ]), + ); + if (state.lastKnownGood.status === "corrupt") { + await this.quarantineFile( + state.paths.lastKnownGood, + `${workflowId}-last-known-good-corrupt`, + ); + } + if (state.journal.status === "corrupt") { + await this.quarantineFile(state.paths.autosave, `${workflowId}-autosave-corrupt`); + } + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(state.current.value); + }); + } + + async recover( + workflowId: string, + source: "last-known-good" | "autosave", + expectedCandidateRevision: number, + recoveredAt: string, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if ( + state.current.status === "unsafe" || + state.lastKnownGood.status === "unsafe" || + state.journal.status === "unsafe" + ) { + throw new WorkflowManifestLoadError( + "unsafe", + state.current.status === "unsafe" + ? state.paths.current + : state.lastKnownGood.status === "unsafe" + ? state.paths.lastKnownGood + : state.paths.autosave, + ); + } + const health = this.recoveryHealthOf(workflowId, state); + const healthyCurrentAutosaveRecovery = + state.current.status === "healthy" && + source === "autosave" && + state.journal.status === "healthy" && + health.status === "recovery-required" && + health.reason === "last-known-good-corrupt"; + if ( + state.current.status === "healthy" && + !healthyCurrentAutosaveRecovery && + !( + health.status === "recovery-required" && + (health.reason === "journal-conflict" || health.reason === "journal-pending") + ) + ) { + throw new Error("A healthy workflow does not require recovery."); + } + const candidate = + source === "last-known-good" + ? this.validRecoveryCandidate(state.lastKnownGood, workflowId) + : this.validRecoveryCandidate(state.journal, workflowId); + if (!candidate || candidate.revision !== expectedCandidateRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedCandidateRevision, + candidate?.revision ?? null, + ); + } + const highestRecoveryRevision = Math.max( + candidate.revision, + state.current.status === "healthy" ? state.current.value.revision : 0, + this.validRecoveryCandidate(state.lastKnownGood, workflowId)?.revision ?? 0, + this.validRecoveryCandidate(state.journal, workflowId)?.revision ?? 0, + ); + const recovered = parseSnapshot({ + ...structuredClone(candidate), + revision: highestRecoveryRevision + 1, + updatedAt: recoveredAt, + }); + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await this.assertMutationWithinLimits( + workflowId, + new Map([ + [state.paths.current, recovered], + [state.paths.lastKnownGood, recovered], + [state.paths.autosave, undefined], + ]), + ); + if (state.current.status === "corrupt") { + await this.quarantineFile(state.paths.current, `${workflowId}-current-corrupt`); + } + if (state.lastKnownGood.status === "corrupt") { + await this.quarantineFile( + state.paths.lastKnownGood, + `${workflowId}-last-known-good-corrupt`, + ); + } + await this.writeAtomic(state.paths.current, recovered, MAX_WORKFLOW_BYTES, () => true); + await this.writeAtomic(state.paths.lastKnownGood, recovered, MAX_WORKFLOW_BYTES, () => true); + if (state.journal.status !== "missing") { + if (state.journal.status === "corrupt") { + await this.quarantineFile(state.paths.autosave, `${workflowId}-autosave-corrupt`); + } else { + await this.removeFileDurably(state.paths.autosave); + } + } + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(recovered); + }); + } +} diff --git a/main/services/create-images/workflow-proposal-service.test.ts b/main/services/create-images/workflow-proposal-service.test.ts new file mode 100644 index 00000000..0f1bfcbd --- /dev/null +++ b/main/services/create-images/workflow-proposal-service.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createModels, + type Api, + type AssistantMessage, + type Model, + type ProviderStreams, +} from "@earendil-works/pi-ai"; +import { createImagesFixture } from "../../../renderer/create-images/fixtures.js"; +import type { ResolvedModelRuntime } from "../model-runtime.js"; +import { CreateImagesWorkflowProposalService } from "./workflow-proposal-service.js"; + +function response(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-completions", + provider: "test-provider", + model: "test-model", + stopReason: "stop", + timestamp: Date.now(), + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +const graph = JSON.stringify({ + version: 1, + nodes: [ + { id: "prompt", type: "prompt", position: { x: 0, y: 0 }, data: { text: "A blue hour lake" } }, + { + id: "generate", + type: "generate-image", + position: { x: 320, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output", type: "output", position: { x: 640, y: 0 }, data: {} }, + ], + edges: [ + { id: "a", source: "prompt", sourcePort: "text", target: "generate", targetPort: "prompt" }, + { id: "b", source: "generate", sourcePort: "images", target: "output", targetPort: "images" }, + ], +}); + +test("workflow proposal generation uses one tool-free, no-retry selected-chat-model request", async () => { + const captured: Array<{ context: unknown; options: unknown }> = []; + const streamSimple = ((_model, context, options) => { + captured.push({ context, options }); + return { result: async () => response(graph) } as ReturnType; + }) as ProviderStreams["streamSimple"]; + const runtime = { + provider: { + id: "test-provider", + kind: "openai", + label: "Test provider", + baseUrl: "http://127.0.0.1", + models: ["test-model"], + needsKey: false, + isPreset: true, + }, + model: { + id: "test-model", + name: "Test model", + api: "openai-completions", + provider: "test-provider", + baseUrl: "http://127.0.0.1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 8_000, + } as Model, + models: createModels(), + apiKey: undefined, + headers: undefined, + streams: { streamSimple }, + } satisfies ResolvedModelRuntime; + const usage: unknown[] = []; + const service = new CreateImagesWorkflowProposalService({ + resolveRuntime: async () => runtime, + recordUsage: async (record) => { + usage.push(record); + }, + }); + const result = await service.propose({ + request: "Build a simple lake image workflow", + current: createImagesFixture("starter")!, + providerId: "test-provider", + model: "test-model", + signal: new AbortController().signal, + }); + assert.equal(result.status, "ready"); + assert.equal(captured.length, 1); + assert.equal((captured[0]?.options as { maxRetries?: number }).maxRetries, 0); + assert.equal("tools" in (captured[0]?.context as object), false); + assert.equal(JSON.stringify(captured[0]?.context).includes("assetRefs"), false); + assert.equal(usage.length, 1); +}); + +test("workflow proposal generation leaves the graph unchanged on hostile model output", async () => { + const service = new CreateImagesWorkflowProposalService({ + resolveRuntime: async () => + ({ + provider: { + id: "test", + kind: "openai", + label: "Test", + baseUrl: "http://127.0.0.1", + models: ["m"], + needsKey: false, + isPreset: true, + }, + model: { + id: "m", + name: "m", + api: "openai-completions", + provider: "test", + baseUrl: "http://127.0.0.1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 8_000, + } as Model, + models: createModels(), + apiKey: undefined, + headers: undefined, + streams: { + streamSimple: (() => ({ + result: async () => + response('{"version":1,"nodes":[],"edges":[],"credential":"secret"}'), + })) as unknown as ProviderStreams["streamSimple"], + }, + }) satisfies ResolvedModelRuntime, + recordUsage: async () => undefined, + }); + const current = createImagesFixture("starter")!; + const result = await service.propose({ + request: "Build something", + current, + providerId: "test", + model: "m", + signal: new AbortController().signal, + }); + assert.equal(result.status, "unavailable"); + assert.equal(current.revision, 1); +}); diff --git a/main/services/create-images/workflow-proposal-service.ts b/main/services/create-images/workflow-proposal-service.ts new file mode 100644 index 00000000..3a9fb7d9 --- /dev/null +++ b/main/services/create-images/workflow-proposal-service.ts @@ -0,0 +1,173 @@ +import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai"; +import type { ResolvedModelRuntime } from "../model-runtime.js"; +import type { UsageRequestRecord } from "../usage-store-core.js"; +import { + assistantUsageRecord, + isLocalModelProvider, + unreportedUsageRecord, +} from "../usage-accounting.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + normalizeCreateImagesWorkflowProposalRequest, + parseCreateImagesWorkflowProposal, + type CreateImagesWorkflowProposal, +} from "../../../renderer/shared/create-images/workflow-proposal.js"; + +const PROPOSAL_TIMEOUT_MS = 90_000; +const SYSTEM_PROMPT = `You propose inert Aiden Create Images workflow graphs. +Return exactly one JSON object and no markdown or commentary. +The exact top-level shape is {"version":1,"nodes":[],"edges":[]}. +Use at most 50 nodes and 200 edges. Every node must be a complete current Aiden workflow node with id, type, position, and data. Every edge must include id, source, sourcePort, target, and targetPort. +Allowed node types: image-input, prompt, prompt-list, generate-image, output, output-gallery, image-compare, annotation, group. +Image Input data must be {} so the user chooses local files after applying. Never invent an asset ID. +Generate Image must use providerId "gemini" and one of these model IDs: gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image. Use count 1. Use a supported aspect ratio, image size, and image/png or image/jpeg. +Create a connected acyclic graph with all required inputs connected. Prompt text must be useful and non-empty. A Prompt List may contain at most eight non-empty items. +Do not include credentials, secrets, paths, asset IDs, provider responses, executable code, tool calls, or instructions to run anything. The graph is only a proposal and must never execute itself.`; + +export type CreateImagesWorkflowProposalServiceResult = + | { + status: "ready"; + proposal: CreateImagesWorkflowProposal; + providerId: string; + model: string; + } + | { status: "unavailable"; message: string }; + +export interface CreateImagesWorkflowProposalServiceDependencies { + resolveRuntime?: ( + providerId: string, + modelId: string, + signal?: AbortSignal, + ) => Promise; + recordUsage?: (record: UsageRequestRecord) => Promise; +} + +function assistantText(message: AssistantMessage): string { + return message.content + .filter((item): item is TextContent => item.type === "text") + .map((item) => item.text) + .join("\n") + .trim(); +} + +export class CreateImagesWorkflowProposalService { + private readonly resolveRuntime: NonNullable< + CreateImagesWorkflowProposalServiceDependencies["resolveRuntime"] + >; + private readonly recordUsage: NonNullable< + CreateImagesWorkflowProposalServiceDependencies["recordUsage"] + >; + + constructor(dependencies: CreateImagesWorkflowProposalServiceDependencies = {}) { + this.resolveRuntime = + dependencies.resolveRuntime ?? + (async (providerId, modelId, signal) => + (await import("../model-runtime.js")).resolveModelRuntime(providerId, modelId, signal)); + this.recordUsage = + dependencies.recordUsage ?? + (async (record) => (await import("../usage-store.js")).usageStore.record(record)); + } + + async propose(input: { + request: string; + current: WorkflowDocumentV1; + providerId: string; + model: string; + signal: AbortSignal; + }): Promise { + const request = normalizeCreateImagesWorkflowProposalRequest(input.request); + if (!request) return { status: "unavailable", message: "Describe the workflow in 4,000 characters or fewer." }; + let runtime: ResolvedModelRuntime; + try { + runtime = await this.resolveRuntime(input.providerId, input.model, input.signal); + } catch { + return { + status: "unavailable", + message: "The currently selected chat model is unavailable. Choose a connected chat model and try again.", + }; + } + let result: AssistantMessage; + try { + result = await runtime.streams + .streamSimple( + runtime.model, + { + systemPrompt: SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: [{ type: "text", text: request }], + timestamp: Date.now(), + }, + ], + }, + { + apiKey: runtime.apiKey, + headers: runtime.headers, + signal: input.signal, + temperature: 0.1, + maxTokens: 8_000, + timeoutMs: PROPOSAL_TIMEOUT_MS, + maxRetries: 0, + cacheRetention: "none", + }, + ) + .result(); + await this.recordUsage( + assistantUsageRecord({ + message: result, + provider: runtime.provider, + model: runtime.model, + source: "workflow-proposal", + }), + ).catch(() => { + console.warn("[create-images] Workflow proposal usage could not be recorded.", { + providerId: runtime.provider.id, + modelId: runtime.model.id, + }); + }); + } catch { + await this.recordUsage( + unreportedUsageRecord({ + source: "workflow-proposal", + providerId: runtime.provider.id, + providerLabel: runtime.provider.label, + modelId: runtime.model.id, + modelLabel: runtime.model.name, + local: isLocalModelProvider(runtime.provider), + status: input.signal.aborted ? "cancelled" : "failed", + }), + ).catch(() => undefined); + return { + status: "unavailable", + message: input.signal.aborted + ? "Workflow proposal generation was cancelled." + : "The selected chat model could not prepare a workflow proposal.", + }; + } + if (result.stopReason === "error" || result.stopReason === "aborted") { + return { + status: "unavailable", + message: "The selected chat model did not complete the proposal.", + }; + } + const parsed = parseCreateImagesWorkflowProposal(assistantText(result), input.current); + if (parsed.status !== "ready") { + console.warn("[create-images] Workflow proposal rejected safely.", { + providerId: runtime.provider.id, + modelId: runtime.model.id, + reason: parsed.message, + }); + return { + status: "unavailable", + message: "The selected model returned an invalid or unsupported graph. The workflow was not changed.", + }; + } + return { + status: "ready", + proposal: parsed.proposal, + providerId: runtime.provider.id, + model: runtime.model.id, + }; + } +} diff --git a/main/services/create-images/workspace-store.test.ts b/main/services/create-images/workspace-store.test.ts new file mode 100644 index 00000000..75c8f8d5 --- /dev/null +++ b/main/services/create-images/workspace-store.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { AssetMetadataDto } from "./asset-store-core.js"; +import { + CreateImagesWorkspaceError, + CreateImagesWorkspaceStore, + createImagesWorkspaceRelativePath, +} from "./workspace-store.js"; + +interface FakeAsset extends AssetMetadataDto { + sourcePath: string; + bytes: Uint8Array; +} + +class FakeAssetStore { + private readonly items = new Map(); + + constructor(private readonly sourceRoot: string) {} + + async add( + label: string, + input: { displayName?: string; origin: AssetMetadataDto["origin"] }, + ): Promise { + const bytes = new TextEncoder().encode(label); + const assetId = createHash("sha256").update(bytes).digest("hex"); + const sourcePath = path.join(this.sourceRoot, `${assetId}.source`); + await fs.writeFile(sourcePath, bytes, { mode: 0o600 }); + const asset: FakeAsset = { + assetId, + mediaType: "image/png", + byteLength: bytes.byteLength, + width: 1, + height: 1, + createdAt: new Date(1_700_000_000_000).toISOString(), + ...(input.displayName ? { displayName: input.displayName } : {}), + origin: input.origin, + referenceCount: 0, + thumbnailSizes: [], + sourcePath, + bytes, + }; + this.items.set(assetId, asset); + return structuredClone(asset); + } + + async list(): Promise { + return [...this.items.values()].map((asset) => structuredClone(asset)); + } + + async get(assetId: string): Promise { + const asset = this.items.get(assetId); + return asset ? structuredClone(asset) : undefined; + } + + async withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: AssetMetadataDto["mediaType"]; + }) => Promise, + ): Promise { + const asset = this.items.get(assetId); + if (!asset) throw new Error("asset missing"); + return callback({ + filePath: asset.sourcePath, + asset: structuredClone(asset), + byteLength: asset.byteLength, + mediaType: asset.mediaType, + }); + } +} + +async function withRoots( + run: (roots: { internal: string; external: string }) => Promise, +): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-workspace-test-")); + const roots = { + internal: path.join(base, "internal"), + external: path.join(base, "external"), + }; + await fs.mkdir(roots.internal); + await fs.mkdir(roots.external); + try { + await run(roots); + } finally { + await fs.rm(base, { recursive: true, force: true }); + } +} + +function readPath(root: string, relativePath: string): string { + return path.join(root, ...relativePath.split("/")); +} + +test("configures a Finder-visible root, auto-syncs assets, and keeps status path-free", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const imported = await assets.add("import-bytes", { + displayName: "family photo.jpg", + origin: { kind: "import" }, + }); + const generated = await assets.add("generated-bytes", { + displayName: "sunset.png", + origin: { kind: "provider", providerId: "provider", modelId: "model", runId: "run" }, + }); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + + assert.equal((await workspace.status()).state, "unconfigured"); + const configured = await workspace.configureChosenDirectory(external); + assert.equal(configured.state, "ready"); + assert.equal(configured.displayName, path.basename(external)); + assert.equal(configured.importedCount, 1); + assert.equal(configured.generatedCount, 1); + assert.equal(configured.lastSyncedAt !== undefined, true); + assert.equal(JSON.stringify(configured).includes(external), false); + assert.equal(JSON.stringify(configured).includes(internal), false); + + const importedRelative = createImagesWorkspaceRelativePath(imported); + const generatedRelative = createImagesWorkspaceRelativePath(generated); + assert.match(importedRelative, /^Imports\/family-photo-[a-f0-9]{64}\.png$/u); + assert.match(generatedRelative, /^Generated\/sunset-[a-f0-9]{64}\.png$/u); + assert.deepEqual( + new Uint8Array(await fs.readFile(readPath(external, importedRelative))), + new TextEncoder().encode("import-bytes"), + ); + assert.deepEqual( + new Uint8Array(await fs.readFile(readPath(external, generatedRelative))), + new TextEncoder().encode("generated-bytes"), + ); + assert.equal( + (await fs.lstat(path.join(external, ".aiden-create-images-workspace.json"))).isFile(), + true, + ); + assert.match(await fs.readFile(path.join(external, "README.txt"), "utf8"), /source of truth/u); + + const reopened = new CreateImagesWorkspaceStore(internal, assets); + assert.equal((await reopened.status()).state, "ready"); + const openRoot = await reopened.openRoot(); + assert.equal(openRoot.filePath, await fs.realpath(external)); + assert.equal(openRoot.displayName, path.basename(external)); + assert.equal((await reopened.openTarget(imported.assetId)).relativePath, importedRelative); + await reopened.syncAsset(imported.assetId); + assert.equal((await reopened.status()).generatedCount, 1); + const repeat = await reopened.syncAll(); + assert.deepEqual(repeat.materializedAssetIds, []); + assert.deepEqual( + repeat.alreadyMaterializedAssetIds.sort(), + [imported.assetId, generated.assetId].sort(), + ); + }); +}); + +test("never overwrites an arbitrary existing target", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const asset = await assets.add("canonical", { + displayName: "same-name.png", + origin: { kind: "import" }, + }); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + await workspace.configureChosenDirectory(external); + const target = readPath(external, createImagesWorkspaceRelativePath(asset)); + await fs.writeFile(target, "user-owned", { mode: 0o600 }); + + const result = await workspace.syncAll(); + assert.equal(result.state, "conflict"); + assert.deepEqual(result.conflictedAssetIds, [asset.assetId]); + assert.equal(await fs.readFile(target, "utf8"), "user-owned"); + await assert.rejects( + workspace.openTarget(asset.assetId), + (error: unknown) => + error instanceof CreateImagesWorkspaceError && error.code === "workspace_target_conflict", + ); + }); +}); + +test("rejects symlinked roots and refuses a symlink target without touching its destination", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const asset = await assets.add("safe-bytes", { + displayName: "safe.png", + origin: { kind: "provider", providerId: "provider", modelId: "model", runId: "run" }, + }); + const linkRoot = path.join(path.dirname(external), "external-link"); + await fs.symlink(external, linkRoot, "dir"); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + await assert.rejects( + workspace.configureChosenDirectory(linkRoot), + (error: unknown) => + error instanceof CreateImagesWorkspaceError && error.code === "workspace_root_unsafe", + ); + + await workspace.configureChosenDirectory(external); + const outside = path.join(path.dirname(external), "outside"); + await fs.mkdir(outside); + const target = readPath(external, createImagesWorkspaceRelativePath(asset)); + const outsideTarget = path.join(outside, "should-stay-empty.png"); + await fs.rm(target); + await fs.symlink(outsideTarget, target, "file"); + const result = await workspace.syncAll(); + assert.equal(result.state, "conflict"); + assert.deepEqual(result.conflictedAssetIds, [asset.assetId]); + await assert.rejects(fs.access(outsideTarget)); + }); +}); + +test("reports replacement/drift after restart and fails closed on corrupt internal config", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + await workspace.configureChosenDirectory(external); + const moved = path.join(path.dirname(external), "external-moved"); + await fs.rename(external, moved); + const restarted = new CreateImagesWorkspaceStore(internal, assets); + assert.equal((await restarted.status()).state, "drifted"); + const preflight = await restarted.preflight(); + assert.equal(preflight.ok, false); + assert.deepEqual(preflight.issues, [{ code: "root_missing" }]); + + const corruptRoot = path.join(path.dirname(internal), "corrupt-internal"); + await fs.mkdir(corruptRoot); + await fs.writeFile(path.join(corruptRoot, "workspace.json"), "{not-json", { mode: 0o600 }); + const corrupt = new CreateImagesWorkspaceStore(corruptRoot, assets); + assert.equal((await corrupt.status()).state, "repair_required"); + assert.equal((await corrupt.configureChosenDirectory(moved)).state, "ready"); + const corruptBackups = (await fs.readdir(corruptRoot)).filter((name) => + name.includes("workspace.json.invalid-"), + ); + assert.equal(corruptBackups.length, 1); + }); +}); diff --git a/main/services/create-images/workspace-store.ts b/main/services/create-images/workspace-store.ts new file mode 100644 index 00000000..5e480867 --- /dev/null +++ b/main/services/create-images/workspace-store.ts @@ -0,0 +1,1163 @@ +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { DataStore, DataStoreExternalChangeError } from "../data-store.js"; +import { readRegularFile } from "../regular-file-read.js"; +import type { + AssetMetadataDto, + AssetOrigin, + ContentAddressedAssetStore, +} from "./asset-store-core.js"; + +const ASSET_ID = /^[a-f0-9]{64}$/u; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const SAFE_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,220}\.(?:jpg|png)$/u; +const WORKSPACE_MARKER = ".aiden-create-images-workspace.json"; +const WORKSPACE_README = "README.txt"; +const WORKSPACE_SCHEMA_VERSION = 1 as const; +const MAX_CONFIG_BYTES = 64 * 1024 * 1024; +const MAX_ENTRIES = 100_000; +const MAX_ENTRY_BYTES = 64 * 1024 * 1024; +const MAX_MARKER_BYTES = 16 * 1024; +const COPY_CHUNK_BYTES = 64 * 1024; +const NO_FOLLOW = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + +export type CreateImagesWorkspaceState = + | "unconfigured" + | "ready" + | "drifted" + | "conflict" + | "unwritable" + | "repair_required"; + +export type CreateImagesWorkspaceEntryState = "materialized" | "conflict" | "drifted" | "orphaned"; + +export type CreateImagesWorkspaceErrorCode = + | "workspace_not_configured" + | "workspace_config_invalid" + | "workspace_config_conflict" + | "workspace_root_invalid" + | "workspace_root_missing" + | "workspace_root_changed" + | "workspace_root_unsafe" + | "workspace_not_writable" + | "workspace_marker_missing" + | "workspace_marker_conflict" + | "workspace_target_conflict" + | "workspace_target_missing" + | "workspace_asset_missing" + | "workspace_sync_failed"; + +export class CreateImagesWorkspaceError extends Error { + constructor( + public readonly code: CreateImagesWorkspaceErrorCode, + message: string, + public readonly assetId?: string, + ) { + super(message); + this.name = "CreateImagesWorkspaceError"; + } +} + +export interface CreateImagesWorkspaceStatus { + state: CreateImagesWorkspaceState; + configured: boolean; + workspaceId?: string; + /** Finder-facing label only; this never contains the selected path. */ + displayName?: string; + lastSyncedAt?: string; + revision: number; + entryCount: number; + materializedCount: number; + driftedCount: number; + conflictCount: number; + importedCount: number; + generatedCount: number; + writable: boolean; +} + +export type CreateImagesWorkspacePreflightIssueCode = + | "root_missing" + | "root_changed" + | "root_unsafe" + | "not_writable" + | "marker_missing" + | "marker_conflict" + | "directory_missing" + | "directory_unsafe" + | "target_missing" + | "target_conflict" + | "target_drifted"; + +export interface CreateImagesWorkspacePreflightIssue { + code: CreateImagesWorkspacePreflightIssueCode; + assetId?: string; +} + +export interface CreateImagesWorkspacePreflight extends CreateImagesWorkspaceStatus { + ok: boolean; + issues: CreateImagesWorkspacePreflightIssue[]; +} + +export interface CreateImagesWorkspaceSyncResult { + state: CreateImagesWorkspaceState; + revision: number; + totalAssets: number; + materializedAssetIds: string[]; + alreadyMaterializedAssetIds: string[]; + conflictedAssetIds: string[]; + driftedAssetIds: string[]; + failed: Array<{ assetId: string; message: string }>; +} + +export interface CreateImagesWorkspaceOpenTarget { + /** Main-process-only absolute path. Never return this over renderer IPC. */ + filePath: string; + assetId: string; + relativePath: string; +} + +export interface CreateImagesWorkspaceOpenRoot { + /** Main-process-only absolute path. Never return this over renderer IPC. */ + filePath: string; + displayName: string; +} + +export interface CreateImagesWorkspaceAssetSource { + list(): Promise; + get(assetId: string): Promise; + withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: AssetMetadataDto["mediaType"]; + }) => Promise, + ): Promise; +} + +interface WorkspacePathIdentity { + device: string; + inode: string; +} + +interface WorkspaceEntry { + assetId: string; + relativePath: string; + mediaType: AssetMetadataDto["mediaType"]; + byteLength: number; + state: CreateImagesWorkspaceEntryState; + updatedAt: string; +} + +interface WorkspaceConfig { + schemaVersion: typeof WORKSPACE_SCHEMA_VERSION; + revision: number; + selectedPath: string | null; + workspaceId: string | null; + identity: WorkspacePathIdentity | null; + lastSyncedAt: string | null; + entries: Record; +} + +interface WorkspaceMarker { + schemaVersion: typeof WORKSPACE_SCHEMA_VERSION; + workspaceId: string; + createdAt: string; +} + +interface WorkspaceRootContext { + selectedPath: string; + identity: WorkspacePathIdentity; +} + +type TargetInspection = "missing" | "materialized" | "conflict"; + +const EMPTY_CONFIG: WorkspaceConfig = { + schemaVersion: WORKSPACE_SCHEMA_VERSION, + revision: 0, + selectedPath: null, + workspaceId: null, + identity: null, + lastSyncedAt: null, + entries: {}, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteDate(value: unknown): value is string { + return typeof value === "string" && Number.isFinite(Date.parse(value)); +} + +function isAbsolutePath(value: unknown): value is string { + return typeof value === "string" && path.isAbsolute(value) && !value.includes("\0"); +} + +function isIdentity(value: unknown): value is WorkspacePathIdentity { + return ( + isRecord(value) && + typeof value.device === "string" && + /^[0-9]+$/u.test(value.device) && + typeof value.inode === "string" && + /^[0-9]+$/u.test(value.inode) + ); +} + +function isSafeRelativePath(value: unknown): value is string { + if (typeof value !== "string" || value.length < 1 || value.length > 240) return false; + if (value.includes("\0") || value.includes("\\") || path.isAbsolute(value)) return false; + const parts = value.split("/"); + return ( + parts.length === 2 && + (parts[0] === "Imports" || parts[0] === "Generated") && + SAFE_FILENAME.test(parts[1] ?? "") + ); +} + +function isWorkspaceEntry(value: unknown, assetId: string): value is WorkspaceEntry { + if (!isRecord(value)) return false; + return ( + value.assetId === assetId && + ASSET_ID.test(assetId) && + isSafeRelativePath(value.relativePath) && + (value.mediaType === "image/jpeg" || value.mediaType === "image/png") && + Number.isSafeInteger(value.byteLength) && + (value.byteLength as number) > 0 && + (value.byteLength as number) <= MAX_ENTRY_BYTES && + (value.state === "materialized" || + value.state === "conflict" || + value.state === "drifted" || + value.state === "orphaned") && + isFiniteDate(value.updatedAt) + ); +} + +function isWorkspaceConfig(value: unknown): value is WorkspaceConfig { + if (!isRecord(value) || value.schemaVersion !== WORKSPACE_SCHEMA_VERSION) return false; + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) return false; + const selectedPath = value.selectedPath; + const workspaceId = value.workspaceId; + const identity = value.identity; + const unconfigured = selectedPath === null && workspaceId === null && identity === null; + const configured = + isAbsolutePath(selectedPath) && + typeof workspaceId === "string" && + SAFE_ID.test(workspaceId) && + isIdentity(identity); + if (!unconfigured && !configured) return false; + if ( + unconfigured && + (value.lastSyncedAt !== null || Object.keys(value.entries ?? {}).length > 0) + ) { + return false; + } + if (value.lastSyncedAt !== null && !isFiniteDate(value.lastSyncedAt)) return false; + if (!isRecord(value.entries)) return false; + const entries = Object.entries(value.entries); + if (entries.length > MAX_ENTRIES) return false; + return entries.every(([assetId, entry]) => isWorkspaceEntry(entry, assetId)); +} + +function cloneConfig(config: WorkspaceConfig): WorkspaceConfig { + return structuredClone(config); +} + +function normalizeConfig(value: unknown): WorkspaceConfig { + return isWorkspaceConfig(value) ? cloneConfig(value) : cloneConfig(EMPTY_CONFIG); +} + +function assertAbsoluteDirectoryPath(directory: string): string { + if (!isAbsolutePath(directory)) { + throw new CreateImagesWorkspaceError( + "workspace_root_invalid", + "The Create Images workspace directory must be an absolute path.", + ); + } + return path.resolve(directory); +} + +function sameIdentity(left: WorkspacePathIdentity, right: WorkspacePathIdentity): boolean { + return left.device === right.device && left.inode === right.inode; +} + +function directoryForOrigin(origin: AssetOrigin): "Imports" | "Generated" { + return origin.kind === "import" || origin.kind === "repair" ? "Imports" : "Generated"; +} + +function safeStem(displayName: string | undefined, origin: AssetOrigin): string { + const slashNormalized = (displayName ?? "").replace(/\\/gu, "/"); + const basename = slashNormalized.slice(slashNormalized.lastIndexOf("/") + 1); + const withoutExtension = basename.replace(/\.[A-Za-z0-9]{1,12}$/u, ""); + const stem = withoutExtension + .normalize("NFKC") + .replace(/[^A-Za-z0-9._ -]/gu, "-") + .replace(/[ ._-]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 80); + if (stem) return stem; + return origin.kind === "import" || origin.kind === "repair" ? "import" : "generated"; +} + +export function createImagesWorkspaceRelativePath( + asset: Pick, +): string { + if (!ASSET_ID.test(asset.assetId)) { + throw new CreateImagesWorkspaceError( + "workspace_asset_missing", + "The workspace asset ID is invalid.", + asset.assetId, + ); + } + const extension = asset.mediaType === "image/jpeg" ? "jpg" : "png"; + return `${directoryForOrigin(asset.origin)}/${safeStem(asset.displayName, asset.origin)}-${asset.assetId}.${extension}`; +} + +function nowIso(now: () => number): string { + return new Date(now()).toISOString(); +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, constants.O_RDONLY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function safeLstat(directory: string): Promise { + try { + return await fs.lstat(directory); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new CreateImagesWorkspaceError( + "workspace_root_missing", + "The configured Create Images workspace directory is missing.", + ); + } + throw error; + } +} + +async function identityFor(directory: string): Promise { + const info = await fs.stat(directory, { bigint: true }); + return { device: info.dev.toString(), inode: info.ino.toString() }; +} + +async function ensureDirectoryChild(root: string, name: string): Promise { + const child = path.join(root, name); + try { + const info = await fs.lstat(child); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + `The Create Images workspace entry ${name} is not a regular directory.`, + ); + } + return child; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + await fs.mkdir(child, { mode: 0o700 }); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; + } + const info = await fs.lstat(child); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + `The Create Images workspace entry ${name} is not a regular directory.`, + ); + } + await syncDirectory(root); + return child; + } +} + +async function readWorkspaceMarker(root: string): Promise { + const markerPath = path.join(root, WORKSPACE_MARKER); + let info; + try { + info = await fs.lstat(markerPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if (info.isSymbolicLink() || !info.isFile()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The Create Images workspace marker is not a regular file.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse((await readRegularFile(markerPath, MAX_MARKER_BYTES)).toString("utf8")); + } catch { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory contains an invalid Create Images workspace marker.", + ); + } + if ( + !isRecord(parsed) || + parsed.schemaVersion !== WORKSPACE_SCHEMA_VERSION || + typeof parsed.workspaceId !== "string" || + !SAFE_ID.test(parsed.workspaceId) || + !isFiniteDate(parsed.createdAt) + ) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory contains an incompatible Create Images workspace marker.", + ); + } + return { + schemaVersion: WORKSPACE_SCHEMA_VERSION, + workspaceId: parsed.workspaceId, + createdAt: parsed.createdAt, + }; +} + +async function createWorkspaceMarker(root: string, workspaceId: string): Promise { + const markerPath = path.join(root, WORKSPACE_MARKER); + const existing = await readWorkspaceMarker(root); + if (existing) { + if (existing.workspaceId !== workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory belongs to another Create Images workspace.", + ); + } + return; + } + const staged = path.join(root, `.${WORKSPACE_MARKER}.${randomUUID()}.tmp`); + const contents = `${JSON.stringify({ + schemaVersion: WORKSPACE_SCHEMA_VERSION, + workspaceId, + createdAt: new Date().toISOString(), + })}\n`; + try { + const handle = await fs.open( + staged, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, + 0o600, + ); + try { + await handle.writeFile(contents, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.link(staged, markerPath); + await syncDirectory(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const raced = await readWorkspaceMarker(root); + if (!raced || raced.workspaceId !== workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "Another workspace marker appeared in the selected directory.", + ); + } + } + } finally { + await fs.rm(staged, { force: true }).catch(() => undefined); + } +} + +async function createWorkspaceReadme(root: string): Promise { + const destination = path.join(root, WORKSPACE_README); + let existing; + try { + existing = await fs.lstat(destination); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (existing) { + if (existing.isSymbolicLink() || !existing.isFile()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The Create Images workspace README is not a regular file.", + ); + } + return; + } + const staged = path.join(root, `.${WORKSPACE_README}.${randomUUID()}.tmp`); + try { + const handle = await fs.open( + staged, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, + 0o600, + ); + try { + await handle.writeFile( + "This folder is a Finder-visible mirror of Aiden Create Images assets.\n\n" + + "Imports are files added to Aiden. Generated contains provider outputs.\n" + + "Aiden's internal Create Images library remains the source of truth.\n", + "utf8", + ); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.link(staged, destination); + await syncDirectory(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const raced = await fs.lstat(destination); + if (raced.isSymbolicLink() || !raced.isFile()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The Create Images workspace README changed during setup.", + ); + } + } + } finally { + await fs.rm(staged, { force: true }).catch(() => undefined); + } +} + +async function hashRegularFile( + filePath: string, + maxBytes: number, +): Promise<{ byteLength: number; digest: string }> { + const handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NONBLOCK | NO_FOLLOW); + try { + const before = await handle.stat(); + if (!before.isFile() || before.size < 1 || before.size > maxBytes) { + throw new Error("The workspace target is not a bounded regular file."); + } + const hash = createHash("sha256"); + let total = 0; + while (total <= maxBytes) { + const chunk = Buffer.allocUnsafe(Math.min(COPY_CHUNK_BYTES, maxBytes + 1 - total)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, total); + if (bytesRead === 0) break; + hash.update(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + if (total > maxBytes) throw new Error("The workspace target grew beyond its byte limit."); + const after = await handle.stat(); + if (after.size !== before.size || total !== before.size) { + throw new Error("The workspace target changed while it was being read."); + } + return { byteLength: total, digest: hash.digest("hex") }; + } finally { + await handle.close(); + } +} + +async function inspectTarget( + filePath: string, + assetId: string, + byteLength: number, +): Promise { + let info; + try { + info = await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; + } + if (info.isSymbolicLink() || !info.isFile()) return "conflict"; + try { + const hashed = await hashRegularFile(filePath, byteLength); + return hashed.byteLength === byteLength && hashed.digest === assetId + ? "materialized" + : "conflict"; + } catch { + return "conflict"; + } +} + +async function copyVerifiedFile( + sourcePath: string, + destination: string, + assetId: string, + byteLength: number, +): Promise<"materialized" | "already" | "conflict"> { + const directory = path.dirname(destination); + const staged = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`); + const source = await fs.open(sourcePath, constants.O_RDONLY | constants.O_NONBLOCK | NO_FOLLOW); + let stagedHandle: fs.FileHandle | undefined; + try { + const sourceInfo = await source.stat(); + if (!sourceInfo.isFile() || sourceInfo.size !== byteLength) { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The canonical asset changed before workspace materialization.", + assetId, + ); + } + stagedHandle = await fs.open( + staged, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, + 0o600, + ); + const hash = createHash("sha256"); + let total = 0; + while (total <= byteLength) { + const chunk = Buffer.allocUnsafe(Math.min(COPY_CHUNK_BYTES, byteLength + 1 - total)); + const { bytesRead } = await source.read(chunk, 0, chunk.byteLength, total); + if (bytesRead === 0) break; + const part = chunk.subarray(0, bytesRead); + hash.update(part); + let written = 0; + while (written < part.byteLength) { + const result = await stagedHandle.write(part, written, part.byteLength - written, null); + if (result.bytesWritten < 1) throw new Error("The workspace write made no progress."); + written += result.bytesWritten; + } + total += bytesRead; + } + if (total !== byteLength || hash.digest("hex") !== assetId) { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The canonical asset changed during workspace materialization.", + assetId, + ); + } + await stagedHandle.sync(); + await stagedHandle.close(); + stagedHandle = undefined; + try { + await fs.link(staged, destination); + await syncDirectory(directory); + return "materialized"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = await inspectTarget(destination, assetId, byteLength); + if (existing === "materialized") return "already"; + return "conflict"; + } + } finally { + await stagedHandle?.close().catch(() => undefined); + await source.close().catch(() => undefined); + await fs.rm(staged, { force: true }).catch(() => undefined); + } +} + +export class CreateImagesWorkspaceStore { + private readonly configStore: DataStore; + private config = cloneConfig(EMPTY_CONFIG); + private configHealthy = true; + private configCorrupt = false; + private configUnsafe = false; + private initializePromise: Promise | undefined; + private mutationTail: Promise = Promise.resolve(); + + constructor( + private readonly rootDirectory: string, + private readonly assets: CreateImagesWorkspaceAssetSource | ContentAddressedAssetStore, + private readonly options: { now?: () => number } = {}, + ) { + if (!path.isAbsolute(rootDirectory)) { + throw new Error("The Create Images workspace config root must be absolute."); + } + this.configStore = new DataStore( + "workspace.json", + cloneConfig(EMPTY_CONFIG), + () => this.rootDirectory, + { + maxBytes: MAX_CONFIG_BYTES, + preserveCorruptFile: true, + normalize: normalizeConfig, + isSafe: (value) => isWorkspaceConfig(value), + reloadBeforeWrite: true, + rejectExternalChanges: true, + rejectUnsafeWrite: true, + }, + ); + } + + private get now(): () => number { + return this.options.now ?? Date.now; + } + + private serialized(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation, operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async initialize(): Promise { + if (!this.initializePromise) { + this.initializePromise = (async () => { + await fs.mkdir(this.rootDirectory, { recursive: true, mode: 0o700 }); + const rootInfo = await fs.lstat(this.rootDirectory); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_config_invalid", + "The Create Images config root is not a regular directory.", + ); + } + this.config = cloneConfig(await this.configStore.load()); + this.configCorrupt = await this.configStore.loadedFromCorruptFile(); + this.configUnsafe = await this.configStore.loadedFromUnsafeFile(); + this.configHealthy = !this.configCorrupt && !this.configUnsafe; + })(); + } + try { + await this.initializePromise; + } catch (error) { + this.initializePromise = undefined; + throw error; + } + } + + private async configuredRoot(): Promise { + if (!this.configHealthy) { + throw new CreateImagesWorkspaceError( + "workspace_config_invalid", + "The Create Images workspace configuration needs repair.", + ); + } + if (!this.config.selectedPath || !this.config.workspaceId || !this.config.identity) { + throw new CreateImagesWorkspaceError( + "workspace_not_configured", + "No Create Images workspace directory has been configured.", + ); + } + const info = await safeLstat(this.config.selectedPath); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The configured Create Images workspace is not a regular directory.", + ); + } + const identity = await identityFor(this.config.selectedPath); + if (!sameIdentity(identity, this.config.identity)) { + throw new CreateImagesWorkspaceError( + "workspace_root_changed", + "The configured Create Images workspace was replaced or moved.", + ); + } + const marker = await readWorkspaceMarker(this.config.selectedPath); + if (!marker) { + throw new CreateImagesWorkspaceError( + "workspace_marker_missing", + "The configured Create Images workspace marker is missing.", + ); + } + if (marker.workspaceId !== this.config.workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The configured directory belongs to another Create Images workspace.", + ); + } + return { selectedPath: this.config.selectedPath, identity }; + } + + private async writableRoot(context: WorkspaceRootContext): Promise { + try { + await fs.access(context.selectedPath, constants.W_OK); + await fs.access(path.join(context.selectedPath, "Imports"), constants.W_OK); + await fs.access(path.join(context.selectedPath, "Generated"), constants.W_OK); + return true; + } catch { + return false; + } + } + + private statusFromConfig( + state: CreateImagesWorkspaceState, + writable: boolean, + ): CreateImagesWorkspaceStatus { + const entries = Object.values(this.config.entries); + const materialized = entries.filter((entry) => entry.state === "materialized"); + return { + state, + configured: this.config.selectedPath !== null, + ...(this.config.workspaceId ? { workspaceId: this.config.workspaceId } : {}), + ...(this.config.selectedPath + ? { displayName: path.basename(this.config.selectedPath) || "Workspace" } + : {}), + ...(this.config.lastSyncedAt ? { lastSyncedAt: this.config.lastSyncedAt } : {}), + revision: this.config.revision, + entryCount: entries.length, + materializedCount: materialized.length, + driftedCount: entries.filter( + (entry) => entry.state === "drifted" || entry.state === "orphaned", + ).length, + conflictCount: entries.filter((entry) => entry.state === "conflict").length, + importedCount: materialized.filter((entry) => entry.relativePath.startsWith("Imports/")) + .length, + generatedCount: materialized.filter((entry) => entry.relativePath.startsWith("Generated/")) + .length, + writable, + }; + } + + private async statusInside(): Promise { + if (!this.configHealthy) return this.statusFromConfig("repair_required", false); + if (!this.config.selectedPath) return this.statusFromConfig("unconfigured", false); + try { + const context = await this.configuredRoot(); + const writable = await this.writableRoot(context); + return this.statusFromConfig(writable ? "ready" : "unwritable", writable); + } catch (error) { + const code = error instanceof CreateImagesWorkspaceError ? error.code : undefined; + const state: CreateImagesWorkspaceState = + code === "workspace_marker_conflict" + ? "conflict" + : code === "workspace_root_unsafe" + ? "conflict" + : code === "workspace_marker_missing" + ? "drifted" + : code === "workspace_not_writable" + ? "unwritable" + : code === "workspace_root_missing" || code === "workspace_root_changed" + ? "drifted" + : "drifted"; + return this.statusFromConfig(state, false); + } + } + + async status(): Promise { + return this.serialized(async () => { + await this.initialize(); + return this.statusInside(); + }); + } + + async configureChosenDirectory(directory: string): Promise { + return this.serialized(async () => { + await this.initialize(); + if (!this.configHealthy && !this.configCorrupt) { + throw new CreateImagesWorkspaceError( + "workspace_config_invalid", + "The Create Images workspace configuration needs repair.", + ); + } + const requestedPath = assertAbsoluteDirectoryPath(directory); + const requestedInfo = await safeLstat(requestedPath); + if (requestedInfo.isSymbolicLink() || !requestedInfo.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The selected Create Images workspace must be a regular directory, not a symlink.", + ); + } + const selectedPath = await fs.realpath(requestedPath); + const identity = await identityFor(selectedPath); + const sameConfiguredPath = + this.config.selectedPath === selectedPath && + this.config.identity !== null && + sameIdentity(this.config.identity, identity); + const marker = await readWorkspaceMarker(selectedPath); + const workspaceId = + sameConfiguredPath && this.config.workspaceId + ? this.config.workspaceId + : this.configCorrupt && marker + ? marker.workspaceId + : randomUUID(); + if (marker && marker.workspaceId !== workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory belongs to another Create Images workspace.", + ); + } + await createWorkspaceMarker(selectedPath, workspaceId); + await createWorkspaceReadme(selectedPath); + await ensureDirectoryChild(selectedPath, "Imports"); + await ensureDirectoryChild(selectedPath, "Generated"); + const next: WorkspaceConfig = { + schemaVersion: WORKSPACE_SCHEMA_VERSION, + revision: this.config.revision + 1, + selectedPath, + workspaceId, + identity, + lastSyncedAt: sameConfiguredPath ? this.config.lastSyncedAt : null, + entries: sameConfiguredPath ? this.config.entries : {}, + }; + try { + await this.configStore.save(next); + } catch (error) { + if (error instanceof DataStoreExternalChangeError) { + throw new CreateImagesWorkspaceError( + "workspace_config_conflict", + "The Create Images workspace configuration changed outside Aiden.", + ); + } + throw error; + } + this.config = cloneConfig(next); + this.configCorrupt = false; + this.configUnsafe = false; + this.configHealthy = true; + const assets = await this.assets.list(); + await this.syncAssets(assets, true); + return this.statusInside(); + }); + } + + async preflight(): Promise { + return this.serialized(async () => { + await this.initialize(); + const base = await this.statusInside(); + const issues: CreateImagesWorkspacePreflightIssue[] = []; + if (!this.configHealthy) { + issues.push({ code: "root_unsafe" }); + return { ...base, ok: false, issues }; + } + if (!this.config.selectedPath) + return { ...base, ok: false, issues: [{ code: "root_missing" }] }; + let context: WorkspaceRootContext; + try { + context = await this.configuredRoot(); + } catch (error) { + const code = error instanceof CreateImagesWorkspaceError ? error.code : undefined; + const issue: CreateImagesWorkspacePreflightIssue = + code === "workspace_root_changed" + ? { code: "root_changed" } + : code === "workspace_root_missing" + ? { code: "root_missing" } + : code === "workspace_marker_missing" + ? { code: "marker_missing" } + : code === "workspace_marker_conflict" + ? { code: "marker_conflict" } + : { code: "root_unsafe" }; + return { ...base, ok: false, issues: [issue] }; + } + if (!(await this.writableRoot(context))) issues.push({ code: "not_writable" }); + for (const directory of ["Imports", "Generated"] as const) { + try { + const info = await fs.lstat(path.join(context.selectedPath, directory)); + if (info.isSymbolicLink() || !info.isDirectory()) + issues.push({ code: "directory_unsafe" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + issues.push({ code: "directory_missing" }); + else issues.push({ code: "directory_unsafe" }); + } + } + for (const entry of Object.values(this.config.entries)) { + const target = path.join(context.selectedPath, ...entry.relativePath.split("/")); + const inspection = await inspectTarget(target, entry.assetId, entry.byteLength); + if (inspection === "missing") + issues.push({ code: "target_missing", assetId: entry.assetId }); + else if (inspection === "conflict") + issues.push({ code: "target_conflict", assetId: entry.assetId }); + } + return { ...base, ok: issues.length === 0, issues }; + }); + } + + private async syncOne( + context: WorkspaceRootContext, + asset: AssetMetadataDto, + ): Promise<"materialized" | "already" | "conflict"> { + const relativePath = createImagesWorkspaceRelativePath(asset); + const destination = path.join(context.selectedPath, ...relativePath.split("/")); + const current = await inspectTarget(destination, asset.assetId, asset.byteLength); + if (current === "materialized") return "already"; + if (current === "conflict") return "conflict"; + const parent = path.dirname(destination); + const parentInfo = await fs.lstat(parent); + if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The workspace materialization directory is not safe.", + asset.assetId, + ); + } + const result = await this.assets.withAssetFile(asset.assetId, async (source) => { + if (source.asset.assetId !== asset.assetId || source.byteLength !== asset.byteLength) { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The canonical asset metadata changed during workspace sync.", + asset.assetId, + ); + } + return copyVerifiedFile(source.filePath, destination, asset.assetId, asset.byteLength); + }); + if (result === "conflict") return "conflict"; + const final = await inspectTarget(destination, asset.assetId, asset.byteLength); + if (final !== "materialized") { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The workspace target could not be verified after publication.", + asset.assetId, + ); + } + return "materialized"; + } + + private async syncAssets( + assets: AssetMetadataDto[], + fullInventory = false, + ): Promise { + const base = await this.statusInside(); + if (base.state !== "ready") { + return { + state: base.state, + revision: base.revision, + totalAssets: assets.length, + materializedAssetIds: [], + alreadyMaterializedAssetIds: [], + conflictedAssetIds: [], + driftedAssetIds: [], + failed: [], + }; + } + const context = await this.configuredRoot(); + const next = cloneConfig(this.config); + const materializedAssetIds: string[] = []; + const alreadyMaterializedAssetIds: string[] = []; + const conflictedAssetIds: string[] = []; + const driftedAssetIds: string[] = []; + const failed: Array<{ assetId: string; message: string }> = []; + if (fullInventory) { + const active = new Set(assets.map((asset) => asset.assetId)); + for (const entry of Object.values(next.entries)) { + if (!active.has(entry.assetId)) entry.state = "orphaned"; + } + } + for (const asset of assets) { + try { + const result = await this.syncOne(context, asset); + const relativePath = createImagesWorkspaceRelativePath(asset); + const entry: WorkspaceEntry = { + assetId: asset.assetId, + relativePath, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + state: result === "conflict" ? "conflict" : "materialized", + updatedAt: nowIso(this.now), + }; + next.entries[asset.assetId] = entry; + if (result === "materialized") materializedAssetIds.push(asset.assetId); + else if (result === "already") alreadyMaterializedAssetIds.push(asset.assetId); + else conflictedAssetIds.push(asset.assetId); + } catch (error) { + const message = + error instanceof Error ? error.message : "The asset could not be materialized."; + failed.push({ assetId: asset.assetId, message }); + if ( + error instanceof CreateImagesWorkspaceError && + error.code === "workspace_root_changed" + ) { + driftedAssetIds.push(asset.assetId); + break; + } + const previous = next.entries[asset.assetId]; + if (previous) previous.state = "drifted"; + } + } + const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(this.config.entries); + const changed = entriesChanged || (assets.length > 0 && this.config.lastSyncedAt === null); + if (changed) { + next.lastSyncedAt = nowIso(this.now); + next.revision += 1; + try { + await this.configStore.save(next); + } catch (error) { + if (error instanceof DataStoreExternalChangeError) { + throw new CreateImagesWorkspaceError( + "workspace_config_conflict", + "The Create Images workspace configuration changed outside Aiden.", + ); + } + throw error; + } + this.config = next; + } + const state: CreateImagesWorkspaceState = + conflictedAssetIds.length > 0 + ? "conflict" + : failed.length > 0 || driftedAssetIds.length > 0 + ? "drifted" + : "ready"; + return { + state, + revision: this.config.revision, + totalAssets: assets.length, + materializedAssetIds: materializedAssetIds.sort(), + alreadyMaterializedAssetIds: alreadyMaterializedAssetIds.sort(), + conflictedAssetIds: conflictedAssetIds.sort(), + driftedAssetIds: driftedAssetIds.sort(), + failed, + }; + } + + async syncAll(): Promise { + return this.serialized(async () => { + await this.initialize(); + const assets = this.configHealthy ? await this.assets.list() : []; + return this.syncAssets(assets, true); + }); + } + + async syncAsset(assetId: string): Promise { + return this.serialized(async () => { + await this.initialize(); + if (!ASSET_ID.test(assetId)) { + throw new CreateImagesWorkspaceError("workspace_asset_missing", "The asset ID is invalid."); + } + const asset = await this.assets.get(assetId); + if (!asset) { + throw new CreateImagesWorkspaceError( + "workspace_asset_missing", + "The requested asset does not exist.", + assetId, + ); + } + return this.syncAssets([asset]); + }); + } + + async openTarget(assetId: string): Promise { + return this.serialized(async () => { + await this.initialize(); + if (!ASSET_ID.test(assetId)) { + throw new CreateImagesWorkspaceError("workspace_asset_missing", "The asset ID is invalid."); + } + const asset = await this.assets.get(assetId); + if (!asset) { + throw new CreateImagesWorkspaceError( + "workspace_asset_missing", + "The requested asset does not exist.", + assetId, + ); + } + const context = await this.configuredRoot(); + const relativePath = createImagesWorkspaceRelativePath(asset); + const filePath = path.join(context.selectedPath, ...relativePath.split("/")); + const inspection = await inspectTarget(filePath, asset.assetId, asset.byteLength); + if (inspection === "missing") { + throw new CreateImagesWorkspaceError( + "workspace_target_missing", + "The workspace copy is missing.", + assetId, + ); + } + if (inspection === "conflict") { + throw new CreateImagesWorkspaceError( + "workspace_target_conflict", + "The workspace copy is not an Aiden-created asset.", + assetId, + ); + } + return { filePath, assetId, relativePath }; + }); + } + + async openRoot(): Promise { + return this.serialized(async () => { + await this.initialize(); + const context = await this.configuredRoot(); + return { + filePath: context.selectedPath, + displayName: path.basename(context.selectedPath) || "Workspace", + }; + }); + } +} diff --git a/main/services/provider-registry.ts b/main/services/provider-registry.ts index 19d80026..fa7dc97a 100644 --- a/main/services/provider-registry.ts +++ b/main/services/provider-registry.ts @@ -165,6 +165,18 @@ export class ProviderRegistry { return this.models.getAuth(providerId); } + /** + * Main-process-only, non-secret credential metadata for capabilities that + * must distinguish a stored API key from OAuth before resolving request + * auth. This never exposes the credential value. + */ + async getBuiltinCredentialKind( + providerId: string, + ): Promise<"api_key" | "oauth" | undefined> { + await this.ensureBuiltinCatalogs(); + return (await this.credentials.list()).find((entry) => entry.providerId === providerId)?.type; + } + /** * Pi's provider-owned setup flows return full credentials (including * provider-specific fields such as Cloudflare account/gateway IDs). Keep diff --git a/main/services/renderer-readiness-core.test.ts b/main/services/renderer-readiness-core.test.ts index 69fb0717..ec07eaa0 100644 --- a/main/services/renderer-readiness-core.test.ts +++ b/main/services/renderer-readiness-core.test.ts @@ -36,6 +36,6 @@ test("main invalidates readiness and reloads after the renderer process exits", ); assert.match( main, - /const recovery = mainWindowLoads\.replace\(createdWindow\.loadURL\(mainWindowUrl\)\)/u, + /const recovery = mainWindowLoads\.replace\(\s*createdWindow\.loadURL\(mainWindowUrl\),?\s*\)/u, ); }); diff --git a/main/services/usage-store-core.ts b/main/services/usage-store-core.ts index fbe0d527..3d825aac 100644 --- a/main/services/usage-store-core.ts +++ b/main/services/usage-store-core.ts @@ -8,6 +8,7 @@ import type { export type UsageRequestSource = | "chat" | "chat-title" + | "workflow-proposal" | "voice-transcription" | "scheduled" | "subagent" diff --git a/main/windows/window-paths.ts b/main/windows/window-paths.ts index 966b954c..72db9ed1 100644 --- a/main/windows/window-paths.ts +++ b/main/windows/window-paths.ts @@ -1,19 +1,21 @@ +import { app } from "electron"; import * as path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; -const currentDir = path.dirname(fileURLToPath(import.meta.url)); -const buildRoot = path.resolve(currentDir, ".."); +function getBuildRoot(): string { + return path.join(app.getAppPath(), "build"); +} export function getPreloadPath(): string { - return path.join(buildRoot, "preload", "preload.cjs"); + return path.join(getBuildRoot(), "preload", "preload.cjs"); } export function getPillPreloadPath(): string { - return path.join(buildRoot, "preload", "preload-pill.cjs"); + return path.join(getBuildRoot(), "preload", "preload-pill.cjs"); } export function getWindowUrl(htmlFileName: string): string { const devServer = process.env.AIDEN_RENDERER_URL; if (devServer) return `${devServer.replace(/\/$/, "")}/${htmlFileName}`; - return pathToFileURL(path.join(buildRoot, "renderer", htmlFileName)).toString(); + return pathToFileURL(path.join(getBuildRoot(), "renderer", htmlFileName)).toString(); } diff --git a/package-lock.json b/package-lock.json index fe05ea55..b9209cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-router": "^1.131.36", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "12.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -24,6 +25,7 @@ "entities": "6.0.1", "highlight.js": "^11.9.0", "katex": "^0.16.47", + "konva": "10.3.1", "lucide-react": "^0.542.0", "mdast-util-to-string": "4.0.0", "node-pty": "^1.1.0", @@ -31,6 +33,7 @@ "re2-wasm": "1.0.2", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-konva": "19.2.5", "react-markdown": "^9.0.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", @@ -40,7 +43,9 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "thinking-orbs": "^0.1.1", - "unified": "11.0.5" + "unified": "11.0.5", + "yauzl": "3.4.0", + "yazl": "3.3.1" }, "devDependencies": { "@electron/asar": "3.4.1", @@ -53,6 +58,8 @@ "@types/node": "^24.3.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", + "@types/yauzl": "3.4.0", + "@types/yazl": "3.3.1", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.0", "@vitejs/plugin-react": "^6.0.1", @@ -4863,6 +4870,55 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -5002,6 +5058,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -5024,6 +5089,26 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", @@ -5314,6 +5399,38 @@ "addons/*" ] }, + "node_modules/@xyflow/react": { + "version": "12.9.3", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.3.tgz", + "integrity": "sha512-PSWoJ8vHiEqSIkLIkge+0eiHWiw4C6dyFDA03VKWJkqbU4A13VlDIVwKqf/Znuysn2GQw/zA61zpHE4rGgax7Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.73", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.73", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.73.tgz", + "integrity": "sha512-C2ymH2V4mYDkdVSiRx0D7R0s3dvfXiupVBcko6tXP5K4tVdSBMo22/e3V9yRNdn+2HQFv44RFKzwOyCcUUDAVQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -6197,6 +6314,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -6489,6 +6615,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -6788,6 +6920,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -9986,6 +10223,27 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -10215,6 +10473,38 @@ "json-buffer": "3.0.1" } }, + "node_modules/konva": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.3.1.tgz", + "integrity": "sha512-7fT/jSkL2bZZOHRBk2QVMKAUbgHoKG72W+KJtpP5YMJUZRmjgoafROgxTADsrxKOY2ZM5eKyN/x6CM9PrlFfOg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "peerDependencies": { + "canvas": "^3.0.0", + "skia-canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + }, + "skia-canvas": { + "optional": true + } + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -12332,6 +12622,12 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12867,6 +13163,37 @@ "react": "^19.2.7" } }, + "node_modules/react-konva": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.5.tgz", + "integrity": "sha512-AdsuDB59GdB86QdenTAqRzr6Tfw3z9x9Opxo4BoqDjdNLsjekF9a1VhN4FlMPvg0qtrMl3d4dNmagaUTGH7fSA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.33.0", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -12894,6 +13221,21 @@ "react": ">=18" } }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -15615,6 +15957,27 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -15646,6 +16009,34 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 13fa870b..bfbd25cb 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "type": "module", "main": "build/main/index.js", "scripts": { - "build": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && vite build && npm run build:electron", + "build": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && vite build && npm run build:electron && node scripts/verify-create-images-lazy-boundary.mjs", "computer-use:vendor": "node scripts/vendor-cua-driver.mjs", "build:native": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && node scripts/build-foundation-models-helper.mjs --required", "build:native:optional": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run build:subagent-file-mutator && npm run build:subagent-shell-runner && node scripts/build-foundation-models-helper.mjs --optional", @@ -41,8 +41,13 @@ "dev:electron": "wait-on http-get://127.0.0.1:4143/main-window.html && node scripts/prepare-macos-dev-runtime.mjs --run", "dev:brand": "node scripts/prepare-macos-dev-runtime.mjs", "lint": "eslint .", - "pretest": "npm run build:worktree-remover && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:provider-failure && npm run test:compaction && npm run test:subagents", - "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:compaction && npm run test:subagents", + "pretest:create-images": "tsx --test main/services/create-images/image-decoder-boundary.test.ts main/services/create-images/renderer-egress-core.test.ts main/services/create-images/mutation-rate-limit-core.test.ts renderer/create-images/autosave-preferences-core.test.ts", + "pretest": "npm run build:worktree-remover && npm run test:create-images && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:provider-failure && npm run test:compaction && npm run test:subagents", + "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:create-images && npm run test:preflight && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:compaction && npm run test:subagents", + "test:create-images": "tsx --test renderer/shared/create-images/schema.test.ts renderer/shared/create-images/templates.test.ts renderer/shared/create-images/node-banana-import.test.ts renderer/shared/create-images/execution.test.ts renderer/shared/create-images/ipc.test.ts renderer/shared/create-images/archive.test.ts renderer/shared/create-images/run-contract.test.ts renderer/shared/create-images/providers.test.ts renderer/shared/create-images/prompt-variables.test.ts renderer/shared/create-images/prompt-list.test.ts renderer/shared/create-images/workflow-proposal.test.ts renderer/create-images/editor-core.test.ts renderer/create-images/graph-fragment-core.test.ts renderer/create-images/recent-output-core.test.ts renderer/create-images/recent-output-presentation-core.test.ts renderer/create-images/power-features-core.test.ts renderer/create-images/canvas-navigation-preferences-core.test.ts renderer/create-images/node-dimensions-core.test.ts renderer/create-images/fixtures.test.ts renderer/create-images/feature-surface.test.ts renderer/create-images/provider-connection.test.tsx renderer/create-images/image-lightbox-core.test.ts renderer/create-images/asset-preview-lifecycle-core.test.ts renderer/create-images/workflow-autosave-core.test.ts renderer/create-images/navigation-guard.test.ts renderer/create-images/run-ui-core.test.ts renderer/create-images/run-ui.test.tsx renderer/lib/lifecycle-guard.test.ts main/services/create-images/workflow-manifest-store.test.ts main/services/create-images/run-journal-store.test.ts main/services/create-images/run-service.test.ts main/services/create-images/scheduler-core.test.ts main/services/create-images/mock-image-provider-core.test.ts main/services/create-images/gemini-provider-status-core.test.ts main/services/create-images/image-provider-execution-core.test.ts main/services/create-images/providers/gemini-image-provider-core.test.ts main/services/create-images/asset-store-core.test.ts main/services/create-images/workspace-store.test.ts main/services/create-images/asset-import-normalization-core.test.ts main/services/create-images/electron-asset-import.test.ts main/services/create-images/asset-protocol-core.test.ts main/services/create-images/native-archive-service.test.ts main/services/create-images/native-output-zip.test.ts main/services/create-images/presentation-store.test.ts main/services/create-images/workflow-proposal-service.test.ts main/services/create-images/node-banana-import-service.test.ts main/services/create-images/phase-two-integration.test.ts main/services/create-images/phase-three-integration.test.ts main/services/create-images/packaged-canvas-acceptance-core.test.ts main/services/create-images/phase-zero-contracts.test.ts && tsx --test main/services/create-images/run-journal-performance.test.ts && node --test scripts/create-images-notices.test.mjs scripts/package-source-fingerprint.test.mjs scripts/create-images-canvas-spike-main.test.mjs scripts/create-images-native-image.test.mjs scripts/bounded-child.test.mjs", + "test:create-images:canvas-spike": "node scripts/create-images-canvas-spike.mjs", + "test:create-images:canvas-product": "node scripts/create-images-product-canvas.mjs", + "test:create-images:packaged": "node scripts/create-images-packaged-acceptance.mjs", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/publish-github-release.test.mjs", "test:scheduled": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-notification.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-script.test.ts main/services/schedule-tool.test.ts renderer/lib/scheduled-task-view.test.ts", @@ -86,7 +91,9 @@ "type-check": "tsc --noEmit", "type-check:e2e": "tsc --noEmit --project tests/e2e/tsconfig.json", "format": "oxfmt .", - "package": "node scripts/prepare-macos-package-output.mjs development && npm run computer-use:vendor && npm run build:native && npm run build && electron-builder --mac dir --config.mac.type=development --config.mac.notarize=false --config.directories.output=release/development", + "package:fingerprint": "node scripts/write-package-source-fingerprint.mjs", + "package:fingerprint:verify": "node scripts/verify-package-source-fingerprint.mjs", + "package": "node scripts/prepare-macos-package-output.mjs development && npm run computer-use:vendor && npm run package:fingerprint && npm run build:native && npm run build && npm run package:fingerprint:verify && electron-builder --mac dir --config.mac.type=development --config.mac.notarize=false --config.directories.output=release/development", "package:verify": "node scripts/verify-macos-package.mjs --development", "release:preflight": "node scripts/check-macos-release.mjs", "release:check-consumers": "node scripts/check-release-consumers.mjs", @@ -105,6 +112,7 @@ "@tanstack/react-router": "^1.131.36", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "12.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -113,6 +121,7 @@ "entities": "6.0.1", "highlight.js": "^11.9.0", "katex": "^0.16.47", + "konva": "10.3.1", "lucide-react": "^0.542.0", "mdast-util-to-string": "4.0.0", "node-pty": "^1.1.0", @@ -120,6 +129,7 @@ "re2-wasm": "1.0.2", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-konva": "19.2.5", "react-markdown": "^9.0.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", @@ -129,7 +139,9 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "thinking-orbs": "^0.1.1", - "unified": "11.0.5" + "unified": "11.0.5", + "yauzl": "3.4.0", + "yazl": "3.3.1" }, "devDependencies": { "@electron/asar": "3.4.1", @@ -142,6 +154,8 @@ "@types/node": "^24.3.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", + "@types/yauzl": "3.4.0", + "@types/yazl": "3.3.1", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.0", "@vitejs/plugin-react": "^6.0.1", @@ -196,9 +210,15 @@ "build/main/**/*", "build/preload/**/*", "build/renderer/**/*", + "build/package-source-fingerprint.json", "resources/model-capabilities.json", "THIRD_PARTY_NOTICES.md", - "package.json" + "package.json", + "!node_modules/@xyflow/**/*", + "!node_modules/@types/d3-*/*", + "!node_modules/classcat/**/*", + "!node_modules/d3-*/*", + "!node_modules/zustand/**/*" ], "extraResources": [ { diff --git a/renderer/assets/onboarding/features/create-images.png b/renderer/assets/onboarding/features/create-images.png new file mode 100644 index 00000000..3ca34bc5 Binary files /dev/null and b/renderer/assets/onboarding/features/create-images.png differ diff --git a/renderer/components/chat-sidebar.test.tsx b/renderer/components/chat-sidebar.test.tsx index 33d437e7..6043cc3e 100644 --- a/renderer/components/chat-sidebar.test.tsx +++ b/renderer/components/chat-sidebar.test.tsx @@ -14,16 +14,20 @@ function between(value: string, start: string, end: string): string { return value.slice(startIndex, endIndex); } -test("sidebar places New Agent above Scheduled beneath search", () => { +test("sidebar places gated Create Images between New Agent and Scheduled", () => { const sidebar = source("./chat-sidebar.tsx"); const sidebarBody = between(sidebar, ""); const newAgentIndex = sidebarBody.indexOf("New Agent"); + const createImagesIndex = sidebarBody.indexOf('title="Create Images"'); const scheduledIndex = sidebarBody.indexOf('title="Scheduled"'); const workspaceIndex = sidebarBody.indexOf("Workspace switcher"); assert.notEqual(newAgentIndex, -1); + assert.notEqual(createImagesIndex, -1); assert.notEqual(scheduledIndex, -1); - assert.ok(newAgentIndex < scheduledIndex, "New Agent should appear before Scheduled"); + assert.ok(newAgentIndex < createImagesIndex, "New Agent should appear before Create Images"); + assert.ok(createImagesIndex < scheduledIndex, "Create Images should appear before Scheduled"); + assert.match(sidebarBody, /appCapabilities\.createImages/u); assert.ok( scheduledIndex < workspaceIndex, "Scheduled should stay above the workspace switcher and chat list", diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index 2995ac9e..b6ce5a64 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -37,12 +37,13 @@ import { Clock3, Folder, FolderGit2, + ImagePlus, Loader2, Settings, SquarePen, UserRound, } from "lucide-react"; -import { appUpdatesApi, chatsApi, gitApi, workspacesApi } from "../lib/ipc"; +import { appUpdatesApi, chatsApi, createImagesApi, gitApi, workspacesApi } from "../lib/ipc"; import { truncatePathMiddle } from "../lib/truncate-path"; import { useAppendReconciliationRequired } from "../lib/append-reconciliation"; import { @@ -56,7 +57,12 @@ import { createSidebarChatShortcutAssignments, sidebarChatNavigationTargets, } from "../lib/sidebar-chat-shortcuts"; -import { queryKeys, useChats, useFoundationModelsConnection } from "../lib/queries"; +import { + queryKeys, + useChats, + useCreateImagesWorkflows, + useFoundationModelsConnection, +} from "../lib/queries"; import { useActiveWorkspace } from "../lib/workspace-context"; import { useEnvironmentPanel } from "./environment-panel"; import type { ChatMeta, Workspace } from "../lib/types"; @@ -66,6 +72,8 @@ import { ariaKeyShortcut, prettyAccelerator } from "../shared/keybindings"; import { removeDeletedChatFromCache } from "../lib/chat-deletion-cache"; import { useAppUpdateSnapshot } from "../lib/use-app-update-snapshot"; import type { AppUpdateRestartResult, AppUpdateSnapshot } from "../shared/app-update"; +import { useAppCapabilities } from "../lib/app-capabilities"; +import { requestCreateImagesNavigation } from "../create-images/navigation-guard"; import { useActiveChatIds } from "../lib/use-chat-activity"; const AIDEN_MARK_URL = new URL("../../resources/app-icon.png", import.meta.url).href; @@ -312,7 +320,6 @@ function ChatActivityIndicator() { title="Working" className="inline-flex size-5 items-center justify-center text-accent" > - {/* A static open ring communicates in-progress work without an animation clock. */}