From 163ca3eb25cd205ba2e72a431d8186728a71f50e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sun, 12 Jul 2026 07:42:45 -0400 Subject: [PATCH 1/5] feat(storage): add composable shared authorities Support filesystem, SQLite, PostgreSQL, and S3-backed writable authority state alongside immutable staged Caplets. Add replica-safe runtime activation, Current Host administration, migrations and recovery, dashboard and CLI parity, provider packaging, operator documentation, and deterministic cross-provider verification. --- .changeset/composable-shared-storage.md | 6 + .github/workflows/ci.yml | 69 + CONCEPTS.md | 30 +- README.md | 12 + .../src/components/DashboardApp.test.tsx | 649 +++ .../dashboard/src/components/DashboardApp.tsx | 1509 +++++- .../src/content/docs/reference/config.mdx | 1 + apps/landing/public/config.schema.json | 164 + docs/agents/domain.md | 54 + ...-portable-cloud-runtime-deployment-plan.md | 706 +++ ...003-feat-composable-shared-storage-plan.md | 644 +++ docs/product/self-hosting.md | 411 ++ ...7-11-cloud-runtime-provider-feasibility.md | 166 + package.json | 2 + packages/core/README.md | 180 + packages/core/package.json | 6 + .../prototypes/cloud-project-binding/model.ts | 513 ++ .../prototypes/cloud-project-binding/tui.ts | 260 ++ packages/core/rolldown.config.ts | 13 +- packages/core/src/auth.ts | 224 +- packages/core/src/auth/store.ts | 304 ++ packages/core/src/cli.ts | 874 +++- packages/core/src/cli/auth.ts | 187 +- packages/core/src/cli/commands.ts | 17 + packages/core/src/cli/completion.ts | 16 +- packages/core/src/cli/install.ts | 201 + packages/core/src/cli/setup-caplet.ts | 31 +- packages/core/src/cli/storage.ts | 1072 +++++ packages/core/src/cli/vault.ts | 150 +- packages/core/src/cloud/runtime-adapter.ts | 122 +- packages/core/src/config.ts | 281 +- .../src/current-host/caplet-operations.ts | 318 ++ .../src/current-host/catalog-operations.ts | 150 +- packages/core/src/current-host/catalog.ts | 25 + .../src/current-host/client-operations.ts | 118 +- packages/core/src/current-host/operations.ts | 839 +++- .../src/current-host/settings-operations.ts | 429 ++ .../core/src/current-host/vault-operations.ts | 250 +- packages/core/src/dashboard/activity-log.ts | 159 + packages/core/src/dashboard/session-store.ts | 349 ++ packages/core/src/downstream.ts | 24 +- packages/core/src/engine.ts | 29 + packages/core/src/errors.ts | 1 + packages/core/src/google-discovery/manager.ts | 59 +- packages/core/src/index.ts | 70 +- packages/core/src/native.ts | 3 + packages/core/src/native/service.ts | 100 +- packages/core/src/remote-control/client.ts | 115 + packages/core/src/remote-control/dispatch.ts | 220 +- packages/core/src/remote-control/types.ts | 9 + packages/core/src/remote/authority-codec.ts | 264 ++ .../src/remote/server-credential-store.ts | 702 +++ packages/core/src/runtime.ts | 77 +- packages/core/src/serve/http.ts | 1275 ++++- packages/core/src/serve/index.ts | 6 + packages/core/src/serve/native-session.ts | 17 +- packages/core/src/serve/session.ts | 11 +- packages/core/src/serve/stdio.ts | 56 +- packages/core/src/setup/local-store.ts | 294 +- packages/core/src/setup/runner.ts | 32 +- packages/core/src/storage/backup.ts | 506 ++ packages/core/src/storage/bundle-cache.ts | 435 ++ packages/core/src/storage/composition.ts | 548 +++ packages/core/src/storage/conformance.ts | 76 + packages/core/src/storage/coordinator.ts | 1048 +++++ packages/core/src/storage/factory.ts | 44 + .../core/src/storage/filesystem-authority.ts | 1848 ++++++++ packages/core/src/storage/migration.ts | 1354 ++++++ packages/core/src/storage/s3-authority.ts | 2801 +++++++++++ packages/core/src/storage/sql/authority.ts | 4119 +++++++++++++++++ packages/core/src/storage/sql/migrate.ts | 665 +++ .../sql/migrations/postgres/0000_initial.sql | 63 + .../migrations/postgres/0001_head_guard.sql | 10 + .../postgres/0002_maintenance_lease.sql | 8 + .../migrations/postgres/meta/_journal.json | 27 + .../sql/migrations/sqlite/0000_initial.sql | 62 + .../sql/migrations/sqlite/0001_head_guard.sql | 6 + .../sqlite/0002_maintenance_lease.sql | 8 + .../sql/migrations/sqlite/meta/_journal.json | 27 + .../core/src/storage/sql/schema-postgres.ts | 86 + .../core/src/storage/sql/schema-sqlite.ts | 86 + packages/core/src/storage/types.ts | 238 + packages/core/src/vault/access.ts | 34 +- packages/core/src/vault/index.ts | 631 ++- packages/core/src/vault/keys.ts | 35 +- packages/core/src/vault/types.ts | 7 +- .../core/test/attach-service-wiring.test.ts | 7 +- .../test/authority-access-session.test.ts | 372 ++ packages/core/test/authority-codec.test.ts | 121 + packages/core/test/authority-secrets.test.ts | 458 ++ packages/core/test/cli-completion.test.ts | 21 + packages/core/test/cli-remote.test.ts | 266 ++ packages/core/test/cli-storage.test.ts | 861 ++++ .../test/current-host-administration.test.ts | 131 +- packages/core/test/dashboard-activity.test.ts | 24 + packages/core/test/dashboard-api.test.ts | 40 +- .../dashboard-authority-mutations.test.ts | 625 +++ .../fixtures/storage/minio-pinned.compose.yml | 15 + .../storage/provider-matrix.compose.yml | 51 + packages/core/test/packed-package.test.ts | 197 + .../core/test/remote-control-client.test.ts | 54 + packages/core/test/serve-http.test.ts | 264 +- packages/core/test/serve-session.test.ts | 26 + packages/core/test/setup-runner.test.ts | 145 + packages/core/test/storage-backup.test.ts | 367 ++ .../core/test/storage-bundle-cache.test.ts | 99 + .../core/test/storage-composition.test.ts | 222 + packages/core/test/storage-contract.test.ts | 186 + .../core/test/storage-coordinator.test.ts | 485 ++ .../test/storage-filesystem-authority.test.ts | 585 +++ packages/core/test/storage-migration.test.ts | 866 ++++ .../core/test/storage-multi-replica.test.ts | 405 ++ .../test/storage-postgres-authority.test.ts | 243 + .../core/test/storage-provider-contract.ts | 541 +++ .../core/test/storage-provider-matrix.test.ts | 93 + .../core/test/storage-s3-authority.test.ts | 682 +++ .../core/test/storage-s3-conformance.test.ts | 201 + .../core/test/storage-sql-migrations.test.ts | 168 + .../test/storage-sqlite-authority.test.ts | 421 ++ pnpm-lock.yaml | 838 +++- pnpm-workspace.yaml | 1 + schemas/caplets-config.schema.json | 164 + scripts/test-storage-providers.ts | 1711 +++++++ 123 files changed, 40882 insertions(+), 721 deletions(-) create mode 100644 .changeset/composable-shared-storage.md create mode 100644 docs/plans/2026-07-11-002-feat-portable-cloud-runtime-deployment-plan.md create mode 100644 docs/plans/2026-07-11-003-feat-composable-shared-storage-plan.md create mode 100644 docs/product/self-hosting.md create mode 100644 docs/research/2026-07-11-cloud-runtime-provider-feasibility.md create mode 100644 packages/core/README.md create mode 100644 packages/core/prototypes/cloud-project-binding/model.ts create mode 100644 packages/core/prototypes/cloud-project-binding/tui.ts create mode 100644 packages/core/src/cli/storage.ts create mode 100644 packages/core/src/current-host/caplet-operations.ts create mode 100644 packages/core/src/current-host/settings-operations.ts create mode 100644 packages/core/src/remote/authority-codec.ts create mode 100644 packages/core/src/storage/backup.ts create mode 100644 packages/core/src/storage/bundle-cache.ts create mode 100644 packages/core/src/storage/composition.ts create mode 100644 packages/core/src/storage/conformance.ts create mode 100644 packages/core/src/storage/coordinator.ts create mode 100644 packages/core/src/storage/factory.ts create mode 100644 packages/core/src/storage/filesystem-authority.ts create mode 100644 packages/core/src/storage/migration.ts create mode 100644 packages/core/src/storage/s3-authority.ts create mode 100644 packages/core/src/storage/sql/authority.ts create mode 100644 packages/core/src/storage/sql/migrate.ts create mode 100644 packages/core/src/storage/sql/migrations/postgres/0000_initial.sql create mode 100644 packages/core/src/storage/sql/migrations/postgres/0001_head_guard.sql create mode 100644 packages/core/src/storage/sql/migrations/postgres/0002_maintenance_lease.sql create mode 100644 packages/core/src/storage/sql/migrations/postgres/meta/_journal.json create mode 100644 packages/core/src/storage/sql/migrations/sqlite/0000_initial.sql create mode 100644 packages/core/src/storage/sql/migrations/sqlite/0001_head_guard.sql create mode 100644 packages/core/src/storage/sql/migrations/sqlite/0002_maintenance_lease.sql create mode 100644 packages/core/src/storage/sql/migrations/sqlite/meta/_journal.json create mode 100644 packages/core/src/storage/sql/schema-postgres.ts create mode 100644 packages/core/src/storage/sql/schema-sqlite.ts create mode 100644 packages/core/src/storage/types.ts create mode 100644 packages/core/test/authority-access-session.test.ts create mode 100644 packages/core/test/authority-codec.test.ts create mode 100644 packages/core/test/authority-secrets.test.ts create mode 100644 packages/core/test/cli-storage.test.ts create mode 100644 packages/core/test/dashboard-authority-mutations.test.ts create mode 100644 packages/core/test/fixtures/storage/minio-pinned.compose.yml create mode 100644 packages/core/test/fixtures/storage/provider-matrix.compose.yml create mode 100644 packages/core/test/packed-package.test.ts create mode 100644 packages/core/test/storage-backup.test.ts create mode 100644 packages/core/test/storage-bundle-cache.test.ts create mode 100644 packages/core/test/storage-composition.test.ts create mode 100644 packages/core/test/storage-contract.test.ts create mode 100644 packages/core/test/storage-coordinator.test.ts create mode 100644 packages/core/test/storage-filesystem-authority.test.ts create mode 100644 packages/core/test/storage-migration.test.ts create mode 100644 packages/core/test/storage-multi-replica.test.ts create mode 100644 packages/core/test/storage-postgres-authority.test.ts create mode 100644 packages/core/test/storage-provider-contract.ts create mode 100644 packages/core/test/storage-provider-matrix.test.ts create mode 100644 packages/core/test/storage-s3-authority.test.ts create mode 100644 packages/core/test/storage-s3-conformance.test.ts create mode 100644 packages/core/test/storage-sql-migrations.test.ts create mode 100644 packages/core/test/storage-sqlite-authority.test.ts create mode 100644 scripts/test-storage-providers.ts diff --git a/.changeset/composable-shared-storage.md b/.changeset/composable-shared-storage.md new file mode 100644 index 00000000..327545f7 --- /dev/null +++ b/.changeset/composable-shared-storage.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": minor +caplets: minor +--- + +Add composable shared storage for self-hosted deployments: choose one filesystem, SQLite, PostgreSQL, or S3-compatible Writable Authority while composing immutable staged Caplets. The core runtime now exposes async authority assembly, generation/health coordination, dashboard-managed durable state, and explicit inventory, migration, backup, restore, and cutover operations. Document provider prerequisites and keep AWS S3/Cloudflare R2 live validation separate from deterministic PostgreSQL/MinIO evidence. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40f15e46..0ee282ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: push: branches: - main + workflow_dispatch: + inputs: + live: + description: "Run credentialed AWS S3/R2 validation (requires repository variable CAPLETS_STORAGE_LIVE_ENABLED=true)" + required: false + default: false + type: boolean permissions: contents: read @@ -40,6 +47,68 @@ jobs: - name: Run quality gates run: pnpm verify + storage-provider-matrix: + name: Storage provider matrix (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [22, 24] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run deterministic storage provider matrix + run: pnpm storage:test:providers + + storage-provider-live: + name: Credentialed live storage providers + if: github.event_name == 'workflow_dispatch' && (inputs.live == true) && vars.CAPLETS_STORAGE_LIVE_ENABLED == 'true' + runs-on: ubuntu-latest + env: + CAPLETS_STORAGE_AWS_BUCKET: ${{ secrets.CAPLETS_STORAGE_AWS_BUCKET }} + CAPLETS_STORAGE_AWS_REGION: ${{ secrets.CAPLETS_STORAGE_AWS_REGION }} + CAPLETS_STORAGE_AWS_ENDPOINT: ${{ secrets.CAPLETS_STORAGE_AWS_ENDPOINT }} + CAPLETS_STORAGE_AWS_ACCESS_KEY_ID: ${{ secrets.CAPLETS_STORAGE_AWS_ACCESS_KEY_ID }} + CAPLETS_STORAGE_AWS_SECRET_ACCESS_KEY: ${{ secrets.CAPLETS_STORAGE_AWS_SECRET_ACCESS_KEY }} + CAPLETS_STORAGE_AWS_SESSION_TOKEN: ${{ secrets.CAPLETS_STORAGE_AWS_SESSION_TOKEN }} + CAPLETS_STORAGE_R2_BUCKET: ${{ secrets.CAPLETS_STORAGE_R2_BUCKET }} + CAPLETS_STORAGE_R2_REGION: ${{ secrets.CAPLETS_STORAGE_R2_REGION }} + CAPLETS_STORAGE_R2_ENDPOINT: ${{ secrets.CAPLETS_STORAGE_R2_ENDPOINT }} + CAPLETS_STORAGE_R2_ACCESS_KEY_ID: ${{ secrets.CAPLETS_STORAGE_R2_ACCESS_KEY_ID }} + CAPLETS_STORAGE_R2_SECRET_ACCESS_KEY: ${{ secrets.CAPLETS_STORAGE_R2_SECRET_ACCESS_KEY }} + CAPLETS_STORAGE_R2_SESSION_TOKEN: ${{ secrets.CAPLETS_STORAGE_R2_SESSION_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run requested live storage targets + run: pnpm storage:test:providers -- --live=aws,r2 + changeset: name: Changeset runs-on: ubuntu-latest diff --git a/CONCEPTS.md b/CONCEPTS.md index 9f2fd85c..1f9222f9 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -60,6 +60,12 @@ Caplets Lockfiles let `caplets install`, no-argument install restore, and `caple 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. +### Caplet Source Overlay + +An ordered composition of Caplet storage sources within one runtime configuration. The v1 cloud overlay reads the global remote store first, then the global filesystem store, then the project filesystem store; a later same-ID definition becomes active while the earlier source remains recorded as shadow provenance. + +Caplet Source Overlay precedence is distinct from Namespace Shadowing Policy: an overlay selects one active definition by source priority, while namespace shadowing keeps colliding local and upstream Caplets separately addressable. + ### Namespace Shadowing Policy A Caplet shadowing policy where a local/upstream ID collision exposes both Caplets under qualified namespace IDs and removes the ambiguous bare ID. @@ -74,9 +80,27 @@ The Caplets Daemon is installed and updated through an install-time service cont ### Current Host -The Caplets host that served the active dashboard session and owns the runtime state being administered in that session. +The Caplets administration target that served the active dashboard session and owns the runtime state being administered in that session. It may be one host process or a logical deployment of replicas that share one Writable Authority. + +Current Host is session-scoped, not a product-wide singleton. The admin dashboard operates on one Current Host while preserving host-scoped terms for future enumeration and switching. + +### Writable Authority + +The single storage provider that owns dashboard-managed mutable state for a Current Host. A deployment selects filesystem, SQLite, PostgreSQL, or S3-compatible object storage as its Writable Authority rather than assigning different writable providers to individual state domains. + +Immutable Staged Filesystem Sources can compose with any Writable Authority, but they do not become additional writable peers. + +### Staged Filesystem Source + +An immutable Caplet source supplied with each runtime replica through its image or a mounted path. + +A Staged Filesystem Source remains readable alongside authority-managed Caplets, reserves its Caplet IDs against dashboard mutation, and is not synchronized through Project Binding or the Writable Authority. + +### Authority Generation + +A committed revision of shared Current Host state exposed by a Writable Authority. -Current Host is a session-scoped administration target, not a product-wide singleton. The admin dashboard may initially operate only on the Current Host while preserving host-scoped terms for future multi-host enumeration and switching. +Runtime replicas consume complete Authority Generations, reject conflicting writes based on stale state, and retain the last known-good generation when a later refresh fails. ### Caplets Admin Dashboard @@ -94,7 +118,7 @@ Daemon-First Setup points MCP clients at `caplets attach ` and A runtime-owned encrypted string store whose values can be referenced from Caplets config with `$vault:NAME` or `${vault:NAME}`. -Caplets Vault replaces fragile agent-harness environment propagation for secret-like config values. Each runtime owns its own Vault store; local Caplets do not read, mirror, or forward remote or Cloud Vault values. +Caplets Vault replaces fragile agent-harness environment propagation for secret-like config values. Each Current Host owns its Vault state; replicas in one Current Host share that state, while separate local, remote, or Cloud Current Hosts do not read, mirror, or forward each other's Vault values. ### Raw Vault Reveal diff --git a/README.md b/README.md index 3ff24e43..c78e8575 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,18 @@ caplets vault access grant GH_TOKEN github --remote Vault values are not exposed through Code Mode, progressive tools, or native agent APIs. Unset or ungranted Vault references quarantine only the affected Caplet and appear in `caplets doctor`. +## Self-hosted shared storage + +Self-hosted deployments choose exactly one Writable Authority—filesystem, local SQLite, +PostgreSQL, or S3-compatible storage—while image- or mount-staged Caplets remain immutable +read-only inputs. PostgreSQL is the networked SQL choice for replicas; SQLite is single-host. +AWS S3 and Cloudflare R2 require credentialed live evidence before they should be called validated. + +See the [self-hosting storage guide](docs/product/self-hosting.md) for bootstrap examples, +secret references, dashboard CRUD, health states, migration/backup/restore, and provider +prerequisites. Embedding the runtime? Start with the +[`@caplets/core` package API](packages/core/README.md). + ## Anonymous Telemetry Caplets collects opt-out anonymous telemetry for product usage and reliability. The first eligible diff --git a/apps/dashboard/src/components/DashboardApp.test.tsx b/apps/dashboard/src/components/DashboardApp.test.tsx index 259f4a62..babc5264 100644 --- a/apps/dashboard/src/components/DashboardApp.test.tsx +++ b/apps/dashboard/src/components/DashboardApp.test.tsx @@ -86,6 +86,16 @@ function button(label: string): HTMLButtonElement { if (!result) throw new Error(`Could not find button: ${label}`); return result; } +async function setInput(selector: string, value: string) { + const input = document.querySelector(selector); + if (!input) throw new Error(`Could not find input: ${selector}`); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} async function mountVault() { container = document.createElement("div"); @@ -102,6 +112,31 @@ async function mountVault() { ); } +async function mountRoute( + initialRoute: "caplets" | "runtime" | "settings", + responses: Record, +) { + dashboardApi.mockImplementation((path: string) => + Promise.resolve( + path === "session" ? { authenticated: true, session } : (responses[path] ?? {}), + ), + ); + window.history.replaceState({}, "", `/dashboard/${initialRoute}`); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + await waitFor(() => + Array.from(document.querySelectorAll("h1")).some( + (heading) => heading.textContent === initialRoute[0]?.toUpperCase() + initialRoute.slice(1), + ) + ? true + : undefined, + ); +} + async function openRevealConfirmation() { await act(async () => { button(`Reveal vault value ${vaultKey}`).click(); @@ -266,3 +301,617 @@ describe("Vault reveal races", () => { expect(toast.success).toHaveBeenCalledWith("Vault value revealed"); }); }); + +describe("authority storage presentation", () => { + it("renders distinct authority, exposure, lag, and safe degraded health", async () => { + await mountRoute("runtime", { + runtime: { + runtime: { + status: "ok", + version: "1.2.3", + bind: "https://internal.example/runtime", + }, + health: { + provider: "postgresql", + authorityId: "production-authority", + connectivity: "degraded", + writable: false, + activeGeneration: { + authorityId: "production-authority", + id: "generation-seven", + sequence: 7, + predecessorId: "generation-six", + }, + observedGeneration: { + authorityId: "production-authority", + id: "generation-nine", + sequence: 9, + predecessorId: "generation-eight", + }, + exposureGeneration: 4, + refresh: "failed", + lifecycle: "degraded", + readiness: "ready", + lag: 2, + stagedFingerprint: "abcdef0123456789abcdef", + endpoint: "https://provider.example/private", + lastError: { + code: "AUTHORITY_UNAVAILABLE", + message: "Could not reach https://db.example/private?token=secret-value", + details: { password: "must-not-render" }, + }, + }, + }, + diagnostics: { checks: [{ id: "runtime", status: "ok" }] }, + }); + + await waitFor(() => + container?.textContent?.includes("Degraded · read-only") ? true : undefined, + ); + const text = container?.textContent ?? ""; + expect(text).toContain("Postgresql"); + expect(text).toContain("production-authority"); + expect(text).toContain("Authority Generation · Active"); + expect(text).toContain("Generation 7"); + expect(text).toContain("Authority Generation · Observed"); + expect(text).toContain("Generation 9"); + expect(text).toContain("Exposure Generation"); + expect(text).toContain("Generation 4"); + expect(text).toContain("2 generations"); + expect(text).toContain("Could not reach remote endpoint"); + expect(text).not.toContain("https://"); + expect(text).not.toContain("secret-value"); + expect(text).not.toContain("must-not-render"); + }); + + it("marks staged IDs reserved and exposes mutation only for authority-owned Caplets", async () => { + const activeGeneration = { + authorityId: "shared-authority", + id: "generation-12", + sequence: 12, + predecessorId: "generation-11", + }; + await mountRoute("caplets", { + caplets: { + caplets: [ + { + id: "staged-tool", + title: "Staged tool", + source: { kind: "project-file", path: "/private/project/CAPLET.md" }, + }, + { + id: "shared-tool", + title: "Shared tool", + source: { + kind: "authority", + authorityId: "shared-authority", + generationId: "generation-12", + }, + }, + ], + }, + "catalog/updates": { + updates: [ + { id: "staged-tool", status: "available" }, + { id: "shared-tool", status: "available" }, + ], + }, + runtime: { + health: { + provider: "s3", + authorityId: "shared-authority", + connectivity: "healthy", + writable: true, + activeGeneration, + observedGeneration: activeGeneration, + exposureGeneration: 5, + refresh: "current", + lifecycle: "ready", + readiness: "ready", + lag: 0, + }, + }, + "catalog/update": {}, + }); + + await waitFor( + () => + document.querySelector('[aria-label="staged-tool is immutable and reserved"]') ?? undefined, + ); + expect( + document.querySelector('button[aria-label^="Review update for staged-tool"]'), + ).toBeNull(); + const updateButton = button( + "Review update for shared-tool; conflicts require refresh and review", + ); + expect(container?.textContent).toContain("Immutable on this host"); + expect(container?.textContent).toContain("Authority Generation conflict checks"); + expect(container?.textContent).not.toContain("/private/project"); + + await act(async () => { + updateButton.click(); + }); + const phrase = await waitFor( + () => + document.querySelector( + 'input[aria-label="Type update shared-tool to confirm"]', + ) ?? undefined, + ); + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(phrase, "update shared-tool"); + phrase.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + button("Confirm").click(); + }); + await waitFor(() => + dashboardApi.mock.calls.some(([path]) => path === "catalog/update") ? true : undefined, + ); + const updateCall = dashboardApi.mock.calls.find(([path]) => path === "catalog/update"); + expect(JSON.parse(String(updateCall?.[1]?.body))).toMatchObject({ + capletId: "shared-tool", + expectedGeneration: activeGeneration, + }); + }); + + it("keeps filesystem-only settings usable without exposing host URLs or session secrets", async () => { + await mountRoute("settings", { + summary: { + host: { baseUrl: "https://private-host.example/dashboard", version: "1.2.3" }, + }, + runtime: { runtime: { status: "ok", bind: "https://private-bind.example" } }, + diagnostics: { status: "ok" }, + }); + + await waitFor(() => + container?.textContent?.includes("Filesystem compatibility") ? true : undefined, + ); + const text = container?.textContent ?? ""; + expect(text).toContain("Existing local storage remains usable"); + expect(text).toContain("Secret values are not displayed"); + expect(text).not.toContain("private-host.example"); + expect(text).not.toContain("private-bind.example"); + expect(text).not.toContain(session.csrfToken); + }); + + it("announces a recovered writable authority without stale error guidance", async () => { + await mountRoute("settings", { + diagnostics: { + health: { + provider: "sqlite", + authorityId: "recovered-authority", + connectivity: "healthy", + writable: true, + activeGeneration: { sequence: 3 }, + observedGeneration: { sequence: 3 }, + exposureGeneration: 8, + refresh: "current", + lifecycle: "recovered", + readiness: "ready", + lag: 0, + }, + }, + }); + + await waitFor(() => (container?.textContent?.includes("Recovered") ? true : undefined)); + expect(container?.textContent).toContain("Authority recovered"); + expect(container?.textContent).toContain("Writes are enabled"); + expect(container?.textContent).toContain("0 generations"); + }); + it("shows authority CRUD controls only for writable authority entries", async () => { + const activeGeneration = { + authorityId: "shared-authority", + id: "generation-12", + sequence: 12, + predecessorId: "generation-11", + }; + await mountRoute("caplets", { + caplets: { + caplets: [ + { + id: "staged-tool", + title: "Staged tool", + source: { kind: "project-file", path: "/private/project/CAPLET.md" }, + }, + { + id: "authority-tool", + name: "Authority tool", + source: { + kind: "authority", + authorityId: "shared-authority", + generationId: "generation-12", + }, + config: { name: "Authority tool", description: "Managed by authority" }, + }, + ], + }, + runtime: { + health: { + authorityId: "shared-authority", + connectivity: "healthy", + writable: true, + activeGeneration, + observedGeneration: activeGeneration, + exposureGeneration: 12, + refresh: "current", + lifecycle: "ready", + readiness: "ready", + lag: 0, + }, + }, + "caplets/create": { status: "active", generation: activeGeneration }, + settings: { settings: {} }, + }); + + await waitFor(() => button("Create Caplet")); + expect( + document.querySelector('button[aria-label="Edit authority Caplet authority-tool"]'), + ).not.toBeNull(); + expect( + document.querySelector('button[aria-label="Delete authority Caplet authority-tool"]'), + ).not.toBeNull(); + expect( + document.querySelector('button[aria-label="Edit authority Caplet staged-tool"]'), + ).toBeNull(); + expect( + document.querySelector('button[aria-label="Delete authority Caplet staged-tool"]'), + ).toBeNull(); + + const idInput = document.querySelector( + 'input[aria-label="Authority Caplet ID"]', + ); + if (!idInput) throw new Error("Authority Caplet ID input missing."); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(idInput, "new-authority-tool"); + idInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await setInput('input[aria-label="MCP stdio command"]', "node"); + await act(async () => { + button("Create Caplet").click(); + }); + await waitFor(() => + dashboardApi.mock.calls.some(([path]) => path === "caplets/create") ? true : undefined, + ); + const createCall = dashboardApi.mock.calls.find(([path]) => path === "caplets/create"); + expect(JSON.parse(String(createCall?.[1]?.body))).toMatchObject({ + record: { + id: "new-authority-tool", + name: "new-authority-tool", + description: "Authority-managed Caplet", + backend: { transport: "stdio", command: "node" }, + }, + expectedGeneration: activeGeneration, + idempotencyKey: expect.any(String), + }); + }); + + it("starts a fresh idempotency intent when editing after an active create", async () => { + const firstGeneration: { + authorityId: string; + id: string; + sequence: number; + predecessorId: string | null; + } = { + authorityId: "shared-authority", + id: "generation-1", + sequence: 1, + predecessorId: null, + }; + let currentGeneration = firstGeneration; + let caplets: Array> = []; + let createKey: string | undefined; + let updateKey: string | undefined; + await mountRoute("caplets", { + caplets: { caplets: [] }, + runtime: { + health: { connectivity: "healthy", writable: true, activeGeneration: firstGeneration }, + }, + settings: { settings: {} }, + }); + dashboardApi.mockImplementation((path: string, options?: { body?: unknown }) => { + if (path === "session") return { authenticated: true, session }; + if (path === "caplets") return Promise.resolve({ caplets }); + if (path === "runtime" || path === "diagnostics") { + return Promise.resolve({ + health: { connectivity: "healthy", writable: true, activeGeneration: currentGeneration }, + }); + } + if (path === "caplets/create") { + createKey = JSON.parse(String(options?.body)).idempotencyKey; + currentGeneration = { + ...firstGeneration, + id: "generation-2", + sequence: 2, + predecessorId: firstGeneration.id, + }; + caplets = [ + { + id: "new-authority-tool", + name: "New authority tool", + description: "Created authority tool", + source: { + kind: "authority", + authorityId: "shared-authority", + generationId: "generation-2", + }, + config: { name: "New authority tool", description: "Created authority tool" }, + backendConfig: { transport: "stdio", command: "node" }, + }, + ]; + return Promise.resolve({ status: "active", generation: currentGeneration }); + } + if (path === "caplets/update") { + updateKey = JSON.parse(String(options?.body)).idempotencyKey; + return Promise.resolve({ + status: "active", + generation: { + ...currentGeneration, + id: "generation-3", + sequence: 3, + predecessorId: currentGeneration.id, + }, + }); + } + return Promise.resolve({}); + }); + await setInput('input[aria-label="Authority Caplet ID"]', "new-authority-tool"); + await setInput('input[aria-label="MCP stdio command"]', "node"); + await act(async () => { + button("Create Caplet").click(); + }); + await waitFor(() => (createKey ? createKey : undefined)); + await waitFor(() => button("Edit authority Caplet new-authority-tool")); + await act(async () => { + button("Edit authority Caplet new-authority-tool").click(); + }); + await waitFor(() => button("Save Caplet")); + await setInput('input[aria-label="Authority Caplet name"]', "Edited authority tool"); + await act(async () => { + button("Save Caplet").click(); + }); + await waitFor(() => (updateKey ? updateKey : undefined)); + expect(updateKey).toBeDefined(); + expect(updateKey).not.toBe(createKey); + }); + + it("starts a fresh idempotency intent when revoking after an active grant", async () => { + const firstGeneration = { + authorityId: "shared-authority", + id: "generation-1", + sequence: 1, + predecessorId: null, + }; + let grantKey: string | undefined; + let revokeKey: string | undefined; + await mountRoute("settings", { + diagnostics: { + health: { connectivity: "healthy", writable: true, activeGeneration: firstGeneration }, + }, + runtime: { + health: { connectivity: "healthy", writable: true, activeGeneration: firstGeneration }, + }, + settings: { + settings: { + telemetry: true, + defaultSearchLimit: 10, + maxSearchLimit: 20, + options: { exposure: "direct" }, + }, + }, + }); + dashboardApi.mockImplementation((path: string, options?: { body?: unknown }) => { + if (path === "session") return { authenticated: true, session }; + if (path === "setup/grant") { + grantKey = JSON.parse(String(options?.body)).idempotencyKey; + return Promise.resolve({ + status: "active", + generation: { + ...firstGeneration, + id: "generation-2", + sequence: 2, + predecessorId: firstGeneration.id, + }, + }); + } + if (path === "setup/revoke") { + revokeKey = JSON.parse(String(options?.body)).idempotencyKey; + return Promise.resolve({ + status: "active", + generation: { + ...firstGeneration, + id: "generation-3", + sequence: 3, + predecessorId: "generation-2", + }, + }); + } + return Promise.resolve({ + health: { connectivity: "healthy", writable: true, activeGeneration: firstGeneration }, + settings: { + telemetry: true, + defaultSearchLimit: 10, + maxSearchLimit: 20, + options: { exposure: "direct" }, + }, + }); + }); + await setInput('input[aria-label="Setup Caplet ID"]', "authority-tool"); + await setInput('input[aria-label="Setup content hash"]', "sha256:12345678"); + await act(async () => { + button("Grant setup approval").click(); + }); + await waitFor(() => (grantKey ? grantKey : undefined)); + await act(async () => { + button("Revoke setup approval").click(); + }); + await waitFor(() => (revokeKey ? revokeKey : undefined)); + expect(revokeKey).toBeDefined(); + expect(revokeKey).not.toBe(grantKey); + }); + + it("clears the edit form after deleting the edited authority Caplet", async () => { + const generation = { + authorityId: "shared-authority", + id: "generation-1", + sequence: 1, + predecessorId: null, + }; + const caplet = { + id: "authority-tool", + name: "Authority tool", + description: "Managed by authority", + source: { kind: "authority", authorityId: "shared-authority", generationId: generation.id }, + config: { name: "Authority tool", description: "Managed by authority" }, + backendConfig: { transport: "stdio", command: "node" }, + }; + let deleted = false; + await mountRoute("caplets", { + caplets: { caplets: [caplet] }, + runtime: { + health: { connectivity: "healthy", writable: true, activeGeneration: generation }, + }, + settings: { settings: {} }, + }); + dashboardApi.mockImplementation((path: string) => { + if (path === "session") return { authenticated: true, session }; + if (path === "caplets") return Promise.resolve({ caplets: deleted ? [] : [caplet] }); + if (path === "runtime" || path === "diagnostics") { + return Promise.resolve({ + health: { connectivity: "healthy", writable: true, activeGeneration: generation }, + }); + } + if (path === "caplets/delete") { + deleted = true; + return Promise.resolve({ status: "active", generation }); + } + return Promise.resolve({}); + }); + await act(async () => { + button("Edit authority Caplet authority-tool").click(); + }); + await waitFor(() => button("Save Caplet")); + await act(async () => { + button("Delete authority Caplet authority-tool").click(); + }); + await waitFor(() => + document.querySelector('input[aria-label="Type delete authority-tool to confirm"]'), + ); + await setInput( + 'input[aria-label="Type delete authority-tool to confirm"]', + "delete authority-tool", + ); + await act(async () => { + button("Confirm").click(); + }); + await waitFor(() => (deleted ? true : undefined)); + await waitFor(() => button("Create Caplet")); + expect(document.querySelector('button[aria-label="Cancel edit"]')).toBeNull(); + expect( + document.querySelector('input[aria-label="Authority Caplet ID"]')?.getAttribute("value"), + ).toBe(""); + }); + + it("preserves a Caplet draft and names the changed generation after conflict", async () => { + const activeGeneration = { + authorityId: "authority", + id: "generation-1", + sequence: 1, + predecessorId: null, + }; + await mountRoute("caplets", { + runtime: { health: { writable: true, activeGeneration } }, + }); + dashboardApi.mockImplementation((path: string) => { + if (path === "caplets/create") { + const error = new Error("Authority Generation conflict") as Error & { body?: unknown }; + error.body = { + error: { + details: { + kind: "conflict", + activeGeneration: { + authorityId: "authority", + id: "generation-2", + sequence: 2, + predecessorId: "generation-1", + }, + }, + }, + }; + return Promise.reject(error); + } + if (path === "diagnostics") + return Promise.resolve({ health: { writable: true, activeGeneration } }); + return Promise.resolve({}); + }); + const idInput = document.querySelector( + 'input[aria-label="Authority Caplet ID"]', + ); + if (!idInput) throw new Error("Authority Caplet ID input missing."); + await setInput('input[aria-label="MCP stdio command"]', "node"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(idInput, "conflict-draft"); + idInput.dispatchEvent(new Event("input", { bubbles: true })); + idInput.dispatchEvent(new Event("change", { bubbles: true })); + button("Create Caplet").click(); + }); + await waitFor(() => + dashboardApi.mock.calls.some(([path]) => path === "caplets/create") ? true : undefined, + ); + await flush(); + expect( + document.querySelector('input[aria-label="Authority Caplet ID"]')?.value, + ).toBe("conflict-draft"); + expect(container?.textContent).toContain("changed to 2"); + expect(container?.textContent).toContain("Refresh and review latest generation"); + }); + + it("keeps a durable pending receipt and disables duplicate Caplet submission", async () => { + const generation = { + authorityId: "authority", + id: "generation-1", + sequence: 1, + predecessorId: null, + }; + await mountRoute("caplets", { + runtime: { health: { writable: true, activeGeneration: generation } }, + }); + dashboardApi.mockImplementation((path: string) => { + if (path === "caplets/create") { + return Promise.resolve({ + status: "pending", + generation: { + authorityId: "authority", + id: "generation-2", + sequence: 2, + predecessorId: "generation-1", + }, + idempotencyKey: "pending-intent", + }); + } + if (path === "diagnostics") { + return Promise.resolve({ health: { writable: true, activeGeneration: generation } }); + } + return Promise.resolve({}); + }); + const idInput = document.querySelector( + 'input[aria-label="Authority Caplet ID"]', + ); + if (!idInput) throw new Error("Authority Caplet ID input missing."); + await setInput('input[aria-label="MCP stdio command"]', "node"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(idInput, "pending-draft"); + idInput.dispatchEvent(new Event("input", { bubbles: true })); + idInput.dispatchEvent(new Event("change", { bubbles: true })); + button("Create Caplet").click(); + }); + await waitFor(() => + container?.textContent?.includes("activation is still pending") ? true : undefined, + ); + expect(button("Create Caplet").disabled).toBe(true); + expect(container?.textContent).toContain("Do not submit a duplicate change"); + }); +}); diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx index 94b68edc..c1926a46 100644 --- a/apps/dashboard/src/components/DashboardApp.tsx +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -108,10 +108,35 @@ type RouteKey = | "activity" | "settings"; +type AuthorityGenerationView = { + authorityId?: string; + id?: string; + predecessorId?: string | null; + sequence: number; +}; + +type AuthorityHealthView = { + provider: string; + authorityId: string; + connectivity: string; + writable: boolean; + activeGeneration: AuthorityGenerationView | null; + refresh: string; + lifecycle: string; + readiness: string; + observedGeneration: AuthorityGenerationView | null; + exposureGeneration: number | null; + stagedFingerprint?: string; + lag: number | null; + lastError?: { code?: string; message: string }; +}; + type Summary = { host?: { baseUrl?: string; version?: string }; attention?: Array<{ label: string; severity?: string; kind?: string }>; sections?: Record; + health?: unknown; + storageHealth?: unknown; }; type CapletRecord = Record & { @@ -120,11 +145,23 @@ type CapletRecord = Record & { title?: string; description?: string; kind?: string; - source?: string; + source?: unknown; + provenance?: unknown; + ownership?: unknown; + sourceOwnership?: unknown; + immutable?: boolean | "true" | "false"; + reserved?: boolean | "true" | "false"; + mutable?: boolean | "true" | "false"; updateState?: string; authRequired?: boolean | "true" | "false"; setupRequired?: boolean | "true" | "false"; projectBindingRequired?: boolean | "true" | "false"; + backendConfig?: { + transport?: string; + command?: string; + args?: string[]; + url?: string; + }; }; type DashboardData = { @@ -137,8 +174,24 @@ type DashboardData = { grants?: Array>; error?: string; }; - runtime?: { runtime?: Record; daemon?: Record; error?: string }; - diagnostics?: { status?: string; checks?: Array>; error?: string }; + settings?: { + settings?: Record; + error?: string; + }; + runtime?: { + runtime?: Record; + daemon?: Record; + health?: unknown; + storageHealth?: unknown; + error?: string; + }; + diagnostics?: { + status?: string; + checks?: Array>; + health?: unknown; + storageHealth?: unknown; + error?: string; + }; activity?: { entries?: Array>; error?: string }; logs?: { entries?: Array>; error?: string }; projectBinding?: { @@ -188,6 +241,174 @@ function dashboardBoolean(value: unknown): boolean { return value === true || value === "true"; } +function dashboardRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function safeDashboardText(value: unknown, fallback: string, limit = 120): string { + if (typeof value !== "string") return fallback; + const sanitized = Array.from(value, (char) => { + const code = char.codePointAt(0) ?? 0; + return code <= 0x1f || code === 0x7f ? " " : char; + }) + .join("") + .replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/giu, "remote endpoint") + .replace(/\s+/gu, " ") + .trim(); + return sanitized ? sanitized.slice(0, limit) : fallback; +} + +function dashboardCount(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function authorityGeneration(value: unknown): AuthorityGenerationView | null { + const record = dashboardRecord(value); + if (!record) return null; + const sequence = dashboardCount(record.sequence); + if (sequence === null) return null; + return { + sequence, + ...(typeof record.authorityId === "string" + ? { authorityId: safeDashboardText(record.authorityId, "Current authority", 80) } + : {}), + ...(typeof record.id === "string" + ? { id: safeDashboardText(record.id, "generation", 80) } + : {}), + ...(record.predecessorId === null + ? { predecessorId: null } + : typeof record.predecessorId === "string" + ? { predecessorId: safeDashboardText(record.predecessorId, "generation", 80) } + : {}), + }; +} + +function authorityHealthFromData(data: DashboardData): AuthorityHealthView | undefined { + const runtime = dashboardRecord(data.runtime?.runtime); + const candidates = [ + data.runtime?.health, + data.runtime?.storageHealth, + runtime?.health, + runtime?.storageHealth, + data.diagnostics?.health, + data.diagnostics?.storageHealth, + data.summary?.health, + data.summary?.storageHealth, + ]; + const record = candidates + .map(dashboardRecord) + .find((candidate) => + candidate + ? "provider" in candidate || + "authorityId" in candidate || + "activeGeneration" in candidate || + "writable" in candidate + : false, + ); + if (!record) return undefined; + + const error = dashboardRecord(record.lastError); + const message = + typeof error?.message === "string" + ? safeDashboardText(error.message, "The authority reported a recoverable error.", 240) + : undefined; + return { + provider: safeDashboardText(record.provider, "filesystem", 40), + authorityId: safeDashboardText(record.authorityId, "Current authority", 80), + connectivity: safeDashboardText(record.connectivity, "unknown", 32).toLowerCase(), + writable: record.writable === true, + activeGeneration: authorityGeneration(record.activeGeneration), + refresh: safeDashboardText(record.refresh, "unknown", 32).toLowerCase(), + lifecycle: safeDashboardText(record.lifecycle, "unknown", 32).toLowerCase(), + readiness: safeDashboardText(record.readiness, "unknown", 32).toLowerCase(), + observedGeneration: authorityGeneration(record.observedGeneration), + exposureGeneration: dashboardCount(record.exposureGeneration), + ...(typeof record.stagedFingerprint === "string" + ? { + stagedFingerprint: safeDashboardText( + record.stagedFingerprint, + "Fingerprint unavailable", + 18, + ), + } + : {}), + lag: dashboardCount(record.lag), + ...(message + ? { + lastError: { + message, + ...(typeof error?.code === "string" + ? { code: safeDashboardText(error.code, "AUTHORITY_ERROR", 48) } + : {}), + }, + } + : {}), + }; +} + +type CapletOwnership = { + kind: "authority" | "staged" | "legacy"; + label: string; + detail: string; +}; + +function capletOwnership(caplet: CapletRecord, health?: AuthorityHealthView): CapletOwnership { + const source = + dashboardRecord(caplet.provenance) ?? + dashboardRecord(caplet.sourceOwnership) ?? + dashboardRecord(caplet.ownership) ?? + dashboardRecord(caplet.source); + const explicitKind = + source?.kind ?? + (typeof caplet.provenance === "string" + ? caplet.provenance + : typeof caplet.sourceOwnership === "string" + ? caplet.sourceOwnership + : typeof caplet.ownership === "string" + ? caplet.ownership + : caplet.source); + const kind = typeof explicitKind === "string" ? explicitKind.toLowerCase() : ""; + const immutable = + dashboardBoolean(caplet.immutable) || + dashboardBoolean(caplet.reserved) || + caplet.mutable === false || + caplet.mutable === "false"; + + if (kind === "authority" || kind.includes("authority-managed") || kind === "shared") { + const authorityId = safeDashboardText( + source?.authorityId, + health?.authorityId ?? "authority", + 80, + ); + return { + kind: "authority", + label: "Authority-managed", + detail: `Mutable through ${authorityId} with Authority Generation conflict checks.`, + }; + } + if ( + immutable || + kind === "staged" || + kind === "global-config" || + kind === "global-file" || + kind === "project-config" || + kind === "project-file" + ) { + return { + kind: "staged", + label: "Staged filesystem · Reserved", + detail: "Immutable on this host. Its Caplet ID is reserved against dashboard mutations.", + }; + } + return { + kind: "legacy", + label: "Local configuration", + detail: "Existing filesystem response; source ownership metadata is not available.", + }; +} + type ConfirmationOptions = { title: string; description: string; @@ -326,6 +547,7 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou load("clients", "access/clients"), load("pending", "access/pending-logins"), load("vault", "vault"), + load("settings", "settings"), load("runtime", "runtime"), load("diagnostics", "diagnostics"), load("activity", "activity?limit=50"), @@ -604,7 +826,10 @@ export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: Rou
- {data.summary?.host?.baseUrl ?? "Current Host"} + Current Host + {data.summary?.host?.version + ? ` · ${safeDashboardText(data.summary.host.version, "Version unknown", 40)}` + : ""}
@@ -741,6 +967,7 @@ function Page({ data, loading, action, + refresh, session, }: { route: RouteKey; @@ -748,16 +975,19 @@ function Page({ loading: boolean; session: DashboardSession; action: (label: string, callback: () => Promise) => Promise; + refresh: () => Promise; }) { const { confirmTyped } = useActionConfirm(); if (route === "access") return ; - if (route === "caplets") return ; + if (route === "caplets") + return ; if (route === "catalog") return ; if (route === "vault") return ; if (route === "runtime") return ; if (route === "activity") return ; - if (route === "settings") return ; + if (route === "settings") + return ; return ; } @@ -881,7 +1111,11 @@ function OverviewPage({ data, loading }: { data: DashboardData; loading: boolean const attention = data.summary?.attention ?? []; const pendingCount = data.pending?.pendingLogins?.length ?? 0; const updateSummary = catalogUpdateSummary(data.updates?.updates ?? []); - const runtimeStatus = data.runtime?.runtime?.status ?? data.diagnostics?.status ?? "unknown"; + const runtimeStatus = safeDashboardText( + data.runtime?.runtime?.status ?? data.diagnostics?.status, + "unknown", + 32, + ); const projectBindingState = data.projectBinding?.projectBinding?.state ?? "not configured"; const vaultGrantCount = data.vault?.grants?.length ?? 0; const inventoryCount = (data.caplets?.caplets ?? []).length; @@ -1355,23 +1589,573 @@ function ClientActions({ ); } +type CapletDraft = { + id: string; + name: string; + description: string; + transport: "stdio" | "http"; + command: string; + args: string; + url: string; +}; + +type DashboardMutationState = { + phase: "idle" | "submitting" | "active" | "pending" | "degraded" | "conflict"; + message?: string; + generation?: AuthorityGenerationView | null; + idempotencyKey?: string; +}; + +function newDashboardIntent(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `dashboard-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; +} + +function dashboardRecordFromError(error: unknown): Record | undefined { + const body = dashboardRecord((error as { body?: unknown } | undefined)?.body); + return dashboardRecord(body?.error); +} + +function dashboardMutationStatus(value: unknown): DashboardMutationState["phase"] { + const status = dashboardRecord(value)?.status; + return status === "pending" || status === "degraded" || status === "active" ? status : "active"; +} + +function dashboardMutationGeneration(value: unknown): AuthorityGenerationView | null { + return authorityGeneration(value); +} + +function generationsMatch( + left: AuthorityGenerationView | null | undefined, + right: AuthorityGenerationView | null | undefined, +): boolean { + return Boolean( + left && + right && + left.sequence === right.sequence && + (left.id === undefined || right.id === undefined || left.id === right.id) && + (left.authorityId === undefined || + right.authorityId === undefined || + left.authorityId === right.authorityId), + ); +} +function dashboardExpectedGeneration( + health?: AuthorityHealthView, +): AuthorityGenerationView | undefined { + const generation = health?.activeGeneration; + if ( + !generation || + typeof generation.authorityId !== "string" || + typeof generation.id !== "string" || + generation.predecessorId === undefined + ) { + return undefined; + } + return generation; +} + +function MutationStatusNotice({ + state, + onRefreshReview, + onRetry, +}: { + state: DashboardMutationState; + onRefreshReview?: () => void; + onRetry?: () => void; +}) { + if (state.phase === "idle") return null; + const generation = state.generation?.sequence; + const copy = + state.phase === "submitting" + ? "Submitting a durable Current Host change…" + : state.phase === "pending" + ? `Committed at generation ${generation ?? "unknown"}; activation is still pending. Do not submit a duplicate change.` + : state.phase === "degraded" + ? `Committed at generation ${generation ?? "unknown"}, but the Current Host is degraded. The receipt is preserved; restore health before retrying.` + : state.phase === "conflict" + ? `The Authority Generation changed${generation === undefined ? "" : ` to ${generation}`}. Refresh and review the latest state before resubmitting with a new intent.` + : `Change active at generation ${generation ?? "unknown"}.`; + return ( + + {state.phase === "conflict" || state.phase === "degraded" ? ( + + ) : ( + + )} + {humanizeToken(state.phase)} + + {state.message ? `${state.message} ${copy}` : copy} + {state.phase === "conflict" && onRefreshReview ? ( + + ) : null} + {state.phase === "pending" && onRetry ? ( + + ) : null} + + + ); +} + +function CapletAdminPanel({ + health, + refresh, + editRequest, + onClearEdit, +}: { + health?: AuthorityHealthView; + refresh: () => Promise; + editRequest?: CapletRecord; + onClearEdit: () => void; +}) { + const [draft, setDraft] = useState({ + id: "", + name: "", + description: "", + transport: "stdio", + command: "", + args: "", + url: "", + }); + const [mutation, setMutation] = useState({ phase: "idle" }); + const [activeIntent, setActiveIntent] = useState(); + const canWrite = health?.writable === true; + const editingId = editRequest?.id ?? undefined; + + useEffect(() => { + if (!editRequest) { + setDraft({ + id: "", + name: "", + description: "", + transport: "stdio", + command: "", + args: "", + url: "", + }); + setActiveIntent(undefined); + setMutation({ phase: "idle" }); + return; + } + const config = dashboardRecord(editRequest.config); + const backend = dashboardRecord(editRequest.backendConfig); + const transport = backend?.transport === "http" ? "http" : "stdio"; + setDraft({ + id: editRequest.id ?? "", + name: typeof config?.name === "string" ? config.name : (editRequest.name ?? ""), + description: + typeof config?.description === "string" + ? config.description + : (editRequest.description ?? ""), + transport, + command: typeof backend?.command === "string" ? backend.command : "", + args: Array.isArray(backend?.args) + ? backend.args.filter((value): value is string => typeof value === "string").join("\n") + : "", + url: typeof backend?.url === "string" ? backend.url : "", + }); + setMutation({ phase: "idle" }); + }, [editRequest]); + + async function pollActivation(generation: AuthorityGenerationView, attempt = 0) { + if (attempt > 0) { + await new Promise((resolve) => window.setTimeout(resolve, 300)); + } + try { + const diagnostics = await dashboardApi<{ health?: unknown; storageHealth?: unknown }>( + "diagnostics", + ); + const nextHealth = authorityHealthFromData({ diagnostics }); + if (generationsMatch(nextHealth?.activeGeneration, generation)) { + setActiveIntent(undefined); + setMutation({ phase: "active", generation, idempotencyKey: activeIntent }); + await refresh(); + return; + } + if ( + nextHealth && + (nextHealth.connectivity === "degraded" || + nextHealth.connectivity === "unavailable" || + !nextHealth.writable) + ) { + setMutation({ + phase: "degraded", + generation, + idempotencyKey: activeIntent, + message: nextHealth.lastError?.message, + }); + await refresh(); + return; + } + } catch { + // Keep the durable pending receipt visible; the bounded poll can be retried manually. + } + if (attempt < 4) void pollActivation(generation, attempt + 1); + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + const id = draft.id.trim(); + const name = draft.name.trim() || id; + const description = draft.description.trim() || "Authority-managed Caplet"; + const command = draft.command.trim(); + const url = draft.url.trim(); + const args = draft.args + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter(Boolean); + if ( + !id || + !canWrite || + mutation.phase === "pending" || + mutation.phase === "submitting" || + mutation.phase === "conflict" + ) + return; + if (draft.transport === "stdio" && !command) { + setMutation({ phase: "degraded", message: "An MCP stdio command is required." }); + return; + } + if (draft.transport === "http" && !url) { + setMutation({ phase: "degraded", message: "An MCP HTTP URL is required." }); + return; + } + const intent = activeIntent ?? newDashboardIntent(); + setActiveIntent(intent); + setMutation({ phase: "submitting", idempotencyKey: intent }); + try { + const response = await dashboardApi>( + editingId ? "caplets/update" : "caplets/create", + { + method: "POST", + body: JSON.stringify({ + ...(editingId ? { id: editingId } : {}), + record: { + id, + name, + description, + backend: + draft.transport === "stdio" + ? { transport: "stdio", command, ...(args.length ? { args } : {}) } + : { transport: "http", url }, + }, + ...(dashboardExpectedGeneration(health) + ? { expectedGeneration: dashboardExpectedGeneration(health) } + : {}), + idempotencyKey: intent, + }), + }, + ); + const phase = dashboardMutationStatus(response); + const generation = dashboardMutationGeneration(dashboardRecord(response)?.generation); + if (phase === "active") { + setActiveIntent(undefined); + } + setMutation({ + phase, + generation, + idempotencyKey: intent, + message: phase === "pending" ? "The authority accepted this change durably." : undefined, + }); + if (phase === "pending" && generation) void pollActivation(generation); + else await refresh(); + } catch (error) { + const details = dashboardRecordFromError(error)?.details; + const conflictGeneration = dashboardMutationGeneration( + dashboardRecord(details)?.changedGeneration ?? dashboardRecord(details)?.activeGeneration, + ); + setMutation({ + phase: conflictGeneration ? "conflict" : "degraded", + generation: conflictGeneration, + idempotencyKey: intent, + message: error instanceof Error ? error.message : "The authority rejected this change.", + }); + } + } + + function refreshReview() { + void refresh().then(() => { + setActiveIntent(newDashboardIntent()); + setMutation({ phase: "idle" }); + }); + } + + return ( + + + + {editingId ? "Edit authority-managed Caplet" : "Add authority-managed Caplet"} + + + {canWrite + ? "Create or update a structured Caplet definition in the Writable Authority. Filesystem-staged IDs remain immutable." + : "Authority CRUD is unavailable until a writable Current Host authority is reported."} + + + + { + if (mutation.generation) void pollActivation(mutation.generation); + }} + /> +
+ + + + + {draft.transport === "stdio" ? ( + <> + +