From d5bce7bcf4cb4b440a5c3e360f859ac440928f18 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 18 Jul 2026 15:23:22 -0400 Subject: [PATCH 1/6] feat: add SQL authoritative host storage --- .changeset/sql-host-storage.md | 6 + CONCEPTS.md | 4 +- CONTEXT.md | 32 + README.md | 1 + .../src/components/DashboardApp.test.tsx | 8 +- .../dashboard/src/components/DashboardApp.tsx | 28 +- .../src/components/StoredCapletsPage.test.tsx | 401 +++ .../src/components/StoredCapletsPage.tsx | 1125 +++++++ apps/dashboard/src/components/ui/empty.tsx | 94 + apps/dashboard/src/components/ui/field.tsx | 222 ++ apps/dashboard/src/components/ui/label.tsx | 18 + apps/dashboard/src/components/ui/textarea.tsx | 18 + .../src/pages/dashboard/stored-caplets.astro | 12 + .../src/content/docs/reference/config.mdx | 1 + apps/landing/public/config.schema.json | 188 ++ deploy/postgres/finalize-runtime-grants.mjs | 53 + deploy/postgres/init-caplets-roles.sh | 52 + deploy/postgres/render-config.mjs | 48 + docker-compose.postgres.yml | 77 + docs/adr/0004-sql-authoritative-host-state.md | 7 + ...005-caplet-file-layers-over-sql-records.md | 7 + .../sql-authoritative-host-state.md | 498 +++ ...2026-07-18-sql-authoritative-host-state.md | 397 +++ package.json | 3 +- packages/cli/package.json | 3 +- packages/core/drizzle.postgres.config.ts | 7 + packages/core/drizzle.sqlite.config.ts | 7 + packages/core/package.json | 13 +- packages/core/rolldown.config.ts | 2 +- .../core/scripts/copy-storage-migrations.mjs | 12 + .../scripts/normalize-storage-migrations.mjs | 14 + packages/core/src/auth.ts | 172 +- packages/core/src/auth/store.ts | 73 + packages/core/src/caplet-files-bundle.ts | 26 +- packages/core/src/caplet-files.ts | 9 +- packages/core/src/caplet-sets.ts | 3 + packages/core/src/cli.ts | 1427 +++++++-- packages/core/src/cli/auth.ts | 93 +- packages/core/src/cli/code-mode.ts | 2 +- packages/core/src/cli/commands.ts | 26 +- packages/core/src/cli/completion.ts | 10 +- packages/core/src/cli/setup-caplet.ts | 150 +- packages/core/src/cli/vault.ts | 12 +- packages/core/src/config-runtime.ts | 85 + packages/core/src/config.ts | 191 +- .../src/current-host/catalog-operations.ts | 113 +- .../src/current-host/client-operations.ts | 97 +- packages/core/src/current-host/operations.ts | 131 +- .../src/current-host/record-operations.ts | 165 + .../core/src/current-host/vault-operations.ts | 243 +- packages/core/src/dashboard/activity-log.ts | 30 + packages/core/src/dashboard/session-store.ts | 259 +- packages/core/src/dashboard/types.ts | 2 + packages/core/src/downstream.ts | 57 +- packages/core/src/engine.ts | 291 +- packages/core/src/google-discovery/manager.ts | 51 +- packages/core/src/graphql.ts | 25 +- packages/core/src/http-actions.ts | 26 +- packages/core/src/{cli => }/install.ts | 27 +- packages/core/src/{cli => }/lockfile.ts | 2 +- packages/core/src/openapi.ts | 32 +- packages/core/src/project-binding/types.ts | 46 + packages/core/src/remote-control/dispatch.ts | 400 ++- packages/core/src/remote-control/types.ts | 23 +- .../src/remote/server-credential-store.ts | 326 +- packages/core/src/serve/http.ts | 638 +++- packages/core/src/serve/stdio.ts | 2 +- packages/core/src/setup/local-store.ts | 214 +- packages/core/src/setup/runner.ts | 19 +- packages/core/src/storage/asset-store.ts | 128 + packages/core/src/storage/backend-auth.ts | 471 +++ packages/core/src/storage/caplet-records.ts | 2752 +++++++++++++++++ .../core/src/storage/catalog-lifecycle.ts | 569 ++++ packages/core/src/storage/coordination.ts | 637 ++++ .../core/src/storage/dashboard-sessions.ts | 333 ++ packages/core/src/storage/database.ts | 276 ++ .../postgres/0000_ancient_firedrake.sql | 74 + .../drizzle/postgres/0001_lean_hellcat.sql | 89 + .../postgres/0002_motionless_magneto.sql | 9 + .../postgres/0003_nice_colonel_america.sql | 5 + .../drizzle/postgres/0004_big_rawhide_kid.sql | 9 + .../postgres/0005_steady_george_stacy.sql | 82 + .../postgres/0006_gray_deathstrike.sql | 13 + .../drizzle/postgres/0007_heavy_kingpin.sql | 47 + .../drizzle/postgres/0008_old_gladiator.sql | 14 + .../postgres/0009_massive_union_jack.sql | 18 + .../postgres/0010_melted_captain_flint.sql | 1 + .../drizzle/postgres/meta/0000_snapshot.json | 470 +++ .../drizzle/postgres/meta/0001_snapshot.json | 1027 ++++++ .../drizzle/postgres/meta/0002_snapshot.json | 1081 +++++++ .../drizzle/postgres/meta/0003_snapshot.json | 1087 +++++++ .../drizzle/postgres/meta/0004_snapshot.json | 1105 +++++++ .../drizzle/postgres/meta/0005_snapshot.json | 1654 ++++++++++ .../drizzle/postgres/meta/0006_snapshot.json | 1746 +++++++++++ .../drizzle/postgres/meta/0007_snapshot.json | 2042 ++++++++++++ .../drizzle/postgres/meta/0008_snapshot.json | 2085 +++++++++++++ .../drizzle/postgres/meta/0009_snapshot.json | 2158 +++++++++++++ .../drizzle/postgres/meta/0010_snapshot.json | 2104 +++++++++++++ .../drizzle/postgres/meta/_journal.json | 83 + .../sqlite/0000_charming_kulan_gath.sql | 73 + .../drizzle/sqlite/0001_plain_puma.sql | 88 + .../drizzle/sqlite/0002_mature_wolfpack.sql | 9 + .../drizzle/sqlite/0003_next_eddie_brock.sql | 17 + .../drizzle/sqlite/0004_nasty_praxagora.sql | 20 + .../sqlite/0005_strange_franklin_richards.sql | 82 + .../sqlite/0006_illegal_harry_osborn.sql | 13 + .../drizzle/sqlite/0007_polite_ser_duncan.sql | 46 + .../sqlite/0008_dry_supreme_intelligence.sql | 14 + .../drizzle/sqlite/0009_gray_sister_grimm.sql | 18 + .../sqlite/0010_friendly_stone_men.sql | 1 + .../drizzle/sqlite/meta/0000_snapshot.json | 465 +++ .../drizzle/sqlite/meta/0001_snapshot.json | 990 ++++++ .../drizzle/sqlite/meta/0002_snapshot.json | 1047 +++++++ .../drizzle/sqlite/meta/0003_snapshot.json | 1054 +++++++ .../drizzle/sqlite/meta/0004_snapshot.json | 1075 +++++++ .../drizzle/sqlite/meta/0005_snapshot.json | 1577 ++++++++++ .../drizzle/sqlite/meta/0006_snapshot.json | 1654 ++++++++++ .../drizzle/sqlite/meta/0007_snapshot.json | 1943 ++++++++++++ .../drizzle/sqlite/meta/0008_snapshot.json | 1988 ++++++++++++ .../drizzle/sqlite/meta/0009_snapshot.json | 2068 +++++++++++++ .../drizzle/sqlite/meta/0010_snapshot.json | 2011 ++++++++++++ .../storage/drizzle/sqlite/meta/_journal.json | 83 + packages/core/src/storage/index.ts | 85 + packages/core/src/storage/installations.ts | 996 ++++++ packages/core/src/storage/legacy-migration.ts | 642 ++++ packages/core/src/storage/migrations.ts | 155 + .../core/src/storage/operator-activity.ts | 415 +++ packages/core/src/storage/project-bindings.ts | 554 ++++ packages/core/src/storage/remote-security.ts | 1515 +++++++++ packages/core/src/storage/schema/postgres.ts | 465 +++ packages/core/src/storage/schema/sqlite.ts | 461 +++ packages/core/src/storage/setup-state.ts | 926 ++++++ packages/core/src/storage/types.ts | 37 + packages/core/src/storage/vault-grants.ts | 780 +++++ packages/core/src/storage/vault-resolver.ts | 52 + packages/core/src/storage/vault-values.ts | 690 +++++ packages/core/src/vault/index.ts | 65 + packages/core/test/auth.test.ts | 329 +- .../test/backend-auth-state-table.test.ts | 133 + .../core/test/backend-auth-storage.test.ts | 229 ++ packages/core/test/caplet-asset-s3.test.ts | 132 + .../caplet-record-storage.postgres.test.ts | 117 + .../core/test/caplet-record-storage.test.ts | 508 +++ packages/core/test/caplets-lockfile.test.ts | 2 +- packages/core/test/catalog-indexing.test.ts | 4 +- packages/core/test/cli-completion.test.ts | 28 +- packages/core/test/cli-install-output.test.ts | 4 +- packages/core/test/cli-remote.test.ts | 70 +- packages/core/test/cli.test.ts | 442 +-- packages/core/test/config.test.ts | 26 + .../test/current-host-administration.test.ts | 124 + .../current-host-catalog-operations.test.ts | 4 +- packages/core/test/dashboard-activity.test.ts | 66 +- packages/core/test/dashboard-api.test.ts | 185 +- packages/core/test/dashboard-catalog.test.ts | 203 +- packages/core/test/dashboard-runtime.test.ts | 27 +- .../core/test/dashboard-session-store.test.ts | 161 + packages/core/test/dashboard-session.test.ts | 128 +- packages/core/test/dashboard-static.test.ts | 45 +- packages/core/test/dashboard-vault.test.ts | 60 +- packages/core/test/downstream.test.ts | 54 +- packages/core/test/exposure-discovery.test.ts | 1 + packages/core/test/google-discovery.test.ts | 153 +- packages/core/test/host-coordination.test.ts | 264 ++ .../core/test/host-storage-config.test.ts | 200 ++ ...ost-storage-domain-parity.postgres.test.ts | 614 ++++ packages/core/test/host-storage.test.ts | 102 + .../core/test/installation-storage.test.ts | 279 ++ packages/core/test/legacy-migration.test.ts | 484 +++ packages/core/test/openapi.test.ts | 59 +- .../test/operator-activity-storage.test.ts | 89 + packages/core/test/package-boundaries.test.ts | 13 + .../core/test/project-binding-storage.test.ts | 185 ++ .../core/test/remote-control-dispatch.test.ts | 428 ++- packages/core/test/remote-login-cli.test.ts | 97 +- .../remote-security-storage.postgres.test.ts | 55 + .../core/test/remote-security-storage.test.ts | 146 + .../core/test/runtime-fingerprint.test.ts | 75 +- packages/core/test/serve-http.test.ts | 40 +- .../core/test/setup-state-storage.test.ts | 128 + .../core/test/storage-catalog-cli.test.ts | 131 + .../core/test/storage-records-cli.test.ts | 300 ++ .../core/test/vault-grant-storage.test.ts | 215 ++ .../core/test/vault-value-storage.test.ts | 173 ++ packages/opencode/test/opencode.test.ts | 6 +- pnpm-lock.yaml | 963 +++++- pnpm-workspace.yaml | 1 + schemas/caplets-config.schema.json | 188 ++ scripts/verify-sqlite-postgres-transfer.sh | 189 ++ 189 files changed, 62025 insertions(+), 1833 deletions(-) create mode 100644 .changeset/sql-host-storage.md create mode 100644 apps/dashboard/src/components/StoredCapletsPage.test.tsx create mode 100644 apps/dashboard/src/components/StoredCapletsPage.tsx create mode 100644 apps/dashboard/src/components/ui/empty.tsx create mode 100644 apps/dashboard/src/components/ui/field.tsx create mode 100644 apps/dashboard/src/components/ui/label.tsx create mode 100644 apps/dashboard/src/components/ui/textarea.tsx create mode 100644 apps/dashboard/src/pages/dashboard/stored-caplets.astro create mode 100755 deploy/postgres/finalize-runtime-grants.mjs create mode 100755 deploy/postgres/init-caplets-roles.sh create mode 100755 deploy/postgres/render-config.mjs create mode 100644 docker-compose.postgres.yml create mode 100644 docs/adr/0004-sql-authoritative-host-state.md create mode 100644 docs/adr/0005-caplet-file-layers-over-sql-records.md create mode 100644 docs/operations/sql-authoritative-host-state.md create mode 100644 docs/specs/2026-07-18-sql-authoritative-host-state.md create mode 100644 packages/core/drizzle.postgres.config.ts create mode 100644 packages/core/drizzle.sqlite.config.ts create mode 100644 packages/core/scripts/copy-storage-migrations.mjs create mode 100644 packages/core/scripts/normalize-storage-migrations.mjs create mode 100644 packages/core/src/current-host/record-operations.ts rename packages/core/src/{cli => }/install.ts (99%) rename packages/core/src/{cli => }/lockfile.ts (99%) create mode 100644 packages/core/src/storage/asset-store.ts create mode 100644 packages/core/src/storage/backend-auth.ts create mode 100644 packages/core/src/storage/caplet-records.ts create mode 100644 packages/core/src/storage/catalog-lifecycle.ts create mode 100644 packages/core/src/storage/coordination.ts create mode 100644 packages/core/src/storage/dashboard-sessions.ts create mode 100644 packages/core/src/storage/database.ts create mode 100644 packages/core/src/storage/drizzle/postgres/0000_ancient_firedrake.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0001_lean_hellcat.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0002_motionless_magneto.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0003_nice_colonel_america.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0004_big_rawhide_kid.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0005_steady_george_stacy.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0006_gray_deathstrike.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0007_heavy_kingpin.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0008_old_gladiator.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0009_massive_union_jack.sql create mode 100644 packages/core/src/storage/drizzle/postgres/0010_melted_captain_flint.sql create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0000_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0001_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0002_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0003_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0004_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0005_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0006_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0007_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0008_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0009_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/0010_snapshot.json create mode 100644 packages/core/src/storage/drizzle/postgres/meta/_journal.json create mode 100644 packages/core/src/storage/drizzle/sqlite/0000_charming_kulan_gath.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0001_plain_puma.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0002_mature_wolfpack.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0003_next_eddie_brock.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0004_nasty_praxagora.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0005_strange_franklin_richards.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0006_illegal_harry_osborn.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0007_polite_ser_duncan.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0008_dry_supreme_intelligence.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0009_gray_sister_grimm.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/0010_friendly_stone_men.sql create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0000_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0001_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0002_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0003_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0004_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0005_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0006_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0007_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0008_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0009_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/0010_snapshot.json create mode 100644 packages/core/src/storage/drizzle/sqlite/meta/_journal.json create mode 100644 packages/core/src/storage/index.ts create mode 100644 packages/core/src/storage/installations.ts create mode 100644 packages/core/src/storage/legacy-migration.ts create mode 100644 packages/core/src/storage/migrations.ts create mode 100644 packages/core/src/storage/operator-activity.ts create mode 100644 packages/core/src/storage/project-bindings.ts create mode 100644 packages/core/src/storage/remote-security.ts create mode 100644 packages/core/src/storage/schema/postgres.ts create mode 100644 packages/core/src/storage/schema/sqlite.ts create mode 100644 packages/core/src/storage/setup-state.ts create mode 100644 packages/core/src/storage/types.ts create mode 100644 packages/core/src/storage/vault-grants.ts create mode 100644 packages/core/src/storage/vault-resolver.ts create mode 100644 packages/core/src/storage/vault-values.ts create mode 100644 packages/core/test/backend-auth-state-table.test.ts create mode 100644 packages/core/test/backend-auth-storage.test.ts create mode 100644 packages/core/test/caplet-asset-s3.test.ts create mode 100644 packages/core/test/caplet-record-storage.postgres.test.ts create mode 100644 packages/core/test/caplet-record-storage.test.ts create mode 100644 packages/core/test/dashboard-session-store.test.ts create mode 100644 packages/core/test/host-coordination.test.ts create mode 100644 packages/core/test/host-storage-config.test.ts create mode 100644 packages/core/test/host-storage-domain-parity.postgres.test.ts create mode 100644 packages/core/test/host-storage.test.ts create mode 100644 packages/core/test/installation-storage.test.ts create mode 100644 packages/core/test/legacy-migration.test.ts create mode 100644 packages/core/test/operator-activity-storage.test.ts create mode 100644 packages/core/test/project-binding-storage.test.ts create mode 100644 packages/core/test/remote-security-storage.postgres.test.ts create mode 100644 packages/core/test/remote-security-storage.test.ts create mode 100644 packages/core/test/setup-state-storage.test.ts create mode 100644 packages/core/test/storage-catalog-cli.test.ts create mode 100644 packages/core/test/storage-records-cli.test.ts create mode 100644 packages/core/test/vault-grant-storage.test.ts create mode 100644 packages/core/test/vault-value-storage.test.ts create mode 100755 scripts/verify-sqlite-postgres-transfer.sh diff --git a/.changeset/sql-host-storage.md b/.changeset/sql-host-storage.md new file mode 100644 index 00000000..5ae937b9 --- /dev/null +++ b/.changeset/sql-host-storage.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": minor +"caplets": minor +--- + +Add authoritative SQLite and PostgreSQL host storage, relational Caplet Records with Markdown import/export, and SQL-backed host administration across CLI, HTTP, and remote-control surfaces. diff --git a/CONCEPTS.md b/CONCEPTS.md index cbae3e7c..3616399f 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -60,9 +60,9 @@ In v1, Catalog Presentation Metadata is limited to `catalog.icon`, which may ide ### Caplets Lockfile -A `caplets.lock.json` file that records installed catalog Caplets, their source repository, source path, destination, tracked source channel, resolved revision when available, content hash, and portability status. +A project-local `.caplets.lock.json` file that records installed project Caplets, their source repository, source path, destination, tracked source channel, resolved revision when available, content hash, and portability status. -Caplets Lockfiles let `caplets install`, no-argument install restore, and `caplets update` manage installed Caplets from recorded provenance rather than from copied files alone. Project installs use `./.caplets.lock.json`; global installs use the target machine's Caplets state directory. +Caplets Lockfiles let project `caplets install`, no-argument restore, and `caplets update` manage filesystem Caplets from recorded provenance rather than from copied files alone. Global and remote SQL-backed Caplets use Caplet Installations instead. Caplets Lockfiles are share-safe and integrity-aware. They strip credential-bearing source URLs, prefer project-relative paths where possible, verify recorded content before restore, and fail closed when local-source entries are unavailable or marked non-portable. diff --git a/CONTEXT.md b/CONTEXT.md index 3fd8cf84..ee346814 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,38 @@ _Avoid_: Caplets utility namespace, custom helper surface The first compatibility-global set for Code Mode: URL and query handling, text encoding, base64 and Buffer-compatible byte handling, web binary payload objects, safe crypto primitives, timing, scheduling, and structured cloning. _Avoid_: Minimal encoding helpers, full Node compatibility +**Caplet Record**: +The canonical structured form of a Caplet persisted in runtime-owned storage. A Caplet File is its portable Markdown import/export projection, not the stored record itself. +_Avoid_: SQL Caplet File, stored Caplet File + +**Caplet Revision**: +An immutable saved version of a Caplet Record's structured content and Caplet Bundle Snapshot; the record's current Caplet ID is stable metadata outside content history. A record selects one current revision; removing it promotes the newest remaining revision, while removing the sole revision removes the record. +_Avoid_: Mutable draft, audit-log entry + +**Caplet Installation**: +A provenance-bearing lifecycle that connects a Caplet Record to an external source and update channel. Detaching ends update tracking without erasing that lifecycle; replacing a detached install starts a new Caplet Installation. +_Avoid_: Global lockfile entry, permanent source ownership + +**Caplet Bundle Snapshot**: +A portable normalized snapshot of every regular auxiliary file in a Caplet bundle, including its relative path, content identity, and executable intent; `CAPLET.md` is represented by the Caplet Record instead. Safe in-bundle symlinks become regular files; escaping or broken links and special files are invalid. +_Avoid_: Bundle archive blob, mounted bundle path + +**Caplet File Layer**: +A live filesystem-backed source whose Caplet Files can shadow lower-precedence Caplets without modifying them. Project Caplet Files outrank global Caplet Files, which outrank SQL-backed Caplet Records. +_Avoid_: One-time bootstrap import, filesystem synchronization + +**Effective Caplet**: +The Caplet selected for an ID after all configured Caplet sources are resolved. Ordinary runtime and dashboard views show only the Effective Caplet; explicit storage operations may still address a shadowed Caplet Record. +_Avoid_: Active record, merged Caplet + +**Authoritative Host State**: +The correctness-bearing state owned by a Caplets host that must present one coherent view across all of that host's server nodes. It excludes node-local caches and artifacts, client-owned credentials, and native service files. +_Avoid_: All runtime files, shared cache + +**Host Node**: +A running server instance participating in one logical Caplets host. Host Nodes share Authoritative Host State but own their live Code Mode heap, bound workspace copy, and any node-local artifacts. +_Avoid_: Independent host, tenant + **Caplets exposure projection**: A shared adapter-neutral runtime view of which local and remote Caplets are exposed as Code Mode handles, progressive tools, direct downstream operations, or direct MCP surfaces, including non-callable diagnostic breadcrumbs for hidden Caplets. MCP, native, and attach host adapters render it differently; they do not own exposure policy or execution behavior. _Avoid_: Tool registration list, exposure wrapper map, transport-specific surface diff --git a/README.md b/README.md index 3ff24e43..fc2ee61f 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,7 @@ Long-lived docs: - [Benchmark methodology](https://github.com/spiritledsoftware/caplets/blob/main/docs/benchmarks/coding-agent.md) - [Native integrations](https://github.com/spiritledsoftware/caplets/blob/main/docs/native-integrations.md) - [Project Binding](https://github.com/spiritledsoftware/caplets/blob/main/docs/project-binding.md) +- [SQL Authoritative Host State operations](https://github.com/spiritledsoftware/caplets/blob/main/docs/operations/sql-authoritative-host-state.md) ## License diff --git a/apps/dashboard/src/components/DashboardApp.test.tsx b/apps/dashboard/src/components/DashboardApp.test.tsx index dbdf8ea8..b2f60625 100644 --- a/apps/dashboard/src/components/DashboardApp.test.tsx +++ b/apps/dashboard/src/components/DashboardApp.test.tsx @@ -23,7 +23,7 @@ vi.mock("@/lib/api", () => ({ vi.mock("@/components/ui/sonner", () => ({ Toaster: () => null })); vi.mock("sonner", () => ({ toast })); -import { DashboardApp, catalogMutationLabel } from "./DashboardApp"; +import { DashboardApp, catalogMutationLabel, routeFromPath } from "./DashboardApp"; type Deferred = { promise: Promise; @@ -176,6 +176,12 @@ describe("catalog update presentation", () => { }); }); +describe("dashboard routing", () => { + it("recognizes the Stored Caplets route", () => { + expect(routeFromPath("/dashboard/stored-caplets")).toBe("stored-caplets"); + }); +}); + describe("Vault reveal races", () => { it("dismisses a parent-owned reveal confirmation when Vault unmounts", async () => { await mountVault(); diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index 5ba20019..56431c40 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -95,6 +95,7 @@ import { EPHEMERAL_REVEAL_TTL_MS, createEphemeralRevealExpiry } from "@/lib/ephe import { dashboardBasePath, dashboardPath } from "@/lib/paths"; import { CatalogPage } from "@/components/catalog/CatalogPage"; +import { StoredCapletsPage } from "@/components/StoredCapletsPage"; const REVEAL_DURATION_SECONDS = EPHEMERAL_REVEAL_TTL_MS / 1_000; const ACTION_DISCARDED = Symbol("dashboard-action-discarded"); @@ -131,6 +132,7 @@ type RouteKey = | "overview" | "access" | "caplets" + | "stored-caplets" | "catalog" | "vault" | "runtime" @@ -191,6 +193,12 @@ const routes: Array<{ { key: "overview", label: "Overview", href: dashboardPath(), icon: HomeIcon }, { key: "access", label: "Access", href: dashboardPath("access"), icon: ShieldCheckIcon }, { key: "caplets", label: "Caplets", href: dashboardPath("caplets"), icon: BoxesIcon }, + { + key: "stored-caplets", + label: "Stored Caplets", + href: dashboardPath("stored-caplets"), + icon: DatabaseIcon, + }, { key: "catalog", label: "Catalog", href: dashboardPath("catalog"), icon: BoxIcon }, { key: "vault", label: "Vault", href: dashboardPath("vault"), icon: KeyRoundIcon }, { key: "runtime", label: "Runtime", href: dashboardPath("runtime"), icon: TerminalIcon }, @@ -255,6 +263,15 @@ function useActionConfirm() { return { confirmAction: async (title: string, description: string) => Boolean(await confirm({ title, description, confirmLabel: "Continue" })), + confirmDestructive: async (title: string, description: string, confirmLabel = "Delete") => + Boolean( + await confirm({ + title, + description, + confirmLabel, + destructive: true, + }), + ), confirmTyped: async (title: string, description: string, expectedPhrase: string) => Boolean( await confirm({ @@ -783,9 +800,18 @@ function Page({ session: DashboardSession; action: DashboardAction; }) { - const { confirmTyped } = useActionConfirm(); + const { confirmAction, confirmDestructive, confirmTyped } = useActionConfirm(); if (route === "access") return ; if (route === "caplets") return ; + if (route === "stored-caplets") + return ( + + ); if (route === "catalog") return ; if (route === "vault") return ; diff --git a/apps/dashboard/src/components/StoredCapletsPage.test.tsx b/apps/dashboard/src/components/StoredCapletsPage.test.tsx new file mode 100644 index 00000000..aaae779f --- /dev/null +++ b/apps/dashboard/src/components/StoredCapletsPage.test.tsx @@ -0,0 +1,401 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { dashboardApi, toast } = vi.hoisted(() => ({ + dashboardApi: vi.fn(), + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("@/lib/api", () => ({ dashboardApi })); +vi.mock("sonner", () => ({ toast })); + +import { + StoredCapletsPage, + type StoredCapletRecord, + type StoredCapletsPageProps, +} from "./StoredCapletsPage"; + +let root: Root | undefined; +let container: HTMLDivElement | undefined; +let records: StoredCapletRecord[]; +let documentBody: string; +let revisions: Array<{ revisionKey: string; sequence: number; name: string }>; +let confirmAction: StoredCapletsPageProps["confirmAction"]; +let confirmDestructive: StoredCapletsPageProps["confirmDestructive"]; +let confirmTyped: StoredCapletsPageProps["confirmTyped"]; +let action: StoredCapletsPageProps["action"]; + +function storedRecord(overrides: Partial = {}): StoredCapletRecord { + return { + id: "alpha", + recordKey: "record-alpha", + headGeneration: 2, + historyLimit: 5, + updatedAt: "2026-07-18T12:00:00.000Z", + currentRevision: { + revisionKey: "revision-2", + sequence: 2, + name: "Alpha tools", + description: "Current SQL record", + contentHash: "sha256:current", + }, + ...overrides, + }; +} + +function generation(record: StoredCapletRecord): number { + return record.generation ?? record.headGeneration ?? 0; +} + +function currentRecord(): StoredCapletRecord { + const record = records[0]; + if (!record) throw new Error("Expected a stored Caplet fixture."); + return record; +} + +function replaceCurrent(record: StoredCapletRecord) { + records = [record]; +} + +function responseFor(path: string, options?: RequestInit) { + const method = options?.method ?? "GET"; + if (path === "stored-caplets" && method === "GET") return { records }; + if (path === "stored-caplets" && method === "POST") { + const body = JSON.parse(String(options?.body)) as { + id: string; + document: string; + historyLimit?: number; + }; + documentBody = body.document; + revisions = [{ revisionKey: "revision-1", sequence: 1, name: "Imported Caplet" }]; + const record = storedRecord({ + id: body.id, + recordKey: `record-${body.id}`, + headGeneration: 1, + historyLimit: body.historyLimit, + currentRevision: { + revisionKey: "revision-1", + sequence: 1, + name: "Imported Caplet", + }, + }); + replaceCurrent(record); + return { record }; + } + + if (path.endsWith("/revisions") && method === "GET") return { revisions }; + if (path.endsWith("/restore") && method === "POST") { + const previous = currentRecord(); + const restored = storedRecord({ + ...previous, + headGeneration: generation(previous) + 1, + currentRevision: { + revisionKey: "revision-restored", + sequence: 3, + name: "Alpha tools restored", + }, + }); + documentBody = "# Alpha restored\n"; + revisions = [ + { revisionKey: "revision-restored", sequence: 3, name: "Alpha tools restored" }, + ...revisions, + ]; + replaceCurrent(restored); + return { record: restored }; + } + + if (path.includes("/revisions/") && method === "DELETE") { + const revisionKey = decodeURIComponent(path.split("/revisions/")[1] ?? ""); + revisions = revisions.filter((revision) => revision.revisionKey !== revisionKey); + const previous = currentRecord(); + const record = storedRecord({ ...previous, headGeneration: generation(previous) + 1 }); + replaceCurrent(record); + return { record }; + } + + if (path === "stored-caplets/alpha" && method === "PUT") { + const body = JSON.parse(String(options?.body)) as { + document: string; + expectedGeneration: number; + }; + documentBody = body.document; + const previous = currentRecord(); + const record = storedRecord({ + ...previous, + headGeneration: body.expectedGeneration + 1, + currentRevision: { + revisionKey: "revision-3", + sequence: 3, + name: "Alpha tools", + }, + }); + revisions = [{ revisionKey: "revision-3", sequence: 3, name: "Alpha tools" }, ...revisions]; + replaceCurrent(record); + return { record }; + } + + if (path === "stored-caplets/alpha" && method === "DELETE") { + records = []; + revisions = []; + return { deleted: true, id: "alpha" }; + } + + if (path.startsWith("stored-caplets/") && method === "GET") { + return { record: currentRecord(), document: documentBody }; + } + throw new Error(`Unexpected dashboard request: ${method} ${path}`); +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function waitFor(read: () => T | undefined): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const value = read(); + if (value !== undefined) return value; + await flush(); + } + throw new Error("Timed out waiting for Stored Caplets state."); +} + +function button(label: string): HTMLButtonElement { + const result = Array.from(document.querySelectorAll("button")).find( + (candidate) => + candidate.getAttribute("aria-label") === label || candidate.textContent?.trim() === label, + ); + if (!result) throw new Error(`Could not find button: ${label}`); + return result; +} + +async function click(label: string) { + await act(async () => { + button(label).click(); + }); + await flush(); +} + +async function setValue(control: HTMLInputElement | HTMLTextAreaElement, value: string) { + const prototype = + control instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + await act(async () => { + valueSetter?.call(control, value); + control.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function mount() { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render( + , + ); + }); + await waitFor(() => + document.body.textContent?.includes("SQL record inventory") ? true : undefined, + ); + await flush(); +} + +async function inspectAlpha() { + await waitFor(() => + document.body.textContent?.includes("Alpha tools") ? button("Inspect") : undefined, + ); + await click("Inspect"); + await waitFor(() => + document.body.textContent?.includes("Current CAPLET.md") ? true : undefined, + ); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + records = [storedRecord()]; + documentBody = "# Alpha tools\n\nCurrent document.\n"; + revisions = [ + { revisionKey: "revision-2", sequence: 2, name: "Alpha tools" }, + { revisionKey: "revision-1", sequence: 1, name: "Alpha tools old" }, + ]; + dashboardApi.mockImplementation((path: string, options?: RequestInit) => + Promise.resolve(responseFor(path, options)), + ); + dashboardApi.mockClear(); + toast.error.mockClear(); + toast.success.mockClear(); + action = vi.fn(async (_label, callback) => { + await callback(); + }); + confirmAction = vi.fn(async () => true); + confirmDestructive = vi.fn(async () => true); + confirmTyped = vi.fn(async () => true); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + container?.remove(); + root = undefined; + container = undefined; + vi.restoreAllMocks(); +}); + +describe("Stored Caplets administration", () => { + it("lists SQL records with provenance, generation, and retention", async () => { + await mount(); + + await waitFor(() => (document.body.textContent?.includes("Alpha tools") ? true : undefined)); + expect(document.body.textContent).toContain("SQL record"); + expect(document.body.textContent).toContain("Generation"); + expect(document.body.textContent).toContain("5 revisions"); + expect(dashboardApi).toHaveBeenCalledWith("stored-caplets"); + }); + + it("imports CAPLET.md with an optional history limit", async () => { + records = []; + await mount(); + await click("Import CAPLET.md"); + + const id = document.querySelector("#stored-caplet-id"); + const historyLimit = document.querySelector("#stored-caplet-history-limit"); + const markdown = document.querySelector("#stored-caplet-document"); + if (!id || !historyLimit || !markdown) throw new Error("Import fields were not rendered."); + await setValue(id, "imported-caplet"); + await setValue(historyLimit, "7"); + await setValue(markdown, "# Imported Caplet\n"); + await click("Import record"); + + await waitFor(() => + dashboardApi.mock.calls.some( + ([path, options]) => path === "stored-caplets" && options?.method === "POST", + ) + ? true + : undefined, + ); + expect(dashboardApi).toHaveBeenCalledWith("stored-caplets", { + method: "POST", + body: JSON.stringify({ + id: "imported-caplet", + document: "# Imported Caplet\n", + historyLimit: 7, + }), + }); + expect(action).toHaveBeenCalledWith("Imported imported-caplet", expect.any(Function)); + }); + + it("edits the current Markdown with its observed CAS generation", async () => { + await mount(); + await inspectAlpha(); + await click("Edit Markdown"); + + const editor = document.querySelector("#stored-caplet-editor"); + if (!editor) throw new Error("Stored Caplet editor was not rendered."); + await setValue(editor, "# Alpha tools\n\nUpdated document.\n"); + await click("Save new revision"); + + await waitFor(() => + dashboardApi.mock.calls.some( + ([path, options]) => path === "stored-caplets/alpha" && options?.method === "PUT", + ) + ? true + : undefined, + ); + expect(dashboardApi).toHaveBeenCalledWith("stored-caplets/alpha", { + method: "PUT", + body: JSON.stringify({ + document: "# Alpha tools\n\nUpdated document.\n", + expectedGeneration: 2, + }), + }); + }); + + it("restores a prior revision after confirmation", async () => { + await mount(); + await inspectAlpha(); + await click("Restore revision 1"); + + await waitFor(() => + dashboardApi.mock.calls.some( + ([path]) => path === "stored-caplets/alpha/revisions/revision-1/restore", + ) + ? true + : undefined, + ); + expect(confirmAction).toHaveBeenCalledWith( + "Restore revision 1?", + expect.stringContaining("creates a new current revision"), + ); + expect(dashboardApi).toHaveBeenCalledWith("stored-caplets/alpha/revisions/revision-1/restore", { + method: "POST", + body: JSON.stringify({ expectedGeneration: 2 }), + }); + }); + + it("deletes a revision only after destructive confirmation", async () => { + await mount(); + await inspectAlpha(); + await click("Delete revision 1"); + + await waitFor(() => + dashboardApi.mock.calls.some( + ([path, options]) => + path === "stored-caplets/alpha/revisions/revision-1" && options?.method === "DELETE", + ) + ? true + : undefined, + ); + expect(confirmDestructive).toHaveBeenCalledWith( + "Delete revision 1?", + expect.stringContaining("permanently removes"), + "Delete revision", + ); + expect(dashboardApi).toHaveBeenCalledWith("stored-caplets/alpha/revisions/revision-1", { + method: "DELETE", + body: JSON.stringify({ expectedGeneration: 2 }), + }); + }); + + it("hard-deletes a record only after typed id confirmation", async () => { + await mount(); + await inspectAlpha(); + await click("Hard-delete record"); + + await waitFor(() => + dashboardApi.mock.calls.some( + ([path, options]) => path === "stored-caplets/alpha" && options?.method === "DELETE", + ) + ? true + : undefined, + ); + expect(confirmTyped).toHaveBeenCalledWith( + "Hard-delete Alpha tools?", + expect.stringContaining("permanently deletes the SQL record"), + "delete alpha", + ); + expect(dashboardApi).toHaveBeenCalledWith("stored-caplets/alpha", { + method: "DELETE", + body: JSON.stringify({ expectedGeneration: 2 }), + }); + await waitFor(() => + document.body.textContent?.includes("No SQL Caplet Records yet") ? true : undefined, + ); + }); +}); diff --git a/apps/dashboard/src/components/StoredCapletsPage.tsx b/apps/dashboard/src/components/StoredCapletsPage.tsx new file mode 100644 index 00000000..433e38dd --- /dev/null +++ b/apps/dashboard/src/components/StoredCapletsPage.tsx @@ -0,0 +1,1125 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FormEvent, +} from "react"; +import { + AlertTriangleIcon, + ArrowLeftIcon, + ClipboardIcon, + DatabaseIcon, + DownloadIcon, + FilePenLineIcon, + HistoryIcon, + RefreshCwIcon, + SaveIcon, + Trash2Icon, + UploadIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; +import { dashboardApi } from "@/lib/api"; + +type StoredCapletRevision = { + revisionKey: string; + sequence?: number; + name?: string; + description?: string; + contentHash?: string; + sourceRevision?: string | null; + createdAt?: string; +}; + +export type StoredCapletRecord = { + id: string; + name?: string; + description?: string; + generation?: number; + headGeneration?: number; + recordKey?: string; + historyLimit?: number | null; + createdAt?: string; + updatedAt?: string; + storageKind?: string; + currentRevision?: StoredCapletRevision | string; +}; + +type StoredCapletDetail = { + record: StoredCapletRecord; + document: string; + revisions: StoredCapletRevision[]; +}; + +type StoredCapletsAction = ( + label: string | ((result: unknown) => string), + callback: () => Promise, +) => Promise; + +export type StoredCapletsPageProps = { + action: StoredCapletsAction; + confirmAction: (title: string, description: string) => Promise; + confirmDestructive: ( + title: string, + description: string, + confirmLabel?: string, + ) => Promise; + confirmTyped: (title: string, description: string, expectedPhrase: string) => Promise; +}; + +function generationOf(record: StoredCapletRecord): number { + return record.generation ?? record.headGeneration ?? 0; +} + +function currentRevisionKey(record: StoredCapletRecord): string | undefined { + if (typeof record.currentRevision === "string") return record.currentRevision; + return record.currentRevision?.revisionKey; +} + +function recordName(record: StoredCapletRecord): string { + if (record.name) return record.name; + if (typeof record.currentRevision === "object" && record.currentRevision?.name) { + return record.currentRevision.name; + } + return record.id; +} + +function recordDescription(record: StoredCapletRecord): string | undefined { + if (record.description) return record.description; + if (typeof record.currentRevision === "object") return record.currentRevision?.description; + return undefined; +} + +function retentionLabel(limit: number | null | undefined): string { + if (limit === null) return "Host default"; + if (limit === undefined) return "Not reported"; + return `${limit} revision${limit === 1 ? "" : "s"}`; +} + +function timestampLabel(value: string | undefined): string { + if (!value) return "Not reported"; + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) return value; + return timestamp.toLocaleString(); +} + +async function fetchStoredCaplets(): Promise { + const response = await dashboardApi<{ records?: StoredCapletRecord[] }>("stored-caplets"); + return response.records ?? []; +} + +async function fetchStoredCapletDetail(id: string): Promise { + const encodedId = encodeURIComponent(id); + const [current, history] = await Promise.all([ + dashboardApi<{ record: StoredCapletRecord; document: string }>(`stored-caplets/${encodedId}`), + dashboardApi<{ revisions?: StoredCapletRevision[] }>(`stored-caplets/${encodedId}/revisions`), + ]); + return { record: current.record, document: current.document, revisions: history.revisions ?? [] }; +} + +export function StoredCapletsPage({ + action, + confirmAction, + confirmDestructive, + confirmTyped, +}: StoredCapletsPageProps) { + const [records, setRecords] = useState([]); + const [listLoading, setListLoading] = useState(true); + const [listError, setListError] = useState(); + const [importOpen, setImportOpen] = useState(false); + const [importId, setImportId] = useState(""); + const [importHistoryLimit, setImportHistoryLimit] = useState(""); + const [importDocument, setImportDocument] = useState(""); + const [importFileName, setImportFileName] = useState(); + const [importError, setImportError] = useState(); + const [selectedId, setSelectedId] = useState(); + const [detail, setDetail] = useState(); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [busyAction, setBusyAction] = useState(); + const [mutationError, setMutationError] = useState(); + const detailRequest = useRef(0); + + const loadRecords = useCallback(async () => { + setListLoading(true); + setListError(undefined); + try { + setRecords(await fetchStoredCaplets()); + } catch (error) { + setListError(error instanceof Error ? error.message : String(error)); + } finally { + setListLoading(false); + } + }, []); + + const loadDetail = useCallback(async (id: string) => { + const request = ++detailRequest.current; + setDetailLoading(true); + setDetailError(undefined); + try { + const loaded = await fetchStoredCapletDetail(id); + if (request !== detailRequest.current) return; + setDetail(loaded); + setDraft(loaded.document); + setEditing(false); + setMutationError(undefined); + } catch (error) { + if (request === detailRequest.current) { + setDetail(undefined); + setDetailError(error instanceof Error ? error.message : String(error)); + } + } finally { + if (request === detailRequest.current) setDetailLoading(false); + } + }, []); + + useEffect(() => { + void loadRecords(); + }, [loadRecords]); + + useEffect(() => { + if (!selectedId) { + ++detailRequest.current; + setDetail(undefined); + setDetailError(undefined); + setEditing(false); + setMutationError(undefined); + return; + } + void loadDetail(selectedId); + }, [loadDetail, selectedId]); + + const sortedRecords = useMemo( + () => [...records].sort((left, right) => left.id.localeCompare(right.id)), + [records], + ); + const dirty = Boolean(detail && editing && draft !== detail.document); + + async function refreshAfterMutation(id: string) { + const [nextDetail, nextRecords] = await Promise.all([ + fetchStoredCapletDetail(id), + fetchStoredCaplets(), + ]); + setDetail(nextDetail); + setDraft(nextDetail.document); + setRecords(nextRecords); + setEditing(false); + setMutationError(undefined); + } + + function mutationFailure(error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + setMutationError(message); + throw error; + } + + function resetImport() { + setImportId(""); + setImportHistoryLimit(""); + setImportDocument(""); + setImportFileName(undefined); + setImportError(undefined); + } + + async function submitImport(event: FormEvent) { + event.preventDefault(); + const id = importId.trim(); + const historyLimitText = importHistoryLimit.trim(); + const historyLimit = historyLimitText === "" ? undefined : Number(historyLimitText); + if (!id) { + setImportError("Enter the stored Caplet id."); + return; + } + if (!importDocument.trim()) { + setImportError("Add a CAPLET.md document or choose a Markdown file."); + return; + } + if (historyLimit !== undefined && (!Number.isSafeInteger(historyLimit) || historyLimit < 0)) { + setImportError("History limit must be a non-negative whole number."); + return; + } + + setImportError(undefined); + setBusyAction("import"); + try { + await action(`Imported ${id}`, async () => { + try { + const response = await dashboardApi<{ record: StoredCapletRecord }>("stored-caplets", { + method: "POST", + body: JSON.stringify({ + id, + document: importDocument, + ...(historyLimit === undefined ? {} : { historyLimit }), + }), + }); + const nextRecords = await fetchStoredCaplets(); + setRecords(nextRecords); + resetImport(); + setImportOpen(false); + setSelectedId(response.record.id); + return response; + } catch (error) { + setImportError(error instanceof Error ? error.message : String(error)); + throw error; + } + }); + } finally { + setBusyAction(undefined); + } + } + + async function chooseMarkdown(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + try { + setImportDocument(await file.text()); + setImportFileName(file.name); + setImportError(undefined); + } catch (error) { + setImportError(error instanceof Error ? error.message : "Could not read the Markdown file."); + } + } + + async function saveDocument() { + if (!detail || !draft.trim() || busyAction) return; + const id = detail.record.id; + setBusyAction("save"); + setMutationError(undefined); + try { + await action(`Saved ${id}`, async () => { + try { + const result = await dashboardApi(`stored-caplets/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify({ + document: draft, + expectedGeneration: generationOf(detail.record), + }), + }); + await refreshAfterMutation(id); + return result; + } catch (error) { + return mutationFailure(error); + } + }); + } finally { + setBusyAction(undefined); + } + } + + async function restoreRevision(revision: StoredCapletRevision) { + if (!detail || busyAction) return; + const id = detail.record.id; + const confirmed = await confirmAction( + `Restore revision ${revision.sequence ?? revision.revisionKey}?`, + "Restoring creates a new current revision from this stored snapshot. The present document remains in SQL history.", + ); + if (!confirmed) return; + + setBusyAction(`restore:${revision.revisionKey}`); + setMutationError(undefined); + try { + await action(`Restored revision ${revision.sequence ?? revision.revisionKey}`, async () => { + try { + const result = await dashboardApi( + `stored-caplets/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision.revisionKey)}/restore`, + { + method: "POST", + body: JSON.stringify({ expectedGeneration: generationOf(detail.record) }), + }, + ); + await refreshAfterMutation(id); + return result; + } catch (error) { + return mutationFailure(error); + } + }); + } finally { + setBusyAction(undefined); + } + } + + async function deleteRevision(revision: StoredCapletRevision) { + if (!detail || busyAction) return; + const id = detail.record.id; + const confirmed = await confirmDestructive( + `Delete revision ${revision.sequence ?? revision.revisionKey}?`, + "This permanently removes the selected SQL revision. It does not delete file overlays or Vault values.", + "Delete revision", + ); + if (!confirmed) return; + + setBusyAction(`delete-revision:${revision.revisionKey}`); + setMutationError(undefined); + try { + await action(`Deleted revision ${revision.sequence ?? revision.revisionKey}`, async () => { + try { + const result = await dashboardApi<{ record: StoredCapletRecord | null }>( + `stored-caplets/${encodeURIComponent(id)}/revisions/${encodeURIComponent(revision.revisionKey)}`, + { + method: "DELETE", + body: JSON.stringify({ expectedGeneration: generationOf(detail.record) }), + }, + ); + const nextRecords = await fetchStoredCaplets(); + setRecords(nextRecords); + if (result.record) { + const nextDetail = await fetchStoredCapletDetail(id); + setDetail(nextDetail); + setDraft(nextDetail.document); + setEditing(false); + } else { + setSelectedId(undefined); + } + return result; + } catch (error) { + return mutationFailure(error); + } + }); + } finally { + setBusyAction(undefined); + } + } + + async function deleteRecord() { + if (!detail || busyAction) return; + const id = detail.record.id; + const phrase = `delete ${id}`; + const confirmed = await confirmTyped( + `Hard-delete ${recordName(detail.record)}?`, + "This permanently deletes the SQL record and its revision history. Effective Caplets supplied by files or overlays are not removed.", + phrase, + ); + if (!confirmed) return; + + setBusyAction("delete-record"); + setMutationError(undefined); + try { + await action(`Deleted stored Caplet ${id}`, async () => { + try { + const result = await dashboardApi(`stored-caplets/${encodeURIComponent(id)}`, { + method: "DELETE", + body: JSON.stringify({ expectedGeneration: generationOf(detail.record) }), + }); + setRecords(await fetchStoredCaplets()); + setSelectedId(undefined); + return result; + } catch (error) { + return mutationFailure(error); + } + }); + } finally { + setBusyAction(undefined); + } + } + + async function returnToList() { + if (dirty) { + const confirmed = await confirmAction( + "Discard unsaved Markdown?", + "Your edits have not been saved to the SQL record.", + ); + if (!confirmed) return; + } + setSelectedId(undefined); + } + + async function copyDocument() { + if (!detail) return; + try { + await navigator.clipboard.writeText(detail.document); + toast.success("CAPLET.md copied"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not copy CAPLET.md"); + } + } + + function downloadDocument() { + if (!detail) return; + const blob = new Blob([detail.document], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${detail.record.id}-CAPLET.md`; + anchor.click(); + URL.revokeObjectURL(url); + toast.success("CAPLET.md downloaded"); + } + + if (selectedId) { + return ( + void returnToList()} + onRetry={() => void loadDetail(selectedId)} + onEdit={() => setEditing(true)} + onCancelEdit={() => { + setDraft(detail?.document ?? ""); + setEditing(false); + setMutationError(undefined); + }} + onDraftChange={setDraft} + onSave={() => void saveDocument()} + onCopy={() => void copyDocument()} + onDownload={downloadDocument} + onRestore={(revision) => void restoreRevision(revision)} + onDeleteRevision={(revision) => void deleteRevision(revision)} + onDeleteRecord={() => void deleteRecord()} + /> + ); + } + + return ( +
+
+
+

+ Stored Caplets +

+

+ Import and maintain versioned Caplet Records in the Current Host SQL store. +

+
+ +
+ + + + SQL records are a separate source + + This inventory shows stored SQL records and their revisions. The effective Caplets view + can also include filesystem or overlay sources. Vault values and stored asset contents are + never shown here. + + + + {importOpen ? ( + + + Import a stored Caplet + + Supply the record id and exact CAPLET.md document. History retention is optional and + can only be set during import through the available dashboard API. + + + +
void submitImport(event)}> + +
+ + Record id + setImportId(event.target.value)} + placeholder="team-caplet" + autoComplete="off" + aria-invalid={Boolean(importError && !importId.trim())} + disabled={busyAction === "import"} + /> + The stable SQL Caplet Record id. + + + + History limit (optional) + + setImportHistoryLimit(event.target.value)} + placeholder="Host default" + disabled={busyAction === "import"} + /> + Leave empty to inherit the host default. + +
+ + Markdown file (optional) + void chooseMarkdown(event)} + disabled={busyAction === "import"} + /> + + {importFileName + ? `${importFileName} loaded into the editor below.` + : "Choose CAPLET.md or paste its contents below."} + + + + CAPLET.md +