diff --git a/README.md b/README.md index a77c9fb..2cb1d9c 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ output stays diff-friendly and cheap in tokens. - **Interactive data tables**: the `table` fence gives you sortable, filterable, number-formatted tables with row counts — [license-audit example](docs/evidence/patterns/license-audit.png) - **Provenance on every page**: frontmatter `source:` lands in the footer as `Data: …` - **Curated themes**: frontmatter `theme: report | ops | editorial` restyles the whole page — [one source, three identities](docs/evidence/patterns/funnel-report.png); unnamed pages follow system dark/light with a three-state header toggle -- **Gallery + versions**: every publish updates `.opencode/artifacts/index.html`; `version: true` keeps numbered history; `restore` rolls back; a stale-version hash guard prevents blind overwrites +- **Durable lifecycle (opt-in migration)**: schema-2 stores use opaque artifact IDs, unconditional immutable revisions, cross-process crash recovery, expected-head updates, auditable restore, recoverable archive, and checksummed export/import. Existing and new preview stores remain on the legacy schema until `migrate apply` is explicitly run. - **Interactive**: chart-bound controls (vega-lite `params.bind`, echarts `dataZoom`), text-selection comments, workshop decision pages the session can read back - **Live reload**: `opencode-artifacts serve` refreshes open pages on every republish - **Sharing**: cost-free public snapshots via GitHub Pages or a user-operated Cloudflare Worker + KV; Cloudflare Access is a manual, unverified perimeter @@ -149,6 +149,17 @@ opencode-artifacts serve # http://127.0.0.1:4173, li opencode-artifacts restore --version 1 # roll the stable page back opencode-artifacts latest --open # reopen the most recent artifact opencode-artifacts state # read workshop answers back +opencode-artifacts migrate inspect # bounded legacy repair/migration report +opencode-artifacts migrate apply # explicit backed-up schema-2 migration +opencode-artifacts list # stable IDs, heads, capabilities, references +opencode-artifacts status +opencode-artifacts read --revision 1 +opencode-artifacts restore --revision 1 --expected-revision 3 +opencode-artifacts archive --preview # returns a head-bound one-use token +opencode-artifacts archive --confirm # recoverable; irreversible delete is absent +opencode-artifacts unarchive [--slug ] +opencode-artifacts export --output ./bundle +opencode-artifacts import ./bundle ``` ## Authoring format @@ -185,6 +196,9 @@ browser-verified screenshots in [`docs/evidence/patterns/`](docs/evidence/patter do not run through each viewer's identity. - No complete Node/OpenCode/OS/browser cell currently meets the supported-platform evidence gate. Existing Linux host and CI observations have narrower scopes. +- The crash-safe schema and CAS lifecycle are therefore not enabled by default on empty or + legacy stores. `migrate apply` is an explicit preview action with exact local backup and + rollback; it does not make the current filesystem a supported platform. ## Governance and support diff --git a/docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png b/docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png new file mode 100644 index 0000000..5d422b1 Binary files /dev/null and b/docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png differ diff --git a/docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md b/docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md new file mode 100644 index 0000000..63846dd --- /dev/null +++ b/docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md @@ -0,0 +1,91 @@ +# Goal 2 lifecycle implementation evidence — 2026-08-16 + +## Scope and approval + +Aaron Zeng (`aaron.zeng`) approved all four high-risk Goal 2 packets at +`2026-08-16T20:32:04Z`. This record covers worktree verification only. No remote push, +provider mutation, real deployment, release, participant result, supported-platform result, +or browser/manual outcome was performed or inferred. + +## Passing supported-runtime and focused diagnostics + +On 2026-08-17, the canonical commands ran on the Ubuntu 24.04.4 LTS host's ext4 workspace +through the official `node:24-bookworm` image at digest +`sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584`. +The isolated runtime reported Debian 12 x86_64, Node `24.19.0`, and npm `11.17.0`; network +access was disabled for the commands. `npm run build` passed, `npm test` passed all 170 tests, +and `npm run check` passed all 35 registered checks and engineering-principle tags. + +The earlier Bun diagnostics below exercised the same TypeScript sources while Node 24 was +being made available. They are supplementary and are not used as support evidence. + +Focused Bun diagnostics passed for the same TypeScript sources: + +- publication/identity/migration: 38 tests covering independent-process writers, every + transaction fault point, deletion rollback, manifest validation, legacy repair, backup, + rollback, schema-2 history, and public-staging exclusions; +- mutable state: 8 tests covering independent-process CAS, replay, bounded conflicts, + distinct-document merge, quota/rate/override ceilings, future/corrupt/symlink isolation, + the bounded model, and exact legacy state backup/rollback; +- lifecycle: 4 tests covering exact hostile references, stale merge payloads, rename, + append-only restore, scoped archive/unarchive, bundle round trip, and the bounded lifecycle + model; +- plugin: 12 tests, including schema-2 publish preconditions, lifecycle operations, archive + permission scope, and CAS state tools; +- CLI: 5 tests, including lifecycle aliases, expected-head restore, archive, export/import, + migration inspection/application/resume, state association, and exact manifest rollback; +- renderer and staging regressions remained green in their focused runs. + +A fixed-port loopback smoke returned HTTP 200, state revision 1, and the expected revision/hash +ETag after a schema-2 CAS write. This is a diagnostic only, not browser or supported-Node +evidence. + +## Real-browser conflict and limit observation + +An isolated Selenium Chromium container at digest +`sha256:1d3d834a2ce93f26cc0d0ae3c61abd189755b32649f5c356c6c5cf9502aa397e` +ran Chrome `151.0.7922.108` on Linux. Chromium and the Node 24 server shared a private network +namespace so the server retained its intentional `127.0.0.1` binding. No external network was +used. Two independent browser tabs loaded the migrated workshop fixture before either client +mutated it. + +- The first decision client selected `layout=tabs` at revision 0. The stale second client + selected `layout=dense`, received the selected revision 1, retained `tabs` on the server, + set the decision-conflict marker, and displayed the reload/merge notice. +- The first comment client committed one thread at revision 0. The stale second client kept + the server at revision 1 with one thread, set `reload-required`, and displayed the reload + notice while retaining the user's unsaved local text. +- An oversized decision value returned HTTP 413 with `quota`; the selected revision and hash + remained unchanged, and the page displayed the server's bounded reason and next action. + +The retained [browser screenshot](goal-2-browser-conflict-2026-08-17.png) has SHA-256 +`7dad0994644b24bae412dda64760087d992e00757e1443586e6f8ac7989281b3`. This is automated +real-browser technical evidence for the Goal 2 state workflow, not human usability, +accessibility, mobile, latest-two-browser, or broad platform certification. + +## Explicitly unavailable or failed gates + +- The host's default shell remains Node `18.19.1`, below the support floor. The exact Node 24 + container run above replaces that earlier runtime gap for repository validation. +- The Ubuntu/ext4 observation does not supply current/previous macOS, Windows 11 native, or + Windows 11/WSL filesystem results. Schema 2 consequently remains opt-in and non-default on + every unverified platform. +- The single automated Chromium observation does not supply human desktop/mobile, keyboard, + screen-reader, Firefox, Safari, or latest-two-browser evidence and creates no broad browser + support claim. +- `npm pack --dry-run` completed locally for the minor-version candidate: 53 files, 92.2 kB + packed and 405.1 kB unpacked. This package inspection is not a release, registry result, or + packed-host compatibility result. +- Exact-candidate CI, packed OpenCode host tests, a draft PR, and the unavailable write-platform + cells have not been completed for this implementation checkpoint. The earlier Node 18 + command attempted 25 test files and failed at module load; it is retained as an environment + observation and is superseded for product verification by the green Node 24 run above. + +## Gate disposition + +The implementation and all four packet verification suites pass, including the canonical +multi-process, every-boundary fault, migration, and real-browser state checks. On 2026-08-17, +the user explicitly made macOS and Windows native/WSL write-filesystem evidence optional for +Goal 2. Phase 1 and Goal 2 therefore pass for the opt-in lifecycle contract. The missing cells +remain unverified, schema 2 remains non-default there, and later support/certification gates +inherit no result from this narrow decision. diff --git a/docs/goal-runbook.md b/docs/goal-runbook.md index 9e099f1..56811ac 100644 --- a/docs/goal-runbook.md +++ b/docs/goal-runbook.md @@ -69,7 +69,7 @@ policy approval, provider result, or manual QA outcome. | Needed by | Human/external input | If unavailable | |---|---|---| | Goal 1 | npm package-owner authentication and exact public-preview provider evidence | retain the provider gate as failed/unverified; do not publish preview | -| Goal 2 | supported OS filesystems for lock/migration/fault tests | do not default-enable the schema on unverified platforms | +| Goal 2 | supported OS filesystems for lock/migration/fault tests | macOS and Windows native/WSL cells are optional for Goal 2 completion; keep them unverified and do not default-enable the schema there | | Goal 3 | supported desktop/mobile browsers, keyboard and screen-reader QA | retain the affected compatibility/accessibility failure | | Goal 4 | oldest-supported and current stable OpenCode hosts; release-policy decisions | narrow the support claim or pause certification | | Goal 5 | representative-user evidence required by `OUT-02`/`OUT-03`, supported-platform access, authorized current Claude Artifact runs, retention permission, and benchmark reviewers | public preview may continue; keep support, equal-or-better, and local-core certification unverified | @@ -224,7 +224,9 @@ Goals 3 and 5. /goal Execute Goal 2 in docs/goal-runbook.md: implement and verify roadmap Phase 1 so artifact identity, revisions, publication, restore, state, and migration are correct across concurrent processes and crashes. Preserve compatibility and rollback. Stop only when the canonical -Phase 1 gate linked below passes on supported write platforms. Deliver on an +Phase 1 technical gate linked below passes on the observed Node 24 Ubuntu/ext4 cell. Missing +macOS and Windows native/WSL filesystem observations are optional for this goal only and must +remain unverified without default schema enablement or inherited support claims. Deliver on an agent/goal-2-lifecycle branch with a draft PR unless I authorize otherwise. ``` diff --git a/docs/redistribution-inventory.json b/docs/redistribution-inventory.json index 0a40c28..e56dcbf 100644 --- a/docs/redistribution-inventory.json +++ b/docs/redistribution-inventory.json @@ -75,6 +75,15 @@ "attribution": "opencode-artifacts contributors", "status": "approved" }, + { + "path": "docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png", + "sha256": "7dad0994644b24bae412dda64760087d992e00757e1443586e6f8ac7989281b3", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/workshop.md in the isolated Goal 2 two-client Chromium observation", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, { "path": "docs/evidence/live-artifact.png", "sha256": "edff691758c877801de3b0d77d519ddc9dd15ec271c967e4d0614192f520e2f9", diff --git a/docs/requirements-traceability.md b/docs/requirements-traceability.md index a464df1..19cc9a9 100644 --- a/docs/requirements-traceability.md +++ b/docs/requirements-traceability.md @@ -37,8 +37,8 @@ not-applicable. | `CONN-01`–`CONN-07` | Viewer-scoped connectors | Phase 6; connector-capable artifacts | Connector maintainer | capability/grant/isolation/fallback/cache/idempotency E2E | Missing | | `SEC-01`–`SEC-10` | Adversarial protection | Every phase; every release gate | Security maintainer | threat model, abuse cases, isolation/resource tests, response/key-rotation policy | Partial | | `PRIV-01`–`PRIV-07` | Data purpose and rights | Phase 0 policy; Phases 5–6 implementation; every release gate | Privacy maintainer | data inventory, no-telemetry proof, export/delete test, retention and public-abuse policy | Missing | -| `OPS-01`–`OPS-08` | Reliability and operation | Phases 1, 4, 5B, and 6; applicable release gate | Operations maintainer | SLO dashboard/report, restore drill, degraded-mode/rollout/incident runbooks, quota alerts | Missing | -| `PERF-01`–`PERF-07` | Speed, capacity, and cost | Phases 2, 4, 5B, and 6; applicable release gate | Performance maintainer | reproducible percentile benchmarks, limits, load/soak report, provider cost model | Missing | +| `OPS-01`–`OPS-08` | Reliability and operation | Phases 1, 4, 5B, and 6; applicable release gate | Operations maintainer | SLO dashboard/report, restore drill, degraded-mode/rollout/incident runbooks, quota alerts | Partial | +| `PERF-01`–`PERF-07` | Speed, capacity, and cost | Phases 2, 4, 5B, and 6; applicable release gate | Performance maintainer | reproducible percentile benchmarks, limits, load/soak report, provider cost model | Partial | | `COMPAT-01`–`COMPAT-08` | Platforms and evolution | Phase 0 preview disclosure; Phases 1 and 3; hosting migration in Phase 5B; every certification gate | Compatibility maintainer | exact tested/unverified preview matrix; support matrix CI/manual QA, schema fixtures, export round trip, upgrade/rollback tests | Partial | | `DIST-01`–`DIST-07` | Release and supply chain | Phase 0 preview gate, Phase 3 automation, every distribution/certification gate | Release maintainer | preview transition model, packed-host test, clean installs, release checklist, SBOM/provenance/license/vulnerability output | Partial | | `QUAL-01`–`QUAL-08` | Acceptance evidence | Every phase and release gate | Quality maintainer | traceability check, automated/manual suites, retained evidence, honest failure report | Partial | diff --git a/docs/roadmap.md b/docs/roadmap.md index d99aee6..1c2d985 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -150,6 +150,13 @@ Exit gate: - fault injection at every write boundary always recovers a complete old or new transaction; - all old fixtures either migrate losslessly or produce an explicit repair report. +Status: **passed for the Phase 1 opt-in lifecycle contract on 2026-08-17**. +The approved worktree passes the Node 24 build, all 170 tests, structural checks, the +Ubuntu/ext4 transaction and migration observation, and real-Chromium decision/comment/limit +workflows. By explicit human decision, current/previous macOS and Windows native/WSL +write-filesystem cells are optional for Goal 2 completion. They remain unverified, schema 2 +stays opt-in there, and neither later certification nor platform support inherits a pass. + ## Phase 2 — Portable page completeness Goal: make the offline file cover the official single-page envelope without relying on raw diff --git a/package-lock.json b/package-lock.json index c4cd870..d37f49e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-artifacts", - "version": "0.14.4", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-artifacts", - "version": "0.14.4", + "version": "0.15.0", "license": "MIT", "dependencies": { "echarts": "^6.1.0", diff --git a/package.json b/package.json index 67beab5..eea6bda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-artifacts", - "version": "0.14.4", + "version": "0.15.0", "description": "Publish OpenCode session output as self-contained, interactive HTML artifact pages.", "license": "MIT", "type": "module", diff --git a/specs/archive/2026-08-17-artifact-identity-schema-migration/change.json b/specs/archive/2026-08-17-artifact-identity-schema-migration/change.json new file mode 100644 index 0000000..0f14c7b --- /dev/null +++ b/specs/archive/2026-08-17-artifact-identity-schema-migration/change.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "id": "artifact-identity-schema-migration", + "title": "Introduce durable artifact identity and schema migration", + "lane": "high-risk", + "status": "archived", + "affectedRequirements": [ + "LIFE-01", + "LIFE-02", + "LIFE-07", + "OPS-03", + "OPS-05", + "OPS-07", + "COMPAT-03", + "COMPAT-04", + "QUAL-02" + ], + "currentSpecs": [ + "specs/current/artifact-lifecycle.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-16T20:32:04Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-16", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-artifact-identity-schema-migration/delta.md b/specs/archive/2026-08-17-artifact-identity-schema-migration/delta.md new file mode 100644 index 0000000..1b1138b --- /dev/null +++ b/specs/archive/2026-08-17-artifact-identity-schema-migration/delta.md @@ -0,0 +1,217 @@ +# Specification delta: Introduce durable artifact identity and schema migration + +## MODIFIED + +### Requirement: LIFE-01 + +Every artifact has a generated opaque ID independent of title, slug, path, content hash, and +deployment URL. A unique human-readable slug is a mutable reference; renaming it preserves the +ID, revisions, state association, and deployment references. + +#### Scenario: Normal behavior + +- **Given:** an existing artifact is renamed +- **When:** the new slug is committed +- **Then:** the artifact ID and history remain unchanged and the new stable path resolves to it + +#### Scenario: Failure or refusal + +- **Given:** a requested slug belongs to another active artifact +- **When:** rename or migration validates the change +- **Then:** it refuses without changing either artifact + +#### Scenario: Relevant boundary + +- **Given:** two legacy entries have the same recoverable content or title +- **When:** they migrate +- **Then:** each receives its own opaque ID unless legacy evidence explicitly proves one identity + +### Requirement: LIFE-02 + +Every successful create, update, rename-with-content, and restore produces one immutable, +monotonically numbered revision record and retained portable-page bytes; history is not +conditional on a flag. + +#### Scenario: Normal behavior + +- **Given:** an artifact at revision 2 +- **When:** an update succeeds +- **Then:** revision 3 is retained and revisions 1 and 2 remain byte-for-byte unchanged + +#### Scenario: Failure or refusal + +- **Given:** a write fails before commit +- **When:** recovery inspects history +- **Then:** no successful revision number is skipped or partially materialized + +#### Scenario: Relevant boundary + +- **Given:** a legacy unversioned page has only one recoverable byte sequence +- **When:** it migrates +- **Then:** exactly one revision is recorded and no earlier content is invented + +### Requirement: LIFE-07 + +Schema-versioned metadata records ID, slug, title, icon, description, timestamps, head, +immutable revision metadata, byte size, content hash, provenance, author when known, and +deployment references, with exact validation and repair diagnostics. + +#### Scenario: Normal behavior + +- **Given:** a valid schema-2 manifest +- **When:** it is read +- **Then:** all artifact and revision fields validate and the selected head matches retained bytes + +#### Scenario: Failure or refusal + +- **Given:** duplicate IDs/slugs, an invalid head, unsafe reference, or mismatched hash +- **When:** the manifest is read or migrated +- **Then:** a typed error or repair report is returned instead of an empty manifest + +#### Scenario: Relevant boundary + +- **Given:** author or deployment information was never known +- **When:** metadata is produced +- **Then:** absence is represented explicitly without fabricated values + +### Requirement: OPS-03 + +Local lifecycle migration creates a verified exact backup, exposes integrity results, and +supports a tested restore of the prior selected store before a new schema is default-enabled. + +#### Scenario: Normal behavior + +- **Given:** a valid legacy store +- **When:** migration completes and its backup is restored in a drill +- **Then:** the restored old bytes and selection match the pre-migration inventory + +#### Scenario: Failure or refusal + +- **Given:** backup creation or verification fails +- **When:** migration is requested +- **Then:** selection remains on the old store and the failure names the next safe action + +#### Scenario: Relevant boundary + +- **Given:** a backup contains private artifact content +- **When:** evidence is retained +- **Then:** only hashes and synthetic fixture results are recorded, not the content + +### Requirement: OPS-05 + +Schema migration has explicit preflight, staged preparation, verification, selection, +post-change verification, and rollback; failed rollout preserves or restores the last known +good compatible state. + +#### Scenario: Normal behavior + +- **Given:** preflight and staged verification pass +- **When:** schema 2 is selected +- **Then:** post-change verification proves all artifact heads and revisions readable + +#### Scenario: Failure or refusal + +- **Given:** interruption or validation failure at any migration boundary +- **When:** recovery runs +- **Then:** it selects a complete old or complete new store and reports which one + +#### Scenario: Relevant boundary + +- **Given:** the filesystem platform lacks required migration evidence +- **When:** default enablement is evaluated +- **Then:** schema 2 remains disabled on that platform + +### Requirement: OPS-07 + +Inspection, migration, repair, and rollback are idempotent or resumable and emit bounded +machine-readable progress/results without requiring undocumented direct store edits. + +#### Scenario: Normal behavior + +- **Given:** an interrupted prepared migration +- **When:** the command is rerun +- **Then:** it resumes or safely restarts from recorded state and reaches the same result + +#### Scenario: Failure or refusal + +- **Given:** the recorded operation token conflicts with current store identity +- **When:** resume is attempted +- **Then:** it refuses mutation and provides a bounded repair report + +#### Scenario: Relevant boundary + +- **Given:** a completed migration is invoked again +- **When:** inputs are unchanged +- **Then:** it reports the prior completion without creating another backup or identity + +### Requirement: COMPAT-03 + +Manifest, revision, state-export, and provider-migration records carry integer schema +versions. Migrations are forward-only, backed up, idempotent, and fault-tested from every +released prior shape; unknown future versions fail without mutation. + +#### Scenario: Normal behavior + +- **Given:** any released legacy fixture +- **When:** it migrates to schema 2 +- **Then:** all recoverable content and metadata validate in the new schema + +#### Scenario: Failure or refusal + +- **Given:** a manifest with a schema version newer than the runtime understands +- **When:** any read-write operation opens it +- **Then:** the operation fails before writing or selecting a replacement + +#### Scenario: Relevant boundary + +- **Given:** historical Cloudflare state uses a shared-KV key shape +- **When:** the offline provider migration fixture runs +- **Then:** records are mapped to explicit site/artifact scope or reported ambiguous without provider mutation + +### Requirement: COMPAT-04 + +Upgrade and rollback preserve artifact IDs, slug references, revision selection, state +association, and deployment references; ambiguous legacy mappings are reported instead of +cross-wired. + +#### Scenario: Normal behavior + +- **Given:** a legacy artifact with local state and deployment metadata +- **When:** upgrade and rollback round-trip +- **Then:** every association returns to the same artifact and selected revision + +#### Scenario: Failure or refusal + +- **Given:** two legacy records cannot be safely associated with one state namespace +- **When:** migration preflight runs +- **Then:** it refuses that association and names the ambiguous records + +#### Scenario: Relevant boundary + +- **Given:** an artifact slug changed after migration +- **When:** a later package upgrade runs +- **Then:** it follows opaque identity rather than attaching data by the old title or slug + +### Requirement: QUAL-02 + +Identity, schema validation, migration, backup, repair, and rollback behavior has deterministic +unit, property, fixture, and fault tests independent of network, wall-clock ordering, and +developer-specific paths. + +#### Scenario: Normal behavior + +- **Given:** the synthetic lifecycle fixture corpus +- **When:** the deterministic suite runs +- **Then:** all normal migration and identity properties pass with fixed observable inputs + +#### Scenario: Failure or refusal + +- **Given:** a migration path has no failure or rollback test +- **When:** implementation verification runs +- **Then:** the packet and Phase 1 gate fail + +#### Scenario: Relevant boundary + +- **Given:** a real supported filesystem result is unavailable +- **When:** unit tests pass elsewhere +- **Then:** the platform remains unverified and is not inferred from synthetic evidence diff --git a/specs/archive/2026-08-17-artifact-identity-schema-migration/design.md b/specs/archive/2026-08-17-artifact-identity-schema-migration/design.md new file mode 100644 index 0000000..b2d474c --- /dev/null +++ b/specs/archive/2026-08-17-artifact-identity-schema-migration/design.md @@ -0,0 +1,69 @@ +# Design: Introduce durable artifact identity and schema migration + +Required for high-risk changes. + +## Context and constraints + +Slug-keyed manifest entries currently combine identity, presentation path, mutable head, and +partial history. Reads catch every error and return an empty manifest, which converts damage +or an unknown schema into apparent absence. The new contract must preserve directly openable +portable HTML, avoid a new dependency, tolerate interruption, and keep old data recoverable. +It must also allow later transactions and exports to use one canonical identity. No platform +may be claimed safe from unit tests on a different filesystem. + +## Chosen design + +Use manifest schema version 2. `ArtifactRecord` is keyed by a random UUID artifact ID and +contains the current unique slug, presentation metadata, created/updated timestamps, selected +head number, ordered `RevisionRecord`s, and deployment references. Each revision records its +monotonic number, immutable content hash, byte size, timestamp, provenance, author when known, +and stored portable-page reference. A validated slug index resolves paths without making the +slug identity. Duplicate IDs, duplicate active slugs, revision gaps/duplicates, mismatched +heads, invalid timestamps, unsafe references, and metadata/file hash disagreements are typed +errors rather than empty-store fallbacks. + +Migration first inventories the old manifest and files without writing. It assigns one opaque +ID per recoverable legacy artifact, imports every existing numbered page in order, and imports +the stable head only when its bytes are not already represented. It never creates a revision +for an advertised file that does not exist; the repair report names missing, orphaned, +duplicate, corrupt, and irrecoverable entries. A canonical migration library also maps the +historical shared Cloudflare KV key shapes to site-scoped export records for later authorized +provider migration; deterministic fixtures exercise this without contacting a provider. + +## Alternatives + +Rejected: retaining slug as identity, because rename and URL changes would keep detaching +history. Rejected: deriving IDs from content, path, or title, because all can change and hashes +can reveal relationships. Rejected: treating malformed input as empty, because it destroys +the distinction between no data and damaged data. Rejected: in-place schema rewriting, +because a crash could destroy the only recoverable copy. Rejected: inferring missing revision +content from metadata, because that fabricates history. + +## Trust, privacy, and failure boundaries + +Manifest bytes, paths, metadata, timestamps, hashes, and legacy provider keys are untrusted. +Parsing is size-bounded and schema-exact; resolved file references remain inside the artifact +root. Migration writes neither provider state nor audience-visible data. Repair reports omit +page bodies, credentials, and raw provider values. A failed validation, ambiguous mapping, +unknown future version, backup error, or hash mismatch leaves the selected store unchanged. + +## Migration, rollout, and rollback + +The migrator has inspect, prepare, verify, select, and rollback phases. Prepare writes a +unique staged generation plus an exact backup and fsyncs the files/directories supported by +the platform adapter. Verify reopens and hashes the staged store. Selection is delegated to +the approved publication transaction primitive so interruption resolves to the complete old +or new store. Re-running inspect/prepare is idempotent; completed migrations report their +existing result. Rollback validates the backup before selection. Schema 2 remains opt-in on +write platforms without the roadmap's lock/migration/fault evidence. + +## Formal-method decision + +- Decision: property model plus exhaustive state-machine testing over bounded legacy stores + and interruption points. +- Property and rationale: migration preserves every recoverable byte exactly once, never + invents a revision, keeps artifact identity stable after selection, is idempotent, rejects + future schemas without mutation, and selects either the complete old or complete new store. +- Model/evidence path: add a dependency-free model under `test/model/` and compare generated + operation traces with filesystem migration tests; retain exact supported-platform fault + reports under `docs/evidence/lifecycle/`. diff --git a/specs/archive/2026-08-17-artifact-identity-schema-migration/evidence.md b/specs/archive/2026-08-17-artifact-identity-schema-migration/evidence.md new file mode 100644 index 0000000..cfeadac --- /dev/null +++ b/specs/archive/2026-08-17-artifact-identity-schema-migration/evidence.md @@ -0,0 +1,60 @@ +# Evidence: Introduce durable artifact identity and schema migration + +Common evidence: [@test](test/artifact-schema.test.ts), +[@test](test/artifact-migration.test.ts), +[@test](test/artifact-publisher-v2.test.ts), and +[@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md). + +## Requirement: LIFE-01 +- Evidence: [@test](test/artifact-schema.test.ts), [@test](test/artifact-migration.test.ts) +- Validation: approved opaque identity and mutable unique slug. +- Verification: create, migration, update, and rename preserve UUID identity; collisions refuse. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform cells unavailable. + +## Requirement: LIFE-02 +- Evidence: [@test](test/artifact-publisher-v2.test.ts), [@test](test/artifact-migration.test.ts) +- Validation: approved unconditional immutable history. +- Verification: schema-2 create/update/restore append contiguous retained revisions, including legacy materialization without invented bytes. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform cells unavailable. + +## Requirement: LIFE-07 +- Evidence: [@test](test/artifact-schema.test.ts) +- Validation: approved exact versioned metadata. +- Verification: exact validators reject duplicate/mismatched identity, slug, head, path, hash, timestamps, and future schemas. +- Result: Node 24 full-suite pass. + +## Requirement: OPS-03 +- Evidence: [@test](test/artifact-migration.test.ts) +- Validation: approved exact verified backup and restore. +- Verification: migration retains exact manifest/index/page/state bytes and the rollback drill selects the original bytes. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform restore drills unavailable. + +## Requirement: OPS-05 +- Evidence: [@test](test/artifact-migration.test.ts) +- Validation: approved inspect, prepare, verify, select, post-verify, and rollback. +- Verification: source changes and failed verification refuse selection; rerun completes or reports current selection. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform fault evidence unavailable. + +## Requirement: OPS-07 +- Evidence: [@test](test/artifact-migration.test.ts), [@test](test/cli.test.ts) +- Validation: approved bounded repair/progress and resumability. +- Verification: inspect reports issues without bodies, apply resumes, completed migration is idempotent, and CLI rollback is explicit. +- Result: Node 24 full-suite pass. + +## Requirement: COMPAT-03 +- Evidence: [@test](test/artifact-migration.test.ts) +- Validation: approved forward schema migration and future-version refusal. +- Verification: released unversioned fixtures and historical shared-KV shapes map losslessly or emit explicit repair items; future schemas do not mutate. +- Result: Node 24 fixture pass; provider mutation was not performed or claimed. + +## Requirement: COMPAT-04 +- Evidence: [@test](test/artifact-migration.test.ts), [@test](test/artifact-state.test.ts) +- Validation: approved preservation of identity and association. +- Verification: local state associations follow opaque IDs, ambiguous records refuse, and rollback restores prior selection. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform cells unavailable. + +## Requirement: QUAL-02 +- Evidence: [@test](test/artifact-schema.test.ts), [@test](test/artifact-migration.test.ts), [@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md) +- Validation: approved deterministic schema/migration/rollback coverage. +- Verification: the common fixture diagnostics cover normal, failure, boundary, idempotence, and hostile paths. +- Result: Node 24 full-suite pass; unavailable platform cells remain explicit. diff --git a/specs/archive/2026-08-17-artifact-identity-schema-migration/proposal.md b/specs/archive/2026-08-17-artifact-identity-schema-migration/proposal.md new file mode 100644 index 0000000..1b3e641 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-identity-schema-migration/proposal.md @@ -0,0 +1,52 @@ +# Proposal: Introduce durable artifact identity and schema migration + +## Outcome + +Give every local artifact a stable opaque identity and unconditional immutable revision +history, with a versioned metadata schema that upgrades all released local shapes without +losing or inventing content. A migration either produces a verified new store plus backup, or +leaves the old bytes selected and emits an actionable repair report. + +## Context + +The current manifest is unversioned, keys artifacts by slug, stores only revision numbers, and +silently replaces missing, malformed, or future-shaped manifests with an empty manifest. +History is optional, title-derived slug changes create a new identity, legacy files can be +omitted from metadata, and no backup/repair/rollback protocol exists. Phase 1 must freeze an +identity and metadata contract before rendering, packaging, collaboration, and export build on +it. The Phase 0 support matrix has no certified write-platform cells, so the new schema cannot +be default-enabled until the required filesystem evidence exists. + +## Scope + +- In scope: schema-versioned `ArtifactRecord` and `RevisionRecord` data; random opaque IDs; + stable unique slugs as mutable references; immutable monotonically numbered revisions; + complete metadata and provenance; validation; migration dry-run, backup, repair, resume, + verification, rollback, and legacy fixtures for every released manifest/state shape, + including historical Cloudflare shared-KV keys. +- Out of scope: the publication locking/commit algorithm (owned by + `artifact-publication-transaction`); state CAS and quotas (owned by + `artifact-state-cas-limits`); user-facing lifecycle commands and plugin arguments (owned by + `artifact-lifecycle-surfaces`); real provider mutation; and promotion of an untested OS or + filesystem to supported. + +## Risks and rollback + +- Risk: an upgrade could detach a stable page from its identity, invent history for missing + files, overwrite an unknown future schema, cross-wire old Cloudflare state, or leave a + partially selected schema after interruption. Platform-specific rename and durability + behavior could make an otherwise passing migration unsafe. +- Rollback: preserve exact pre-migration bytes in a transaction-scoped backup, keep selection + on the old schema until copy/validate completes, and provide an idempotent rollback that + restores the verified backup and records the result. Unknown future schemas and ambiguous + legacy data fail before mutation. New-schema default enablement remains off for any + unverified write platform. + +## Validation plan + +Validation reviews representative create, rename, revise, repair, upgrade, and rollback +journeys against `LIFE-01`, `LIFE-02`, and `LIFE-07`. Verification uses table-driven fixtures +for every released shape, round-trip and idempotence properties, missing/corrupt/future-schema +cases, backup byte comparison, interruption at every migration boundary, and an explicit +legacy Cloudflare key mapping fixture. Exact supported-filesystem runs remain required before +default enablement and Phase 1 completion. diff --git a/specs/archive/2026-08-17-artifact-identity-schema-migration/tasks.md b/specs/archive/2026-08-17-artifact-identity-schema-migration/tasks.md new file mode 100644 index 0000000..cb56139 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-identity-schema-migration/tasks.md @@ -0,0 +1,12 @@ +# Tasks: Introduce durable artifact identity and schema migration + +- [x] Confirm proposal validation and human approval. +- [x] Add the artifact/revision schemas, exact validators, typed errors, and opaque-ID/slug index. +- [x] Add every released local manifest/state fixture and historical shared-KV key fixture. +- [x] Implement bounded inspect, dry-run repair report, backup, staged migration, resume, + verification, and rollback without enabling unverified platforms by default. +- [x] Add deterministic identity, migration, idempotence, backup/restore, and fault/property tests. +- [x] Retain exact supported-write-platform results and explicit unavailable cells. +- [x] Record validation and verification evidence, including failures and exclusions. +- [x] Add or update `specs/current/artifact-lifecycle.spec.md` and reconcile roadmap/traceability. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-artifact-lifecycle-surfaces/change.json b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/change.json new file mode 100644 index 0000000..ee5412a --- /dev/null +++ b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/change.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "id": "artifact-lifecycle-surfaces", + "title": "Expose complete artifact lifecycle operations", + "lane": "high-risk", + "status": "archived", + "affectedRequirements": [ + "LIFE-03", + "LIFE-05", + "LIFE-06", + "UX-01", + "UX-02", + "UX-04", + "UX-06", + "SEC-02", + "COMPAT-05", + "COMPAT-07", + "QUAL-02" + ], + "currentSpecs": [ + "specs/current/artifact-lifecycle.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-16T20:32:04Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-16", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-artifact-lifecycle-surfaces/delta.md b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/delta.md new file mode 100644 index 0000000..6e741f3 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/delta.md @@ -0,0 +1,266 @@ +# Specification delta: Expose complete artifact lifecycle operations + +## MODIFIED + +### Requirement: LIFE-03 + +Stable paths and registered URLs resolve to the selected head revision. Restoring an earlier +revision creates one new auditable head revision with restored-from provenance and preserves +all prior history. + +#### Scenario: Normal behavior + +- **Given:** artifact revision 3 and retained revision 1 +- **When:** revision 1 is restored against expected head 3 +- **Then:** revision 4 becomes head with revision-1 bytes and explicit restore provenance + +#### Scenario: Failure or refusal + +- **Given:** the expected head changed or requested revision is absent +- **When:** restore is attempted +- **Then:** it refuses without moving the head or changing history + +#### Scenario: Relevant boundary + +- **Given:** an old path or URL is not a registered reference +- **When:** resolution is attempted +- **Then:** it is not guessed from title or similar slug + +### Requirement: LIFE-05 + +Update resolves an exact ID, contained path, registered URL, or deprecated exact slug and +accepts expected revision/hash. A stale refusal carries bounded current identity, head, +metadata, and merge content or an immutable pinned content reference for the same session. + +#### Scenario: Normal behavior + +- **Given:** an exact artifact reference and matching expected revision +- **When:** update commits +- **Then:** one new revision is selected under the same opaque identity + +#### Scenario: Failure or refusal + +- **Given:** the expected revision/hash is stale +- **When:** update is attempted +- **Then:** no bytes change and the bounded result identifies current merge input and retry token + +#### Scenario: Relevant boundary + +- **Given:** a title-derived slug already exists but no artifact/precondition was supplied +- **When:** legacy-style publish runs +- **Then:** it returns a conflict instead of overwriting the existing artifact + +### Requirement: LIFE-06 + +CLI and plugin expose list, read/status, restore, archive/unarchive, export, and import with +consistent identities/results. Archive is previewed, explicitly confirmed, transactional, and +recoverable; irreversible delete is unavailable. + +#### Scenario: Normal behavior + +- **Given:** an active artifact with history and local state +- **When:** archive is previewed and confirmed with its current token +- **Then:** it leaves active listings while all data remains recoverable by opaque ID + +#### Scenario: Failure or refusal + +- **Given:** confirmation is missing, stale, or scoped to another artifact/head +- **When:** archive is requested +- **Then:** it refuses and reports that the artifact remains active + +#### Scenario: Relevant boundary + +- **Given:** an archived artifact's slug is now used by another active artifact +- **When:** unarchive is requested +- **Then:** it requires an explicit non-conflicting slug and preserves both identities + +### Requirement: UX-01 + +Documented CLI/plugin create, revise, read, restore, export, archive, and unarchive paths show +artifact ID, slug, head revision/hash, visibility, and target capability at each decision. + +#### Scenario: Normal behavior + +- **Given:** a user requests artifact status +- **When:** CLI or plugin returns it +- **Then:** the same identity, head, visibility, capability, and deployment references appear + +#### Scenario: Failure or refusal + +- **Given:** a reference resolves no artifact +- **When:** a lifecycle operation runs +- **Then:** output explains accepted reference forms and makes no mutation + +#### Scenario: Relevant boundary + +- **Given:** a local artifact also has public-static deployment references +- **When:** status is shown +- **Then:** local and public-static capabilities are distinguished rather than labeled private/live + +### Requirement: UX-02 + +Empty, stale, validation, quota, denied, archived, incompatible, and partial-recovery lifecycle +states say what happened, what remained unchanged, and the next safe action in bounded output. + +#### Scenario: Normal behavior + +- **Given:** a stale update +- **When:** the refusal is displayed +- **Then:** it identifies unchanged current head and gives merge/retry information + +#### Scenario: Failure or refusal + +- **Given:** a corrupt bundle or store cannot be safely read +- **When:** an operation is requested +- **Then:** it does not present empty success and points to bounded repair/status output + +#### Scenario: Relevant boundary + +- **Given:** recovery completed with exclusions +- **When:** status is displayed +- **Then:** exclusions remain visible beside the usable selected state + +### Requirement: UX-04 + +Archive previews exact artifact/head, state/revision scope, bytes, deployment references, and +recovery behavior; execution requires a one-use confirmation bound to that scope. Irreversible +local deletion remains unavailable. + +#### Scenario: Normal behavior + +- **Given:** a current archive preview +- **When:** its exact token is confirmed +- **Then:** only the named artifact/head and associated local data are archived recoverably + +#### Scenario: Failure or refusal + +- **Given:** scope changed after preview +- **When:** the old token is submitted +- **Then:** archive refuses and requires a new preview + +#### Scenario: Relevant boundary + +- **Given:** deployment references point to external retained copies +- **When:** local archive is previewed +- **Then:** output states those external copies are not deleted + +### Requirement: UX-06 + +Export produces a documented schema-versioned checksummed directory bundle with portable page, +metadata, all revisions, sources when available, comments, decisions, and supported documents. +Import validates the entire bundle before atomic mutation and reports unsupported content. + +#### Scenario: Normal behavior + +- **Given:** a representable artifact bundle +- **When:** export then import completes +- **Then:** identity, revisions, selected head, metadata, state, and documents round-trip + +#### Scenario: Failure or refusal + +- **Given:** a corrupt, oversized, escaping, or future-schema bundle +- **When:** import preflight runs +- **Then:** it fails without creating or modifying an artifact + +#### Scenario: Relevant boundary + +- **Given:** an export contains authoring source or local mutable state +- **When:** public deployment staging runs +- **Then:** those internal bundle/store areas remain excluded unless separately authorized + +### Requirement: SEC-02 + +Lifecycle references, paths, URLs, bundle entries, metadata, confirmation tokens, operation +IDs, and results are validated and bounded before authority or filesystem access; resolved +paths remain within the intended root and ambiguous references fail closed. + +#### Scenario: Normal behavior + +- **Given:** a valid contained path or registered URL +- **When:** it resolves +- **Then:** exactly one artifact ID is returned within configured result limits + +#### Scenario: Failure or refusal + +- **Given:** encoded traversal, symlink escape, unsupported scheme, duplicate match, or foreign URL +- **When:** resolution/import is attempted +- **Then:** it is rejected before reading or writing outside the artifact root + +#### Scenario: Relevant boundary + +- **Given:** a stale payload refers to a large immutable source +- **When:** it is returned +- **Then:** inline output remains bounded and the pinned path cannot escape or change revisions + +### Requirement: COMPAT-05 + +Lifecycle CLI/tool arguments and versioned result/bundle schemas follow SemVer. Existing +`version`, `expectedHash`, `latest`, `state`, restore spelling, and exact bare-slug behavior are +retained as documented compatibility aliases for at least one supported minor; removals +require notice and a migration path. + +#### Scenario: Normal behavior + +- **Given:** an existing supported caller uses a retained legacy spelling +- **When:** the new minor runs it +- **Then:** equivalent safe behavior occurs with bounded deprecation guidance + +#### Scenario: Failure or refusal + +- **Given:** a legacy update omits the precondition for an existing artifact +- **When:** compatibility handling runs +- **Then:** it refuses overwrite and explains the new safe call instead of silently weakening CAS + +#### Scenario: Relevant boundary + +- **Given:** `version:false` is supplied +- **When:** publication succeeds +- **Then:** immutable history is still created and the argument is reported deprecated + +### Requirement: COMPAT-07 + +Export/import preserves every representable artifact identity, revision, timestamp/time zone, +authorship/provenance field, deployment policy/reference, comment, decision, and supported +document with checksummed validation. + +#### Scenario: Normal behavior + +- **Given:** an artifact containing every supported field +- **When:** it round-trips through a bundle +- **Then:** semantic equality and immutable revision hashes are preserved + +#### Scenario: Failure or refusal + +- **Given:** a bundle field or revision cannot be represented safely +- **When:** import validates it +- **Then:** the whole import is refused with an exact unsupported-content report + +#### Scenario: Relevant boundary + +- **Given:** target storage already contains the same ID with divergent history +- **When:** import preflight runs +- **Then:** it requires an explicit supported collision policy and never cross-wires histories + +### Requirement: QUAL-02 + +Reference resolution, lifecycle operations, stale payloads, permissions, archive recovery, and +export/import have deterministic unit and CLI/plugin end-to-end tests, including compatibility +and hostile boundaries. + +#### Scenario: Normal behavior + +- **Given:** the lifecycle journey and bundle fixture corpus +- **When:** CLI and plugin suites run +- **Then:** every operation produces the same domain state and bounded result + +#### Scenario: Failure or refusal + +- **Given:** a public lifecycle path or changed argument lacks a test +- **When:** packet verification runs +- **Then:** implementation and Phase 1 acceptance fail + +#### Scenario: Relevant boundary + +- **Given:** OpenCode host/platform evidence is unavailable +- **When:** worktree tests pass +- **Then:** no packed-host or supported-platform claim is inferred diff --git a/specs/archive/2026-08-17-artifact-lifecycle-surfaces/design.md b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/design.md new file mode 100644 index 0000000..779305b --- /dev/null +++ b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/design.md @@ -0,0 +1,89 @@ +# Design: Expose complete artifact lifecycle operations + +Required for high-risk changes. + +## Context and constraints + +Lifecycle semantics must be shared without expanding the deployment `Publisher` interface, +which currently owns only `publish`. References arrive from users/agents and are untrusted; +paths and URLs can be ambiguous or hostile. OpenCode tool arguments are public API and require +explicit approval. The package is pre-1.0 but still follows SemVer and notice rules. Exported +portable pages must survive package removal, while local state and authoring sources must not +silently enter public-static deployment trees. + +## Chosen design + +Add a local `ArtifactLifecycleStore` over the approved identity, transaction, and mutable-state +primitives. `ArtifactRef` accepts an opaque ID, a contained stable/revision HTML path, or an +exact URL recorded in deployment references. A bare slug remains an exact deprecated lookup +through the unique slug index for current CLI compatibility; fuzzy title/slug guesses and +unregistered URLs are refused. Results use a bounded versioned domain shape shared by CLI and +plugin formatters. + +Create omits `artifact`; update supplies `artifact` plus `expectedRevision` or `expectedHash`. +If a legacy title-derived create collides with an existing slug without a precondition, it +returns a conflict rather than overwriting. `version` remains accepted but history is always +created. Each revision retains the portable HTML and, when publication originates from the +renderer, its input format and exact authoring source in a non-public history area. A stale +result includes ID/slug/head/hash/metadata and current source inline up to 256 KiB; larger +sources return a transaction-pinned immutable source path plus bounded beginning/end preview, +so the same session can read and merge without rediscovering identity. + +Restore resolves a historical revision and commits its bytes/source as a new head revision +whose provenance names the restored-from revision; no pointer rewinding or history deletion +occurs. Archive preflight returns ID, slug, revision/state counts, bytes, deployment references, +and recovery behavior plus a one-use transaction-bound confirmation token. Confirmed archive +moves the complete logical artifact into the internal archive namespace and removes active +references/gallery entries atomically; unarchive resolves slug conflicts explicitly. + +Export is a schema-versioned directory bundle containing a checksummed manifest, portable +pages, all revisions, metadata/provenance, authoring sources when present, comments, decisions, +and supported documents. It is staged and verified before selection. Import validates schema, +paths, hashes, sizes, identity collisions, and representability without mutation, then commits +atomically with an explicit collision policy; unknown future schemas fail. Public deployment +adapters continue excluding source, mutable-state, archive, transaction, and backup areas. + +CLI adds `list`, `status`, `read`, `archive`, `unarchive`, `export`, and `import`, while keeping +`latest`, `state`, and the old restore spelling as aliases. Plugin adds approved `artifact` and +`expectedRevision` publish arguments, CAS arguments to mutable tools, and one +`artifact_lifecycle` operation tool. Archive requires an `artifact_archive` `ctx.ask` scoped to +the exact opaque ID and confirmation token. The base `Publisher` interface does not change. + +## Alternatives + +Rejected: fuzzy title/slug matching, because it can mutate the wrong artifact. Rejected: +rewinding the head pointer for restore, because it erases the audit meaning of revisions. +Rejected: immediate delete, because recoverable archive is safer and the target does not yet +require irreversible local deletion. Rejected: ZIP/tar or a new archive dependency, because a +checksummed directory bundle is inspectable and portable without new code authority. Rejected: +one overloaded `artifact_publish` operation enum, because read/lifecycle authority and +publication input would become harder to review. + +## Trust, privacy, and failure boundaries + +Artifact refs, paths, URLs, bundles, metadata, source, state, confirmation tokens, and operation +IDs are untrusted. Resolution requires exact normalized containment and rejects symlink escape, +encoded traversal, unsupported schemes, duplicate matches, and foreign deployment URLs. +Results cap lists, source previews, state/doc previews, errors, and diagnostics; secrets are +scanned before export across an audience boundary. Archive authority is distinct from publish, +tokens are single-use and bound to current head/scope, and failures state that nothing changed. + +## Migration, rollout, and rollback + +Implement only after all four Goal 2 packets are approved. Add read/list/status first, then +stale-protected create/update, restore, archive/unarchive, and export/import on the transaction +store. Keep compatibility aliases with deprecation output through at least one supported +minor; changed update semantics ship only in a new minor. Rollback disables new mutations, +recovers any transaction, selects the verified prior schema/store, and leaves portable HTML +readable. Export/import and archive remain unavailable if full-state verification fails. + +## Formal-method decision + +- Decision: reference-resolution and lifecycle state-machine property model. +- Property and rationale: an accepted reference resolves exactly one artifact; stale updates + never write; restore adds exactly one revision; archive is reversible and preserves all + associated data; import is all-or-nothing; export/import round-trip every representable + field; and confirmation tokens cannot authorize a different head or artifact. +- Model/evidence path: dependency-free lifecycle traces under `test/model/`, CLI/plugin E2E and + hostile bundle/reference fixtures, plus retained browser/manual workflow evidence under + `docs/evidence/lifecycle/`. diff --git a/specs/archive/2026-08-17-artifact-lifecycle-surfaces/evidence.md b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/evidence.md new file mode 100644 index 0000000..8d6fa85 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/evidence.md @@ -0,0 +1,72 @@ +# Evidence: Expose complete artifact lifecycle operations + +Common evidence: [@test](test/artifact-lifecycle.test.ts), +[@model](test/model/artifact-lifecycle-model.ts), [@test](test/cli.test.ts), +[@test](test/plugin.test.ts), and +[@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md). + +## Requirement: LIFE-03 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@model](test/model/artifact-lifecycle-model.ts) +- Validation: approved expected-head append-only restore. +- Verification: restore adds one provenance-linked revision, preserves history, and stale restore refuses. +- Result: Node 24 full-suite pass. + +## Requirement: LIFE-05 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@test](test/plugin.test.ts) +- Validation: approved exact references and bounded merge input. +- Verification: ID/slug/path/registered URL resolve exactly; stale updates return identity/head/source and do not write. +- Result: Node 24 full-suite pass. + +## Requirement: LIFE-06 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@test](test/cli.test.ts), [@test](test/plugin.test.ts) +- Validation: approved consistent CLI/plugin lifecycle operations and recoverable archive. +- Verification: list/status/read/restore/archive/unarchive/export/import E2E diagnostics pass; irreversible delete is absent. +- Result: Node 24 CLI/plugin pass; packed-host evidence unavailable. + +## Requirement: UX-01 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@test](test/cli.test.ts) +- Validation: approved identity/head/capability fields at decisions. +- Verification: shared status results expose ID, slug, revision/hash, local visibility, capabilities, and deployment references. +- Result: Node 24 CLI/plugin and real-browser state workflow pass. + +## Requirement: UX-02 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@test](test/plugin.test.ts) +- Validation: approved bounded actionable degraded results. +- Verification: stale, absent, corrupt, incompatible, archive-conflict, and bundle errors state unchanged selection and next action. +- Result: Node 24 domain/plugin and visible real-browser degraded-state pass. + +## Requirement: UX-04 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@test](test/plugin.test.ts) +- Validation: approved one-use scope-bound recoverable archive. +- Verification: preview covers head/state/bytes/deployments; changed scope and reused tokens refuse; explicit-slug unarchive preserves identity. +- Result: Node 24 CLI/plugin pass; human workflow review unavailable. + +## Requirement: UX-06 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@test](test/cli.test.ts) +- Validation: approved checksummed directory bundle. +- Verification: full preflight rejects corrupt/unlisted/future/escaping/colliding content and round-trips revisions, sources, decisions, comments, and documents. +- Result: Node 24 full-suite pass. + +## Requirement: SEC-02 +- Evidence: [@test](test/artifact-lifecycle.test.ts) +- Validation: approved bounded exact resolution and bundle containment. +- Verification: traversal, encoding, backslash, symlink, foreign scheme, fuzzy title, ambiguity, hostile bundle path, and collision cases fail closed. +- Result: Node 24 full-suite pass. + +## Requirement: COMPAT-05 +- Evidence: [@test](test/artifact-publisher-v2.test.ts), [@test](test/cli.test.ts) +- Validation: approved one-minor aliases and safe changed semantics. +- Verification: version/hash/latest/state/restore/bare-slug aliases remain; schema-2 blind overwrite and restore without expected head refuse; package advances one minor. +- Result: Node 24 compatibility pass; packed-host evidence unavailable. + +## Requirement: COMPAT-07 +- Evidence: [@test](test/artifact-lifecycle.test.ts) +- Validation: approved semantic full-field bundle round trip. +- Verification: identity, revisions, provenance, source, selected head, and state compare equal after import; collisions reject. +- Result: Node 24 full-suite pass. + +## Requirement: QUAL-02 +- Evidence: [@test](test/artifact-lifecycle.test.ts), [@model](test/model/artifact-lifecycle-model.ts), [@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md) +- Validation: approved deterministic domain/CLI/plugin/model coverage. +- Verification: the common evidence exercises normal, failure, boundary, permission, compatibility, and hostile cases. +- Result: Node 24 full-suite pass; packed-host suite unavailable. diff --git a/specs/archive/2026-08-17-artifact-lifecycle-surfaces/proposal.md b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/proposal.md new file mode 100644 index 0000000..4893af8 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/proposal.md @@ -0,0 +1,55 @@ +# Proposal: Expose complete artifact lifecycle operations + +## Outcome + +Expose one coherent local lifecycle through CLI and OpenCode: exact artifact references, +create/update with stale protection, list/status/read, auditable revision restore, recoverable +archive/unarchive, and validated versioned export/import. Every result identifies artifact, +head, hash, visibility/capability, unchanged state on failure, and the next safe action. + +## Context + +The CLI currently renders by title-derived slug, restores by bare slug/version, reports only +the latest artifact, and reads decision state. The plugin exposes publish plus separate DB, +state, and comment tools but no artifact list/read/status/restore/archive/export/import. Update +identity is inferred from title, history is optional, stale metadata is incomplete, archive is +absent, and no portable lifecycle bundle exists. Goal 2 must freeze these contracts for Goals +3, 4, and 6. + +## Scope + +- In scope: exact ID/contained path/registered URL reference resolution plus deprecated exact + bare-slug compatibility; required expected revision/hash for updates; stable structured + domain results; full CLI and plugin lifecycle operations; auditable restore as a new + revision; archive preview/confirmation/unarchive; schema-versioned directory export/import; + retained authoring source for stale merge; and SemVer/deprecation documentation. +- Public API proposed for approval: add `artifact` and `expectedRevision` to + `artifact_publish`; retain `version` as a deprecated no-op and `expectedHash` as a supported + compatibility precondition; add `expectedRevision`/`operationId` where state/comment/DB + mutations require CAS; add an `artifact_lifecycle` tool with bounded operations and an + `artifact_archive` permission checkpoint. The `Publisher` interface is not changed. +- Out of scope: hosted lifecycle, audience/deployment permissions, structured OpenCode host + result integration and reopen fallback (Goal 4), event collaboration (Goal 6), irreversible + deletion, release, real deployment, and provider-side import/export. + +## Risks and rollback + +- Risk: reference parsing could target the wrong artifact or escape the root; restore could + destroy history; archive could orphan state; export could omit private/local data or leak it + to public staging; import could partially mutate; and public tool-argument changes could + break existing agents or scripts. +- Rollback: keep existing CLI spellings and tool arguments as documented compatibility + aliases for at least one supported minor; use a new pre-1.0 minor for changed update + semantics; implement archive as a reversible transaction; validate an entire import before + mutation; and roll back selected schema/store using the approved lifecycle transaction. + If lifecycle surfaces cannot safely initialize, existing portable HTML stays readable and + all mutations fail closed. + +## Validation plan + +Validation follows documented create, revise, stale merge, rename, restore, archive/unarchive, +export, and import journeys through both CLI and plugin, including preview and failure text. +Verification covers exact/fuzzy/hostile references, stale payload bounds, immutable restore, +archive recovery, export round trips, corrupt/unknown bundles, old argument compatibility, +permission denial, and packed CLI/plugin behavior. Human approval of the listed public tool +arguments and permission name is required before implementation. diff --git a/specs/archive/2026-08-17-artifact-lifecycle-surfaces/tasks.md b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/tasks.md new file mode 100644 index 0000000..51738f9 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-lifecycle-surfaces/tasks.md @@ -0,0 +1,12 @@ +# Tasks: Expose complete artifact lifecycle operations + +- [x] Confirm proposal validation and human approval. +- [x] Implement exact artifact reference parsing/resolution and bounded domain results. +- [x] Retain authoring source and implement stale-protected create/update compatibility behavior. +- [x] Implement list/read/status and auditable restore on the lifecycle store. +- [x] Implement archive preview/token/confirmation, archive, and unarchive transactions. +- [x] Implement checksummed schema-versioned directory export/import with full preflight. +- [x] Add the approved CLI commands, plugin arguments/tool, CAS arguments, and archive permission. +- [x] Add deterministic CLI/plugin/reference/permission/archive/bundle/compatibility tests and docs. +- [x] Record validation/verification evidence and update current specs, roadmap, and traceability. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-artifact-publication-transaction/change.json b/specs/archive/2026-08-17-artifact-publication-transaction/change.json new file mode 100644 index 0000000..1a8cdc2 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-publication-transaction/change.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "id": "artifact-publication-transaction", + "title": "Make artifact publication crash-safe across processes", + "lane": "high-risk", + "status": "archived", + "affectedRequirements": [ + "LIFE-04", + "SEC-07", + "OPS-04", + "OPS-05", + "QUAL-02", + "QUAL-06" + ], + "currentSpecs": [ + "specs/current/artifact-lifecycle.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-16T20:32:04Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-16", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-artifact-publication-transaction/delta.md b/specs/archive/2026-08-17-artifact-publication-transaction/delta.md new file mode 100644 index 0000000..5cbd2aa --- /dev/null +++ b/specs/archive/2026-08-17-artifact-publication-transaction/delta.md @@ -0,0 +1,145 @@ +# Specification delta: Make artifact publication crash-safe across processes + +## MODIFIED + +### Requirement: LIFE-04 + +Create, update, restore, manifest mutation, revision retention, and gallery generation execute +under one fenced inter-process transaction. Recovery before managed access yields a complete +old or complete new logical state and never reports a mixed commit as successful. + +#### Scenario: Normal behavior + +- **Given:** two processes update the same expected head concurrently +- **When:** both attempt to publish +- **Then:** exactly one commits and the other receives a stale result with no partial writes + +#### Scenario: Failure or refusal + +- **Given:** interruption at any stage, journal, replacement, or cleanup boundary +- **When:** a new process opens the store +- **Then:** recovery selects and verifies the complete old or complete new transaction + +#### Scenario: Relevant boundary + +- **Given:** two processes publish different artifacts concurrently +- **When:** both complete +- **Then:** the manifest and gallery contain both commits without a lost entry + +### Requirement: SEC-07 + +Filesystem lifecycle writes use bounded serialization with expected-head checks, fencing, and +idempotent recovery. Lock waits, staged bytes, target counts, retries, and diagnostics have +enforced limits; a retry cannot duplicate a committed revision. + +#### Scenario: Normal behavior + +- **Given:** a writer holds the valid fencing token and the expected head matches +- **When:** it commits within limits +- **Then:** one revision is created and the lock is released after verification + +#### Scenario: Failure or refusal + +- **Given:** a stale owner resumes after lock takeover +- **When:** it reaches a commit boundary +- **Then:** fencing validation refuses its commit without altering the selected state + +#### Scenario: Relevant boundary + +- **Given:** a caller retries after losing the success response +- **When:** recovery finds the operation already committed +- **Then:** it returns the existing commit result instead of creating another revision + +### Requirement: OPS-04 + +Lock timeout, cancellation, process crash, corrupt stage, and interrupted cleanup produce a +typed degraded state that names what remains selected and the next safe recovery action. +Last-known safe reads continue only after integrity is verified. + +#### Scenario: Normal behavior + +- **Given:** cleanup was interrupted after a verified commit +- **When:** the store reopens +- **Then:** the committed state remains readable and cleanup resumes idempotently + +#### Scenario: Failure or refusal + +- **Given:** neither old nor staged targets can be fully verified +- **When:** recovery runs +- **Then:** mutations fail closed and diagnostics identify the bounded repair scope + +#### Scenario: Relevant boundary + +- **Given:** a caller cancels while waiting for another writer +- **When:** cancellation is observed +- **Then:** no transaction is started and the selected state remains unchanged + +### Requirement: OPS-05 + +Publication transaction rollout has preflight, staged opt-in, post-commit verification, and a +tested rollback to the prior compatible store. Failed rollout never silently enables the new +commit path. + +#### Scenario: Normal behavior + +- **Given:** transaction preflight and platform fault tests pass +- **When:** the new path is enabled +- **Then:** post-change verification confirms the selected head, manifest, revision, and gallery + +#### Scenario: Failure or refusal + +- **Given:** post-change verification fails +- **When:** rollout recovery executes +- **Then:** it restores the verified last-known-good store and reports the failed candidate + +#### Scenario: Relevant boundary + +- **Given:** a target filesystem has not run the required fault suite +- **When:** enablement is evaluated +- **Then:** the old compatible path remains selected on that platform + +### Requirement: QUAL-02 + +Transaction, locking, fencing, stale checks, recovery, and fault boundaries have deterministic +unit, property, multi-process, and filesystem tests that do not rely on timing luck. + +#### Scenario: Normal behavior + +- **Given:** deterministic worker barriers and fault indices +- **When:** the lifecycle suite runs +- **Then:** every race and write boundary produces the modeled result + +#### Scenario: Failure or refusal + +- **Given:** a commit boundary lacks a deterministic failure test +- **When:** packet verification runs +- **Then:** implementation and archive validation are not accepted + +#### Scenario: Relevant boundary + +- **Given:** the same test repeats under scheduling variation +- **When:** results are compared +- **Then:** correctness depends on explicit barriers/state, not wall-clock ordering + +### Requirement: QUAL-06 + +Adversarial lifecycle tests cover stale/replayed writes, lock exhaustion, path/symlink attacks, +resource exhaustion, split-brain takeover, process crash, journal corruption, and recovery. + +#### Scenario: Normal behavior + +- **Given:** the complete adversarial transaction corpus +- **When:** it runs on a supported write platform +- **Then:** every attack is contained and a complete selected state remains + +#### Scenario: Failure or refusal + +- **Given:** a crafted journal or target path escapes the artifact root +- **When:** recovery validates it +- **Then:** recovery refuses the input without reading or writing outside the root + +#### Scenario: Relevant boundary + +- **Given:** repeated writers exceed wait or recovery limits +- **When:** overload is reached +- **Then:** excess work is rejected without corrupting existing artifacts or leaking content diff --git a/specs/archive/2026-08-17-artifact-publication-transaction/design.md b/specs/archive/2026-08-17-artifact-publication-transaction/design.md new file mode 100644 index 0000000..34dc3f7 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-publication-transaction/design.md @@ -0,0 +1,71 @@ +# Design: Make artifact publication crash-safe across processes + +Required for high-risk changes. + +## Context and constraints + +The transaction spans several portable files, must preserve direct HTML access, and cannot +assume one process. Node provides atomic creation/rename primitives but no portable atomic +multi-file rename. The design therefore needs a recoverable commit protocol, a clear managed +visibility boundary, and filesystem-specific evidence. It must not add a dependency without a +separate approval, weaken the final-byte cap, or publish local lock/journal data. + +## Chosen design + +Add a lifecycle-store transaction primitive used by every managed read and mutation. An +atomic lock directory contains a unique owner token, process diagnostics, heartbeat, and +monotonic fencing generation. Waits are bounded and abortable. A takeover is allowed only +after the owner is provably gone or its lease is expired; the new fencing generation prevents +a resumed stale writer from committing. Ownership is revalidated before each visible step. + +Within the lock, recovery runs first. A mutation reads and validates the selected state, +performs its expected-head check, renders all target bytes, and writes a unique same-filesystem +stage. It fsyncs supported files/directories, records hashes and old/new targets in a durable +journal, then atomically replaces individual targets with verified backups retained. The +journal records prepared, committing, committed, and cleaned states. The stable page is not +reported or returned until manifest, revision, gallery, and stable bytes reopen consistently. +All managed reads and server startup recover an unfinished journal before resolving a head; +public-static staging excludes lock, journal, backup, and temporary paths. + +Recovery rolls forward only when every staged target matches the journal and the transaction +has a valid commit decision; otherwise it restores every verified old target. It is +idempotent and uses the fencing token. A corrupt or ambiguous journal fails closed with a +bounded repair result instead of guessing. The fault injector is an explicit internal +adapter, not timing-dependent test behavior. + +## Alternatives + +Rejected: the current in-memory promise queue, because it cannot serialize processes. +Rejected: a lock file without fencing, because a suspended stale writer can resume after +takeover. Rejected: direct sequential writes without a journal, because no deterministic +recovery decision exists. Rejected: cross-filesystem temporary paths, because rename loses +atomicity. Rejected: adding SQLite or a locking package at proposal time, because the target +can first be met with reviewed platform adapters and no new dependency. + +## Trust, privacy, and failure boundaries + +Paths, journal bytes, lock metadata, expected heads, and staged files are untrusted. Every +resolved target stays beneath the artifact root; symlinks and unexpected file types fail +closed. Journals and diagnostics contain names, hashes, sizes, and operation IDs but no page +bodies or credentials. Lock waits, transaction bytes, target count, recovery attempts, and +diagnostic output are bounded. Cancellation before commit leaves the old state; cancellation +after a commit decision completes recovery before returning. + +## Migration, rollout, and rollback + +Land the transaction primitive and fault/model tests behind the schema-2 opt-in path. Run +upgrade and crash recovery on each proposed write platform before default enablement. Existing +schema-1 publishing remains the selected fallback until its migration commits. Rollback first +recovers any journal, selects the verified old schema/store, and retains the failed transaction +report. A platform that cannot demonstrate the required atomic-create, rename, and durability +properties remains unverified and schema 2 stays disabled there. + +## Formal-method decision + +- Decision: explicit transaction state machine with exhaustive bounded trace exploration. +- Property and rationale: at most one writer commits for a given expected head; different + artifacts do not lose manifest entries; a stale fencing token never commits; every injected + interruption recovers to the full old or full new logical state; and recovery is idempotent. +- Model/evidence path: dependency-free model traces under `test/model/`, multi-process worker + fixtures under `test/fixtures/`, and exact filesystem fault reports under + `docs/evidence/lifecycle/`. diff --git a/specs/archive/2026-08-17-artifact-publication-transaction/evidence.md b/specs/archive/2026-08-17-artifact-publication-transaction/evidence.md new file mode 100644 index 0000000..d1e73d2 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-publication-transaction/evidence.md @@ -0,0 +1,41 @@ +# Evidence: Make artifact publication crash-safe across processes + +Common evidence: [@test](test/file-transaction.test.ts), +[@model](test/model/file-transaction-model.ts), and +[@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md). + +## Requirement: LIFE-04 +- Evidence: [@test](test/file-transaction.test.ts), [@model](test/model/file-transaction-model.ts) +- Validation: approved by `aaron.zeng` on 2026-08-16. +- Verification: independent-process races and every modeled write boundary recover one complete old or new publication. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform cells unavailable. + +## Requirement: SEC-07 +- Evidence: [@test](test/file-transaction.test.ts) +- Validation: approved bounded serialization, wait, cancellation, path, and resource controls. +- Verification: hostile path, symlink, target-count, byte, cancellation, and live-lock diagnostics pass. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform cells unavailable. + +## Requirement: OPS-04 +- Evidence: [@test](test/file-transaction.test.ts) +- Validation: approved typed safe-state outcomes. +- Verification: caught pre/post-decision failures name old selection or finish recovered new selection. +- Result: Node 24 full-suite pass. + +## Requirement: OPS-05 +- Evidence: [@test](test/file-transaction.test.ts), [@model](test/model/file-transaction-model.ts) +- Validation: approved staged rollout and recovery. +- Verification: prepared journals roll back and decided journals roll forward with hash verification. +- Result: Node 24/Ubuntu-ext4 pass; other write-platform cells unavailable. + +## Requirement: QUAL-02 +- Evidence: [@test](test/file-transaction.test.ts), [@model](test/model/file-transaction-model.ts), [@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md) +- Validation: approved deterministic process/fault/model coverage. +- Verification: the common test and model evidence is independent of network and developer paths. +- Result: Node 24 full-suite pass; unavailable platform cells remain explicit. + +## Requirement: QUAL-06 +- Evidence: [@test](test/file-transaction.test.ts), [@model](test/model/file-transaction-model.ts) +- Validation: approved adversarial recovery coverage. +- Verification: malformed journals, unsafe transaction entries, replay/retry, resource exhaustion, and crash cases fail closed. +- Result: Node 24 full-suite pass; unavailable platform cells remain explicit. diff --git a/specs/archive/2026-08-17-artifact-publication-transaction/proposal.md b/specs/archive/2026-08-17-artifact-publication-transaction/proposal.md new file mode 100644 index 0000000..d4f4450 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-publication-transaction/proposal.md @@ -0,0 +1,47 @@ +# Proposal: Make artifact publication crash-safe across processes + +## Outcome + +Make create, update, restore, manifest mutation, revision retention, and gallery generation one +recoverable filesystem transaction across processes. Concurrent writers either serialize or +receive a typed stale refusal; interruption recovers a complete old or new logical state. + +## Context + +`FilePublisher` currently serializes only promises in one JavaScript process and writes the +stable page, optional version file, manifest, and gallery directly in sequence. Two processes +can both pass the stale check and lose updates, while a crash can expose mixed files. Restore +also performs multiple in-place writes. Phase 1 requires exact race and fault-injection proof +before later goals depend on the lifecycle store. + +## Scope + +- In scope: a dependency-free inter-process lock with fencing; bounded wait/cancellation; + same-filesystem staging; durable transaction journal; atomic replacement and backups; + startup/read recovery; typed stale, lock, commit, and recovery results; injectable fault + points; and multi-process/model tests for same and different artifacts. +- Out of scope: identity/schema content (owned by `artifact-identity-schema-migration`), + mutable comments/state/database transactions (owned by `artifact-state-cas-limits`), public + lifecycle arguments (owned by `artifact-lifecycle-surfaces`), network filesystems without + verified semantics, and any real deployment or provider mutation. + +## Risks and rollback + +- Risk: stale-lock takeover could create split-brain writers; a journal could be reordered or + corrupted; an interrupted replacement could lose both copies; different-artifact writers + could overwrite one another's manifest entry; or an OS/filesystem could violate assumed + rename/durability semantics. +- Rollback: keep the existing store selected until an approved migration and platform gate + pass. Every replacement retains a transaction-scoped old copy until the committed state is + reopened and verified. Recovery uses a durable journal to roll forward a verified prepared + generation or roll back from verified old copies; ambiguous/corrupt recovery refuses new + writes and reports exact repair scope. + +## Validation plan + +Validation reviews stale-edit, concurrent publish, interrupted publish, and recovery output +for clarity and safe next actions. Verification spawns independent Node processes against one +directory, proves exactly one winner for the same expected head and no lost manifest entries +for different artifacts, injects failure before and after every filesystem boundary, and +compares implementation traces with the bounded transaction model. Supported write-platform +evidence remains mandatory for Phase 1. diff --git a/specs/archive/2026-08-17-artifact-publication-transaction/tasks.md b/specs/archive/2026-08-17-artifact-publication-transaction/tasks.md new file mode 100644 index 0000000..c9e9ed9 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-publication-transaction/tasks.md @@ -0,0 +1,13 @@ +# Tasks: Make artifact publication crash-safe across processes + +- [x] Confirm proposal validation and human approval. +- [x] Implement the bounded fenced inter-process lock and typed lock outcomes. +- [x] Implement same-filesystem staging, durable journal, verified backups, commit, recovery, + cleanup, and public-staging exclusions. +- [x] Route create, update, restore, and managed reads through recovery and the transaction. +- [x] Add deterministic fault injection and the bounded transaction state model. +- [x] Add independent-process same-head/different-artifact races, stale takeover, retry, path, + corruption, cancellation, and resource-limit tests. +- [x] Retain exact supported-filesystem results and explicit unavailable cells. +- [x] Record validation/verification evidence and update `specs/current/artifact-lifecycle.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-artifact-state-cas-limits/change.json b/specs/archive/2026-08-17-artifact-state-cas-limits/change.json new file mode 100644 index 0000000..7010d44 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-state-cas-limits/change.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "artifact-state-cas-limits", + "title": "Make mutable artifact state atomic and bounded", + "lane": "high-risk", + "status": "archived", + "affectedRequirements": [ + "LOCAL-04", + "SEC-07", + "OPS-04", + "PERF-05", + "COMPAT-03", + "QUAL-02", + "QUAL-06" + ], + "currentSpecs": [ + "specs/current/artifact-lifecycle.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-16T20:32:04Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-16", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-artifact-state-cas-limits/delta.md b/specs/archive/2026-08-17-artifact-state-cas-limits/delta.md new file mode 100644 index 0000000..68a9d19 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-state-cas-limits/delta.md @@ -0,0 +1,171 @@ +# Specification delta: Make mutable artifact state atomic and bounded + +## MODIFIED + +### Requirement: LOCAL-04 + +Local decisions, comments, and mini-database stores use versioned envelopes and atomic +expected-revision mutations across clients and processes. Document, collection, thread, body, +and request-rate limits are enforced consistently through HTTP and plugin surfaces. + +#### Scenario: Normal behavior + +- **Given:** two clients update different documents from the same collection revision +- **When:** their operations serialize +- **Then:** both documents remain and each response reports the committed store revision + +#### Scenario: Failure or refusal + +- **Given:** two clients replace the same state from one expected revision +- **When:** both submit +- **Then:** one commits and one receives a bounded current value/revision without mutation + +#### Scenario: Relevant boundary + +- **Given:** a public-static or legacy Cloudflare KV surface +- **When:** local concurrency capability is evaluated +- **Then:** it cannot inherit the local CAS claim or accept an unsupported strong-state label + +### Requirement: SEC-07 + +Every mutable local write is serialized and CAS-checked with bounded body, shape, count, byte, +rate, wait, retry, and response limits. Replayed operation IDs return the prior result and do +not duplicate effects. + +#### Scenario: Normal behavior + +- **Given:** a valid expected revision and operation ID within limits +- **When:** a mutation commits +- **Then:** exactly one new store revision is selected + +#### Scenario: Failure or refusal + +- **Given:** stale, oversized, malformed, rate-exceeded, or future-schema input +- **When:** mutation is attempted +- **Then:** it fails before commit and reports what remained unchanged + +#### Scenario: Relevant boundary + +- **Given:** a success response is lost and the operation is retried +- **When:** the same operation ID reaches the store +- **Then:** the original bounded result is returned without another revision + +### Requirement: OPS-04 + +Stale, quota, overload, corrupt-store, timeout, cancellation, and migration states return typed +degraded results with the selected safe revision and next action. Reads continue only from a +validated last-known-safe envelope. + +#### Scenario: Normal behavior + +- **Given:** a stale client receives the current bounded envelope +- **When:** it merges and retries against that revision +- **Then:** the new mutation can commit without rediscovering artifact identity + +#### Scenario: Failure or refusal + +- **Given:** current state cannot be validated +- **When:** a read or write is requested +- **Then:** writes fail closed and reads do not present corrupt data as empty state + +#### Scenario: Relevant boundary + +- **Given:** the mutation rate reaches its warning threshold but not its hard limit +- **When:** another mutation succeeds +- **Then:** the response includes an actionable warning without weakening consistency + +### Requirement: PERF-05 + +Mutable state publishes and enforces defaults for encoded bytes, answer/thread/document counts, +field sizes, collection size, mutation rate, and bounded operator override ranges with warning +thresholds before hard rejection. + +#### Scenario: Normal behavior + +- **Given:** a store remains below every configured hard limit +- **When:** its mutation commits +- **Then:** measured usage and remaining capacity are available in the bounded result + +#### Scenario: Failure or refusal + +- **Given:** final encoded state would exceed any hard limit +- **When:** mutation is evaluated +- **Then:** it is rejected without changing the existing store + +#### Scenario: Relevant boundary + +- **Given:** an operator requests a value above the absolute override ceiling +- **When:** configuration loads +- **Then:** startup/preflight refuses the invalid configuration + +### Requirement: COMPAT-03 + +Decision, comment, and document stores carry integer schema versions and migrate under backup +from every released local shape. Unknown future versions fail without mutation; legacy +Cloudflare keys are mapped only through explicit provider-migration records. + +#### Scenario: Normal behavior + +- **Given:** a released local JSON state shape +- **When:** migration completes +- **Then:** its payload is preserved under artifact identity with revision and hash metadata + +#### Scenario: Failure or refusal + +- **Given:** an unknown future state schema +- **When:** the runtime opens it +- **Then:** reads/writes refuse rather than treating it as an empty store + +#### Scenario: Relevant boundary + +- **Given:** a historical shared-KV key cannot be assigned unambiguously +- **When:** offline mapping runs +- **Then:** it emits a repair item and performs no provider mutation + +### Requirement: QUAL-02 + +CAS, serialization, migration, validation, and limit behavior is covered by deterministic +unit, property, multi-process, HTTP, plugin, and browser tests independent of network and +developer-specific state. + +#### Scenario: Normal behavior + +- **Given:** the local state fixture corpus and deterministic barriers +- **When:** the suite runs +- **Then:** every normal mutation and limit boundary matches the model + +#### Scenario: Failure or refusal + +- **Given:** a public mutation path bypasses the CAS store or lacks a test +- **When:** verification runs +- **Then:** the packet and Phase 1 gate fail + +#### Scenario: Relevant boundary + +- **Given:** provider access is unavailable +- **When:** local and fake migration tests pass +- **Then:** no real provider or hosted consistency result is claimed + +### Requirement: QUAL-06 + +Adversarial state tests cover stale/replayed writes, malformed schemas/encodings, path names, +deep/large payloads, thread/document exhaustion, mutation-rate overload, process crash, and +cross-artifact association attempts. + +#### Scenario: Normal behavior + +- **Given:** the complete adversarial local-state corpus +- **When:** it executes +- **Then:** isolation, limits, atomicity, and recoverability remain intact + +#### Scenario: Failure or refusal + +- **Given:** a crafted artifact/store name or legacy record targets another artifact +- **When:** it is validated +- **Then:** access is refused before filesystem mutation and no foreign payload is returned + +#### Scenario: Relevant boundary + +- **Given:** controlled overload beyond rate and capacity limits +- **When:** excess writes arrive +- **Then:** they are rejected while existing state remains readable and bounded diff --git a/specs/archive/2026-08-17-artifact-state-cas-limits/design.md b/specs/archive/2026-08-17-artifact-state-cas-limits/design.md new file mode 100644 index 0000000..25534b3 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-state-cas-limits/design.md @@ -0,0 +1,75 @@ +# Design: Make mutable artifact state atomic and bounded + +Required for high-risk changes. + +## Context and constraints + +Three local mutable-store families are exposed both through loopback HTTP and plugin tools. +Their current JSON shapes lack schema versions and revisions. Correctness must hold across +server/plugin processes, not merely request handlers in one process. Limits must apply after +JSON encoding and before writes, conflict responses must be useful but bounded, and state must +follow opaque artifact identity after migration. The portable on-disk page remains network +independent and public-static targets cannot inherit local mutable-state claims. + +## Chosen design + +Store schema 2 uses an envelope containing integer `schemaVersion`, opaque artifact ID, store +kind/key, monotonic revision, SHA-256 content hash, normalized payload, and updated timestamp. +Every mutation supplies an expected revision/hash or create-only precondition. The store is +re-read inside the fenced lifecycle transaction; mismatch returns the current revision/hash +and a bounded current payload sufficient to merge. Mutations to distinct document IDs are +applied to the latest collection inside the lock, so one process cannot replace another's +unrelated document. A repeated operation ID returns its prior result. + +Default limits are: decision document 64 KiB, 256 answers, 256-byte keys, and 4 KiB values; +comment store 256 KiB, 200 threads, 128-byte IDs, 8 KiB quotes, and 16 KiB comment text; +database document 256 KiB, collection 1,000 documents and 16 MiB encoded; and 120 mutations per +artifact/store per rolling minute with a warning at 80 percent. Operator configuration may +lower limits or raise them only up to fixed ceilings of four times each default (1,000 +mutations/minute is the absolute rate ceiling). Reads, writes, response previews, lock wait, +and retry count remain independently bounded. Limit accounting uses final UTF-8 JSON bytes. + +HTTP uses ETag/`If-Match` and `If-None-Match`; structured bodies also carry an operation ID. +The served bridge tracks revisions and shows a reload/merge state on conflict. Plugin +operations expose equivalent expected revision and bounded structured results after the +lifecycle-surfaces packet approves their public shape. Legacy local JSON migrates under +backup; historical Cloudflare records can be exported/mapped but remain unverified and are +never labeled strongly consistent. + +## Alternatives + +Rejected: retaining last-write-wins replacement, because it loses concurrent work. Rejected: +process-local mutexes, because plugin and server processes are independent. Rejected: merging +arbitrary JSON automatically, because conflicts are domain-dependent and can silently corrupt +intent. Rejected: unbounded live payloads on conflict, because they create privacy and +resource risks. Rejected: claiming Cloudflare KV satisfies CAS, because eventual consistency +does not meet the contract. + +## Trust, privacy, and failure boundaries + +Request bodies, document values, IDs, expected tokens, envelopes, and legacy files are +untrusted. Names and artifact identity are validated before filesystem access; payload shape, +depth, scalar lengths, encoded bytes, counts, and rate are checked before locking where safe +and again before commit. Diagnostics redact content by default and cap merge previews. +Malformed, future-schema, stale, quota, timeout, replay-conflict, and corrupt-store cases fail +without mutation. Local rate keys do not contain viewer identity or page content. + +## Migration, rollout, and rollback + +Introduce schema-2 readers, validators, and dry-run migration before enabling writes. Migrate +one store under the lifecycle transaction, verify bytes/hash/identity, then select it; retain +the exact old file until rollback expiry is explicitly recorded. Update HTTP, bridge, and +plugin paths together so no compatibility surface bypasses CAS. On rollback, disable writes, +recover outstanding transactions, verify and select the backup, and report revisions that +cannot be represented. Hosted KV remains a separate unavailable migration target until its +later architecture is approved. + +## Formal-method decision + +- Decision: CAS state-machine/property model with exhaustive bounded interleavings. +- Property and rationale: one expected revision commits at most once; stale mutations never + write; distinct-document concurrent mutations are both retained; revisions increase by one; + migration preserves payload; limits are monotonic and enforced before commit; and retry by + operation ID does not duplicate effects. +- Model/evidence path: add dependency-free traces under `test/model/`, independent-process + worker tests, and local browser/limit evidence under `docs/evidence/lifecycle/`. diff --git a/specs/archive/2026-08-17-artifact-state-cas-limits/evidence.md b/specs/archive/2026-08-17-artifact-state-cas-limits/evidence.md new file mode 100644 index 0000000..4b42f0d --- /dev/null +++ b/specs/archive/2026-08-17-artifact-state-cas-limits/evidence.md @@ -0,0 +1,47 @@ +# Evidence: Make mutable artifact state atomic and bounded + +Common evidence: [@test](test/artifact-state.test.ts), +[@model](test/model/artifact-state-model.ts), [@test](test/plugin.test.ts), and +[@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md). + +## Requirement: LOCAL-04 +- Evidence: [@test](test/artifact-state.test.ts), [@test](test/plugin.test.ts), [@manual](docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png) +- Validation: approved artifact-scoped versioned state and CAS surfaces. +- Verification: decisions, comments, and collections share exact envelopes; distinct-document races retain both values. +- Result: Node 24 and real two-tab Chromium pass. + +## Requirement: SEC-07 +- Evidence: [@test](test/artifact-state.test.ts), [@manual](docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png) +- Validation: approved replay, serialization, and complete resource bounds. +- Verification: process races, replay conflict, body/shape/count/byte/depth/rate/wait bounds, and unsafe identities fail before mutation. +- Result: Node 24/Ubuntu-ext4 and real Chromium pass; other write-platform cells unavailable. + +## Requirement: OPS-04 +- Evidence: [@test](test/artifact-state.test.ts), [@test](test/plugin.test.ts), [@manual](docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png) +- Validation: approved typed stale/quota/rate/corrupt outcomes. +- Verification: bounded conflicts identify selected revision/hash and next action; corrupt/future stores never appear empty. +- Result: Node 24 full-suite and visible real-browser degraded-state pass. + +## Requirement: PERF-05 +- Evidence: [@test](test/artifact-state.test.ts), [@manual](docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png) +- Validation: approved documented defaults and four-times ceilings with an absolute rate ceiling. +- Verification: warning, hard rate, field, document, collection, thread, payload, envelope, and override boundaries are exercised. +- Result: Node 24 boundary and real-browser 413 pass; load/soak evidence remains later-phase work. + +## Requirement: COMPAT-03 +- Evidence: [@test](test/artifact-state.test.ts) +- Validation: approved exact legacy state migration. +- Verification: decision, comment, and collection payloads migrate under exact backup, rerun idempotently, and rollback without losing legacy bytes. +- Result: Node 24 fixture pass; hosted provider migration was not performed or claimed. + +## Requirement: QUAL-02 +- Evidence: [@test](test/artifact-state.test.ts), [@model](test/model/artifact-state-model.ts), [@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md), [@manual](docs/evidence/lifecycle/goal-2-browser-conflict-2026-08-17.png) +- Validation: approved model, process, plugin, HTTP, migration, and limit coverage. +- Verification: deterministic state/model/plugin diagnostics pass; a fixed-port loopback smoke passes. +- Result: Node 24 full-suite and real two-tab Chromium pass. + +## Requirement: QUAL-06 +- Evidence: [@test](test/artifact-state.test.ts), [@model](test/model/artifact-state-model.ts) +- Validation: approved adversarial stale/replay/schema/path/limit/overload coverage. +- Verification: future/corrupt/symlink/cross-artifact and controlled overload cases preserve selected state. +- Result: Node 24 full-suite and real-browser conflict/limit pass. diff --git a/specs/archive/2026-08-17-artifact-state-cas-limits/proposal.md b/specs/archive/2026-08-17-artifact-state-cas-limits/proposal.md new file mode 100644 index 0000000..78e1caa --- /dev/null +++ b/specs/archive/2026-08-17-artifact-state-cas-limits/proposal.md @@ -0,0 +1,47 @@ +# Proposal: Make mutable artifact state atomic and bounded + +## Outcome + +Make local decisions, comments, and mini-database mutations atomic and concurrency safe, with +explicit schema/revision CAS semantics, bounded live conflict payloads, and enforced document, +collection, thread, body, and mutation-rate limits. + +## Context + +The local server and plugin currently perform unprotected read-modify-write operations on JSON +files. Concurrent clients/processes can lose answers, threads, or documents. Most parse errors +become empty stores, writes are in place, collections have no count/total-byte limit, and no +request-rate limit exists. Cloudflare's historical KV handlers use the same shape but cannot +provide strong CAS; their data requires explicit migration treatment rather than being cited +as Phase 1 proof. + +## Scope + +- In scope: versioned decision/comment/document envelopes; per-store monotonic revisions and + content hashes; compare-and-swap and create-only preconditions; atomic mutation via the + approved lifecycle transaction primitive; bounded conflict responses; strict validation; + configurable defaults and hard override ceilings; warning/hard-limit diagnostics; local + HTTP/plugin parity; legacy-shape migration; and process/client race tests. +- Out of scope: hosted strong-state architecture, treating Cloudflare KV as CAS-capable, + datasource execution, live event/reconnect behavior, connector state, provider mutation, + and the final public plugin-tool argument design (owned by `artifact-lifecycle-surfaces`). + +## Risks and rollback + +- Risk: a compatibility fallback could silently bypass CAS; concurrent writes to different + documents could still lose changes; oversized conflict payloads could leak or exhaust + resources; schema migration could detach state from artifact identity; or rate limiting + could make local authoring unusable. +- Rollback: back up each legacy store before schema selection, keep old shapes read-only until + verified migration, and roll back through the lifecycle transaction. Conflict or limit + failures never mutate. The old unsafe write path is not a rollback option after schema 2 is + selected; disabling mutable service routes is the safe fallback. + +## Validation plan + +Validation exercises two-client edit/merge/refusal journeys and checks that errors state the +live revision, unchanged data, limit, and safe retry. Verification uses deterministic +barriers across processes, property traces for CAS and distinct-document updates, migration +fixtures, malformed/oversized inputs, warning and hard-limit boundaries, and rate/overload +tests. Browser evidence is retained for changed decision/comment conflict states; no hosted +or provider result is inferred. diff --git a/specs/archive/2026-08-17-artifact-state-cas-limits/tasks.md b/specs/archive/2026-08-17-artifact-state-cas-limits/tasks.md new file mode 100644 index 0000000..25624c9 --- /dev/null +++ b/specs/archive/2026-08-17-artifact-state-cas-limits/tasks.md @@ -0,0 +1,11 @@ +# Tasks: Make mutable artifact state atomic and bounded + +- [x] Confirm proposal validation and human approval. +- [x] Implement schema-2 state/comment/collection envelopes, validators, typed errors, and migration. +- [x] Implement atomic CAS/replay handling on the approved lifecycle transaction primitive. +- [x] Enforce documented byte/count/field/rate defaults, warnings, and override ceilings. +- [x] Route loopback HTTP, served bridge, and approved plugin operations through one store API. +- [x] Add model/property, process/client race, migration, malformed, quota, replay, and overload tests. +- [x] Retain browser evidence for decision/comment conflict and limit states. +- [x] Record validation/verification evidence and update current lifecycle/local-service specs. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-goal-2-optional-platform-evidence/change.json b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/change.json new file mode 100644 index 0000000..58a549d --- /dev/null +++ b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/change.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "goal-2-optional-platform-evidence", + "title": "Make cross-platform filesystem evidence optional for Goal 2", + "lane": "standard", + "status": "archived", + "affectedRequirements": [ + "COMPAT-01" + ], + "currentSpecs": [ + "specs/current/governance-policy.spec.md", + "specs/current/artifact-lifecycle.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "user", + "at": "2026-08-17T14:52:36Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-17", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-goal-2-optional-platform-evidence/delta.md b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/delta.md new file mode 100644 index 0000000..188574b --- /dev/null +++ b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/delta.md @@ -0,0 +1,30 @@ +# Specification delta: Make cross-platform filesystem evidence optional for Goal 2 + +## MODIFIED + +### Requirement: COMPAT-01 + +The target support matrix remains Ubuntu 24.04 LTS, current and previous macOS, Windows 11 +native/WSL, supported OpenCode, and named browser generations. Goal-specific implementation +gates may explicitly treat an unavailable target-platform observation as optional when a human +approves that narrower gate, provided the cell remains unverified, no support/certification is +claimed, and platform-dependent behavior remains opt-in or disabled there. + +#### Scenario: Normal behavior + +- **Given:** Goal 2's technical gate passes on Node 24 and an observed Ubuntu/ext4 filesystem +- **When:** the approved optional-platform rule is applied +- **Then:** Goal 2 and Phase 1 may complete while unavailable macOS and Windows cells remain + explicitly unverified and schema 2 remains opt-in +#### Scenario: Failure or refusal + +- **Given:** an unverified platform cell has no retained result +- **When:** compatibility or certification status is evaluated +- **Then:** the optional goal evidence cannot be promoted to a pass, support claim, or default + schema enablement + +#### Scenario: Relevant boundary + +- **Given:** a later goal or release gate explicitly requires the full target matrix +- **When:** that gate is evaluated +- **Then:** Goal 2's narrow waiver supplies no inherited evidence and the later gate remains open diff --git a/specs/archive/2026-08-17-goal-2-optional-platform-evidence/evidence.md b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/evidence.md new file mode 100644 index 0000000..fa905af --- /dev/null +++ b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/evidence.md @@ -0,0 +1,11 @@ +# Evidence: Make cross-platform filesystem evidence optional for Goal 2 + +## Requirement: COMPAT-01 + +- Validation: the user explicitly made macOS and Windows native/WSL write-filesystem evidence + optional for Goal 2. +- Verification: governance checks preserve every target cell and prevent unverified evidence + from becoming a support claim; the Goal 2 record retains the unavailable cells and opt-in + rollout boundary. +- Result: pass; Goal 2 closes narrowly while all missing platform cells remain unverified. +- Evidence: [@test](test/governance-policy.test.ts), [@manual](docs/evidence/lifecycle/goal-2-implementation-2026-08-16.md) diff --git a/specs/archive/2026-08-17-goal-2-optional-platform-evidence/proposal.md b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/proposal.md new file mode 100644 index 0000000..a91898f --- /dev/null +++ b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/proposal.md @@ -0,0 +1,33 @@ +# Proposal: Make cross-platform filesystem evidence optional for Goal 2 + +## Outcome + +Goal 2 can complete from its verified Node 24, Ubuntu/ext4, migration, recovery, and browser +evidence without fabricating macOS or Windows results. Missing target-platform cells remain +visible and cannot create a support or default-enablement claim. + +## Context + +The technical Phase 1 gate passes, but the runbook currently makes unavailable macOS and +Windows native/WSL filesystem observations block all later goals. The user explicitly made +those Goal 2 evidence cells optional. The narrower gate must not weaken the separate support +matrix or schema rollout boundary. + +## Scope + +- In scope: Goal 2 completion semantics, Phase 1 status, and explicit treatment of unavailable + platform evidence. +- Out of scope: declaring any platform supported, default-enabling schema 2, changing the + product support matrix, or waiving platform/browser evidence for later certification gates. + +## Risks and rollback + +- Risk: readers could mistake optional goal evidence for a broad compatibility waiver. The + runbook and current specs must explicitly prohibit that inference. +- Rollback: restore the cross-platform Goal 2 blocker and Phase 1 incomplete status; schema 2 + remains opt-in in either direction. + +## Validation plan + +Validate the user's explicit decision, retain the exact green Goal 2 evidence, and enforce in +repository tests that Goal 2 may pass while unverified cells remain optional and non-supporting. diff --git a/specs/archive/2026-08-17-goal-2-optional-platform-evidence/tasks.md b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/tasks.md new file mode 100644 index 0000000..4b3fe9d --- /dev/null +++ b/specs/archive/2026-08-17-goal-2-optional-platform-evidence/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Make cross-platform filesystem evidence optional for Goal 2 + +- [x] Confirm proposal validation and human approval. +- [x] Add or update tests for every changed scenario. +- [x] Implement the approved delta. +- [x] Record validation and verification evidence. +- [x] Update affected `specs/current/` files. +- [x] Run repository validation and archive the packet. diff --git a/specs/current/artifact-lifecycle.spec.md b/specs/current/artifact-lifecycle.spec.md new file mode 100644 index 0000000..7c163df --- /dev/null +++ b/specs/current/artifact-lifecycle.spec.md @@ -0,0 +1,50 @@ +# Artifact lifecycle and mutable local state + +## Rollout boundary + +- `COMPAT-03`, `OPS-05`: manifest and mutable-state schema 2 are explicit migrations. Empty + and legacy preview stores remain on schema 1 unless an operator runs migration. Unknown, + malformed, oversized, escaping, or ambiguous input fails before selection. +- The schema remains non-default because only the Ubuntu/ext4 write-platform observation is + available; current/previous macOS and Windows native/WSL lock, migration, and fault cells + remain unverified. The observation does not create a broad support claim. + +## Identity, history, and publication + +- `LIFE-01`, `LIFE-02`, `LIFE-07`: active artifacts are keyed by opaque UUID identity with a + unique mutable slug. Every successful schema-2 create, update, and restore appends one + immutable contiguous revision with full content hash, portable-page path, metadata, + provenance, optional authoring-source reference, and deployment references. +- `LIFE-03`–`LIFE-05`: exact IDs, active slugs, contained registered paths, and recorded HTTP(S) + URLs resolve without fuzzy guesses. Updates and restores require a matching expected head; + stale results leave bytes unchanged and return bounded identity plus immutable merge input. +- `LIFE-04`: one fenced inter-process transaction selects page, revision, manifest, gallery, + and source changes. Recovery rolls a prepared transaction back and a decided transaction + forward; malformed internal journals and unsafe paths fail closed. + +## State and lifecycle surfaces + +- `LOCAL-04`, `SEC-07`, `PERF-05`: schema-2 decisions, comments, and collections use exact + artifact-scoped envelopes, monotonic revisions, payload hashes, operation-ID replay records, + CAS, rolling mutation limits, and encoded byte/count/field/depth ceilings. Distinct-document + mutations merge under the transaction while same-document stale mutations conflict. +- `LIFE-06`, `UX-01`, `UX-04`, `UX-06`: the CLI and plugin expose list, status/read, auditable + restore, preview-bound recoverable archive/unarchive, and checksummed directory export/import. + Archive confirmation is one-use and head/state/deployment scoped. Irreversible deletion is + not exposed. Plugin archive confirmation uses a distinct permission request. +- Authoring sources, local state, migration backups/reports, transactions, archive records, + previews, and exported bundles are not copied into public-static staging by default. + +## Compatibility and unavailable evidence + +- `COMPAT-05`: legacy `version`, 12-character expected hash, `latest`, `state`, exact bare slug, + and restore spelling remain accepted compatibility aliases. Under schema 2, history is + unconditional, an existing-artifact publish without a precondition refuses, and restore + requires the expected head. These public changes use the next pre-1.0 minor. +- `QUAL-02`, `QUAL-06`: the Node 24 full suite covers deterministic unit, model, multi-process, + crash, migration, CLI, plugin, hostile-reference, bundle, and loopback behavior. A real + two-tab Chromium run verifies visible decision/comment conflicts and a non-mutating quota + refusal. The approved Goal 2 gate treats unavailable macOS and Windows native/WSL + write-platform cells as optional for Phase 1 completion only. Those cells remain unverified, + schema 2 remains opt-in there, and local-artifact-core certification still requires its + complete target matrix. diff --git a/specs/current/governance-policy.spec.md b/specs/current/governance-policy.spec.md index 89e2411..e37fea0 100644 --- a/specs/current/governance-policy.spec.md +++ b/specs/current/governance-policy.spec.md @@ -17,7 +17,10 @@ available. An exact Ubuntu/Node/OpenCode/single-Chromium packed-candidate observation is tested but incomplete. There are zero supported complete cells; all target cells are unverified. Public preview may expose that state but cannot promote the observation or claim - support; certification still requires the target matrix. + support; certification still requires the target matrix. A goal-specific implementation + gate may treat an unavailable cell as optional only after an explicit human decision and + only when the cell remains unverified, platform-dependent behavior remains opt-in or + disabled, and no later support/certification gate inherits evidence. - `SEC-01`: current, partial, and planned trust boundaries have owners, threats, controls, tests, residual risk and review triggers. Planned identity/connectors do not become shipped. - `SEC-10`: private GitHub vulnerability reporting is enabled and provider-verified, so the diff --git a/src/artifact-lifecycle.ts b/src/artifact-lifecycle.ts new file mode 100644 index 0000000..328ac0a --- /dev/null +++ b/src/artifact-lifecycle.ts @@ -0,0 +1,928 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { + ARTIFACT_ID_RE, + ARTIFACT_MANIFEST_FILE, + ARTIFACT_SLUG_RE, + CONTENT_HASH_RE, + emptyArtifactManifestV2, + parseArtifactManifestV2, + validateArtifactManifestV2, + type ArtifactManifestV2, + type ArtifactRecordV2, + type AuthoringSourceReferenceV2, + type RevisionRecordV2, +} from "./artifact-schema.ts"; +import { runFileTransaction, recoverFileTransactions, type FileTransactionContext } from "./file-transaction.ts"; +import { renderGallery } from "./gallery.ts"; +import { + applyArtifactFooter, + fullContentHash, + type ArtifactMeta, + type Manifest, +} from "./publisher.ts"; +import { ArtifactTooLargeError, DEFAULT_MAX_BYTES } from "./render.ts"; +import { validateArtifactStateEnvelope } from "./artifact-state.ts"; + +const MAX_LIST_RESULTS = 1_000; +const MAX_INLINE_MERGE_BYTES = 256 * 1024; +const MAX_SOURCE_BYTES = 16 * 1024 * 1024; +const ARCHIVE_TOKEN_TTL_MS = 15 * 60 * 1_000; +const HASH_PREFIX_RE = /^[0-9a-f]{12}$/; + +export class ArtifactReferenceError extends Error { + readonly reference: string; + + constructor(reference: string, message: string) { + super(`${message}; accepted references are an exact artifact ID, active slug, contained artifact path, or registered deployment URL`); + this.name = "ArtifactReferenceError"; + this.reference = reference; + } +} + +export class ArtifactLifecycleConflictError extends Error { + readonly artifact: ArtifactLifecycleStatus; + readonly merge: ArtifactMergePayload; + + constructor(message: string, artifact: ArtifactLifecycleStatus, merge: ArtifactMergePayload) { + super(message); + this.name = "ArtifactLifecycleConflictError"; + this.artifact = artifact; + this.merge = merge; + } +} + +export interface ArtifactMergePayload { + schemaVersion: 1; + format: "markdown" | "html"; + bytes: number; + contentHash: string; + inline?: string; + preview?: string; + pinnedPath?: string; +} + +export interface ArtifactLifecycleStatus { + schemaVersion: 1; + id: string; + slug: string; + active: boolean; + headRevision: number; + contentHash: string; + bytes: number; + title: string; + icon: string; + updatedAt: string | null; + visibility: "local"; + capabilities: Array<"portable-local" | "public-static" | "authenticated" | "connector-capable">; + deploymentReferences: ArtifactRecordV2["deploymentReferences"]; + stablePath: string | null; +} + +export interface LifecycleWriteInput { + artifact?: string; + slug: string; + html: string; + title?: string; + icon?: string; + description?: string; + source?: string; + author?: string; + charts?: number; + expectedRevision?: number; + expectedHash?: string; + authoringSource?: string; + inputFormat?: "markdown" | "html"; + now?: string; +} + +export interface ArchivePreview { + schemaVersion: 1; + token: string; + expiresAt: string; + scopeHash: string; + artifact: ArtifactLifecycleStatus; + revisionCount: number; + stateStoreCount: number; + retainedBytes: number; + externalCopiesDeleted: false; + recovery: "unarchive-by-id"; +} + +interface StoredArchivePreview extends ArchivePreview { + createdAt: string; +} + +interface ArchivedArtifactRecord { + schemaVersion: 1; + archivedAt: string; + previewScopeHash: string; + artifact: ArtifactRecordV2; +} + +interface ArtifactBundleFile { + path: string; + bytes: number; + contentHash: string; +} + +interface ArtifactBundleManifest { + schemaVersion: 1; + artifact: ArtifactRecordV2; + files: ArtifactBundleFile[]; +} + +function errnoCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sha256(value: string | Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function validTimestamp(value: string): boolean { + return /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value)); +} + +function galleryManifest(manifest: ArtifactManifestV2): Manifest { + const artifacts: Manifest["artifacts"] = {}; + for (const artifact of Object.values(manifest.artifacts)) { + artifacts[artifact.slug] = { + slug: artifact.slug, + title: artifact.title, + icon: artifact.icon, + description: artifact.description, + source: artifact.source, + createdAt: artifact.createdAt ?? "unknown", + updatedAt: artifact.updatedAt ?? "unknown", + current: artifact.headRevision, + versions: artifact.revisions.map((revision) => revision.revision), + charts: artifact.charts, + bytes: artifact.bytes, + hash: artifact.contentHash.slice(0, 12), + }; + } + return { artifacts }; +} + +async function readManifestLocked(root: string): Promise { + const path = join(root, ARTIFACT_MANIFEST_FILE); + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) throw new Error("artifact manifest path is unsafe"); + return parseArtifactManifestV2(await readFile(path, "utf8")); + } catch (error) { + if (errnoCode(error) === "ENOENT") return emptyArtifactManifestV2(); + throw error; + } +} + +function statusFor(root: string, artifact: ArtifactRecordV2, active: boolean): ArtifactLifecycleStatus { + const capabilities = new Set(["portable-local"]); + for (const deployment of artifact.deploymentReferences) capabilities.add(deployment.capability); + return { + schemaVersion: 1, + id: artifact.id, + slug: artifact.slug, + active, + headRevision: artifact.headRevision, + contentHash: artifact.contentHash, + bytes: artifact.bytes, + title: artifact.title, + icon: artifact.icon, + updatedAt: artifact.updatedAt, + visibility: "local", + capabilities: [...capabilities], + deploymentReferences: artifact.deploymentReferences, + stablePath: active ? join(root, `${artifact.slug}.html`) : null, + }; +} + +function uniqueMatch(reference: string, matches: ArtifactRecordV2[]): ArtifactRecordV2 { + if (matches.length === 0) throw new ArtifactReferenceError(reference, "artifact reference did not resolve"); + if (matches.length > 1) throw new ArtifactReferenceError(reference, "artifact reference is ambiguous"); + return matches[0]; +} + +async function resolveActiveLocked( + root: string, + manifest: ArtifactManifestV2, + reference: string, +): Promise { + if (reference.length === 0 || reference.length > 16_384 || reference.includes("\0")) { + throw new ArtifactReferenceError(reference, "artifact reference is invalid or oversized"); + } + if (ARTIFACT_ID_RE.test(reference)) { + const artifact = manifest.artifacts[reference]; + if (!artifact) throw new ArtifactReferenceError(reference, "artifact ID is not active"); + return artifact; + } + if (ARTIFACT_SLUG_RE.test(reference)) { + const id = manifest.slugIndex[reference]; + if (!id) throw new ArtifactReferenceError(reference, "artifact slug is not active"); + return manifest.artifacts[id]; + } + if (/^[a-z][a-z0-9+.-]*:/i.test(reference)) { + let url: URL; + try { + url = new URL(reference); + } catch { + throw new ArtifactReferenceError(reference, "artifact URL is malformed"); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new ArtifactReferenceError(reference, "artifact URL scheme is unsupported"); + } + return uniqueMatch(reference, Object.values(manifest.artifacts).filter((artifact) => artifact.deploymentReferences.some((deployment) => deployment.url === url.href))); + } + if (reference.includes("%") || reference.includes("\\")) { + throw new ArtifactReferenceError(reference, "encoded or backslash artifact paths are refused"); + } + const candidate = resolve(root, reference); + const rootPath = resolve(root); + if (candidate !== rootPath && !candidate.startsWith(`${rootPath}${sep}`)) { + throw new ArtifactReferenceError(reference, "artifact path escapes the store"); + } + const info = await lstat(candidate).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + }); + if (!info || info.isSymbolicLink() || !info.isFile()) { + throw new ArtifactReferenceError(reference, "artifact path is absent or unsafe"); + } + const local = relative(rootPath, candidate).split(sep).join("/"); + const canonical = /^revisions\/([0-9a-f-]{36})\/(\d+)\.html$/.exec(local); + if (canonical && ARTIFACT_ID_RE.test(canonical[1])) { + const artifact = manifest.artifacts[canonical[1]]; + if (artifact?.revisions.some((revision) => revision.pagePath === local)) return artifact; + } + const stable = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.html$/.exec(local); + if (stable) { + const id = manifest.slugIndex[stable[1]]; + if (id) return manifest.artifacts[id]; + } + const legacyRevision = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.v(\d+)\.html$/.exec(local); + if (legacyRevision) { + const id = manifest.slugIndex[legacyRevision[1]]; + const artifact = id ? manifest.artifacts[id] : undefined; + if (artifact?.revisions.some((revision) => revision.revision === Number(legacyRevision[2]))) return artifact; + } + throw new ArtifactReferenceError(reference, "contained path is not a registered artifact reference"); +} + +async function verifiedRevisionBytes(root: string, artifact: ArtifactRecordV2, revision: RevisionRecordV2): Promise { + const path = join(root, ...revision.pagePath.split("/")); + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) throw new Error(`revision ${revision.revision} path is unsafe`); + const html = await readFile(path, "utf8"); + if (Buffer.byteLength(html, "utf8") !== revision.bytes || fullContentHash(html) !== revision.contentHash) { + throw new Error(`revision ${revision.revision} for artifact ${artifact.id} failed integrity verification`); + } + return html; +} + +async function mergePayload(root: string, artifact: ArtifactRecordV2): Promise { + const revision = artifact.revisions.at(-1); + if (!revision) throw new Error("artifact has no head revision"); + let format: "markdown" | "html" = "html"; + let path = revision.pagePath; + let contentHash = revision.contentHash; + let bytes = revision.bytes; + if (revision.authoringSource) { + format = revision.authoringSource.format; + path = revision.authoringSource.path; + contentHash = revision.authoringSource.contentHash; + bytes = revision.authoringSource.bytes; + } + const value = await readFile(join(root, ...path.split("/")), "utf8"); + if (Buffer.byteLength(value, "utf8") !== bytes || sha256(value) !== contentHash) throw new Error("merge source failed integrity verification"); + if (bytes <= MAX_INLINE_MERGE_BYTES) return { schemaVersion: 1, format, bytes, contentHash, inline: value }; + return { + schemaVersion: 1, + format, + bytes, + contentHash, + preview: `${value.slice(0, 8_192)}\n…\n${value.slice(-8_192)}`, + pinnedPath: join(root, ...path.split("/")), + }; +} + +function expectedMatches(artifact: ArtifactRecordV2, revision: number | undefined, hash: string | undefined): boolean { + if (revision !== undefined && artifact.headRevision !== revision) return false; + if (hash !== undefined && artifact.contentHash !== hash && artifact.contentHash.slice(0, 12) !== hash) return false; + return true; +} + +function validateWriteInput(input: LifecycleWriteInput): string { + if (!ARTIFACT_SLUG_RE.test(input.slug)) throw new Error("slug must be a safe lowercase identifier"); + if (input.expectedRevision !== undefined && (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0)) throw new Error("expectedRevision must be a non-negative integer"); + if (input.expectedHash !== undefined && !CONTENT_HASH_RE.test(input.expectedHash) && !HASH_PREFIX_RE.test(input.expectedHash)) throw new Error("expectedHash must be a full or 12-character SHA-256 digest"); + const now = input.now ?? new Date().toISOString(); + if (!validTimestamp(now)) throw new Error("lifecycle timestamp must be an ISO timestamp with timezone"); + if (input.authoringSource !== undefined && Buffer.byteLength(input.authoringSource, "utf8") > MAX_SOURCE_BYTES) throw new Error(`authoring source exceeds ${MAX_SOURCE_BYTES} bytes`); + if (input.authoringSource !== undefined && input.inputFormat === undefined) throw new Error("inputFormat is required with authoringSource"); + return now; +} + +function bundleSegments(path: string): string[] { + if (path === "" || path.includes("\\") || path.startsWith("/") || path.endsWith("/") || path.includes("%")) throw new Error(`unsafe bundle path ${JSON.stringify(path)}`); + const segments = path.split("/"); + if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error(`unsafe bundle path ${JSON.stringify(path)}`); + return segments; +} + +async function optionalRegularFile(path: string, maxBytes = 17 * 1024 * 1024): Promise { + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) throw new Error(`bundle/store path is unsafe: ${path}`); + if (info.size > maxBytes) throw new Error(`bundle/store file exceeds ${maxBytes} bytes: ${path}`); + return await readFile(path); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } +} + +async function inventoryBundleFiles(root: string): Promise { + const files: string[] = []; + const pending = [""]; + while (pending.length > 0) { + const current = pending.shift(); + if (current === undefined) break; + const entries = await readdir(current === "" ? root : join(root, ...current.split("/")), { withFileTypes: true }); + for (const entry of entries) { + const path = current === "" ? entry.name : `${current}/${entry.name}`; + bundleSegments(path); + if (entry.isSymbolicLink()) throw new Error(`bundle contains symbolic link ${path}`); + if (entry.isDirectory()) pending.push(path); + else if (entry.isFile()) files.push(path); + else throw new Error(`bundle contains unsupported entry ${path}`); + if (files.length + pending.length > 20_101) throw new Error("artifact bundle exceeds the file-count limit"); + } + } + return files.sort(); +} + +function parseBundleManifest(value: unknown): ArtifactBundleManifest { + if (!isRecord(value) || value["schemaVersion"] !== 1 || !isRecord(value["artifact"]) || !Array.isArray(value["files"]) || Object.keys(value).some((key) => key !== "schemaVersion" && key !== "artifact" && key !== "files")) throw new Error("artifact bundle manifest is malformed or from an unsupported future schema"); + const artifact = value["artifact"] as unknown as ArtifactRecordV2; + validateArtifactManifestV2({ schemaVersion: 2, artifacts: { [artifact.id]: artifact }, slugIndex: { [artifact.slug]: artifact.id } }); + const files: ArtifactBundleFile[] = []; + const paths = new Set(); + for (const entry of value["files"]) { + if (!isRecord(entry) || Object.keys(entry).some((key) => key !== "path" && key !== "bytes" && key !== "contentHash") || typeof entry["path"] !== "string" || typeof entry["bytes"] !== "number" || !Number.isSafeInteger(entry["bytes"]) || entry["bytes"] < 0 || typeof entry["contentHash"] !== "string" || !CONTENT_HASH_RE.test(entry["contentHash"]) || paths.has(entry["path"])) throw new Error("artifact bundle file inventory is malformed"); + bundleSegments(entry["path"]); + paths.add(entry["path"]); + files.push({ path: entry["path"], bytes: entry["bytes"], contentHash: entry["contentHash"] }); + } + if (files.length > 20_100) throw new Error("artifact bundle exceeds the file-count limit"); + return { schemaVersion: 1, artifact, files }; +} + +export class ArtifactLifecycleStore { + private readonly root: string; + private readonly artifactIdFactory: () => string; + + constructor(root: string, options: { artifactIdFactory?: () => string } = {}) { + this.root = resolve(root); + this.artifactIdFactory = options.artifactIdFactory ?? randomUUID; + } + + async list(): Promise { + await recoverFileTransactions(this.root); + const manifest = await readManifestLocked(this.root); + const artifacts = Object.values(manifest.artifacts); + if (artifacts.length > MAX_LIST_RESULTS) throw new Error(`artifact list exceeds bounded result limit ${MAX_LIST_RESULTS}`); + return artifacts.sort((left, right) => left.slug.localeCompare(right.slug)).map((artifact) => statusFor(this.root, artifact, true)); + } + + async status(reference: string): Promise { + await recoverFileTransactions(this.root); + const manifest = await readManifestLocked(this.root); + try { + return statusFor(this.root, await resolveActiveLocked(this.root, manifest, reference), true); + } catch (error) { + if (!ARTIFACT_ID_RE.test(reference)) throw error; + const archived = await this.readArchive(reference); + if (!archived) throw error; + return statusFor(this.root, archived.artifact, false); + } + } + + async read(reference: string, revision?: number): Promise<{ status: ArtifactLifecycleStatus; revision: RevisionRecordV2; html: string }> { + await recoverFileTransactions(this.root); + const manifest = await readManifestLocked(this.root); + const artifact = await resolveActiveLocked(this.root, manifest, reference); + const selected = revision === undefined ? artifact.revisions.at(-1) : artifact.revisions.find((entry) => entry.revision === revision); + if (!selected) throw new Error(`artifact ${artifact.id} has no revision ${String(revision)}`); + return { status: statusFor(this.root, artifact, true), revision: selected, html: await verifiedRevisionBytes(this.root, artifact, selected) }; + } + + async write(input: LifecycleWriteInput): Promise { + const now = validateWriteInput(input); + return runFileTransaction(this.root, async (transaction) => { + const manifest = await readManifestLocked(this.root); + const existing = input.artifact === undefined ? undefined : await resolveActiveLocked(this.root, manifest, input.artifact); + if (existing === undefined && manifest.slugIndex[input.slug]) { + const collision = manifest.artifacts[manifest.slugIndex[input.slug]]; + throw new ArtifactLifecycleConflictError("create refused because the slug already belongs to an artifact; update requires its exact reference and expected head", statusFor(this.root, collision, true), await mergePayload(this.root, collision)); + } + if (existing !== undefined && input.expectedRevision === undefined && input.expectedHash === undefined) { + throw new ArtifactLifecycleConflictError("update requires expectedRevision or expectedHash; the current artifact remains unchanged", statusFor(this.root, existing, true), await mergePayload(this.root, existing)); + } + if (existing !== undefined && !expectedMatches(existing, input.expectedRevision, input.expectedHash)) { + throw new ArtifactLifecycleConflictError("update precondition is stale; the current artifact remains unchanged", statusFor(this.root, existing, true), await mergePayload(this.root, existing)); + } + const occupied = manifest.slugIndex[input.slug]; + if (existing && occupied && occupied !== existing.id) throw new Error(`slug ${input.slug} belongs to another artifact`); + const id = existing?.id ?? this.artifactIdFactory(); + if (!ARTIFACT_ID_RE.test(id) || (!existing && manifest.artifacts[id])) throw new Error("artifact ID factory returned an invalid or duplicate UUID"); + const revisionNumber = (existing?.headRevision ?? 0) + 1; + const title = input.title ?? existing?.title ?? input.slug; + const icon = input.icon ?? existing?.icon ?? "📄"; + const description = input.description ?? existing?.description; + const source = input.source ?? existing?.source; + const author = input.author ?? existing?.author; + const charts = input.charts ?? existing?.charts ?? 0; + const meta: ArtifactMeta = { + slug: input.slug, + title, + icon, + description, + source, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + current: revisionNumber, + versions: Array.from({ length: revisionNumber }, (_, index) => index + 1), + charts, + bytes: Buffer.byteLength(input.html, "utf8"), + hash: "", + }; + const html = applyArtifactFooter(input.html, meta); + const bytes = Buffer.byteLength(html, "utf8"); + if (bytes > DEFAULT_MAX_BYTES) throw new ArtifactTooLargeError(bytes, DEFAULT_MAX_BYTES); + const contentHash = fullContentHash(html); + let authoringSource: AuthoringSourceReferenceV2 | undefined; + if (input.authoringSource !== undefined && input.inputFormat !== undefined) { + authoringSource = { + format: input.inputFormat, + path: `.sources/${id}/${revisionNumber}.${input.inputFormat}.txt`, + bytes: Buffer.byteLength(input.authoringSource, "utf8"), + contentHash: sha256(input.authoringSource), + }; + } + const revision: RevisionRecordV2 = { + revision: revisionNumber, + createdAt: now, + bytes, + contentHash, + pagePath: `revisions/${id}/${revisionNumber}.html`, + title, + icon, + description, + source, + author, + charts, + provenance: { kind: existing ? "update" : "create", timestampSource: "recorded" }, + authoringSource, + }; + const record: ArtifactRecordV2 = { + id, + slug: input.slug, + title, + icon, + description, + source, + author, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + headRevision: revisionNumber, + revisions: [...(existing?.revisions ?? []), revision], + charts, + bytes, + contentHash, + deploymentReferences: existing?.deploymentReferences ?? [], + }; + if (existing && existing.slug !== input.slug) delete manifest.slugIndex[existing.slug]; + manifest.artifacts[id] = record; + manifest.slugIndex[input.slug] = id; + validateArtifactManifestV2(manifest); + const files = new Map([ + [`${input.slug}.html`, html], + [`${input.slug}.v${revisionNumber}.html`, html], + [revision.pagePath, html], + [ARTIFACT_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`], + ["index.html", renderGallery(galleryManifest(manifest))], + ]); + if (authoringSource && input.authoringSource !== undefined) files.set(authoringSource.path, input.authoringSource); + if (existing && existing.slug !== input.slug) files.set(`${existing.slug}.html`, null); + await transaction.commit(files); + return statusFor(this.root, record, true); + }); + } + + async restore(reference: string, revisionNumber: number, expectedRevision: number): Promise { + if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1 || !Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw new Error("restore revisions must be positive integers"); + return runFileTransaction(this.root, async (transaction) => { + const manifest = await readManifestLocked(this.root); + const artifact = await resolveActiveLocked(this.root, manifest, reference); + if (artifact.headRevision !== expectedRevision) throw new ArtifactLifecycleConflictError("restore precondition is stale; history remains unchanged", statusFor(this.root, artifact, true), await mergePayload(this.root, artifact)); + const selected = artifact.revisions.find((revision) => revision.revision === revisionNumber); + if (!selected) throw new Error(`artifact ${artifact.id} has no revision ${revisionNumber}`); + const sourceHtml = await verifiedRevisionBytes(this.root, artifact, selected); + const now = new Date().toISOString(); + const next = artifact.headRevision + 1; + const meta: ArtifactMeta = { + slug: artifact.slug, + title: selected.title, + icon: selected.icon, + description: selected.description, + source: selected.source, + createdAt: artifact.createdAt ?? now, + updatedAt: now, + current: next, + versions: Array.from({ length: next }, (_, index) => index + 1), + charts: selected.charts, + bytes: selected.bytes, + hash: "", + }; + const html = applyArtifactFooter(sourceHtml, meta); + const bytes = Buffer.byteLength(html, "utf8"); + const contentHash = sha256(html); + let sourceValue: string | undefined; + let authoringSource: AuthoringSourceReferenceV2 | undefined; + if (selected.authoringSource) { + sourceValue = await readFile(join(this.root, ...selected.authoringSource.path.split("/")), "utf8"); + if (Buffer.byteLength(sourceValue, "utf8") !== selected.authoringSource.bytes || sha256(sourceValue) !== selected.authoringSource.contentHash) throw new Error("restored authoring source failed integrity verification"); + authoringSource = { ...selected.authoringSource, path: `.sources/${artifact.id}/${next}.${selected.authoringSource.format}.txt` }; + } + const revision: RevisionRecordV2 = { + ...selected, + revision: next, + createdAt: now, + bytes, + contentHash, + pagePath: `revisions/${artifact.id}/${next}.html`, + provenance: { kind: "restore", restoredFrom: revisionNumber, timestampSource: "recorded" }, + authoringSource, + }; + const updated: ArtifactRecordV2 = { + ...artifact, + title: revision.title, + icon: revision.icon, + description: revision.description, + source: revision.source, + author: revision.author, + updatedAt: now, + headRevision: next, + revisions: [...artifact.revisions, revision], + charts: revision.charts, + bytes, + contentHash, + }; + manifest.artifacts[artifact.id] = updated; + validateArtifactManifestV2(manifest); + const files = new Map([ + [`${artifact.slug}.html`, html], + [`${artifact.slug}.v${next}.html`, html], + [revision.pagePath, html], + [ARTIFACT_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`], + ["index.html", renderGallery(galleryManifest(manifest))], + ]); + if (authoringSource && sourceValue !== undefined) files.set(authoringSource.path, sourceValue); + await transaction.commit(files); + return statusFor(this.root, updated, true); + }); + } + + async recordDeployment( + reference: string, + deployment: { capability: "public-static" | "authenticated" | "connector-capable"; target: string; url: string; createdAt?: string }, + ): Promise { + const createdAt = deployment.createdAt ?? new Date().toISOString(); + if (!validTimestamp(createdAt) || deployment.target.length === 0 || deployment.target.length > 16_384) throw new Error("deployment reference metadata is invalid"); + const url = new URL(deployment.url); + if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("deployment URL must use http or https"); + return runFileTransaction(this.root, async (transaction) => { + const manifest = await readManifestLocked(this.root); + const artifact = await resolveActiveLocked(this.root, manifest, reference); + const entry = { capability: deployment.capability, target: deployment.target, url: url.href, revision: artifact.headRevision, createdAt }; + const references = artifact.deploymentReferences.filter((existing) => existing.target !== entry.target || existing.url !== entry.url); + const updated: ArtifactRecordV2 = { ...artifact, deploymentReferences: [...references, entry] }; + manifest.artifacts[artifact.id] = updated; + validateArtifactManifestV2(manifest); + await transaction.commit(new Map([ + [ARTIFACT_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`], + ["index.html", renderGallery(galleryManifest(manifest))], + ])); + return statusFor(this.root, updated, true); + }); + } + + async previewArchive(reference: string, options: { now?: string; token?: string } = {}): Promise { + const now = options.now ?? new Date().toISOString(); + const token = options.token ?? randomUUID(); + if (!validTimestamp(now) || !ARTIFACT_ID_RE.test(token)) throw new Error("archive preview time/token is invalid"); + return runFileTransaction(this.root, async (transaction) => { + const manifest = await readManifestLocked(this.root); + const artifact = await resolveActiveLocked(this.root, manifest, reference); + const stateStoreCount = await this.stateStoreCount(artifact.id); + const scope = { id: artifact.id, slug: artifact.slug, headRevision: artifact.headRevision, contentHash: artifact.contentHash, stateStoreCount, deploymentReferences: artifact.deploymentReferences }; + const scopeHash = sha256(JSON.stringify(scope)); + const preview: StoredArchivePreview = { + schemaVersion: 1, + token, + createdAt: now, + expiresAt: new Date(Date.parse(now) + ARCHIVE_TOKEN_TTL_MS).toISOString(), + scopeHash, + artifact: statusFor(this.root, artifact, true), + revisionCount: artifact.revisions.length, + stateStoreCount, + retainedBytes: artifact.revisions.reduce((sum, revision) => sum + revision.bytes + (revision.authoringSource?.bytes ?? 0), 0), + externalCopiesDeleted: false, + recovery: "unarchive-by-id", + }; + if (await optionalRegularFile(join(this.root, ".archive-previews", `${token}.json`), 1024 * 1024)) throw new Error("archive preview token already exists; generate a new token"); + await transaction.commit(new Map([[`.archive-previews/${token}.json`, `${JSON.stringify(preview, null, 2)}\n`]])); + const { createdAt: _createdAt, ...result } = preview; + return result; + }); + } + + async archive(token: string, options: { now?: string } = {}): Promise { + if (!ARTIFACT_ID_RE.test(token)) throw new Error("archive confirmation token is invalid"); + const now = options.now ?? new Date().toISOString(); + if (!validTimestamp(now)) throw new Error("archive timestamp is invalid"); + return runFileTransaction(this.root, async (transaction) => { + const previewPath = `.archive-previews/${token}.json`; + const preview = this.parseArchivePreview(JSON.parse(await readFile(join(this.root, ...previewPath.split("/")), "utf8")) as unknown); + if (Date.parse(now) > Date.parse(preview.expiresAt)) throw new Error("archive confirmation expired; artifact remains active and requires a new preview"); + const manifest = await readManifestLocked(this.root); + const artifact = manifest.artifacts[preview.artifact.id]; + if (!artifact) throw new Error("archive target is no longer active"); + const stateStoreCount = await this.stateStoreCount(artifact.id); + const scopeHash = sha256(JSON.stringify({ id: artifact.id, slug: artifact.slug, headRevision: artifact.headRevision, contentHash: artifact.contentHash, stateStoreCount, deploymentReferences: artifact.deploymentReferences })); + if (scopeHash !== preview.scopeHash) throw new Error("archive scope changed after preview; artifact remains active and requires a new preview"); + if (await this.readArchive(artifact.id)) throw new Error("archive record already exists for this artifact; artifact remains active pending repair"); + const archived: ArchivedArtifactRecord = { schemaVersion: 1, archivedAt: now, previewScopeHash: scopeHash, artifact }; + delete manifest.artifacts[artifact.id]; + delete manifest.slugIndex[artifact.slug]; + validateArtifactManifestV2(manifest); + await transaction.commit(new Map([ + [`.archives/${artifact.id}/record.json`, `${JSON.stringify(archived, null, 2)}\n`], + [ARTIFACT_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`], + ["index.html", renderGallery(galleryManifest(manifest))], + [`${artifact.slug}.html`, null], + [previewPath, null], + ])); + return statusFor(this.root, artifact, false); + }); + } + + async inspectArchivePreview(token: string): Promise { + if (!ARTIFACT_ID_RE.test(token)) throw new Error("archive confirmation token is invalid"); + await recoverFileTransactions(this.root); + const preview = this.parseArchivePreview(JSON.parse(await readFile(join(this.root, ".archive-previews", `${token}.json`), "utf8")) as unknown); + const { createdAt: _createdAt, ...result } = preview; + return result; + } + + async unarchive(artifactId: string, slug?: string): Promise { + if (!ARTIFACT_ID_RE.test(artifactId)) throw new Error("unarchive requires an opaque artifact ID"); + return runFileTransaction(this.root, async (transaction) => { + const archived = await this.readArchive(artifactId); + if (!archived) throw new Error(`artifact ${artifactId} is not archived`); + const manifest = await readManifestLocked(this.root); + if (manifest.artifacts[artifactId]) throw new Error(`artifact ${artifactId} is already active`); + const selectedSlug = slug ?? archived.artifact.slug; + if (!ARTIFACT_SLUG_RE.test(selectedSlug)) throw new Error("unarchive slug is invalid"); + if (manifest.slugIndex[selectedSlug]) throw new Error(`unarchive slug ${selectedSlug} belongs to another active artifact; supply a different explicit slug`); + const artifact: ArtifactRecordV2 = { ...archived.artifact, slug: selectedSlug }; + const head = artifact.revisions.at(-1); + if (!head) throw new Error("archived artifact has no head revision"); + const html = await verifiedRevisionBytes(this.root, artifact, head); + manifest.artifacts[artifact.id] = artifact; + manifest.slugIndex[selectedSlug] = artifact.id; + validateArtifactManifestV2(manifest); + await transaction.commit(new Map([ + [`${selectedSlug}.html`, html], + [ARTIFACT_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`], + ["index.html", renderGallery(galleryManifest(manifest))], + [`.archives/${artifact.id}/record.json`, null], + ])); + return statusFor(this.root, artifact, true); + }); + } + + async exportBundle(reference: string, destination: string): Promise<{ path: string; files: number; bytes: number; artifact: ArtifactLifecycleStatus }> { + await recoverFileTransactions(this.root); + const manifest = await readManifestLocked(this.root); + const artifact = await resolveActiveLocked(this.root, manifest, reference); + const destinationPath = resolve(destination); + if (destinationPath === this.root || destinationPath.startsWith(`${this.root}${sep}`)) throw new Error("export destination must be outside the artifact store so internal bundle data cannot enter public staging"); + if (await lstat(destinationPath).then(() => true, (error: unknown) => errnoCode(error) === "ENOENT" ? false : Promise.reject(error))) throw new Error("export destination already exists"); + const stage = `${destinationPath}.stage-${randomUUID()}`; + const files = new Map(); + for (const revision of artifact.revisions) { + const html = Buffer.from(await verifiedRevisionBytes(this.root, artifact, revision), "utf8"); + files.set(`pages/${revision.revision}.html`, html); + if (revision.authoringSource) { + const source = await optionalRegularFile(join(this.root, ...revision.authoringSource.path.split("/")), MAX_SOURCE_BYTES); + if (!source || source.byteLength !== revision.authoringSource.bytes || sha256(source) !== revision.authoringSource.contentHash) throw new Error(`authoring source for revision ${revision.revision} failed export verification`); + files.set(`sources/${revision.revision}.${revision.authoringSource.format}.txt`, source); + } + } + for (const [kind, bundlePath] of [["decisions", "state/decisions.json"], ["comments", "state/comments.json"]] as const) { + const path = join(this.root, ".state", "v2", artifact.id, `${kind}.json`); + const value = await optionalRegularFile(path); + if (value) { + validateArtifactStateEnvelope(JSON.parse(Buffer.from(value).toString("utf8")) as unknown, { artifactId: artifact.id, kind, key: "default" }); + files.set(bundlePath, value); + } + } + const collectionRoot = join(this.root, ".db", "v2", artifact.id); + const collections = await readdir(collectionRoot, { withFileTypes: true }).catch((error: unknown) => errnoCode(error) === "ENOENT" ? [] : Promise.reject(error)); + for (const entry of collections.sort((left, right) => left.name.localeCompare(right.name))) { + const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/.exec(entry.name); + if (!entry.isFile() || !match) throw new Error(`unsupported collection entry ${entry.name}`); + const value = await optionalRegularFile(join(collectionRoot, entry.name)); + if (!value) throw new Error(`collection disappeared during export: ${entry.name}`); + validateArtifactStateEnvelope(JSON.parse(Buffer.from(value).toString("utf8")) as unknown, { artifactId: artifact.id, kind: "collection", key: match[1] }); + files.set(`collections/${entry.name}`, value); + } + const inventory: ArtifactBundleManifest = { + schemaVersion: 1, + artifact, + files: [...files.entries()].map(([path, bytes]) => ({ path, bytes: bytes.byteLength, contentHash: sha256(bytes) })).sort((left, right) => left.path.localeCompare(right.path)), + }; + try { + for (const [path, bytes] of files) { + const target = join(stage, ...bundleSegments(path)); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, bytes, { flag: "wx" }); + } + await mkdir(stage, { recursive: true }); + await writeFile(join(stage, "bundle.json"), `${JSON.stringify(inventory, null, 2)}\n`, { flag: "wx" }); + for (const entry of inventory.files) { + const bytes = await optionalRegularFile(join(stage, ...bundleSegments(entry.path))); + if (!bytes || bytes.byteLength !== entry.bytes || sha256(bytes) !== entry.contentHash) throw new Error(`export verification failed for ${entry.path}`); + } + await mkdir(dirname(destinationPath), { recursive: true }); + await rename(stage, destinationPath); + } catch (error) { + await rm(stage, { recursive: true, force: true }); + throw error; + } + return { path: destinationPath, files: inventory.files.length, bytes: inventory.files.reduce((sum, entry) => sum + entry.bytes, 0), artifact: statusFor(this.root, artifact, true) }; + } + + async importBundle(bundleDirectory: string): Promise { + const bundleRoot = resolve(bundleDirectory); + const info = await lstat(bundleRoot); + if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("bundle path must be a real directory"); + const manifestBytes = await optionalRegularFile(join(bundleRoot, "bundle.json"), 4 * 1024 * 1024); + if (!manifestBytes) throw new Error("bundle.json is missing"); + const bundle = parseBundleManifest(JSON.parse(Buffer.from(manifestBytes).toString("utf8")) as unknown); + const actualFiles = await inventoryBundleFiles(bundleRoot); + const inventoriedFiles = ["bundle.json", ...bundle.files.map((entry) => entry.path)].sort(); + if (actualFiles.length !== inventoriedFiles.length || actualFiles.some((path, index) => path !== inventoriedFiles[index])) throw new Error("bundle contains missing or unlisted files"); + const contents = new Map(); + let totalBytes = 0; + for (const entry of bundle.files) { + const bytes = await optionalRegularFile(join(bundleRoot, ...bundleSegments(entry.path)), Math.max(entry.bytes, 1)); + if (!bytes || bytes.byteLength !== entry.bytes || sha256(bytes) !== entry.contentHash) throw new Error(`bundle verification failed for ${entry.path}`); + totalBytes += bytes.byteLength; + if (totalBytes > 512 * 1024 * 1024) throw new Error("bundle exceeds the 512 MiB import limit"); + contents.set(entry.path, bytes); + } + const expectedPaths = new Set(); + for (const revision of bundle.artifact.revisions) { + const pagePath = `pages/${revision.revision}.html`; + const page = contents.get(pagePath); + if (!page || page.byteLength !== revision.bytes || sha256(page) !== revision.contentHash) throw new Error(`bundle revision ${revision.revision} is absent or corrupt`); + expectedPaths.add(pagePath); + if (revision.authoringSource) { + const sourcePath = `sources/${revision.revision}.${revision.authoringSource.format}.txt`; + const source = contents.get(sourcePath); + if (!source || source.byteLength !== revision.authoringSource.bytes || sha256(source) !== revision.authoringSource.contentHash) throw new Error(`bundle source ${revision.revision} is absent or corrupt`); + expectedPaths.add(sourcePath); + } + } + for (const path of contents.keys()) { + if (path.startsWith("state/") || path.startsWith("collections/")) continue; + if (!expectedPaths.has(path)) throw new Error(`bundle contains unsupported content ${path}`); + } + const targetFiles = new Map(); + for (const revision of bundle.artifact.revisions) { + const page = contents.get(`pages/${revision.revision}.html`); + if (!page) throw new Error("verified bundle page disappeared"); + targetFiles.set(revision.pagePath, page); + targetFiles.set(`${bundle.artifact.slug}.v${revision.revision}.html`, page); + if (revision.authoringSource) { + const source = contents.get(`sources/${revision.revision}.${revision.authoringSource.format}.txt`); + if (!source) throw new Error("verified bundle source disappeared"); + targetFiles.set(revision.authoringSource.path, source); + } + } + for (const [path, bytes] of contents) { + let target: string | undefined; + if (path === "state/decisions.json") target = `.state/v2/${bundle.artifact.id}/decisions.json`; + else if (path === "state/comments.json") target = `.state/v2/${bundle.artifact.id}/comments.json`; + else { + const collection = /^collections\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/.exec(path); + if (collection) target = `.db/v2/${bundle.artifact.id}/${collection[1]}.json`; + } + if (target) targetFiles.set(target, bytes); + } + for (const [target, bytes] of targetFiles) { + if (target.startsWith(".state/") || target.startsWith(".db/")) { + const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as unknown; + const collection = /^\.db\/v2\/[^/]+\/([^.]+)\.json$/.exec(target); + validateArtifactStateEnvelope(parsed, { artifactId: bundle.artifact.id, kind: collection ? "collection" : target.endsWith("comments.json") ? "comments" : "decisions", key: collection?.[1] ?? "default" }); + } + } + const initialManifest = await readManifestLocked(this.root); + if (initialManifest.artifacts[bundle.artifact.id] || initialManifest.slugIndex[bundle.artifact.slug]) throw new Error("bundle identity or slug collides with existing storage; only reject collision policy is supported"); + const entries = [...targetFiles.entries()]; + for (let offset = 0; offset < entries.length; offset += 24) { + const batch = entries.slice(offset, offset + 24); + await runFileTransaction(this.root, async (transaction) => { + const current = await readManifestLocked(this.root); + if (current.artifacts[bundle.artifact.id] || current.slugIndex[bundle.artifact.slug]) throw new Error("bundle identity or slug collided during import"); + const writes = new Map(); + for (const [target, bytes] of batch) { + const existing = await optionalRegularFile(join(this.root, ...target.split("/")), Math.max(bytes.byteLength, 1)); + if (existing) { + if (sha256(existing) !== sha256(bytes)) throw new Error(`import target conflicts: ${target}`); + } else { + writes.set(target, bytes); + } + } + if (writes.size > 0) await transaction.commit(writes); + }); + } + return runFileTransaction(this.root, async (transaction) => { + const manifest = await readManifestLocked(this.root); + if (manifest.artifacts[bundle.artifact.id] || manifest.slugIndex[bundle.artifact.slug]) throw new Error("bundle identity or slug collided before selection"); + for (const [target, bytes] of targetFiles) { + const selected = await optionalRegularFile(join(this.root, ...target.split("/")), Math.max(bytes.byteLength, 1)); + if (!selected || sha256(selected) !== sha256(bytes)) throw new Error(`prepared import target failed verification: ${target}`); + } + manifest.artifacts[bundle.artifact.id] = bundle.artifact; + manifest.slugIndex[bundle.artifact.slug] = bundle.artifact.id; + validateArtifactManifestV2(manifest); + const head = contents.get(`pages/${bundle.artifact.headRevision}.html`); + if (!head) throw new Error("bundle head page is absent"); + await transaction.commit(new Map([ + [`${bundle.artifact.slug}.html`, head], + [ARTIFACT_MANIFEST_FILE, `${JSON.stringify(manifest, null, 2)}\n`], + ["index.html", renderGallery(galleryManifest(manifest))], + ])); + return statusFor(this.root, bundle.artifact, true); + }); + } + + private async stateStoreCount(artifactId: string): Promise { + let count = 0; + for (const relativeRoot of [`.state/v2/${artifactId}`, `.db/v2/${artifactId}`]) { + const entries = await readdir(join(this.root, ...relativeRoot.split("/")), { withFileTypes: true }).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return []; + throw error; + }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) throw new Error(`artifact state archive scope contains unsupported entry ${relativeRoot}/${entry.name}`); + count++; + } + } + return count; + } + + private parseArchivePreview(value: unknown): StoredArchivePreview { + const keys = new Set(["schemaVersion", "token", "createdAt", "expiresAt", "scopeHash", "artifact", "revisionCount", "stateStoreCount", "retainedBytes", "externalCopiesDeleted", "recovery"]); + if (!isRecord(value) || Object.keys(value).some((key) => !keys.has(key)) || value["schemaVersion"] !== 1 || typeof value["token"] !== "string" || !ARTIFACT_ID_RE.test(value["token"]) || typeof value["createdAt"] !== "string" || !validTimestamp(value["createdAt"]) || typeof value["expiresAt"] !== "string" || !validTimestamp(value["expiresAt"]) || typeof value["scopeHash"] !== "string" || !CONTENT_HASH_RE.test(value["scopeHash"]) || !isRecord(value["artifact"]) || value["artifact"]["schemaVersion"] !== 1 || typeof value["artifact"]["id"] !== "string" || !ARTIFACT_ID_RE.test(value["artifact"]["id"]) || value["artifact"]["active"] !== true || typeof value["artifact"]["slug"] !== "string" || !ARTIFACT_SLUG_RE.test(value["artifact"]["slug"]) || typeof value["revisionCount"] !== "number" || !Number.isSafeInteger(value["revisionCount"]) || value["revisionCount"] < 1 || typeof value["stateStoreCount"] !== "number" || !Number.isSafeInteger(value["stateStoreCount"]) || value["stateStoreCount"] < 0 || typeof value["retainedBytes"] !== "number" || !Number.isSafeInteger(value["retainedBytes"]) || value["retainedBytes"] < 0 || value["externalCopiesDeleted"] !== false || value["recovery"] !== "unarchive-by-id") throw new Error("archive preview record is malformed"); + return value as unknown as StoredArchivePreview; + } + + private async readArchive(artifactId: string): Promise { + const path = join(this.root, ".archives", artifactId, "record.json"); + let value: unknown; + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) throw new Error("archive record path is unsafe"); + value = JSON.parse(await readFile(path, "utf8")) as unknown; + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } + if (!isRecord(value) || Object.keys(value).some((key) => key !== "schemaVersion" && key !== "archivedAt" && key !== "previewScopeHash" && key !== "artifact") || value["schemaVersion"] !== 1 || typeof value["archivedAt"] !== "string" || !validTimestamp(value["archivedAt"]) || typeof value["previewScopeHash"] !== "string" || !CONTENT_HASH_RE.test(value["previewScopeHash"]) || !isRecord(value["artifact"])) throw new Error("archive record is malformed"); + const artifact = value["artifact"] as unknown as ArtifactRecordV2; + validateArtifactManifestV2({ schemaVersion: 2, artifacts: { [artifactId]: artifact }, slugIndex: { [artifact.slug]: artifactId } }); + return value as unknown as ArchivedArtifactRecord; + } +} diff --git a/src/artifact-migration.ts b/src/artifact-migration.ts new file mode 100644 index 0000000..844c8bb --- /dev/null +++ b/src/artifact-migration.ts @@ -0,0 +1,867 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { + ARTIFACT_ID_RE, + ARTIFACT_MANIFEST_FILE, + ARTIFACT_MANIFEST_SCHEMA_VERSION, + ARTIFACT_SLUG_RE, + emptyArtifactManifestV2, + readArtifactManifestV2, + validateArtifactManifestV2, + type ArtifactManifestV2, + type ArtifactRecordV2, + type RevisionRecordV2, +} from "./artifact-schema.ts"; +import { recoverFileTransactions, runFileTransaction } from "./file-transaction.ts"; +import { renderGallery } from "./gallery.ts"; +import { DEFAULT_MAX_BYTES } from "./render.ts"; +import type { ArtifactMeta, Manifest } from "./publisher.ts"; + +const MIGRATION_PLAN_SCHEMA_VERSION = 1; +const MAX_LEGACY_ARTIFACTS = 10_000; +const MAX_LEGACY_FILES = 50_000; +const COPY_BATCH_TARGETS = 32; +const UUID_RE = ARTIFACT_ID_RE; + +export type MigrationIssueSeverity = "warning" | "error"; + +export interface ArtifactMigrationIssue { + severity: MigrationIssueSeverity; + code: string; + artifact?: string; + path?: string; + detail: string; +} + +export interface ArtifactMigrationCopy { + sourcePath: string; + targetPath: string; + contentHash: string; + bytes: number; + purpose: "backup" | "revision"; +} + +export interface LegacyStateAssociation { + path: string; + artifactId: string; + kind: "decisions" | "comments" | "collection" | "datasource"; +} + +export interface ArtifactMigrationPlan { + schemaVersion: 1; + migrationId: string; + createdAt: string; + sourceManifestExisted: boolean; + sourceManifestHash: string | null; + alreadyCurrent: boolean; + canMigrate: boolean; + manifest: ArtifactManifestV2 | null; + copies: ArtifactMigrationCopy[]; + stateAssociations: LegacyStateAssociation[]; + issues: ArtifactMigrationIssue[]; +} + +export interface ArtifactMigrationOptions { + artifactIdFactory?: () => string; + migrationId?: string; + now?: string; +} + +export interface ArtifactMigrationResult { + migrationId: string; + status: "already-current" | "migrated" | "rolled-back"; + manifestHash: string | null; + issues: ArtifactMigrationIssue[]; +} + +interface MigrationInventory { + schemaVersion: 1; + migrationId: string; + sourceManifestExisted: boolean; + sourceManifestHash: string | null; + selectedManifestHash: string; + originalIndexExisted: boolean; + originalIndexHash: string | null; + copies: ArtifactMigrationCopy[]; +} + +interface LegacyPage { + sourcePath: string; + legacyRevision?: number; + bytes: number; + contentHash: string; + content: Uint8Array; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errnoCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function sha256(value: Uint8Array | string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function validTimestamp(value: unknown): string | null { + if ( + typeof value === "string" && + /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && + !Number.isNaN(Date.parse(value)) + ) { + return value; + } + return null; +} + +function legacyString(value: unknown): string | undefined { + return typeof value === "string" && value.length <= 16_384 ? value : undefined; +} + +function issue( + issues: ArtifactMigrationIssue[], + severity: MigrationIssueSeverity, + code: string, + detail: string, + fields: { artifact?: string; path?: string } = {}, +): void { + issues.push({ severity, code, detail, ...fields }); +} + +async function readOptional(path: string): Promise { + try { + return await readFile(path); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } +} + +async function readBoundedSource( + dir: string, + relativePath: string, + issues: ArtifactMigrationIssue[], + artifact?: string, +): Promise { + const path = join(dir, ...relativePath.split("/")); + let info; + try { + info = await lstat(path); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } + if (info.isSymbolicLink() || !info.isFile()) { + issue(issues, "error", "unsafe-legacy-file", "legacy path is not a regular contained file", { + artifact, + path: relativePath, + }); + return undefined; + } + if (info.size > DEFAULT_MAX_BYTES) { + issue( + issues, + "error", + "oversized-legacy-file", + `legacy page has ${info.size} bytes; limit is ${DEFAULT_MAX_BYTES}`, + { artifact, path: relativePath }, + ); + return undefined; + } + const content = await readFile(path); + return { + sourcePath: relativePath, + bytes: content.byteLength, + contentHash: sha256(content), + content, + }; +} + +function legacyVersions(value: unknown): number[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set( + value.filter( + (entry): entry is number => + typeof entry === "number" && Number.isSafeInteger(entry) && entry > 0, + ), + ), + ].sort((left, right) => left - right); +} + +function revisionTimestamp( + index: number, + total: number, + createdAt: string | null, + updatedAt: string | null, +): { value: string | null; source: "legacy-artifact" | "unknown" } { + if (index === total - 1 && updatedAt !== null) return { value: updatedAt, source: "legacy-artifact" }; + if (index === 0 && createdAt !== null) return { value: createdAt, source: "legacy-artifact" }; + return { value: null, source: "unknown" }; +} + +function asLegacyManifest(manifest: ArtifactManifestV2): Manifest { + const artifacts: Record = {}; + for (const artifact of Object.values(manifest.artifacts)) { + artifacts[artifact.slug] = { + slug: artifact.slug, + title: artifact.title, + icon: artifact.icon, + description: artifact.description, + source: artifact.source, + createdAt: artifact.createdAt ?? "unknown", + updatedAt: artifact.updatedAt ?? "unknown", + current: artifact.headRevision, + versions: artifact.revisions.map((revision) => revision.revision), + charts: artifact.charts, + bytes: artifact.bytes, + hash: artifact.contentHash.slice(0, 12), + }; + } + return { artifacts }; +} + +async function walkRegularFiles( + dir: string, + relativeRoot: string, + issues: ArtifactMigrationIssue[], +): Promise { + const root = join(dir, ...relativeRoot.split("/")); + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (errnoCode(error) === "ENOENT") return []; + throw error; + } + const found: string[] = []; + const pending = entries.map((entry) => `${relativeRoot}/${entry.name}`); + while (pending.length > 0) { + const relativePath = pending.shift(); + if (relativePath === undefined) break; + if (found.length + pending.length > MAX_LEGACY_FILES) { + issue(issues, "error", "legacy-file-limit", `legacy file inventory exceeds ${MAX_LEGACY_FILES}`); + break; + } + const info = await lstat(join(dir, ...relativePath.split("/"))); + if (info.isSymbolicLink()) { + issue(issues, "error", "unsafe-legacy-file", "legacy state contains a symbolic link", { + path: relativePath, + }); + } else if (info.isDirectory()) { + const children = await readdir(join(dir, ...relativePath.split("/")), { withFileTypes: true }); + for (const child of children) pending.push(`${relativePath}/${child.name}`); + } else if (info.isFile()) { + found.push(relativePath); + } else { + issue(issues, "error", "unsafe-legacy-file", "legacy state contains an unsupported file type", { + path: relativePath, + }); + } + } + return found.sort(); +} + +function associationFor( + path: string, + slugIndex: Record, +): LegacyStateAssociation | undefined { + const stateMatch = /^\.state\/([a-z0-9]+(?:-[a-z0-9]+)*)\.(comments\.)?json$/.exec(path); + if (stateMatch) { + const artifactId = slugIndex[stateMatch[1]]; + if (!artifactId) return undefined; + return { + path, + artifactId, + kind: stateMatch[2] === undefined ? "decisions" : "comments", + }; + } + const dbMatch = /^\.db\/([a-z0-9]+(?:-[a-z0-9]+)*)\/[^/]+\.json$/.exec(path); + if (dbMatch) { + const artifactId = slugIndex[dbMatch[1]]; + return artifactId ? { path, artifactId, kind: "collection" } : undefined; + } + const datasourceMatch = /^\.datasources\/([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/.exec(path); + if (datasourceMatch) { + const artifactId = slugIndex[datasourceMatch[1]]; + return artifactId ? { path, artifactId, kind: "datasource" } : undefined; + } + return undefined; +} + +export function mapLegacyCloudflareKey( + key: string, + siteId: string, + slugIndex: Record, +): + | { siteId: string; artifactId: string; kind: "decisions" | "comments" | "collection"; collection?: string } + | { issue: ArtifactMigrationIssue } { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(siteId)) { + return { + issue: { + severity: "error", + code: "invalid-site-id", + detail: "site identity is not a safe canonical identifier", + }, + }; + } + const state = /^(state|comments):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(key); + if (state) { + const artifactId = slugIndex[state[2]]; + if (artifactId) { + return { + siteId, + artifactId, + kind: state[1] === "state" ? "decisions" : "comments", + }; + } + } + const collection = /^db:([a-z0-9]+(?:-[a-z0-9]+)*):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(key); + if (collection) { + const artifactId = slugIndex[collection[1]]; + if (artifactId) { + return { siteId, artifactId, kind: "collection", collection: collection[2] }; + } + } + return { + issue: { + severity: "error", + code: "ambiguous-cloudflare-key", + path: key, + detail: "historical shared-KV key cannot be mapped to one known artifact", + }, + }; +} + +export async function planArtifactMigration( + dir: string, + options: ArtifactMigrationOptions = {}, +): Promise { + await recoverFileTransactions(dir); + const migrationId = options.migrationId ?? randomUUID(); + if (!UUID_RE.test(migrationId)) throw new Error("migrationId must be a UUID"); + const createdAt = options.now ?? new Date().toISOString(); + if (Number.isNaN(Date.parse(createdAt))) throw new Error("migration time must be an ISO timestamp"); + const idFactory = options.artifactIdFactory ?? randomUUID; + const issues: ArtifactMigrationIssue[] = []; + const manifestPath = join(dir, ARTIFACT_MANIFEST_FILE); + let manifestBytes: Uint8Array | undefined; + try { + const info = await lstat(manifestPath); + if (info.isSymbolicLink() || !info.isFile()) { + issue(issues, "error", "unsafe-legacy-file", "managed legacy path is not a regular file", { + path: ARTIFACT_MANIFEST_FILE, + }); + } else { + manifestBytes = await readFile(manifestPath); + } + } catch (error) { + if (errnoCode(error) !== "ENOENT") throw error; + } + const sourceManifestHash = manifestBytes === undefined ? null : sha256(manifestBytes); + const base: Omit = { + schemaVersion: MIGRATION_PLAN_SCHEMA_VERSION, + migrationId, + createdAt, + sourceManifestExisted: manifestBytes !== undefined, + sourceManifestHash, + issues, + }; + + let legacy: unknown = { artifacts: {} }; + if (manifestBytes !== undefined) { + try { + legacy = JSON.parse(Buffer.from(manifestBytes).toString("utf8")) as unknown; + } catch (error) { + issue(issues, "error", "malformed-manifest", error instanceof Error ? error.message : String(error), { + path: ARTIFACT_MANIFEST_FILE, + }); + return { ...base, alreadyCurrent: false, canMigrate: false, manifest: null, copies: [], stateAssociations: [] }; + } + } + if (isRecord(legacy) && legacy["schemaVersion"] === ARTIFACT_MANIFEST_SCHEMA_VERSION) { + try { + const manifest = validateArtifactManifestV2(legacy); + return { ...base, alreadyCurrent: true, canMigrate: true, manifest, copies: [], stateAssociations: [] }; + } catch (error) { + issue(issues, "error", "invalid-current-manifest", error instanceof Error ? error.message : String(error)); + return { ...base, alreadyCurrent: false, canMigrate: false, manifest: null, copies: [], stateAssociations: [] }; + } + } + if (isRecord(legacy) && typeof legacy["schemaVersion"] === "number") { + issue( + issues, + "error", + "unknown-future-schema", + `schemaVersion ${String(legacy["schemaVersion"])} is not supported`, + { path: ARTIFACT_MANIFEST_FILE }, + ); + return { ...base, alreadyCurrent: false, canMigrate: false, manifest: null, copies: [], stateAssociations: [] }; + } + if (!isRecord(legacy) || !isRecord(legacy["artifacts"])) { + issue(issues, "error", "invalid-legacy-manifest", "legacy manifest must contain an artifacts object"); + return { ...base, alreadyCurrent: false, canMigrate: false, manifest: null, copies: [], stateAssociations: [] }; + } + + const legacyArtifacts = Object.entries(legacy["artifacts"]); + if (legacyArtifacts.length > MAX_LEGACY_ARTIFACTS) { + issue(issues, "error", "legacy-artifact-limit", `legacy manifest exceeds ${MAX_LEGACY_ARTIFACTS} artifacts`); + } + const rootEntries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return []; + throw error; + }); + for (const entry of rootEntries) { + if ( + entry.isSymbolicLink() && + (entry.name === ARTIFACT_MANIFEST_FILE || + entry.name === "index.html" || + /^([a-z0-9]+(?:-[a-z0-9]+)*)\.html$/.test(entry.name) || + /^([a-z0-9]+(?:-[a-z0-9]+)*)\.v\d+\.html$/.test(entry.name)) + ) { + issue(issues, "error", "unsafe-legacy-file", "managed legacy path is a symbolic link", { + path: entry.name, + }); + } + } + const rootFiles = new Set(rootEntries.filter((entry) => entry.isFile()).map((entry) => entry.name)); + const manifest = emptyArtifactManifestV2(); + const copies: ArtifactMigrationCopy[] = []; + const sourceFiles = new Set(); + if (manifestBytes !== undefined) sourceFiles.add(ARTIFACT_MANIFEST_FILE); + if (rootFiles.has("index.html")) sourceFiles.add("index.html"); + const usedIds = new Set(); + const knownSlugs = new Set(); + + for (const [key, rawMeta] of legacyArtifacts) { + if (!ARTIFACT_SLUG_RE.test(key)) { + issue(issues, "error", "invalid-legacy-slug", "legacy manifest key is not a safe slug", { artifact: key }); + continue; + } + knownSlugs.add(key); + const meta = isRecord(rawMeta) ? rawMeta : {}; + if (typeof meta["slug"] === "string" && meta["slug"] !== key) { + issue(issues, "warning", "legacy-slug-mismatch", "manifest key is used as the stable slug", { + artifact: key, + }); + } + const id = idFactory(); + if (!UUID_RE.test(id) || usedIds.has(id)) { + issue(issues, "error", "invalid-generated-id", "artifact ID factory returned an invalid or duplicate UUID", { + artifact: key, + }); + continue; + } + usedIds.add(id); + + const advertised = legacyVersions(meta["versions"]); + const diskVersions = [...rootFiles] + .map((name) => new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.v(\\d+)\\.html$`).exec(name)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => Number(match[1])) + .filter((value) => Number.isSafeInteger(value) && value > 0) + .sort((left, right) => left - right); + for (const version of advertised) { + if (!diskVersions.includes(version)) { + issue(issues, "warning", "missing-advertised-revision", `advertised revision ${version} has no file`, { + artifact: key, + path: `${key}.v${version}.html`, + }); + } + } + for (const version of diskVersions) { + if (!advertised.includes(version)) { + issue(issues, "warning", "orphan-revision-recovered", `unadvertised revision ${version} is recoverable`, { + artifact: key, + path: `${key}.v${version}.html`, + }); + } + } + + const pages: LegacyPage[] = []; + for (const version of diskVersions) { + const relativePath = `${key}.v${version}.html`; + const page = await readBoundedSource(dir, relativePath, issues, key); + if (page) { + pages.push({ ...page, legacyRevision: version }); + sourceFiles.add(relativePath); + } + } + const stablePath = `${key}.html`; + const stable = await readBoundedSource(dir, stablePath, issues, key); + if (stable) sourceFiles.add(stablePath); + const legacyCurrent = + typeof meta["current"] === "number" && Number.isSafeInteger(meta["current"]) && meta["current"] > 0 + ? meta["current"] + : undefined; + let selected = stable; + if (!selected && legacyCurrent !== undefined) { + selected = pages.find((page) => page.legacyRevision === legacyCurrent); + } + selected ??= pages.at(-1); + if (!selected) { + issue(issues, "error", "irrecoverable-legacy-artifact", "no stable or revision page bytes exist", { + artifact: key, + }); + continue; + } + if (!stable) { + issue(issues, "warning", "missing-stable-head", "stable page is missing; selected recoverable revision is used", { + artifact: key, + path: stablePath, + }); + } + if (legacyCurrent !== undefined && !pages.some((page) => page.legacyRevision === legacyCurrent)) { + issue(issues, "warning", "missing-current-revision", `current revision ${legacyCurrent} is not retained`, { + artifact: key, + }); + } + if (pages.at(-1)?.contentHash !== selected.contentHash) { + pages.push({ ...selected, legacyRevision: legacyCurrent ?? selected.legacyRevision }); + issue( + issues, + "warning", + "selected-head-materialized", + "selected legacy head is materialized as a new final revision to preserve history ordering", + { artifact: key }, + ); + } + + const titleValue = legacyString(meta["title"]); + const iconValue = legacyString(meta["icon"]); + if (meta["title"] !== undefined && titleValue === undefined) { + issue(issues, "error", "invalid-legacy-title", "legacy title is not representable", { artifact: key }); + continue; + } + if (meta["icon"] !== undefined && iconValue === undefined) { + issue(issues, "error", "invalid-legacy-icon", "legacy icon is not representable", { artifact: key }); + continue; + } + const title = titleValue && titleValue.length > 0 ? titleValue : key; + const icon = iconValue && iconValue.length > 0 ? iconValue : "📄"; + if (!titleValue) issue(issues, "warning", "defaulted-legacy-title", "missing title defaults to exact slug", { artifact: key }); + if (!iconValue) issue(issues, "warning", "defaulted-legacy-icon", "missing icon defaults to the legacy document icon", { artifact: key }); + const description = legacyString(meta["description"]); + const source = legacyString(meta["source"]); + const author = legacyString(meta["author"]); + const createdAt = validTimestamp(meta["createdAt"]); + const updatedAt = validTimestamp(meta["updatedAt"]); + if (meta["createdAt"] !== undefined && createdAt === null) { + issue(issues, "warning", "unknown-created-time", "invalid legacy createdAt is retained as unknown", { artifact: key }); + } + if (meta["updatedAt"] !== undefined && updatedAt === null) { + issue(issues, "warning", "unknown-updated-time", "invalid legacy updatedAt is retained as unknown", { artifact: key }); + } + const charts = + typeof meta["charts"] === "number" && Number.isSafeInteger(meta["charts"]) && meta["charts"] >= 0 + ? meta["charts"] + : 0; + const revisions: RevisionRecordV2[] = []; + for (let index = 0; index < pages.length; index++) { + const page = pages[index]; + const revision = index + 1; + const timestamp = revisionTimestamp(index, pages.length, createdAt, updatedAt); + const pagePath = `revisions/${id}/${revision}.html`; + revisions.push({ + revision, + createdAt: timestamp.value, + bytes: page.bytes, + contentHash: page.contentHash, + pagePath, + title, + icon, + description, + source, + author, + charts, + provenance: { + kind: "migration", + legacyRevision: page.legacyRevision, + timestampSource: timestamp.source, + }, + }); + copies.push({ + sourcePath: page.sourcePath, + targetPath: pagePath, + contentHash: page.contentHash, + bytes: page.bytes, + purpose: "revision", + }); + } + const head = revisions.at(-1); + if (!head) continue; + const record: ArtifactRecordV2 = { + id, + slug: key, + title, + icon, + description, + source, + author, + createdAt, + updatedAt: head.createdAt, + headRevision: head.revision, + revisions, + charts, + bytes: head.bytes, + contentHash: head.contentHash, + deploymentReferences: [], + }; + manifest.artifacts[id] = record; + manifest.slugIndex[key] = id; + } + + for (const name of rootFiles) { + const stableMatch = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.html$/.exec(name); + const versionMatch = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.v\d+\.html$/.exec(name); + const slug = versionMatch?.[1] ?? (name === "index.html" ? undefined : stableMatch?.[1]); + if (slug && !knownSlugs.has(slug)) { + issue(issues, "warning", "untracked-page", "HTML file is preserved but not attached to an artifact", { + artifact: slug, + path: name, + }); + sourceFiles.add(name); + } + } + + const internalFiles = ( + await Promise.all( + [".state", ".db", ".datasources"].map((root) => walkRegularFiles(dir, root, issues)), + ) + ).flat(); + const stateAssociations: LegacyStateAssociation[] = []; + for (const path of internalFiles) { + sourceFiles.add(path); + const association = associationFor(path, manifest.slugIndex); + if (association) stateAssociations.push(association); + else { + issue(issues, "warning", "unmapped-local-state", "local state is backed up but cannot be mapped to one artifact", { + path, + }); + } + } + + for (const sourcePath of [...sourceFiles].sort()) { + const content = await readBoundedSource(dir, sourcePath, issues); + if (!content) continue; + copies.push({ + sourcePath, + targetPath: `.backups/migrations/${migrationId}/files/${sourcePath}`, + contentHash: content.contentHash, + bytes: content.bytes, + purpose: "backup", + }); + } + + try { + validateArtifactManifestV2(manifest); + } catch (error) { + issue(issues, "error", "generated-manifest-invalid", error instanceof Error ? error.message : String(error)); + } + const canMigrate = !issues.some((entry) => entry.severity === "error"); + return { + ...base, + alreadyCurrent: false, + canMigrate, + manifest, + copies, + stateAssociations, + }; +} + +function migrationReport(plan: ArtifactMigrationPlan): string { + return `${JSON.stringify( + { + schemaVersion: 1, + migrationId: plan.migrationId, + createdAt: plan.createdAt, + canMigrate: plan.canMigrate, + sourceManifestHash: plan.sourceManifestHash, + artifacts: plan.manifest ? Object.keys(plan.manifest.artifacts).length : 0, + copies: plan.copies.length, + stateAssociations: plan.stateAssociations, + issues: plan.issues, + }, + null, + 2, + )}\n`; +} + +function parseInventory(value: unknown): MigrationInventory { + if ( + !isRecord(value) || + value["schemaVersion"] !== 1 || + typeof value["migrationId"] !== "string" || + !UUID_RE.test(value["migrationId"]) || + typeof value["sourceManifestExisted"] !== "boolean" || + (value["sourceManifestHash"] !== null && typeof value["sourceManifestHash"] !== "string") || + typeof value["selectedManifestHash"] !== "string" || + typeof value["originalIndexExisted"] !== "boolean" || + (value["originalIndexHash"] !== null && typeof value["originalIndexHash"] !== "string") || + !Array.isArray(value["copies"]) + ) { + throw new Error("migration inventory is invalid"); + } + return value as unknown as MigrationInventory; +} + +export async function executeArtifactMigration( + dir: string, + plan: ArtifactMigrationPlan, +): Promise { + if (plan.alreadyCurrent) { + return { + migrationId: plan.migrationId, + status: "already-current", + manifestHash: plan.manifest ? sha256(`${JSON.stringify(plan.manifest, null, 2)}\n`) : null, + issues: plan.issues, + }; + } + if (!plan.canMigrate || !plan.manifest) { + throw new Error("artifact migration preflight failed; inspect the repair report before retrying"); + } + const targetManifest = plan.manifest; + validateArtifactManifestV2(targetManifest); + const manifestText = `${JSON.stringify(targetManifest, null, 2)}\n`; + const selectedManifestHash = sha256(manifestText); + const currentManifest = await readOptional(join(dir, ARTIFACT_MANIFEST_FILE)); + const currentHash = currentManifest === undefined ? null : sha256(currentManifest); + if (currentHash !== plan.sourceManifestHash) { + if (currentHash === selectedManifestHash) { + return { + migrationId: plan.migrationId, + status: "migrated", + manifestHash: selectedManifestHash, + issues: plan.issues, + }; + } + throw new Error("artifact migration source changed after preflight; regenerate the plan"); + } + + for (let offset = 0; offset < plan.copies.length; offset += COPY_BATCH_TARGETS) { + const batch = plan.copies.slice(offset, offset + COPY_BATCH_TARGETS); + await runFileTransaction(dir, async (transaction) => { + const files = new Map(); + for (const copy of batch) { + const existing = await readOptional(join(dir, ...copy.targetPath.split("/"))); + if (existing !== undefined) { + if (sha256(existing) !== copy.contentHash) { + throw new Error(`migration target already exists with different bytes: ${copy.targetPath}`); + } + continue; + } + const source = await readFile(join(dir, ...copy.sourcePath.split("/"))); + if (source.byteLength !== copy.bytes || sha256(source) !== copy.contentHash) { + throw new Error(`migration source changed after preflight: ${copy.sourcePath}`); + } + files.set(copy.targetPath, source); + } + if (files.size > 0) await transaction.commit(files); + }); + } + + const originalIndexCopy = plan.copies.find( + (copy) => copy.purpose === "backup" && copy.sourcePath === "index.html", + ); + const inventory: MigrationInventory = { + schemaVersion: 1, + migrationId: plan.migrationId, + sourceManifestExisted: plan.sourceManifestExisted, + sourceManifestHash: plan.sourceManifestHash, + selectedManifestHash, + originalIndexExisted: originalIndexCopy !== undefined, + originalIndexHash: originalIndexCopy?.contentHash ?? null, + copies: plan.copies, + }; + const inventoryText = `${JSON.stringify(inventory, null, 2)}\n`; + await runFileTransaction(dir, async (transaction) => { + const liveManifest = await readOptional(join(dir, ARTIFACT_MANIFEST_FILE)); + const liveHash = liveManifest === undefined ? null : sha256(liveManifest); + if (liveHash !== plan.sourceManifestHash) { + throw new Error("artifact migration source changed before selection"); + } + for (const source of new Map(plan.copies.map((copy) => [copy.sourcePath, copy])).values()) { + const live = await readFile(join(dir, ...source.sourcePath.split("/"))); + if (live.byteLength !== source.bytes || sha256(live) !== source.contentHash) { + throw new Error(`migration source changed before selection: ${source.sourcePath}`); + } + } + for (const copy of plan.copies.filter((entry) => entry.purpose === "revision")) { + const prepared = await readFile(join(dir, ...copy.targetPath.split("/"))); + if (sha256(prepared) !== copy.contentHash) { + throw new Error(`prepared revision failed verification: ${copy.targetPath}`); + } + } + await transaction.commit( + new Map([ + [ARTIFACT_MANIFEST_FILE, manifestText], + ["index.html", renderGallery(asLegacyManifest(targetManifest))], + [`.backups/migrations/${plan.migrationId}/inventory.json`, inventoryText], + [`.migrations/${plan.migrationId}/report.json`, migrationReport(plan)], + ]), + ); + }); + const selected = await readArtifactManifestV2(dir); + if (sha256(`${JSON.stringify(selected, null, 2)}\n`) !== selectedManifestHash) { + throw new Error("selected artifact manifest failed post-migration verification"); + } + return { + migrationId: plan.migrationId, + status: "migrated", + manifestHash: selectedManifestHash, + issues: plan.issues, + }; +} + +export async function rollbackArtifactMigration( + dir: string, + migrationId: string, +): Promise { + if (!UUID_RE.test(migrationId)) throw new Error("migrationId must be a UUID"); + const inventoryPath = `.backups/migrations/${migrationId}/inventory.json`; + const inventory = parseInventory( + JSON.parse(await readFile(join(dir, ...inventoryPath.split("/")), "utf8")) as unknown, + ); + const liveManifest = await readFile(join(dir, ARTIFACT_MANIFEST_FILE)); + if (sha256(liveManifest) !== inventory.selectedManifestHash) { + throw new Error("selected manifest changed after migration; rollback requires a new preflight"); + } + const manifestBackupPath = `.backups/migrations/${migrationId}/files/${ARTIFACT_MANIFEST_FILE}`; + const indexBackupPath = `.backups/migrations/${migrationId}/files/index.html`; + const oldManifest = inventory.sourceManifestExisted + ? await readFile(join(dir, ...manifestBackupPath.split("/"))) + : null; + const oldIndex = inventory.originalIndexExisted + ? await readFile(join(dir, ...indexBackupPath.split("/"))) + : null; + if ( + (oldManifest !== null && sha256(oldManifest) !== inventory.sourceManifestHash) || + (oldIndex !== null && sha256(oldIndex) !== inventory.originalIndexHash) + ) { + throw new Error("migration backup verification failed; rollback made no changes"); + } + await runFileTransaction(dir, (transaction) => + transaction.commit( + new Map([ + [ARTIFACT_MANIFEST_FILE, oldManifest], + ["index.html", oldIndex], + ]), + ), + ); + const restoredManifest = await readOptional(join(dir, ARTIFACT_MANIFEST_FILE)); + const restoredHash = restoredManifest === undefined ? null : sha256(restoredManifest); + if (restoredHash !== inventory.sourceManifestHash) { + throw new Error("migration rollback post-verification failed"); + } + return { + migrationId, + status: "rolled-back", + manifestHash: restoredHash, + issues: [], + }; +} diff --git a/src/artifact-schema.ts b/src/artifact-schema.ts new file mode 100644 index 0000000..9b48d4f --- /dev/null +++ b/src/artifact-schema.ts @@ -0,0 +1,446 @@ +import { lstat, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { recoverFileTransactions } from "./file-transaction.ts"; + +export const ARTIFACT_MANIFEST_SCHEMA_VERSION = 2; +export const ARTIFACT_MANIFEST_FILE = "manifest.json"; +export const ARTIFACT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +export const ARTIFACT_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +export const CONTENT_HASH_RE = /^[0-9a-f]{64}$/; + +const MAX_ARTIFACTS = 10_000; +const MAX_REVISIONS_PER_ARTIFACT = 10_000; +const MAX_TEXT_LENGTH = 16_384; +const MAX_DEPLOYMENT_REFERENCES = 1_000; + +export type RevisionProvenanceKind = "create" | "update" | "restore" | "migration"; + +export interface RevisionProvenanceV2 { + kind: RevisionProvenanceKind; + restoredFrom?: number; + legacyRevision?: number; + timestampSource?: "recorded" | "legacy-artifact" | "unknown"; +} + +export interface RevisionRecordV2 { + revision: number; + createdAt: string | null; + bytes: number; + contentHash: string; + pagePath: string; + title: string; + icon: string; + description?: string; + source?: string; + author?: string; + charts: number; + provenance: RevisionProvenanceV2; + authoringSource?: AuthoringSourceReferenceV2; +} + +export interface AuthoringSourceReferenceV2 { + format: "markdown" | "html"; + path: string; + bytes: number; + contentHash: string; +} + +export interface DeploymentReferenceV2 { + capability: "public-static" | "authenticated" | "connector-capable"; + target: string; + url: string; + revision: number; + createdAt: string; +} + +export interface ArtifactRecordV2 { + id: string; + slug: string; + title: string; + icon: string; + description?: string; + source?: string; + author?: string; + createdAt: string | null; + updatedAt: string | null; + headRevision: number; + revisions: RevisionRecordV2[]; + charts: number; + bytes: number; + contentHash: string; + deploymentReferences: DeploymentReferenceV2[]; +} + +export interface ArtifactManifestV2 { + schemaVersion: 2; + artifacts: Record; + slugIndex: Record; +} + +export class ArtifactSchemaError extends Error { + readonly issues: string[]; + + constructor(issues: string[]) { + super(`artifact manifest schema validation failed: ${issues.join("; ")}`); + this.name = "ArtifactSchemaError"; + this.issues = [...issues]; + } +} + +export class ArtifactMigrationRequiredError extends Error { + constructor() { + super("artifact manifest uses a legacy schema; run lifecycle migration preflight before enabling schema 2"); + this.name = "ArtifactMigrationRequiredError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: ReadonlySet): boolean { + return Object.keys(value).every((key) => allowed.has(key)); +} + +function boundedString(value: unknown, allowEmpty = false): value is string { + return ( + typeof value === "string" && + value.length <= MAX_TEXT_LENGTH && + (allowEmpty || value.length > 0) + ); +} + +function validTimestamp(value: unknown, nullable = false): value is string | null { + if (nullable && value === null) return true; + return ( + typeof value === "string" && + value.length <= 64 && + /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && + !Number.isNaN(Date.parse(value)) + ); +} + +function positiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function nonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +const PROVENANCE_KEYS = new Set([ + "kind", + "restoredFrom", + "legacyRevision", + "timestampSource", +]); + +function validateProvenance(value: unknown, path: string, issues: string[]): void { + if (!isRecord(value) || !hasOnlyKeys(value, PROVENANCE_KEYS)) { + issues.push(`${path} must be an exact provenance object`); + return; + } + if ( + value["kind"] !== "create" && + value["kind"] !== "update" && + value["kind"] !== "restore" && + value["kind"] !== "migration" + ) { + issues.push(`${path}.kind is invalid`); + } + if (value["restoredFrom"] !== undefined && !positiveInteger(value["restoredFrom"])) { + issues.push(`${path}.restoredFrom must be a positive integer`); + } + if (value["legacyRevision"] !== undefined && !positiveInteger(value["legacyRevision"])) { + issues.push(`${path}.legacyRevision must be a positive integer`); + } + if ( + value["timestampSource"] !== undefined && + value["timestampSource"] !== "recorded" && + value["timestampSource"] !== "legacy-artifact" && + value["timestampSource"] !== "unknown" + ) { + issues.push(`${path}.timestampSource is invalid`); + } + if (value["kind"] === "restore" && !positiveInteger(value["restoredFrom"])) { + issues.push(`${path}.restoredFrom is required for restore provenance`); + } +} + +const REVISION_KEYS = new Set([ + "revision", + "createdAt", + "bytes", + "contentHash", + "pagePath", + "title", + "icon", + "description", + "source", + "author", + "charts", + "provenance", + "authoringSource", +]); + +const AUTHORING_SOURCE_KEYS = new Set(["format", "path", "bytes", "contentHash"]); + +function validateAuthoringSource(value: unknown, artifactId: string, revision: number, path: string, issues: string[]): void { + if (!isRecord(value) || !hasOnlyKeys(value, AUTHORING_SOURCE_KEYS)) { + issues.push(`${path} must be an exact authoring source reference`); + return; + } + if (value["format"] !== "markdown" && value["format"] !== "html") issues.push(`${path}.format is invalid`); + if (value["path"] !== `.sources/${artifactId}/${revision}.${String(value["format"])}.txt`) issues.push(`${path}.path is not canonical`); + if (!nonNegativeInteger(value["bytes"])) issues.push(`${path}.bytes is invalid`); + if (typeof value["contentHash"] !== "string" || !CONTENT_HASH_RE.test(value["contentHash"])) issues.push(`${path}.contentHash is invalid`); +} + +function validateRevision( + value: unknown, + artifactId: string, + expectedRevision: number, + path: string, + issues: string[], +): void { + if (!isRecord(value) || !hasOnlyKeys(value, REVISION_KEYS)) { + issues.push(`${path} must be an exact revision object`); + return; + } + if (value["revision"] !== expectedRevision) { + issues.push(`${path}.revision must be contiguous and equal ${expectedRevision}`); + } + if (!validTimestamp(value["createdAt"], true)) issues.push(`${path}.createdAt is invalid`); + if (!nonNegativeInteger(value["bytes"])) issues.push(`${path}.bytes is invalid`); + if (typeof value["contentHash"] !== "string" || !CONTENT_HASH_RE.test(value["contentHash"])) { + issues.push(`${path}.contentHash must be a SHA-256 digest`); + } + if (value["pagePath"] !== `revisions/${artifactId}/${expectedRevision}.html`) { + issues.push(`${path}.pagePath is not canonical`); + } + for (const key of ["title", "icon"] as const) { + if (!boundedString(value[key])) issues.push(`${path}.${key} is invalid`); + } + for (const key of ["description", "source", "author"] as const) { + if (value[key] !== undefined && !boundedString(value[key], true)) { + issues.push(`${path}.${key} is invalid`); + } + } + if (!nonNegativeInteger(value["charts"])) issues.push(`${path}.charts is invalid`); + validateProvenance(value["provenance"], `${path}.provenance`, issues); + if (value["authoringSource"] !== undefined) { + validateAuthoringSource(value["authoringSource"], artifactId, expectedRevision, `${path}.authoringSource`, issues); + } +} + +const DEPLOYMENT_KEYS = new Set([ + "capability", + "target", + "url", + "revision", + "createdAt", +]); + +function validateDeployment(value: unknown, path: string, issues: string[]): void { + if (!isRecord(value) || !hasOnlyKeys(value, DEPLOYMENT_KEYS)) { + issues.push(`${path} must be an exact deployment reference`); + return; + } + if ( + value["capability"] !== "public-static" && + value["capability"] !== "authenticated" && + value["capability"] !== "connector-capable" + ) { + issues.push(`${path}.capability is invalid`); + } + if (!boundedString(value["target"])) issues.push(`${path}.target is invalid`); + if (!boundedString(value["url"])) { + issues.push(`${path}.url is invalid`); + } else { + try { + const url = new URL(value["url"]); + if (url.protocol !== "https:" && url.protocol !== "http:") { + issues.push(`${path}.url must use http or https`); + } + } catch { + issues.push(`${path}.url is invalid`); + } + } + if (!positiveInteger(value["revision"])) issues.push(`${path}.revision is invalid`); + if (!validTimestamp(value["createdAt"])) issues.push(`${path}.createdAt is invalid`); +} + +const ARTIFACT_KEYS = new Set([ + "id", + "slug", + "title", + "icon", + "description", + "source", + "author", + "createdAt", + "updatedAt", + "headRevision", + "revisions", + "charts", + "bytes", + "contentHash", + "deploymentReferences", +]); + +function validateArtifact(value: unknown, id: string, path: string, issues: string[]): void { + if (!isRecord(value) || !hasOnlyKeys(value, ARTIFACT_KEYS)) { + issues.push(`${path} must be an exact artifact object`); + return; + } + if (value["id"] !== id || !ARTIFACT_ID_RE.test(id)) issues.push(`${path}.id is invalid`); + if (typeof value["slug"] !== "string" || !ARTIFACT_SLUG_RE.test(value["slug"])) { + issues.push(`${path}.slug is invalid`); + } + for (const key of ["title", "icon"] as const) { + if (!boundedString(value[key])) issues.push(`${path}.${key} is invalid`); + } + for (const key of ["description", "source", "author"] as const) { + if (value[key] !== undefined && !boundedString(value[key], true)) { + issues.push(`${path}.${key} is invalid`); + } + } + if (!validTimestamp(value["createdAt"], true)) issues.push(`${path}.createdAt is invalid`); + if (!validTimestamp(value["updatedAt"], true)) issues.push(`${path}.updatedAt is invalid`); + if (!positiveInteger(value["headRevision"])) issues.push(`${path}.headRevision is invalid`); + if ( + !Array.isArray(value["revisions"]) || + value["revisions"].length === 0 || + value["revisions"].length > MAX_REVISIONS_PER_ARTIFACT + ) { + issues.push(`${path}.revisions must be a bounded non-empty array`); + } else { + for (let index = 0; index < value["revisions"].length; index++) { + validateRevision(value["revisions"][index], id, index + 1, `${path}.revisions[${index}]`, issues); + } + const head = value["revisions"].at(-1); + if (value["headRevision"] !== value["revisions"].length) { + issues.push(`${path}.headRevision must select the latest contiguous revision`); + } + if (isRecord(head)) { + for (const key of ["title", "icon", "description", "source", "author", "charts", "bytes", "contentHash"] as const) { + if (value[key] !== head[key]) issues.push(`${path}.${key} must match the head revision`); + } + if (value["updatedAt"] !== head["createdAt"]) { + issues.push(`${path}.updatedAt must match the head revision timestamp`); + } + } + } + if (!nonNegativeInteger(value["charts"])) issues.push(`${path}.charts is invalid`); + if (!nonNegativeInteger(value["bytes"])) issues.push(`${path}.bytes is invalid`); + if (typeof value["contentHash"] !== "string" || !CONTENT_HASH_RE.test(value["contentHash"])) { + issues.push(`${path}.contentHash is invalid`); + } + if ( + !Array.isArray(value["deploymentReferences"]) || + value["deploymentReferences"].length > MAX_DEPLOYMENT_REFERENCES + ) { + issues.push(`${path}.deploymentReferences is invalid`); + } else { + for (let index = 0; index < value["deploymentReferences"].length; index++) { + validateDeployment( + value["deploymentReferences"][index], + `${path}.deploymentReferences[${index}]`, + issues, + ); + const deployment = value["deploymentReferences"][index]; + if (isRecord(deployment) && positiveInteger(deployment["revision"]) && positiveInteger(value["headRevision"]) && deployment["revision"] > value["headRevision"]) { + issues.push(`${path}.deploymentReferences[${index}].revision exceeds the artifact head`); + } + } + } +} + +const MANIFEST_KEYS = new Set(["schemaVersion", "artifacts", "slugIndex"]); + +export function validateArtifactManifestV2(value: unknown): ArtifactManifestV2 { + const issues: string[] = []; + if (!isRecord(value) || !hasOnlyKeys(value, MANIFEST_KEYS)) { + throw new ArtifactSchemaError(["manifest must be an exact object"]); + } + if (value["schemaVersion"] !== ARTIFACT_MANIFEST_SCHEMA_VERSION) { + issues.push(`schemaVersion must equal ${ARTIFACT_MANIFEST_SCHEMA_VERSION}`); + } + if (!isRecord(value["artifacts"])) { + issues.push("artifacts must be an object"); + } else { + const entries = Object.entries(value["artifacts"]); + if (entries.length > MAX_ARTIFACTS) issues.push(`artifacts exceeds ${MAX_ARTIFACTS}`); + for (const [id, artifact] of entries) { + validateArtifact(artifact, id, `artifacts.${id}`, issues); + } + } + if (!isRecord(value["slugIndex"])) { + issues.push("slugIndex must be an object"); + } else if (isRecord(value["artifacts"])) { + const expected = new Map(); + for (const [id, artifact] of Object.entries(value["artifacts"])) { + if (isRecord(artifact) && typeof artifact["slug"] === "string") { + if (expected.has(artifact["slug"])) issues.push(`duplicate active slug ${artifact["slug"]}`); + expected.set(artifact["slug"], id); + } + } + for (const [slug, id] of Object.entries(value["slugIndex"])) { + if (!ARTIFACT_SLUG_RE.test(slug) || typeof id !== "string") { + issues.push(`slugIndex.${slug} is invalid`); + } else if (expected.get(slug) !== id) { + issues.push(`slugIndex.${slug} does not match artifact records`); + } + } + if (Object.keys(value["slugIndex"]).length !== expected.size) { + issues.push("slugIndex does not contain exactly one entry per artifact"); + } + } + if (issues.length > 0) throw new ArtifactSchemaError(issues); + return value as unknown as ArtifactManifestV2; +} + +export function emptyArtifactManifestV2(): ArtifactManifestV2 { + return { schemaVersion: ARTIFACT_MANIFEST_SCHEMA_VERSION, artifacts: {}, slugIndex: {} }; +} + +export function parseArtifactManifestV2(raw: string): ArtifactManifestV2 { + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new ArtifactSchemaError([`manifest is not valid JSON: ${detail}`]); + } + if (isRecord(value) && value["schemaVersion"] === ARTIFACT_MANIFEST_SCHEMA_VERSION) { + return validateArtifactManifestV2(value); + } + if (isRecord(value) && typeof value["schemaVersion"] === "number") { + throw new ArtifactSchemaError([`unsupported future or unknown schemaVersion ${String(value["schemaVersion"])}`]); + } + throw new ArtifactMigrationRequiredError(); +} + +export async function readArtifactManifestV2(dir: string): Promise { + await recoverFileTransactions(dir); + const manifestPath = join(dir, ARTIFACT_MANIFEST_FILE); + let raw: string; + try { + const info = await lstat(manifestPath); + if (info.isSymbolicLink() || !info.isFile()) { + throw new ArtifactSchemaError(["manifest path must be a regular file"]); + } + raw = await readFile(manifestPath, "utf8"); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return emptyArtifactManifestV2(); + } + throw error; + } + return parseArtifactManifestV2(raw); +} diff --git a/src/artifact-state.ts b/src/artifact-state.ts new file mode 100644 index 0000000..6def1bc --- /dev/null +++ b/src/artifact-state.ts @@ -0,0 +1,833 @@ +import { randomUUID, createHash } from "node:crypto"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { ARTIFACT_ID_RE } from "./artifact-schema.ts"; +import { recoverFileTransactions, runFileTransaction } from "./file-transaction.ts"; + +export const ARTIFACT_STATE_SCHEMA_VERSION = 2; +export const STATE_OPERATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +export const STATE_KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export type ArtifactStateKind = "decisions" | "comments" | "collection"; + +export interface DecisionPayload { + answers: Record; +} + +export interface CommentThreadV2 { + id: string; + quote: string; + text: string; + createdAt: string; + resolved: boolean; +} + +export interface CommentPayload { + threads: CommentThreadV2[]; +} + +export interface CollectionPayload { + docs: Record; +} + +export type ArtifactStatePayload = DecisionPayload | CommentPayload | CollectionPayload; + +export interface StateOperationRecord { + id: string; + inputHash: string; + revision: number; + contentHash: string; + committedAt: string; +} + +export interface ArtifactStateEnvelope { + schemaVersion: 2; + artifactId: string; + kind: ArtifactStateKind; + key: string; + revision: number; + contentHash: string; + payload: T; + updatedAt: string; + operations: StateOperationRecord[]; +} + +export interface ArtifactStateLimits { + decisionBytes: number; + answerCount: number; + answerKeyBytes: number; + answerValueBytes: number; + commentBytes: number; + threadCount: number; + threadIdBytes: number; + quoteBytes: number; + commentTextBytes: number; + documentBytes: number; + collectionDocuments: number; + collectionBytes: number; + mutationRatePerMinute: number; + mutationWarningRatio: number; + operationRecords: number; + conflictPreviewBytes: number; + maxJsonDepth: number; +} + +export const DEFAULT_ARTIFACT_STATE_LIMITS: Readonly = Object.freeze({ + decisionBytes: 64 * 1024, + answerCount: 256, + answerKeyBytes: 256, + answerValueBytes: 4 * 1024, + commentBytes: 256 * 1024, + threadCount: 200, + threadIdBytes: 128, + quoteBytes: 8 * 1024, + commentTextBytes: 16 * 1024, + documentBytes: 256 * 1024, + collectionDocuments: 1_000, + collectionBytes: 16 * 1024 * 1024, + mutationRatePerMinute: 120, + mutationWarningRatio: 0.8, + operationRecords: 1_000, + conflictPreviewBytes: 16 * 1024, + maxJsonDepth: 32, +}); + +const ABSOLUTE_LIMITS: Readonly> = Object.freeze({ + decisionBytes: DEFAULT_ARTIFACT_STATE_LIMITS.decisionBytes * 4, + answerCount: DEFAULT_ARTIFACT_STATE_LIMITS.answerCount * 4, + answerKeyBytes: DEFAULT_ARTIFACT_STATE_LIMITS.answerKeyBytes * 4, + answerValueBytes: DEFAULT_ARTIFACT_STATE_LIMITS.answerValueBytes * 4, + commentBytes: DEFAULT_ARTIFACT_STATE_LIMITS.commentBytes * 4, + threadCount: DEFAULT_ARTIFACT_STATE_LIMITS.threadCount * 4, + threadIdBytes: DEFAULT_ARTIFACT_STATE_LIMITS.threadIdBytes * 4, + quoteBytes: DEFAULT_ARTIFACT_STATE_LIMITS.quoteBytes * 4, + commentTextBytes: DEFAULT_ARTIFACT_STATE_LIMITS.commentTextBytes * 4, + documentBytes: DEFAULT_ARTIFACT_STATE_LIMITS.documentBytes * 4, + collectionDocuments: DEFAULT_ARTIFACT_STATE_LIMITS.collectionDocuments * 4, + collectionBytes: DEFAULT_ARTIFACT_STATE_LIMITS.collectionBytes * 4, + mutationRatePerMinute: 1_000, + mutationWarningRatio: 0.95, + operationRecords: DEFAULT_ARTIFACT_STATE_LIMITS.operationRecords * 4, + conflictPreviewBytes: DEFAULT_ARTIFACT_STATE_LIMITS.conflictPreviewBytes * 4, + maxJsonDepth: DEFAULT_ARTIFACT_STATE_LIMITS.maxJsonDepth * 4, +}); + +export type ArtifactStateErrorCode = + | "invalid" + | "corrupt" + | "future-schema" + | "stale" + | "replay-conflict" + | "quota" + | "rate-limit"; + +export class ArtifactStateError extends Error { + readonly code: ArtifactStateErrorCode; + readonly selectedRevision: number; + readonly nextAction: string; + + constructor(code: ArtifactStateErrorCode, message: string, selectedRevision: number, nextAction: string) { + super(message); + this.name = "ArtifactStateError"; + this.code = code; + this.selectedRevision = selectedRevision; + this.nextAction = nextAction; + } +} + +export class ArtifactStateConflictError extends ArtifactStateError { + readonly currentHash: string; + readonly current: string; + + constructor(message: string, current: ArtifactStateEnvelope, previewBytes: number) { + super("stale", message, current.revision, "merge onto the bounded current value and retry with its revision/hash"); + this.name = "ArtifactStateConflictError"; + this.currentHash = current.contentHash; + const encoded = stableJson(current.payload); + this.current = Buffer.byteLength(encoded, "utf8") <= previewBytes + ? encoded + : `${Buffer.from(encoded, "utf8").subarray(0, previewBytes).toString("utf8")}…`; + } +} + +export interface StateMutationResult { + status: "committed" | "replayed"; + revision: number; + contentHash: string; + envelope: ArtifactStateEnvelope; + warnings: string[]; +} + +export interface ReplaceStateInput { + root: string; + artifactId: string; + kind: ArtifactStateKind; + key?: string; + expectedRevision: number; + expectedHash?: string; + operationId: string; + payload: T; + now?: string; + limits?: Partial; +} + +export interface MutateCollectionDocumentInput { + root: string; + artifactId: string; + collection: string; + id: string; + operation: "set" | "delete"; + document?: unknown; + expectedRevision: number; + expectedDocumentHash: string | null; + operationId: string; + now?: string; + limits?: Partial; +} + +export interface LegacyStateMigrationItem { + sourcePath: string; + backupPath: string; + targetPath: string; + sourceHash: string; + targetHash: string; + envelope: ArtifactStateEnvelope; +} + +export interface LegacyStateMigrationPlan { + schemaVersion: 1; + migrationId: string; + artifactId: string; + slug: string; + createdAt: string; + items: LegacyStateMigrationItem[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record, keys: ReadonlySet): boolean { + return Object.keys(value).every((key) => keys.has(key)); +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function errnoCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function normalizeJson(value: unknown, depth = 0, maxDepth = DEFAULT_ARTIFACT_STATE_LIMITS.maxJsonDepth): unknown { + if (depth > maxDepth) throw new ArtifactStateError("quota", `JSON depth exceeds ${maxDepth}`, 0, "reduce nesting"); + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (Array.isArray(value)) return value.map((entry) => normalizeJson(entry, depth + 1, maxDepth)); + if (isRecord(value)) { + const normalized: Record = {}; + for (const key of Object.keys(value).sort()) { + if (utf8Bytes(key) > 1_024) { + throw new ArtifactStateError("quota", "JSON field name exceeds 1024 bytes", 0, "shorten field names"); + } + normalized[key] = normalizeJson(value[key], depth + 1, maxDepth); + } + return normalized; + } + throw new ArtifactStateError("invalid", "payload must be JSON-compatible", 0, "send a JSON value without undefined or non-finite numbers"); +} + +function stableJson(value: unknown, maxDepth = DEFAULT_ARTIFACT_STATE_LIMITS.maxJsonDepth): string { + return JSON.stringify(normalizeJson(value, 0, maxDepth)); +} + +function mergeLimits(overrides: Partial = {}): ArtifactStateLimits { + const merged: ArtifactStateLimits = { ...DEFAULT_ARTIFACT_STATE_LIMITS, ...overrides }; + for (const key of Object.keys(DEFAULT_ARTIFACT_STATE_LIMITS) as Array) { + const value = merged[key]; + if (key === "mutationWarningRatio") { + if (typeof value !== "number" || value <= 0 || value > ABSOLUTE_LIMITS[key]) { + throw new ArtifactStateError("invalid", `${key} must be within (0, ${ABSOLUTE_LIMITS[key]}]`, 0, "use a bounded limit override"); + } + } else if (!Number.isSafeInteger(value) || value < 1 || value > ABSOLUTE_LIMITS[key]) { + throw new ArtifactStateError("invalid", `${key} must be an integer within [1, ${ABSOLUTE_LIMITS[key]}]`, 0, "use a bounded limit override"); + } + } + return merged; +} + +function statePath(artifactId: string, kind: ArtifactStateKind, key: string): string { + if (!ARTIFACT_ID_RE.test(artifactId)) { + throw new ArtifactStateError("invalid", "artifactId must be a UUID", 0, "resolve the artifact to its opaque ID"); + } + if (!STATE_KEY_RE.test(key)) { + throw new ArtifactStateError("invalid", "store key must be a safe lowercase identifier", 0, "use lowercase letters, numbers, and internal hyphens"); + } + return kind === "collection" + ? `.db/v2/${artifactId}/${key}.json` + : `.state/v2/${artifactId}/${kind}.json`; +} + +function defaultPayload(kind: ArtifactStateKind): ArtifactStatePayload { + if (kind === "decisions") return { answers: {} }; + if (kind === "comments") return { threads: [] }; + return { docs: {} }; +} + +function validateDecisionPayload(value: unknown, limits: ArtifactStateLimits, revision: number): asserts value is DecisionPayload { + if (!isRecord(value) || !exactKeys(value, new Set(["answers"])) || !isRecord(value["answers"])) { + throw new ArtifactStateError("invalid", "decision payload must be exactly {answers}", revision, "send a string-valued answers object"); + } + const entries = Object.entries(value["answers"]); + if (entries.length > limits.answerCount) { + throw new ArtifactStateError("quota", `answer count exceeds ${limits.answerCount}`, revision, "remove answers before retrying"); + } + for (const [key, answer] of entries) { + if (typeof answer !== "string" || utf8Bytes(key) > limits.answerKeyBytes || utf8Bytes(answer) > limits.answerValueBytes) { + throw new ArtifactStateError("quota", "answer key or value exceeds its limit", revision, "shorten the answer key/value"); + } + } +} + +function validTimestamp(value: unknown): value is string { + return typeof value === "string" && /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value)); +} + +const THREAD_KEYS = new Set(["id", "quote", "text", "createdAt", "resolved"]); + +function validateCommentPayload(value: unknown, limits: ArtifactStateLimits, revision: number): asserts value is CommentPayload { + if (!isRecord(value) || !exactKeys(value, new Set(["threads"])) || !Array.isArray(value["threads"])) { + throw new ArtifactStateError("invalid", "comment payload must be exactly {threads}", revision, "send a thread array"); + } + if (value["threads"].length > limits.threadCount) { + throw new ArtifactStateError("quota", `thread count exceeds ${limits.threadCount}`, revision, "resolve/archive threads before retrying"); + } + const ids = new Set(); + for (const thread of value["threads"]) { + if ( + !isRecord(thread) || + !exactKeys(thread, THREAD_KEYS) || + typeof thread["id"] !== "string" || + typeof thread["quote"] !== "string" || + typeof thread["text"] !== "string" || + !validTimestamp(thread["createdAt"]) || + typeof thread["resolved"] !== "boolean" || + ids.has(thread["id"]) || + utf8Bytes(thread["id"]) > limits.threadIdBytes || + utf8Bytes(thread["quote"]) > limits.quoteBytes || + utf8Bytes(thread["text"]) > limits.commentTextBytes + ) { + throw new ArtifactStateError("invalid", "comment thread shape, identity, timestamp, or field size is invalid", revision, "send unique bounded exact thread records"); + } + ids.add(thread["id"]); + } +} + +function validateCollectionPayload(value: unknown, limits: ArtifactStateLimits, revision: number): asserts value is CollectionPayload { + if (!isRecord(value) || !exactKeys(value, new Set(["docs"])) || !isRecord(value["docs"])) { + throw new ArtifactStateError("invalid", "collection payload must be exactly {docs}", revision, "send a document object"); + } + const entries = Object.entries(value["docs"]); + if (entries.length > limits.collectionDocuments) { + throw new ArtifactStateError("quota", `document count exceeds ${limits.collectionDocuments}`, revision, "delete documents before retrying"); + } + for (const [id, document] of entries) { + if (!STATE_KEY_RE.test(id)) { + throw new ArtifactStateError("invalid", `document ID ${JSON.stringify(id)} is unsafe`, revision, "use a safe lowercase document ID"); + } + const bytes = utf8Bytes(stableJson(document, limits.maxJsonDepth)); + if (bytes > limits.documentBytes) { + throw new ArtifactStateError("quota", `document ${id} exceeds ${limits.documentBytes} bytes`, revision, "reduce the document before retrying"); + } + } +} + +function validatePayload(kind: ArtifactStateKind, value: unknown, limits: ArtifactStateLimits, revision: number): ArtifactStatePayload { + if (kind === "decisions") validateDecisionPayload(value, limits, revision); + else if (kind === "comments") validateCommentPayload(value, limits, revision); + else validateCollectionPayload(value, limits, revision); + const bytes = utf8Bytes(stableJson(value, limits.maxJsonDepth)); + const cap = kind === "decisions" ? limits.decisionBytes : kind === "comments" ? limits.commentBytes : limits.collectionBytes; + if (bytes > cap) { + throw new ArtifactStateError("quota", `${kind} payload exceeds ${cap} encoded bytes`, revision, "reduce state before retrying"); + } + return value; +} + +function encodedStateCap(kind: ArtifactStateKind, limits: ArtifactStateLimits): number { + return kind === "decisions" ? limits.decisionBytes : kind === "comments" ? limits.commentBytes : limits.collectionBytes; +} + +const OPERATION_KEYS = new Set(["id", "inputHash", "revision", "contentHash", "committedAt"]); +const ENVELOPE_KEYS = new Set(["schemaVersion", "artifactId", "kind", "key", "revision", "contentHash", "payload", "updatedAt", "operations"]); +const HASH_RE = /^[0-9a-f]{64}$/; + +export function validateArtifactStateEnvelope( + value: unknown, + expected: { artifactId: string; kind: ArtifactStateKind; key: string }, + limitsInput: Partial = {}, +): ArtifactStateEnvelope { + const limits = mergeLimits(limitsInput); + if (isRecord(value) && typeof value["schemaVersion"] === "number" && value["schemaVersion"] !== ARTIFACT_STATE_SCHEMA_VERSION) { + throw new ArtifactStateError("future-schema", `unsupported state schemaVersion ${String(value["schemaVersion"])}`, 0, "upgrade the runtime or restore a compatible backup"); + } + if ( + !isRecord(value) || + !exactKeys(value, ENVELOPE_KEYS) || + value["schemaVersion"] !== ARTIFACT_STATE_SCHEMA_VERSION || + value["artifactId"] !== expected.artifactId || + value["kind"] !== expected.kind || + value["key"] !== expected.key || + typeof value["revision"] !== "number" || + !Number.isSafeInteger(value["revision"]) || + value["revision"] < 1 || + typeof value["contentHash"] !== "string" || + !HASH_RE.test(value["contentHash"]) || + !validTimestamp(value["updatedAt"]) || + !Array.isArray(value["operations"]) || + value["operations"].length > limits.operationRecords + ) { + throw new ArtifactStateError("corrupt", "state envelope is malformed or belongs to another artifact/store", 0, "restore a verified backup or run state repair preflight"); + } + const seen = new Set(); + let priorOperationRevision = 0; + let priorOperationTime = -Infinity; + for (const operation of value["operations"]) { + if ( + !isRecord(operation) || + !exactKeys(operation, OPERATION_KEYS) || + typeof operation["id"] !== "string" || + !STATE_OPERATION_ID_RE.test(operation["id"]) || + seen.has(operation["id"]) || + typeof operation["inputHash"] !== "string" || + !HASH_RE.test(operation["inputHash"]) || + typeof operation["revision"] !== "number" || + !Number.isSafeInteger(operation["revision"]) || + operation["revision"] < 1 || + operation["revision"] > value["revision"] || + operation["revision"] <= priorOperationRevision || + typeof operation["contentHash"] !== "string" || + !HASH_RE.test(operation["contentHash"]) || + !validTimestamp(operation["committedAt"]) || + Date.parse(operation["committedAt"]) < priorOperationTime || + Date.parse(operation["committedAt"]) > Date.parse(value["updatedAt"]) + ) { + throw new ArtifactStateError("corrupt", "state operation ledger is malformed", value["revision"], "restore a verified backup or run state repair preflight"); + } + seen.add(operation["id"]); + priorOperationRevision = operation["revision"]; + priorOperationTime = Date.parse(operation["committedAt"]); + } + const payload = validatePayload(expected.kind, value["payload"], limits, value["revision"]); + const contentHash = sha256(stableJson(payload, limits.maxJsonDepth)); + if (contentHash !== value["contentHash"]) { + throw new ArtifactStateError("corrupt", "state payload hash does not match its envelope", value["revision"], "restore a verified backup or run state repair preflight"); + } + return value as unknown as ArtifactStateEnvelope; +} + +async function readEnvelopeLocked( + root: string, + artifactId: string, + kind: ArtifactStateKind, + key: string, + limits: ArtifactStateLimits, +): Promise { + const relativePath = statePath(artifactId, kind, key); + const path = join(root, ...relativePath.split("/")); + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) { + throw new ArtifactStateError("corrupt", "state path is not a regular file", 0, "remove the unsafe path after preserving evidence"); + } + const raw = await readFile(path, "utf8"); + if (utf8Bytes(raw) > encodedStateCap(kind, limits)) { + throw new ArtifactStateError("quota", "encoded state envelope exceeds the read limit", 0, "repair or archive the oversized store"); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new ArtifactStateError("corrupt", "state envelope is not valid JSON", 0, "restore a verified backup or run state repair preflight"); + } + return validateArtifactStateEnvelope(parsed, { artifactId, kind, key }, limits); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } +} + +function virtualEnvelope( + artifactId: string, + kind: ArtifactStateKind, + key: string, + limits: ArtifactStateLimits, +): ArtifactStateEnvelope { + const payload = validatePayload(kind, defaultPayload(kind), limits, 0); + return { + schemaVersion: ARTIFACT_STATE_SCHEMA_VERSION, + artifactId, + kind, + key, + revision: 0, + contentHash: sha256(stableJson(payload, limits.maxJsonDepth)), + payload, + updatedAt: "1970-01-01T00:00:00.000Z", + operations: [], + }; +} + +export async function readArtifactState( + root: string, + artifactId: string, + kind: ArtifactStateKind, + key = "default", + overrides: Partial = {}, +): Promise> { + const limits = mergeLimits(overrides); + statePath(artifactId, kind, key); + await recoverFileTransactions(root); + return (await readEnvelopeLocked(root, artifactId, kind, key, limits) ?? virtualEnvelope(artifactId, kind, key, limits)) as ArtifactStateEnvelope; +} + +function assertMutationInput(expectedRevision: number, expectedHash: string | undefined, operationId: string): void { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new ArtifactStateError("invalid", "expectedRevision must be a non-negative integer", 0, "read the current revision and retry"); + } + if (expectedHash !== undefined && !HASH_RE.test(expectedHash)) { + throw new ArtifactStateError("invalid", "expectedHash must be a SHA-256 digest", 0, "read the current hash and retry"); + } + if (!STATE_OPERATION_ID_RE.test(operationId)) { + throw new ArtifactStateError("invalid", "operationId must be a UUID", 0, "generate one UUID per logical mutation and retain it for retries"); + } +} + +function mutationWarnings(operations: StateOperationRecord[], nowMs: number, limits: ArtifactStateLimits): string[] { + const recent = operations.filter((operation) => nowMs - Date.parse(operation.committedAt) < 60_000); + if (recent.length >= limits.mutationRatePerMinute) { + throw new ArtifactStateError("rate-limit", `mutation rate exceeds ${limits.mutationRatePerMinute} per minute`, operations.at(-1)?.revision ?? 0, "wait for the rolling minute window before retrying"); + } + return recent.length + 1 >= Math.ceil(limits.mutationRatePerMinute * limits.mutationWarningRatio) + ? [`mutation rate is at ${recent.length + 1}/${limits.mutationRatePerMinute} for the rolling minute`] + : []; +} + +async function mutateInternal( + input: ReplaceStateInput, + resolvePayload: (current: ArtifactStateEnvelope) => T, + conflict: (current: ArtifactStateEnvelope) => boolean, + inputDescriptor: unknown, +): Promise> { + const key = input.key ?? "default"; + const limits = mergeLimits(input.limits); + statePath(input.artifactId, input.kind, key); + assertMutationInput(input.expectedRevision, input.expectedHash, input.operationId); + const now = input.now ?? new Date().toISOString(); + if (!validTimestamp(now)) throw new ArtifactStateError("invalid", "mutation time is invalid", 0, "use an ISO timestamp with timezone"); + const inputHash = sha256(stableJson(inputDescriptor, limits.maxJsonDepth)); + return runFileTransaction(input.root, async (transaction) => { + const existing = await readEnvelopeLocked(input.root, input.artifactId, input.kind, key, limits); + const current = (existing ?? virtualEnvelope(input.artifactId, input.kind, key, limits)) as ArtifactStateEnvelope; + const replay = current.operations.find((operation) => operation.id === input.operationId); + if (replay) { + if (replay.inputHash !== inputHash) { + throw new ArtifactStateError("replay-conflict", "operationId was already used for different input", current.revision, "generate a new operationId for a different mutation"); + } + return { + status: "replayed", + revision: replay.revision, + contentHash: replay.contentHash, + envelope: current, + warnings: [], + }; + } + if (conflict(current)) { + throw new ArtifactStateConflictError("state mutation precondition is stale", current, limits.conflictPreviewBytes); + } + const committedAt = Date.parse(now) < Date.parse(current.updatedAt) ? current.updatedAt : now; + const warnings = mutationWarnings(current.operations, Date.parse(committedAt), limits); + const payload = validatePayload(input.kind, resolvePayload(current), limits, current.revision) as T; + const contentHash = sha256(stableJson(payload, limits.maxJsonDepth)); + const revision = current.revision + 1; + const operation: StateOperationRecord = { + id: input.operationId, + inputHash, + revision, + contentHash, + committedAt, + }; + const envelope: ArtifactStateEnvelope = { + schemaVersion: ARTIFACT_STATE_SCHEMA_VERSION, + artifactId: input.artifactId, + kind: input.kind, + key, + revision, + contentHash, + payload, + updatedAt: committedAt, + operations: [...current.operations, operation].slice(-limits.operationRecords), + }; + validateArtifactStateEnvelope(envelope, { artifactId: input.artifactId, kind: input.kind, key }, limits); + const relativePath = statePath(input.artifactId, input.kind, key); + const encoded = `${JSON.stringify(envelope, null, 2)}\n`; + if (utf8Bytes(encoded) > encodedStateCap(input.kind, limits)) { + throw new ArtifactStateError("quota", `${input.kind} envelope exceeds ${encodedStateCap(input.kind, limits)} encoded bytes`, current.revision, "reduce payload or operation-ledger pressure before retrying"); + } + await transaction.commit(new Map([[relativePath, encoded]])); + return { status: "committed", revision, contentHash, envelope, warnings }; + }); +} + +export async function replaceArtifactState( + input: ReplaceStateInput, +): Promise> { + return mutateInternal( + input, + () => input.payload, + (current) => + current.revision !== input.expectedRevision || + (input.expectedHash !== undefined && current.contentHash !== input.expectedHash), + { + kind: input.kind, + key: input.key ?? "default", + expectedRevision: input.expectedRevision, + ...(input.expectedHash === undefined ? {} : { expectedHash: input.expectedHash }), + payload: input.payload, + }, + ); +} + +export function artifactDocumentHash(document: unknown): string { + return sha256(stableJson(document)); +} + +export async function mutateCollectionDocument( + input: MutateCollectionDocumentInput, +): Promise> { + if (!STATE_KEY_RE.test(input.id)) { + throw new ArtifactStateError("invalid", "document ID is unsafe", 0, "use lowercase letters, numbers, and internal hyphens"); + } + if (input.operation === "set" && input.document === undefined) { + throw new ArtifactStateError("invalid", "set requires a JSON document", 0, "supply the document body"); + } + const replacement: ReplaceStateInput = { + root: input.root, + artifactId: input.artifactId, + kind: "collection", + key: input.collection, + expectedRevision: input.expectedRevision, + operationId: input.operationId, + payload: { docs: {} }, + now: input.now, + limits: input.limits, + }; + return mutateInternal( + replacement, + (current) => { + const docs = { ...current.payload.docs }; + if (input.operation === "set") docs[input.id] = normalizeJson(input.document); + else delete docs[input.id]; + return { docs }; + }, + (current) => { + const currentDocument = current.payload.docs[input.id]; + const currentDocumentHash = currentDocument === undefined ? null : artifactDocumentHash(currentDocument); + return currentDocumentHash !== input.expectedDocumentHash; + }, + { + collection: input.collection, + id: input.id, + operation: input.operation, + ...(input.document === undefined ? {} : { document: input.document }), + expectedDocumentHash: input.expectedDocumentHash, + }, + ); +} + +async function legacyMigrationItem( + root: string, + migrationId: string, + artifactId: string, + kind: ArtifactStateKind, + key: string, + sourcePath: string, + now: string, + limits: ArtifactStateLimits, +): Promise { + const path = join(root, ...sourcePath.split("/")); + let bytes: Uint8Array; + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) { + throw new ArtifactStateError("corrupt", `legacy state path ${sourcePath} is unsafe`, 0, "replace the path with a reviewed regular file before migration"); + } + if (info.size > limits.collectionBytes + 1024 * 1024) { + throw new ArtifactStateError("quota", `legacy state ${sourcePath} exceeds the migration read limit`, 0, "archive or reduce the store before migration"); + } + bytes = await readFile(path); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as unknown; + } catch { + throw new ArtifactStateError("corrupt", `legacy state ${sourcePath} is not valid JSON`, 0, "repair from a verified backup before migration"); + } + if (isRecord(parsed) && typeof parsed["schemaVersion"] === "number") { + throw new ArtifactStateError("future-schema", `legacy path ${sourcePath} already carries schemaVersion ${String(parsed["schemaVersion"])}`, 0, "use the matching runtime or an explicit migration adapter"); + } + const payloadRecord = isRecord(parsed) ? parsed : {}; + const payload = kind === "decisions" + ? { answers: payloadRecord["answers"] } + : kind === "comments" + ? { threads: payloadRecord["threads"] } + : { docs: payloadRecord["docs"] }; + const validated = validatePayload(kind, payload, limits, 0); + const contentHash = sha256(stableJson(validated, limits.maxJsonDepth)); + const envelope: ArtifactStateEnvelope = { + schemaVersion: ARTIFACT_STATE_SCHEMA_VERSION, + artifactId, + kind, + key, + revision: 1, + contentHash, + payload: validated, + updatedAt: now, + operations: [], + }; + validateArtifactStateEnvelope(envelope, { artifactId, kind, key }, limits); + const targetPath = statePath(artifactId, kind, key); + const targetText = `${JSON.stringify(envelope, null, 2)}\n`; + if (utf8Bytes(targetText) > encodedStateCap(kind, limits)) { + throw new ArtifactStateError("quota", `migrated ${kind} envelope exceeds ${encodedStateCap(kind, limits)} encoded bytes`, 0, "reduce or partition legacy state before migration"); + } + const existingTarget = await readEnvelopeLocked(root, artifactId, kind, key, limits); + if (existingTarget) { + if (existingTarget.contentHash !== envelope.contentHash) { + throw new ArtifactStateError("stale", `schema-2 target already differs from legacy state: ${targetPath}`, existingTarget.revision, "review both states and choose an explicit repair before migration"); + } + return undefined; + } + return { + sourcePath, + backupPath: `.backups/state-v2/${migrationId}/files/${sourcePath}`, + targetPath, + sourceHash: createHash("sha256").update(bytes).digest("hex"), + targetHash: createHash("sha256").update(targetText).digest("hex"), + envelope, + }; +} + +export async function planLegacyStateMigration( + root: string, + artifactId: string, + slug: string, + options: { migrationId?: string; now?: string; limits?: Partial } = {}, +): Promise { + if (!ARTIFACT_ID_RE.test(artifactId) || !STATE_KEY_RE.test(slug)) { + throw new ArtifactStateError("invalid", "state migration identity or slug is invalid", 0, "use a resolved opaque artifact ID and safe legacy slug"); + } + const migrationId = options.migrationId ?? randomUUID(); + if (!STATE_OPERATION_ID_RE.test(migrationId)) { + throw new ArtifactStateError("invalid", "state migrationId must be a UUID", 0, "generate a UUID for the migration attempt"); + } + const now = options.now ?? new Date().toISOString(); + if (!validTimestamp(now)) throw new ArtifactStateError("invalid", "state migration time is invalid", 0, "use an ISO timestamp with timezone"); + const limits = mergeLimits(options.limits); + await recoverFileTransactions(root); + const items: LegacyStateMigrationItem[] = []; + for (const candidate of [ + await legacyMigrationItem(root, migrationId, artifactId, "decisions", "default", `.state/${slug}.json`, now, limits), + await legacyMigrationItem(root, migrationId, artifactId, "comments", "default", `.state/${slug}.comments.json`, now, limits), + ]) { + if (candidate) items.push(candidate); + } + const dbRoot = join(root, ".db", slug); + const entries = await readdir(dbRoot, { withFileTypes: true }).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return []; + throw error; + }); + if (entries.length > limits.collectionDocuments) { + throw new ArtifactStateError("quota", "legacy collection-file count exceeds the migration limit", 0, "archive collections before migration"); + } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.json$/.exec(entry.name); + if (!entry.isFile() || !match) { + throw new ArtifactStateError("corrupt", `unsafe legacy collection entry ${entry.name}`, 0, "remove or rename the unsupported entry after preserving evidence"); + } + const candidate = await legacyMigrationItem(root, migrationId, artifactId, "collection", match[1], `.db/${slug}/${entry.name}`, now, limits); + if (candidate) items.push(candidate); + } + return { schemaVersion: 1, migrationId, artifactId, slug, createdAt: now, items }; +} + +function stateMigrationInventory(plan: LegacyStateMigrationPlan): string { + return `${JSON.stringify({ + schemaVersion: 1, + migrationId: plan.migrationId, + artifactId: plan.artifactId, + slug: plan.slug, + createdAt: plan.createdAt, + items: plan.items.map(({ sourcePath, backupPath, targetPath, sourceHash, targetHash }) => ({ sourcePath, backupPath, targetPath, sourceHash, targetHash })), + }, null, 2)}\n`; +} + +export async function executeLegacyStateMigration(root: string, plan: LegacyStateMigrationPlan): Promise { + const inventoryPath = `.backups/state-v2/${plan.migrationId}/inventory.json`; + const inventory = stateMigrationInventory(plan); + await runFileTransaction(root, async (transaction) => { + const existing = await readFile(join(root, ...inventoryPath.split("/"))).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + }); + if (existing !== undefined) { + if (Buffer.from(existing).toString("utf8") !== inventory) throw new ArtifactStateError("corrupt", "state migration token conflicts with its inventory", 0, "generate a new plan after reviewing the recorded migration"); + return; + } + await transaction.commit(new Map([[inventoryPath, inventory]])); + }); + for (const item of plan.items) { + await runFileTransaction(root, async (transaction) => { + const source = await readFile(join(root, ...item.sourcePath.split("/"))); + if (createHash("sha256").update(source).digest("hex") !== item.sourceHash) { + throw new ArtifactStateError("stale", `legacy state changed after preflight: ${item.sourcePath}`, 0, "regenerate the migration plan"); + } + const targetText = `${JSON.stringify(item.envelope, null, 2)}\n`; + const existing = await readFile(join(root, ...item.targetPath.split("/"))).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + }); + if (existing !== undefined) { + if (createHash("sha256").update(existing).digest("hex") !== item.targetHash) { + throw new ArtifactStateError("corrupt", `state migration target conflicts: ${item.targetPath}`, 0, "preserve evidence and run repair preflight"); + } + return; + } + await transaction.commit(new Map([[item.backupPath, source], [item.targetPath, targetText]])); + }); + } +} + +export async function rollbackLegacyStateMigration(root: string, plan: LegacyStateMigrationPlan): Promise { + for (const item of [...plan.items].reverse()) { + await runFileTransaction(root, async (transaction) => { + const backup = await readFile(join(root, ...item.backupPath.split("/"))); + const target = await readFile(join(root, ...item.targetPath.split("/"))); + if ( + createHash("sha256").update(backup).digest("hex") !== item.sourceHash || + createHash("sha256").update(target).digest("hex") !== item.targetHash + ) { + throw new ArtifactStateError("corrupt", `state rollback verification failed for ${item.targetPath}`, 0, "preserve both copies and run repair preflight"); + } + await transaction.commit(new Map([[item.targetPath, null]])); + }); + } +} diff --git a/src/cli.ts b/src/cli.ts index 9153557..c609de7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { renderArtifact, renderRawHtml } from "./render.ts"; import { FilePublisher, slugify } from "./publisher.ts"; @@ -16,6 +16,18 @@ import { import { formatFindings, scanArtifactDirectory, scanSensitive } from "./guard.ts"; import { serveArtifacts } from "./serve.ts"; import { openFile } from "./open.ts"; +import { ArtifactLifecycleStore } from "./artifact-lifecycle.ts"; +import { + executeLegacyStateMigration, + planLegacyStateMigration, + readArtifactState, + type DecisionPayload, +} from "./artifact-state.ts"; +import { + executeArtifactMigration, + planArtifactMigration, + rollbackArtifactMigration, +} from "./artifact-migration.ts"; const DEFAULT_DIR = join(".opencode", "artifacts"); @@ -26,6 +38,16 @@ function usage(): never { opencode-artifacts restore --version [--dir ] opencode-artifacts latest [--dir ] [--open] opencode-artifacts state [--dir ] + opencode-artifacts list [--dir ] + opencode-artifacts status [--dir ] + opencode-artifacts read [--revision ] [--dir ] + opencode-artifacts archive --preview [--dir ] + opencode-artifacts archive --confirm [--dir ] + opencode-artifacts unarchive [--slug ] [--dir ] + opencode-artifacts export --output [--dir ] + opencode-artifacts import [--dir ] + opencode-artifacts migrate inspect|apply [--dir ] + opencode-artifacts migrate rollback --migration-id [--dir ] opencode-artifacts deploy --repo [--dir ] [--branch ] [--force] opencode-artifacts deploy --target cloudflare --name [--dir ] [--force] opencode-artifacts init [--global] [--target github|cloudflare] [--repo ] [--worker-name ] [--yes]`); @@ -50,14 +72,29 @@ function positional(args: string[], flagsWithValues: string[]): string[] { return out; } +async function isSchema2Store(dir: string): Promise { + try { + const info = await lstat(join(dir, "manifest.json")); + if (info.isSymbolicLink() || !info.isFile()) throw new Error("artifact manifest path is unsafe"); + const value = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")) as unknown; + return typeof value === "object" && value !== null && "schemaVersion" in value && value.schemaVersion === 2; + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + async function renderCommand(args: string[]): Promise { - const [input] = positional(args, ["-o", "--title", "--format"]); + const [input] = positional(args, ["-o", "--title", "--format", "--artifact", "--expected-revision", "--expected-hash"]); const out = optionValue(args, "-o"); const title = optionValue(args, "--title"); const format = optionValue(args, "--format") ?? "markdown"; const open = args.includes("--open"); const version = args.includes("--version"); const force = args.includes("--force"); + const artifact = optionValue(args, "--artifact"); + const expectedRevisionArg = optionValue(args, "--expected-revision"); + const expectedHash = optionValue(args, "--expected-hash"); if (!input) usage(); if (format !== "markdown" && format !== "html") usage(); @@ -79,18 +116,41 @@ async function renderCommand(args: string[]): Promise { await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, rendered.html.replace("", ""), "utf8"); } else { - const publisher = new FilePublisher(join(process.cwd(), DEFAULT_DIR)); - const result = await publisher.publish({ - slug: slugify(finalTitle), - html: rendered.html, - title: finalTitle, - icon: rendered.meta.icon, - description: rendered.meta.description, - source: rendered.meta.source, - charts: rendered.chartCount, - version, - }); - outPath = result.path; + const root = join(process.cwd(), DEFAULT_DIR); + if (await isSchema2Store(root)) { + const expectedRevision = expectedRevisionArg === undefined ? undefined : Number(expectedRevisionArg); + const result = await new ArtifactLifecycleStore(root).write({ + artifact, + slug: slugify(finalTitle), + html: rendered.html, + title: finalTitle, + icon: rendered.meta.icon, + description: rendered.meta.description, + source: rendered.meta.source, + charts: rendered.chartCount, + expectedRevision, + expectedHash, + authoringSource: markdown, + inputFormat: format, + }); + outPath = result.stablePath ?? join(root, `${result.slug}.html`); + if (args.includes("--version")) console.error("warning: --version is deprecated; schema 2 always retains immutable history"); + } else { + if (artifact !== undefined || expectedRevisionArg !== undefined) throw new Error("artifact references and expected revisions require a migrated schema-2 store"); + const publisher = new FilePublisher(root); + const result = await publisher.publish({ + slug: slugify(finalTitle), + html: rendered.html, + title: finalTitle, + icon: rendered.meta.icon, + description: rendered.meta.description, + source: rendered.meta.source, + charts: rendered.chartCount, + version, + expectedHash, + }); + outPath = result.path; + } } if (open) openFile(outPath); @@ -111,16 +171,24 @@ async function serveCommand(args: string[]): Promise { } async function restoreCommand(args: string[]): Promise { - const [slug] = positional(args, ["--dir", "--version"]); + const [slug] = positional(args, ["--dir", "--version", "--revision", "--expected-revision"]); const dir = optionValue(args, "--dir") ?? DEFAULT_DIR; - const versionArg = optionValue(args, "--version"); + const versionArg = optionValue(args, "--revision") ?? optionValue(args, "--version"); if (!slug || versionArg === undefined) usage(); const version = Number(versionArg); if (!Number.isInteger(version) || version < 1) usage(); - - const publisher = new FilePublisher(resolve(dir)); - const result = await publisher.restore(slug, version); - console.log(result.path); + const root = resolve(dir); + if (await isSchema2Store(root)) { + const expectedArg = optionValue(args, "--expected-revision"); + if (expectedArg === undefined) throw new Error("schema-2 restore requires --expected-revision; history remains unchanged"); + const status = await new ArtifactLifecycleStore(root).restore(slug, version, Number(expectedArg)); + console.log(JSON.stringify(status, null, 2)); + } else { + const publisher = new FilePublisher(root); + const result = await publisher.restore(slug, version); + console.error("warning: legacy restore spelling is deprecated; migrate to schema 2 for expected-head protection"); + console.log(result.path); + } } async function latestCommand(args: string[]): Promise { @@ -140,14 +208,108 @@ async function latestCommand(args: string[]): Promise { async function stateCommand(args: string[]): Promise { const [slug] = positional(args, ["--dir"]); const dir = optionValue(args, "--dir") ?? DEFAULT_DIR; if (!slug) usage(); + const root = resolve(dir); + if (await isSchema2Store(root)) { + const status = await new ArtifactLifecycleStore(root).status(slug); + const envelope = await readArtifactState(root, status.id, "decisions"); + process.stdout.write(`${JSON.stringify({ revision: envelope.revision, contentHash: envelope.contentHash, answers: envelope.payload.answers }, null, 2)}\n`); + return; + } try { - process.stdout.write(await readFile(join(resolve(dir), ".state", `${slug}.json`), "utf8")); + process.stdout.write(await readFile(join(root, ".state", `${slug}.json`), "utf8")); } catch { console.error(`no saved state for artifact '${slug}'`); process.exit(1); } } +async function listCommand(args: string[]): Promise { + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + console.log(JSON.stringify({ schemaVersion: 1, artifacts: await new ArtifactLifecycleStore(root).list() }, null, 2)); +} + +async function statusCommand(args: string[]): Promise { + const [reference] = positional(args, ["--dir"]); + if (!reference) usage(); + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + console.log(JSON.stringify(await new ArtifactLifecycleStore(root).status(reference), null, 2)); +} + +async function readCommand(args: string[]): Promise { + const [reference] = positional(args, ["--dir", "--revision"]); + if (!reference) usage(); + const revisionArg = optionValue(args, "--revision"); + const revision = revisionArg === undefined ? undefined : Number(revisionArg); + if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1)) usage(); + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + const result = await new ArtifactLifecycleStore(root).read(reference, revision); + process.stdout.write(result.html); +} + +async function archiveCommand(args: string[]): Promise { + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + const store = new ArtifactLifecycleStore(root); + const token = optionValue(args, "--confirm"); + if (token !== undefined) { + console.log(JSON.stringify(await store.archive(token), null, 2)); + return; + } + const [reference] = positional(args, ["--dir", "--confirm"]); + if (!reference || !args.includes("--preview")) usage(); + console.log(JSON.stringify(await store.previewArchive(reference), null, 2)); +} + +async function unarchiveCommand(args: string[]): Promise { + const [artifactId] = positional(args, ["--dir", "--slug"]); + if (!artifactId) usage(); + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + console.log(JSON.stringify(await new ArtifactLifecycleStore(root).unarchive(artifactId, optionValue(args, "--slug")), null, 2)); +} + +async function exportCommand(args: string[]): Promise { + const [reference] = positional(args, ["--dir", "--output"]); + const output = optionValue(args, "--output"); + if (!reference || !output) usage(); + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + console.log(JSON.stringify(await new ArtifactLifecycleStore(root).exportBundle(reference, output), null, 2)); +} + +async function importCommand(args: string[]): Promise { + const [bundle] = positional(args, ["--dir"]); + if (!bundle) usage(); + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + console.log(JSON.stringify(await new ArtifactLifecycleStore(root).importBundle(bundle), null, 2)); +} + +async function migrateCommand(args: string[]): Promise { + const [operation] = positional(args, ["--dir", "--migration-id"]); + const root = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); + if (operation === "rollback") { + const migrationId = optionValue(args, "--migration-id"); + if (!migrationId) usage(); + console.log(JSON.stringify(await rollbackArtifactMigration(root, migrationId), null, 2)); + return; + } + if (operation !== "inspect" && operation !== "apply") usage(); + const plan = await planArtifactMigration(root); + if (operation === "inspect") { + console.log(JSON.stringify(plan, null, 2)); + return; + } + const result = await executeArtifactMigration(root, plan); + const manifest = plan.manifest; + const stateMigrations: Array<{ artifactId: string; migrationId: string; stores: number }> = []; + if (manifest) { + for (const artifact of Object.values(manifest.artifacts)) { + const statePlan = await planLegacyStateMigration(root, artifact.id, artifact.slug); + if (statePlan.items.length === 0) continue; + await executeLegacyStateMigration(root, statePlan); + stateMigrations.push({ artifactId: artifact.id, migrationId: statePlan.migrationId, stores: statePlan.items.length }); + } + } + console.log(JSON.stringify({ ...result, stateMigrations }, null, 2)); +} + async function deployCommand(args: string[]): Promise { const target = optionValue(args, "--target") ?? "github"; const dir = resolve(optionValue(args, "--dir") ?? DEFAULT_DIR); @@ -269,6 +431,22 @@ async function main(argv: string[]): Promise { return latestCommand(rest); case "state": return stateCommand(rest); + case "list": + return listCommand(rest); + case "status": + return statusCommand(rest); + case "read": + return readCommand(rest); + case "archive": + return archiveCommand(rest); + case "unarchive": + return unarchiveCommand(rest); + case "export": + return exportCommand(rest); + case "import": + return importCommand(rest); + case "migrate": + return migrateCommand(rest); case "deploy": return deployCommand(rest); case "init": diff --git a/src/file-transaction.ts b/src/file-transaction.ts new file mode 100644 index 0000000..9be2374 --- /dev/null +++ b/src/file-transaction.ts @@ -0,0 +1,721 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, +} from "node:fs/promises"; +import { dirname, join, resolve, sep } from "node:path"; + +export const TRANSACTION_DIRECTORY = ".transactions"; + +const LOCK_DIRECTORY = "lock"; +const OWNER_FILE = "owner.json"; +const FENCE_FILE = "fence"; +const JOURNAL_FILE = "journal.json"; +const JOURNAL_SCHEMA_VERSION = 1; +const DEFAULT_LOCK_TIMEOUT_MS = 5_000; +const DEFAULT_POLL_INTERVAL_MS = 20; +const OWNER_INITIALIZATION_GRACE_MS = 1_000; +const MAX_TRANSACTION_TARGETS = 64; +const MAX_TRANSACTION_BYTES = 64 * 1024 * 1024; +const TRANSACTION_ID_RE = /^\d+-\d+-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const SHA256_RE = /^[0-9a-f]{64}$/; + +export type TransactionFaultPoint = + | "stage-created" + | "target-staged" + | "journal-prepared" + | "commit-decided" + | "target-backed-up" + | "target-replaced" + | "commit-verified" + | "journal-committed"; + +export interface FileTransactionOptions { + signal?: AbortSignal; + lockTimeoutMs?: number; + pollIntervalMs?: number; + fault?: (point: TransactionFaultPoint, target?: string) => void; +} + +export interface FileTransactionContext { + commit(files: ReadonlyMap): Promise; +} + +interface LockOwner { + schemaVersion: 1; + pid: number; + token: string; + fence: number; + createdAt: string; +} + +type JournalState = "prepared" | "committing" | "committed"; + +interface JournalTarget { + path: string; + existed: boolean; + oldHash: string | null; + newHash: string | null; + bytes: number; +} + +interface TransactionJournal { + schemaVersion: 1; + id: string; + fence: number; + state: JournalState; + targets: JournalTarget[]; +} + +interface HeldLock { + token: string; + fence: number; + release(): Promise; +} + +export class TransactionLockTimeoutError extends Error { + readonly root: string; + + constructor(root: string) { + super(`timed out waiting for the artifact transaction lock in ${root}`); + this.name = "TransactionLockTimeoutError"; + this.root = root; + } +} + +export class TransactionRecoveryError extends Error { + readonly transactionId: string; + + constructor(transactionId: string, message: string) { + super(`transaction ${transactionId} cannot be recovered safely: ${message}`); + this.name = "TransactionRecoveryError"; + this.transactionId = transactionId; + } +} + +export class TransactionCommitError extends Error { + readonly transactionId: string; + readonly selectedState = "old" as const; + readonly cause: unknown; + + constructor(transactionId: string, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause); + super(`transaction ${transactionId} did not commit; the old state remains selected: ${detail}`); + this.name = "TransactionCommitError"; + this.transactionId = transactionId; + this.cause = cause; + } +} + +function hashBytes(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function errnoCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +async function exists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (errnoCode(error) === "ENOENT") return false; + throw error; + } +} + +function parseInteger(value: string): number | undefined { + if (!/^\d+$/.test(value.trim())) return undefined; + const parsed = Number(value.trim()); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseLockOwner(value: unknown): LockOwner | undefined { + if (!isRecord(value)) return undefined; + if ( + value["schemaVersion"] !== 1 || + typeof value["pid"] !== "number" || + !Number.isSafeInteger(value["pid"]) || + typeof value["token"] !== "string" || + typeof value["fence"] !== "number" || + !Number.isSafeInteger(value["fence"]) || + typeof value["createdAt"] !== "string" + ) { + return undefined; + } + return { + schemaVersion: 1, + pid: value["pid"], + token: value["token"], + fence: value["fence"], + createdAt: value["createdAt"], + }; +} + +function parseJournal(value: unknown): TransactionJournal | undefined { + if (!isRecord(value)) return undefined; + if ( + value["schemaVersion"] !== JOURNAL_SCHEMA_VERSION || + typeof value["id"] !== "string" || + !TRANSACTION_ID_RE.test(value["id"]) || + typeof value["fence"] !== "number" || + !Number.isSafeInteger(value["fence"]) || + (value["state"] !== "prepared" && + value["state"] !== "committing" && + value["state"] !== "committed") || + !Array.isArray(value["targets"]) + ) { + return undefined; + } + const targets: JournalTarget[] = []; + for (const target of value["targets"]) { + if ( + !isRecord(target) || + typeof target["path"] !== "string" || + typeof target["existed"] !== "boolean" || + (target["oldHash"] !== null && typeof target["oldHash"] !== "string") || + (target["newHash"] !== null && + (typeof target["newHash"] !== "string" || !SHA256_RE.test(target["newHash"]))) || + typeof target["bytes"] !== "number" || + !Number.isSafeInteger(target["bytes"]) || + target["bytes"] < 0 || + (target["existed"] && + (typeof target["oldHash"] !== "string" || !SHA256_RE.test(target["oldHash"]))) || + (!target["existed"] && target["oldHash"] !== null) + ) { + return undefined; + } + targets.push({ + path: target["path"], + existed: target["existed"], + oldHash: target["oldHash"], + newHash: target["newHash"], + bytes: target["bytes"], + }); + } + if (targets.length === 0 || targets.length > MAX_TRANSACTION_TARGETS) return undefined; + if (new Set(targets.map((target) => target.path)).size !== targets.length) return undefined; + if (targets.reduce((sum, target) => sum + target.bytes, 0) > MAX_TRANSACTION_BYTES) { + return undefined; + } + return { + schemaVersion: 1, + id: value["id"], + fence: value["fence"], + state: value["state"], + targets, + }; +} + +function safeSegments(relativePath: string): string[] { + if ( + relativePath === "" || + relativePath.includes("\\") || + relativePath.startsWith("/") || + relativePath.endsWith("/") + ) { + throw new Error(`unsafe transaction target ${JSON.stringify(relativePath)}`); + } + const segments = relativePath.split("/"); + if ( + segments.some( + (segment) => + segment === "" || segment === "." || segment === ".." || segment === TRANSACTION_DIRECTORY, + ) + ) { + throw new Error(`unsafe transaction target ${JSON.stringify(relativePath)}`); + } + return segments; +} + +async function containedPath(root: string, relativePath: string): Promise { + const segments = safeSegments(relativePath); + const rootPath = resolve(root); + let current = rootPath; + for (let index = 0; index < segments.length - 1; index++) { + current = join(current, segments[index]); + try { + const info = await lstat(current); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error(`transaction target parent is not a real directory: ${relativePath}`); + } + } catch (error) { + if (errnoCode(error) !== "ENOENT") throw error; + break; + } + } + const target = join(rootPath, ...segments); + if (target !== rootPath && !target.startsWith(`${rootPath}${sep}`)) { + throw new Error(`transaction target escapes its root: ${relativePath}`); + } + if (await exists(target)) { + const info = await lstat(target); + if (info.isSymbolicLink() || !info.isFile()) { + throw new Error(`transaction target is not a regular file: ${relativePath}`); + } + } + return target; +} + +async function syncFile(path: string): Promise { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function writeDurable(path: string, value: Uint8Array): Promise { + await mkdir(dirname(path), { recursive: true }); + const handle = await open(path, "wx"); + try { + await handle.writeFile(value); + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(dirname(path)); +} + +async function replaceDurable(path: string, value: string): Promise { + const temporary = `${path}.${randomUUID()}.tmp`; + await writeDurable(temporary, Buffer.from(value, "utf8")); + await rename(temporary, path); + await syncDirectory(dirname(path)); +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as unknown; +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return errnoCode(error) === "EPERM"; + } +} + +function abortError(): Error { + const error = new Error("artifact transaction was cancelled"); + error.name = "AbortError"; + return error; +} + +async function wait(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw abortError(); + await new Promise((resolveWait, rejectWait) => { + const onAbort = () => { + clearTimeout(timer); + rejectWait(abortError()); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolveWait(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function stealDeadLock(lockPath: string): Promise { + let owner: LockOwner | undefined; + try { + owner = parseLockOwner(await readJson(join(lockPath, OWNER_FILE))); + if (!owner) throw new Error("artifact transaction lock owner metadata is invalid"); + } catch (error) { + if (errnoCode(error) !== "ENOENT") throw error; + } + if (owner && processIsAlive(owner.pid)) return false; + if (!owner) { + const info = await stat(lockPath).catch((error: unknown) => { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + }); + if (!info) return true; + if (Date.now() - info.mtimeMs < OWNER_INITIALIZATION_GRACE_MS) return false; + } + const stalePath = `${lockPath}.stale-${randomUUID()}`; + try { + await rename(lockPath, stalePath); + } catch (error) { + if (errnoCode(error) === "ENOENT") return true; + throw error; + } + await rm(stalePath, { recursive: true, force: true }); + return true; +} + +async function acquireLock(root: string, options: FileTransactionOptions): Promise { + await mkdir(root, { recursive: true }); + const transactionsPath = join(root, TRANSACTION_DIRECTORY); + await mkdir(transactionsPath, { recursive: true }); + const lockPath = join(transactionsPath, LOCK_DIRECTORY); + const deadline = Date.now() + (options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS); + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + + for (;;) { + if (options.signal?.aborted) throw abortError(); + try { + await mkdir(lockPath); + const priorFence = await readFile(join(transactionsPath, FENCE_FILE), "utf8").then( + (value) => parseInteger(value) ?? 0, + (error: unknown) => { + if (errnoCode(error) === "ENOENT") return 0; + throw error; + }, + ); + const fence = priorFence + 1; + await replaceDurable(join(transactionsPath, FENCE_FILE), `${fence}\n`); + const token = randomUUID(); + const owner: LockOwner = { + schemaVersion: 1, + pid: process.pid, + token, + fence, + createdAt: new Date().toISOString(), + }; + await replaceDurable(join(lockPath, OWNER_FILE), `${JSON.stringify(owner)}\n`); + await syncDirectory(lockPath); + let released = false; + return { + token, + fence, + async release() { + if (released) return; + released = true; + let current: LockOwner | undefined; + try { + current = parseLockOwner(await readJson(join(lockPath, OWNER_FILE))); + } catch (error) { + if (errnoCode(error) !== "ENOENT") throw error; + } + if (current?.token === token) { + await rm(lockPath, { recursive: true, force: true }); + await syncDirectory(transactionsPath); + } + }, + }; + } catch (error) { + if (errnoCode(error) !== "EEXIST") throw error; + } + + if (await stealDeadLock(lockPath)) continue; + if (Date.now() >= deadline) throw new TransactionLockTimeoutError(root); + await wait(pollInterval, options.signal); + } +} + +async function assertLock(root: string, lock: HeldLock): Promise { + const transactionsPath = join(root, TRANSACTION_DIRECTORY); + const owner = parseLockOwner(await readJson(join(transactionsPath, LOCK_DIRECTORY, OWNER_FILE))); + const fence = parseInteger(await readFile(join(transactionsPath, FENCE_FILE), "utf8")); + if (!owner || owner.token !== lock.token || owner.fence !== lock.fence || fence !== lock.fence) { + throw new Error("artifact transaction lock ownership changed before commit"); + } +} + +async function writeJournal(transactionPath: string, journal: TransactionJournal): Promise { + await replaceDurable(join(transactionPath, JOURNAL_FILE), `${JSON.stringify(journal, null, 2)}\n`); + await syncDirectory(transactionPath); +} + +async function targetHash(path: string): Promise { + try { + return hashBytes(await readFile(path)); + } catch (error) { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + } +} + +function matchesSelectedHash(actual: string | undefined, expected: string | null): boolean { + return expected === null ? actual === undefined : actual === expected; +} + +async function restorePrepared(root: string, transactionPath: string, journal: TransactionJournal): Promise { + for (const target of journal.targets) { + const destination = await containedPath(root, target.path); + const backup = join(transactionPath, "old", ...safeSegments(target.path)); + const current = await targetHash(destination); + if (target.existed) { + if (current === target.oldHash) continue; + if (await exists(backup)) { + await mkdir(dirname(destination), { recursive: true }); + if (current !== undefined) await rm(destination, { force: true }); + await rename(backup, destination); + await syncDirectory(dirname(backup)); + await syncDirectory(dirname(destination)); + } else { + throw new TransactionRecoveryError(journal.id, `old bytes missing for ${target.path}`); + } + } else if (current !== undefined) { + if (!matchesSelectedHash(current, target.newHash)) { + throw new TransactionRecoveryError(journal.id, `unexpected new bytes for ${target.path}`); + } + await rm(destination, { force: true }); + await syncDirectory(dirname(destination)); + } + const restored = await targetHash(destination); + if ((target.existed && restored !== target.oldHash) || (!target.existed && restored !== undefined)) { + throw new TransactionRecoveryError(journal.id, `rollback verification failed for ${target.path}`); + } + } + await rm(transactionPath, { recursive: true, force: true }); + await syncDirectory(join(root, TRANSACTION_DIRECTORY)); +} + +async function rollForward(root: string, transactionPath: string, journal: TransactionJournal): Promise { + for (const target of journal.targets) { + const destination = await containedPath(root, target.path); + const staged = join(transactionPath, "new", ...safeSegments(target.path)); + const backup = join(transactionPath, "old", ...safeSegments(target.path)); + const current = await targetHash(destination); + if (matchesSelectedHash(current, target.newHash)) continue; + if ( + target.newHash !== null && + (!(await exists(staged)) || (await targetHash(staged)) !== target.newHash) + ) { + throw new TransactionRecoveryError(journal.id, `staged bytes missing for ${target.path}`); + } + if (current !== undefined) { + if (!target.existed || current !== target.oldHash) { + throw new TransactionRecoveryError(journal.id, `unexpected selected bytes for ${target.path}`); + } + await mkdir(dirname(backup), { recursive: true }); + if (!(await exists(backup))) await rename(destination, backup); + else await rm(destination, { force: true }); + await syncDirectory(dirname(backup)); + await syncDirectory(dirname(destination)); + } + if (target.newHash !== null) { + await mkdir(dirname(destination), { recursive: true }); + await rename(staged, destination); + await syncFile(destination); + await syncDirectory(dirname(staged)); + await syncDirectory(dirname(destination)); + } + } + for (const target of journal.targets) { + const destination = await containedPath(root, target.path); + if (!matchesSelectedHash(await targetHash(destination), target.newHash)) { + throw new TransactionRecoveryError(journal.id, `commit verification failed for ${target.path}`); + } + } + const committed: TransactionJournal = { ...journal, state: "committed" }; + await writeJournal(transactionPath, committed); + await rm(transactionPath, { recursive: true, force: true }); + await syncDirectory(join(root, TRANSACTION_DIRECTORY)); +} + +async function recoverHeld(root: string): Promise { + const transactionsPath = join(root, TRANSACTION_DIRECTORY); + const entries = await readdir(transactionsPath, { withFileTypes: true }); + const candidates = entries + .filter( + (entry) => + entry.isDirectory() && entry.name !== LOCK_DIRECTORY && !entry.name.startsWith(`${LOCK_DIRECTORY}.stale-`), + ) + .map((entry) => entry.name) + .sort(); + for (const name of candidates) { + if (!TRANSACTION_ID_RE.test(name)) { + throw new TransactionRecoveryError(name, "unexpected directory in the transaction store"); + } + const transactionPath = join(transactionsPath, name); + let journal: TransactionJournal | undefined; + try { + journal = parseJournal(await readJson(join(transactionPath, JOURNAL_FILE))); + } catch (error) { + if (errnoCode(error) === "ENOENT") { + await rm(transactionPath, { recursive: true, force: true }); + continue; + } + throw new TransactionRecoveryError(name, "journal is unreadable"); + } + if (!journal || journal.id !== name) { + throw new TransactionRecoveryError(name, "journal is malformed or mismatched"); + } + for (const target of journal.targets) safeSegments(target.path); + if (journal.state === "prepared") await restorePrepared(root, transactionPath, journal); + else await rollForward(root, transactionPath, journal); + } +} + +async function commitHeld( + root: string, + lock: HeldLock, + files: ReadonlyMap, + options: FileTransactionOptions, +): Promise { + if (files.size === 0) throw new Error("artifact transaction has no target files"); + if (files.size > MAX_TRANSACTION_TARGETS) { + throw new Error(`artifact transaction has ${files.size} targets; limit is ${MAX_TRANSACTION_TARGETS}`); + } + const normalized = [...files.entries()] + .map( + ([path, value]) => + [ + path, + value === null + ? null + : typeof value === "string" + ? Buffer.from(value, "utf8") + : Buffer.from(value), + ] as const, + ) + .sort(([left], [right]) => left.localeCompare(right)); + const totalBytes = normalized.reduce((sum, [, value]) => sum + (value?.byteLength ?? 0), 0); + if (totalBytes > MAX_TRANSACTION_BYTES) { + throw new Error(`artifact transaction has ${totalBytes} bytes; limit is ${MAX_TRANSACTION_BYTES}`); + } + const unique = new Set(normalized.map(([path]) => path)); + if (unique.size !== normalized.length) throw new Error("artifact transaction contains duplicate targets"); + for (const [path] of normalized) await containedPath(root, path); + await assertLock(root, lock); + + const id = `${Date.now()}-${process.pid}-${randomUUID()}`; + const transactionPath = join(root, TRANSACTION_DIRECTORY, id); + await mkdir(join(transactionPath, "new"), { recursive: true }); + await mkdir(join(transactionPath, "old"), { recursive: true }); + options.fault?.("stage-created"); + + const targets: JournalTarget[] = []; + for (const [path, value] of normalized) { + const destination = await containedPath(root, path); + const current = await readFile(destination).then( + (bytes) => bytes, + (error: unknown) => { + if (errnoCode(error) === "ENOENT") return undefined; + throw error; + }, + ); + if (value !== null) { + await writeDurable(join(transactionPath, "new", ...safeSegments(path)), value); + } + targets.push({ + path, + existed: current !== undefined, + oldHash: current === undefined ? null : hashBytes(current), + newHash: value === null ? null : hashBytes(value), + bytes: value?.byteLength ?? 0, + }); + options.fault?.("target-staged", path); + } + + let journal: TransactionJournal = { + schemaVersion: JOURNAL_SCHEMA_VERSION, + id, + fence: lock.fence, + state: "prepared", + targets, + }; + try { + await writeJournal(transactionPath, journal); + options.fault?.("journal-prepared"); + await assertLock(root, lock); + journal = { ...journal, state: "committing" }; + await writeJournal(transactionPath, journal); + options.fault?.("commit-decided"); + + for (const target of targets) { + await assertLock(root, lock); + const destination = await containedPath(root, target.path); + const backup = join(transactionPath, "old", ...safeSegments(target.path)); + const staged = join(transactionPath, "new", ...safeSegments(target.path)); + if (target.existed) { + await mkdir(dirname(backup), { recursive: true }); + await rename(destination, backup); + await syncDirectory(dirname(backup)); + await syncDirectory(dirname(destination)); + options.fault?.("target-backed-up", target.path); + } + if (target.newHash !== null) { + await mkdir(dirname(destination), { recursive: true }); + await rename(staged, destination); + await syncFile(destination); + await syncDirectory(dirname(staged)); + await syncDirectory(dirname(destination)); + } + options.fault?.("target-replaced", target.path); + } + + for (const target of targets) { + const destination = await containedPath(root, target.path); + if (!matchesSelectedHash(await targetHash(destination), target.newHash)) { + throw new TransactionRecoveryError(id, `verification failed for ${target.path}`); + } + } + options.fault?.("commit-verified"); + journal = { ...journal, state: "committed" }; + await writeJournal(transactionPath, journal); + options.fault?.("journal-committed"); + await rm(transactionPath, { recursive: true, force: true }); + await syncDirectory(join(root, TRANSACTION_DIRECTORY)); + } catch (error) { + await recoverHeld(root); + const selectedNew = + targets.length > 0 && + (await Promise.all( + targets.map(async (target) => { + const destination = await containedPath(root, target.path); + return matchesSelectedHash(await targetHash(destination), target.newHash); + }), + )).every(Boolean); + if (selectedNew) return; + throw new TransactionCommitError(id, error); + } +} + +export async function runFileTransaction( + root: string, + operation: (context: FileTransactionContext) => Promise, + options: FileTransactionOptions = {}, +): Promise { + const resolvedRoot = resolve(root); + const lock = await acquireLock(resolvedRoot, options); + try { + await recoverHeld(resolvedRoot); + let committed = false; + const result = await operation({ + async commit(files) { + if (committed) throw new Error("artifact transaction already committed"); + committed = true; + await commitHeld(resolvedRoot, lock, files, options); + }, + }); + return result; + } finally { + await lock.release(); + } +} + +export async function recoverFileTransactions( + root: string, + options: FileTransactionOptions = {}, +): Promise { + await runFileTransaction(root, async () => {}, options); +} diff --git a/src/github-pages.ts b/src/github-pages.ts index 38b9a50..b7c6795 100644 --- a/src/github-pages.ts +++ b/src/github-pages.ts @@ -30,7 +30,18 @@ export function pagesBaseUrl(repo: string): string { return `https://${owner}.github.io/${name}/`; } -const SKIP_ENTRIES = new Set([".git", ".state", ".db", ".datasources"]); +const SKIP_ENTRIES = new Set([ + ".git", + ".state", + ".db", + ".datasources", + ".transactions", + ".backups", + ".migrations", + ".sources", + ".archives", + ".archive-previews", +]); export async function copyArtifacts(fromDir: string, toDir: string): Promise { await mkdir(toDir, { recursive: true }); diff --git a/src/plugin.ts b/src/plugin.ts index e686b3d..45e2c1a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,7 +1,7 @@ -import { join, dirname } from "node:path"; +import { join, dirname, resolve } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { tool, type Hooks, type Plugin } from "@opencode-ai/plugin"; import { ArtifactTooLargeError, @@ -16,6 +16,25 @@ import { loadConfig, resolveDeploy } from "./config.ts"; import { formatFindings, scanSensitive } from "./guard.ts"; import { NAME_RE, readCollection, writeCollection } from "./serve.ts"; import { openFile } from "./open.ts"; +import { + ArtifactStateError, + STATE_KEY_RE, + artifactDocumentHash, + mutateCollectionDocument, + readArtifactState, + replaceArtifactState, + type CollectionPayload, + type CommentPayload, + type DecisionPayload, +} from "./artifact-state.ts"; +import { + ArtifactMigrationRequiredError, + readArtifactManifestV2, +} from "./artifact-schema.ts"; +import { + ArtifactLifecycleConflictError, + ArtifactLifecycleStore, +} from "./artifact-lifecycle.ts"; function ghPagesCloneDir(repo: string): string { return join(homedir(), ".cache", "opencode-artifacts", "ghpages", repo.replace("/", "__")); @@ -29,6 +48,43 @@ function workRoot(ctx: { directory: string; worktree: string }): string { return ctx.worktree === "/" ? ctx.directory : ctx.worktree; } +async function stateArtifactId(root: string, slug: string): Promise { + try { + await lstat(join(root, "manifest.json")); + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined; + throw error; + } + try { + const manifest = await readArtifactManifestV2(root); + const artifactId = manifest.slugIndex[slug]; + if (!artifactId) throw new ArtifactStateError("invalid", `unknown artifact slug ${slug}`, 0, "list artifacts and use an active reference"); + return artifactId; + } catch (error) { + if (error instanceof ArtifactMigrationRequiredError) return undefined; + throw error; + } +} + +async function isSchema2Store(root: string): Promise { + try { + const info = await lstat(join(root, "manifest.json")); + if (info.isSymbolicLink() || !info.isFile()) throw new Error("artifact manifest path is unsafe"); + const value = JSON.parse(await readFile(join(root, "manifest.json"), "utf8")) as unknown; + return typeof value === "object" && value !== null && "schemaVersion" in value && value.schemaVersion === 2; + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +function stateToolFailure(error: unknown): string { + if (error instanceof ArtifactStateError) { + return JSON.stringify({ error: error.code, revision: error.selectedRevision, message: error.message, nextAction: error.nextAction }, null, 2); + } + throw error; +} + const PROACTIVE_FALLBACK = `## Artifact pages You have the artifact_publish tool (opencode-artifacts). A finished deliverable with an audience — a report, a plan others will follow, a reference document — is not fully delivered @@ -87,6 +143,14 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { .describe( "Hash from a previous publish result; publishing fails with a conflict if the artifact changed since", ), + artifact: tool.schema + .string() + .optional() + .describe("Exact artifact ID, active slug, contained path, or registered URL for an update"), + expectedRevision: tool.schema + .number() + .optional() + .describe("Expected schema-2 head revision for an update"), force: tool.schema .boolean() .optional() @@ -143,43 +207,55 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { }); const localDir = join(workRoot(ctx), ".opencode", "artifacts"); - const publisher = await (async () => { - if (!args.deploy) return new FilePublisher(localDir); - const config = await loadConfig(workRoot(ctx)); - const resolved = resolveDeploy( - { repo: args.repo, target: args.target, workerName: args.workerName }, - config, - ); - if (resolved.target === "github" && resolved.repo) { - return new GitHubPagesPublisher(localDir, { - repo: resolved.repo, - branch: resolved.branch, - cloneDir: ghPagesCloneDir(resolved.repo), - allowSensitive: args.force === true, - }); - } - if (resolved.target === "cloudflare" && resolved.workerName) { - return new CloudflarePublisher(localDir, { - workerName: resolved.workerName, - stagingDir: cfStagingDir(resolved.workerName), - allowSensitive: args.force === true, - }); + let result: { path: string; version: number; gallery: string; hash: string; url?: string }; + if (await isSchema2Store(localDir)) { + const lifecycle = new ArtifactLifecycleStore(localDir); + let status = await lifecycle.write({ + artifact: args.artifact, + slug, + html: rendered.html, + title, + icon: rendered.meta.icon, + description: rendered.meta.description, + source: rendered.meta.source, + charts: rendered.chartCount, + expectedRevision: args.expectedRevision, + expectedHash: args.expectedHash, + authoringSource: args.markdown, + inputFormat: args.format ?? "markdown", + }); + let url: string | undefined; + if (args.deploy) { + const config = await loadConfig(workRoot(ctx)); + const resolved = resolveDeploy({ repo: args.repo, target: args.target, workerName: args.workerName }, config); + if (resolved.target === "github" && resolved.repo) { + const adapter = new GitHubPagesPublisher(localDir, { repo: resolved.repo, branch: resolved.branch, cloneDir: ghPagesCloneDir(resolved.repo), allowSensitive: args.force === true }); + const base = await adapter.sync(`publish ${slug} v${status.headRevision}`); + url = `${base}${slug}.html`; + status = await lifecycle.recordDeployment(status.id, { capability: "public-static", target: `github:${resolved.repo}:${resolved.branch}`, url }); + } else if (resolved.target === "cloudflare" && resolved.workerName) { + const adapter = new CloudflarePublisher(localDir, { workerName: resolved.workerName, stagingDir: cfStagingDir(resolved.workerName), allowSensitive: args.force === true }); + const base = await adapter.deploy(); + url = base === undefined ? undefined : `${base}/${slug}.html`; + if (url) status = await lifecycle.recordDeployment(status.id, { capability: "public-static", target: `cloudflare:${resolved.workerName}`, url }); + } else { + throw new Error(`deploy target '${resolved.target}' is missing its ${resolved.target === "github" ? "repo" : "workerName"} — run \`opencode-artifacts init\``); + } } - throw new Error( - `deploy target '${resolved.target}' is missing its ${resolved.target === "github" ? "repo" : "workerName"} — run \`opencode-artifacts init\``, - ); - })(); - const result = await publisher.publish({ - slug, - html: rendered.html, - title, - icon: rendered.meta.icon, - description: rendered.meta.description, - source: rendered.meta.source, - charts: rendered.chartCount, - version: args.version ?? false, - expectedHash: args.expectedHash, - }); + if (args.version !== undefined) console.error("artifact_publish version is deprecated for schema 2; immutable history is always retained"); + result = { path: status.stablePath ?? join(localDir, `${status.slug}.html`), version: status.headRevision, gallery: join(localDir, "index.html"), hash: status.contentHash.slice(0, 12), ...(url === undefined ? {} : { url }) }; + } else { + if (args.artifact !== undefined || args.expectedRevision !== undefined) throw new Error("artifact and expectedRevision require a migrated schema-2 store"); + const publisher = await (async () => { + if (!args.deploy) return new FilePublisher(localDir); + const config = await loadConfig(workRoot(ctx)); + const resolved = resolveDeploy({ repo: args.repo, target: args.target, workerName: args.workerName }, config); + if (resolved.target === "github" && resolved.repo) return new GitHubPagesPublisher(localDir, { repo: resolved.repo, branch: resolved.branch, cloneDir: ghPagesCloneDir(resolved.repo), allowSensitive: args.force === true }); + if (resolved.target === "cloudflare" && resolved.workerName) return new CloudflarePublisher(localDir, { workerName: resolved.workerName, stagingDir: cfStagingDir(resolved.workerName), allowSensitive: args.force === true }); + throw new Error(`deploy target '${resolved.target}' is missing its ${resolved.target === "github" ? "repo" : "workerName"} — run \`opencode-artifacts init\``); + })(); + result = await publisher.publish({ slug, html: rendered.html, title, icon: rendered.meta.icon, description: rendered.meta.description, source: rendered.meta.source, charts: rendered.chartCount, version: args.version ?? false, expectedHash: args.expectedHash }); + } if (args.open) openFile(result.path); if (args.dataSources && args.dataSources.length > 0) { const registryDir = join(workRoot(ctx), ".opencode", "artifacts", ".datasources"); @@ -229,10 +305,70 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { "[End of live content]", ].join("\n\n"); } + if (err instanceof ArtifactLifecycleConflictError) { + return JSON.stringify({ error: "stale", message: err.message, artifact: err.artifact, merge: err.merge, nextAction: "merge onto the returned immutable input and retry with artifact plus expectedRevision/hash" }, null, 2); + } throw err; } }, }), + artifact_lifecycle: tool({ + description: "List, inspect, read, restore, archive/unarchive, export, or import schema-2 artifacts by exact identity/reference. Archive is recoverable and requires a preview-bound permission confirmation.", + args: { + op: tool.schema.enum(["list", "status", "read", "restore", "archive-preview", "archive-confirm", "unarchive", "export", "import"]), + artifact: tool.schema.string().optional().describe("Exact artifact reference or opaque ID"), + revision: tool.schema.number().optional().describe("Revision to read or restore"), + expectedRevision: tool.schema.number().optional().describe("Expected current head for restore"), + token: tool.schema.string().optional().describe("One-use archive confirmation token"), + slug: tool.schema.string().optional().describe("Explicit non-conflicting slug for unarchive"), + path: tool.schema.string().optional().describe("Export destination or import bundle directory"), + }, + async execute(args, ctx) { + const root = join(workRoot(ctx), ".opencode", "artifacts"); + if (!(await isSchema2Store(root))) return "artifact_lifecycle requires a migrated schema-2 store"; + const lifecycle = new ArtifactLifecycleStore(root); + switch (args.op) { + case "list": + return JSON.stringify({ schemaVersion: 1, artifacts: await lifecycle.list() }, null, 2); + case "status": + if (!args.artifact) return "status requires artifact"; + return JSON.stringify(await lifecycle.status(args.artifact), null, 2); + case "read": { + if (!args.artifact) return "read requires artifact"; + const result = await lifecycle.read(args.artifact, args.revision); + const bytes = Buffer.byteLength(result.html, "utf8"); + return JSON.stringify({ status: result.status, revision: result.revision, ...(bytes <= 256 * 1024 ? { html: result.html } : { pinnedPath: join(root, ...result.revision.pagePath.split("/")), preview: `${result.html.slice(0, 8192)}\n…\n${result.html.slice(-8192)}` }) }, null, 2); + } + case "restore": + if (!args.artifact || args.revision === undefined || args.expectedRevision === undefined) return "restore requires artifact, revision, and expectedRevision"; + try { + return JSON.stringify(await lifecycle.restore(args.artifact, args.revision, args.expectedRevision), null, 2); + } catch (error) { + if (error instanceof ArtifactLifecycleConflictError) return JSON.stringify({ error: "stale", message: error.message, artifact: error.artifact, merge: error.merge }, null, 2); + throw error; + } + case "archive-preview": + if (!args.artifact) return "archive-preview requires artifact"; + return JSON.stringify(await lifecycle.previewArchive(args.artifact), null, 2); + case "archive-confirm": { + if (!args.token) return "archive-confirm requires token"; + const preview = await lifecycle.inspectArchivePreview(args.token); + await ctx.ask({ permission: "artifact_archive", patterns: [preview.artifact.id, args.token], always: [], metadata: { artifactId: preview.artifact.id, slug: preview.artifact.slug, headRevision: preview.artifact.headRevision, token: args.token } }); + return JSON.stringify(await lifecycle.archive(args.token), null, 2); + } + case "unarchive": + if (!args.artifact) return "unarchive requires artifact ID"; + return JSON.stringify(await lifecycle.unarchive(args.artifact, args.slug), null, 2); + case "export": + if (!args.artifact || !args.path) return "export requires artifact and path"; + return JSON.stringify(await lifecycle.exportBundle(args.artifact, args.path), null, 2); + case "import": + if (!args.path) return "import requires path"; + await ctx.ask({ permission: "artifact_import", patterns: [resolve(args.path)], always: [], metadata: { bundle: resolve(args.path) } }); + return JSON.stringify(await lifecycle.importBundle(args.path), null, 2); + } + }, + }), artifact_db: tool({ description: [ "Read or write an artifact's shared mini-database (collections of JSON documents,", @@ -249,12 +385,59 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { .string() .optional() .describe("Equality filter for list, as field:value"), + expectedRevision: tool.schema.number().optional().describe("Current collection revision for set/delete"), + expectedDocumentHash: tool.schema.string().optional().describe("Current document SHA-256 for update/delete"), + createOnly: tool.schema.boolean().optional().describe("Require the document ID to be absent"), + operationId: tool.schema.string().optional().describe("UUID retained across retries of one mutation"), }, async execute(args, ctx) { - if (!NAME_RE.test(args.slug) || !NAME_RE.test(args.collection)) { - return "slug and collection must contain only lowercase letters, numbers, and hyphens"; + if (!STATE_KEY_RE.test(args.slug) || !STATE_KEY_RE.test(args.collection) || (args.id !== undefined && !STATE_KEY_RE.test(args.id))) { + return "slug and collection must be safe lowercase identifiers; document id must follow the same rule"; } const root = join(workRoot(ctx), ".opencode", "artifacts"); + const artifactId = await stateArtifactId(root, args.slug); + if (artifactId !== undefined) { + try { + const envelope = await readArtifactState(root, artifactId, "collection", args.collection); + if (args.op === "get") { + if (!args.id) return "get requires id"; + const doc = envelope.payload.docs[args.id]; + return doc === undefined + ? `No document '${args.id}' in ${args.slug}/${args.collection}. Current revision: ${envelope.revision}.` + : JSON.stringify({ id: args.id, doc, hash: artifactDocumentHash(doc), revision: envelope.revision }, null, 2); + } + if (args.op === "list") { + let entries = Object.entries(envelope.payload.docs); + if (args.q) { + const [field, ...rest] = args.q.split(":"); + const want = rest.join(":"); + entries = entries.filter(([, doc]) => typeof doc === "object" && doc !== null && String((doc as Record)[field]) === want); + } + return JSON.stringify({ revision: envelope.revision, contentHash: envelope.contentHash, docs: entries.map(([id, doc]) => ({ id, doc, hash: artifactDocumentHash(doc) })) }, null, 2); + } + if (!args.id) return `${args.op} requires id`; + if (args.expectedRevision === undefined || args.operationId === undefined) { + return `${args.op} requires expectedRevision and operationId`; + } + if (args.createOnly === true && args.expectedDocumentHash !== undefined) { + return "createOnly and expectedDocumentHash are mutually exclusive"; + } + const result = await mutateCollectionDocument({ + root, + artifactId, + collection: args.collection, + id: args.id, + operation: args.op, + ...(args.op === "set" ? { document: args.doc ?? null } : {}), + expectedRevision: args.expectedRevision, + expectedDocumentHash: args.createOnly === true ? null : args.expectedDocumentHash ?? null, + operationId: args.operationId, + }); + return JSON.stringify({ status: result.status, revision: result.revision, contentHash: result.contentHash, warnings: result.warnings }, null, 2); + } catch (error) { + return stateToolFailure(error); + } + } const store = await readCollection(root, args.slug, args.collection); switch (args.op) { case "get": { @@ -303,10 +486,19 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { slug: tool.schema.string().describe("Artifact slug (the filename without .html)"), }, async execute(args, ctx) { + if (!STATE_KEY_RE.test(args.slug)) return "slug must be a safe lowercase identifier"; + const root = join(workRoot(ctx), ".opencode", "artifacts"); + const artifactId = await stateArtifactId(root, args.slug); + if (artifactId !== undefined) { + try { + const envelope = await readArtifactState(root, artifactId, "decisions"); + return JSON.stringify({ revision: envelope.revision, contentHash: envelope.contentHash, answers: envelope.payload.answers }, null, 2); + } catch (error) { + return stateToolFailure(error); + } + } const statePath = join( - workRoot(ctx), - ".opencode", - "artifacts", + root, ".state", `${args.slug}.json`, ); @@ -330,12 +522,46 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { .boolean() .optional() .describe("Return a compact triage digest instead of raw threads"), + expectedRevision: tool.schema.number().optional().describe("Current comment-store revision required with resolveId"), + expectedHash: tool.schema.string().optional().describe("Current comment-store hash required with resolveId"), + operationId: tool.schema.string().optional().describe("UUID retained across retries of one resolve mutation"), }, async execute(args, ctx) { + if (!STATE_KEY_RE.test(args.slug)) return "slug must be a safe lowercase identifier"; + const root = join(workRoot(ctx), ".opencode", "artifacts"); + const artifactId = await stateArtifactId(root, args.slug); + if (artifactId !== undefined) { + try { + const envelope = await readArtifactState(root, artifactId, "comments"); + const threads = envelope.payload.threads; + if (args.resolveId !== undefined) { + if (args.expectedRevision === undefined || args.operationId === undefined) { + return "resolveId requires expectedRevision and operationId"; + } + const target = threads.find((thread) => thread.id === args.resolveId); + if (!target) return `No comment thread '${args.resolveId}' on '${args.slug}'.`; + const result = await replaceArtifactState({ + root, + artifactId, + kind: "comments", + expectedRevision: args.expectedRevision, + ...(args.expectedHash === undefined ? {} : { expectedHash: args.expectedHash }), + operationId: args.operationId, + payload: { threads: threads.map((thread) => thread.id === args.resolveId ? { ...thread, resolved: true } : thread) }, + }); + return JSON.stringify({ status: result.status, revision: result.revision, contentHash: result.contentHash }, null, 2); + } + if (args.digest === true) { + const open = threads.filter((thread) => !thread.resolved); + return [`${open.length} open, ${threads.length - open.length} resolved on '${args.slug}'.`, ...open.map((thread) => `- [${thread.id}] "${thread.quote.slice(0, 60)}" — ${thread.text.slice(0, 120)}`)].join("\n"); + } + return JSON.stringify({ revision: envelope.revision, contentHash: envelope.contentHash, threads }, null, 2); + } catch (error) { + return stateToolFailure(error); + } + } const threadsPath = join( - workRoot(ctx), - ".opencode", - "artifacts", + root, ".state", `${args.slug}.comments.json`, ); diff --git a/src/publisher.ts b/src/publisher.ts index 4c720ba..c8cd2c2 100644 --- a/src/publisher.ts +++ b/src/publisher.ts @@ -1,7 +1,22 @@ -import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { join, resolve } from "node:path"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; import { renderGallery } from "./gallery.ts"; +import { + recoverFileTransactions, + runFileTransaction, + type FileTransactionContext, +} from "./file-transaction.ts"; +import { + ARTIFACT_ID_RE, + ARTIFACT_MANIFEST_FILE, + emptyArtifactManifestV2, + parseArtifactManifestV2, + validateArtifactManifestV2, + type ArtifactManifestV2, + type ArtifactRecordV2, + type RevisionRecordV2, +} from "./artifact-schema.ts"; import { ArtifactTooLargeError, DEFAULT_MAX_BYTES, @@ -15,6 +30,10 @@ export function contentHash(html: string): string { return createHash("sha256").update(html, "utf8").digest("hex").slice(0, 12); } +export function fullContentHash(html: string): string { + return createHash("sha256").update(html, "utf8").digest("hex"); +} + export class StaleArtifactError extends Error { readonly currentHash: string; constructor(slug: string, currentHash: string) { @@ -67,26 +86,13 @@ export interface Publisher { publish(input: PublishInput): Promise; } +export interface FilePublisherOptions { + schemaVersion?: 1 | 2; + artifactIdFactory?: () => string; +} + const MANIFEST_FILE = "manifest.json"; const GALLERY_FILE = "index.html"; -const directoryWrites = new Map>(); - -async function serializeDirectory(dir: string, operation: () => Promise): Promise { - const key = resolve(dir); - const previous = directoryWrites.get(key) ?? Promise.resolve(); - let release: () => void = () => {}; - const current = new Promise((done) => { - release = done; - }); - directoryWrites.set(key, current); - await previous; - try { - return await operation(); - } finally { - release(); - if (directoryWrites.get(key) === current) directoryWrites.delete(key); - } -} async function readManifest(dir: string): Promise { try { @@ -100,6 +106,48 @@ async function readManifest(dir: string): Promise { } } +async function readManifestV2Locked(dir: string): Promise { + const path = join(dir, ARTIFACT_MANIFEST_FILE); + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) { + throw new Error("artifact manifest path must be a regular file"); + } + return parseArtifactManifestV2(await readFile(path, "utf8")); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return emptyArtifactManifestV2(); + } + throw error; + } +} + +function manifestV2GalleryView(manifest: ArtifactManifestV2): Manifest { + const artifacts: Record = {}; + for (const artifact of Object.values(manifest.artifacts)) { + artifacts[artifact.slug] = { + slug: artifact.slug, + title: artifact.title, + icon: artifact.icon, + description: artifact.description, + source: artifact.source, + createdAt: artifact.createdAt ?? "unknown", + updatedAt: artifact.updatedAt ?? "unknown", + current: artifact.headRevision, + versions: artifact.revisions.map((revision) => revision.revision), + charts: artifact.charts, + bytes: artifact.bytes, + hash: artifact.contentHash.slice(0, 12), + }; + } + return { artifacts }; +} + function footerHtml(meta: ArtifactMeta): string { return [ '