From cc83dea3c682bab9b9e988382a736569bd4f5197 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:13:50 -0700 Subject: [PATCH] feat(hub)!: delete dead mounts run-key-history, preferences, bench-model-policy, resolved-offerings (CL-8158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CL-8155's inventory found zero web callers for four apps/hub/src/index.ts mounts. Hard cutover, no fallbacks: - @corbits/run-key-history (diagnostics-only mount plus its agent.deploy.ack listener) and @corbits/preferences (a client built but never imported — the only caller, chat-ui's dormant WorkbenchSettingsSurface/NotificationsSection, is unreachable from web) are deleted outright, packages and all. - bench-model-policy's HTTP mount is deleted; the store and bench_model_policy table stay, since a workflow-catalog read still uses them. - catalog/resolved-offerings is deleted; its only caller (packages/onboarding's seed CLI) is dead post-T7. scripts/db-setup.ts drops the run_key_history and preferences Postgres schemas forward-only, mirroring the routines-schema handoff. Updated the no-product-tenancy and browser-safe-subpaths check allowlists and regenerated tsconfig.build.json's project references. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/hub/package.json | 2 - apps/hub/src/index.ts | 103 +----- apps/web/package.json | 1 - bun.lock | 65 +--- docs/package-migrations.md | 2 +- packages/bench/src/pg-store.ts | 8 +- packages/bench/test/migrations.test.ts | 5 +- packages/chat-ui/package.json | 1 - .../workbench-settings/notifications-api.ts | 42 --- .../notifications-section.tsx | 92 ----- .../src/workbench-settings/surface.tsx | 8 - .../test/notifications-section.test.tsx | 179 ---------- packages/evals/src/routes.ts | 8 +- packages/inference-catalog/src/index.ts | 8 - .../src/resolved-offerings-routes.ts | 44 --- packages/inference-catalog/src/routes.ts | 46 --- .../test/resolved-offerings-routes.test.ts | 99 ------ packages/preferences/LICENSE | 176 ---------- packages/preferences/README.md | 51 --- packages/preferences/package.json | 31 -- packages/preferences/src/client.ts | 76 ---- packages/preferences/src/index.ts | 17 - packages/preferences/src/migrations.ts | 48 --- packages/preferences/src/pg-store.ts | 73 ---- packages/preferences/src/routes.test.ts | 122 ------- packages/preferences/src/routes.ts | 61 ---- packages/preferences/src/schema.ts | 34 -- packages/preferences/src/store.test.ts | 67 ---- packages/preferences/src/store.ts | 41 --- packages/preferences/test/migrations.test.ts | 116 ------ packages/preferences/tsconfig.json | 29 -- packages/preferences/tsconfig.src.json | 26 -- packages/run-key-history/LICENSE | 176 ---------- packages/run-key-history/package.json | 30 -- packages/run-key-history/src/diagnostics.ts | 209 ----------- packages/run-key-history/src/index.ts | 32 -- packages/run-key-history/src/listener.ts | 61 ---- packages/run-key-history/src/migrations.ts | 95 ----- packages/run-key-history/src/reconnect.ts | 98 ------ packages/run-key-history/src/routes.ts | 93 ----- packages/run-key-history/src/schema.ts | 41 --- packages/run-key-history/src/store.ts | 99 ------ .../test/diagnostics.drizzle.test.ts | 329 ------------------ .../run-key-history/test/listener.test.ts | 112 ------ .../run-key-history/test/migrations.test.ts | 74 ---- .../test/reconnect.drizzle.test.ts | 170 --------- .../test/routes.drizzle.test.ts | 245 ------------- packages/run-key-history/test/store.test.ts | 157 --------- packages/run-key-history/tsconfig.json | 26 -- packages/run-key-history/tsconfig.src.json | 23 -- scripts/checks/browser-safe-subpaths.ts | 5 +- scripts/checks/no-product-tenancy.ts | 14 - scripts/db-setup.ts | 35 +- tsconfig.build.json | 6 - 54 files changed, 60 insertions(+), 3751 deletions(-) delete mode 100644 packages/chat-ui/src/workbench-settings/notifications-api.ts delete mode 100644 packages/chat-ui/src/workbench-settings/notifications-section.tsx delete mode 100644 packages/chat-ui/test/notifications-section.test.tsx delete mode 100644 packages/inference-catalog/src/resolved-offerings-routes.ts delete mode 100644 packages/inference-catalog/src/routes.ts delete mode 100644 packages/inference-catalog/test/resolved-offerings-routes.test.ts delete mode 100644 packages/preferences/LICENSE delete mode 100644 packages/preferences/README.md delete mode 100644 packages/preferences/package.json delete mode 100644 packages/preferences/src/client.ts delete mode 100644 packages/preferences/src/index.ts delete mode 100644 packages/preferences/src/migrations.ts delete mode 100644 packages/preferences/src/pg-store.ts delete mode 100644 packages/preferences/src/routes.test.ts delete mode 100644 packages/preferences/src/routes.ts delete mode 100644 packages/preferences/src/schema.ts delete mode 100644 packages/preferences/src/store.test.ts delete mode 100644 packages/preferences/src/store.ts delete mode 100644 packages/preferences/test/migrations.test.ts delete mode 100644 packages/preferences/tsconfig.json delete mode 100644 packages/preferences/tsconfig.src.json delete mode 100644 packages/run-key-history/LICENSE delete mode 100644 packages/run-key-history/package.json delete mode 100644 packages/run-key-history/src/diagnostics.ts delete mode 100644 packages/run-key-history/src/index.ts delete mode 100644 packages/run-key-history/src/listener.ts delete mode 100644 packages/run-key-history/src/migrations.ts delete mode 100644 packages/run-key-history/src/reconnect.ts delete mode 100644 packages/run-key-history/src/routes.ts delete mode 100644 packages/run-key-history/src/schema.ts delete mode 100644 packages/run-key-history/src/store.ts delete mode 100644 packages/run-key-history/test/diagnostics.drizzle.test.ts delete mode 100644 packages/run-key-history/test/listener.test.ts delete mode 100644 packages/run-key-history/test/migrations.test.ts delete mode 100644 packages/run-key-history/test/reconnect.drizzle.test.ts delete mode 100644 packages/run-key-history/test/routes.drizzle.test.ts delete mode 100644 packages/run-key-history/test/store.test.ts delete mode 100644 packages/run-key-history/tsconfig.json delete mode 100644 packages/run-key-history/tsconfig.src.json diff --git a/apps/hub/package.json b/apps/hub/package.json index 5c3fdcdd27..2a6b1893d3 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -45,12 +45,10 @@ "@corbits/morning-brief-workflow": "workspace:*", "@corbits/notify": "workspace:*", "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/process-granola-call-workflow": "workspace:*", "@corbits/process-provisioner": "workspace:*", "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", - "@corbits/run-key-history": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@corbits/turn-artifacts": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 30159487f4..fedd692129 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -110,11 +110,6 @@ import { createUsageSink, withTurnPartPersistGuard, } from "@corbits/insights"; -import { - applyPreferencesMigrations, - createPostgresPreferencesStore, - createPreferencesRoutes, -} from "@corbits/preferences"; import { applyBenchMigrations, createBenchRoutes, @@ -127,9 +122,7 @@ import { } from "@corbits/evals"; import { applyInferenceCatalogMigrations, - createBenchModelPolicyRoutes, createPostgresBenchModelPolicyStore, - createResolvedOfferingsRoutes, createWorkflowCatalogRoutes, } from "@corbits/inference-catalog"; import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; @@ -171,12 +164,6 @@ import { createSidecarProvisioner as createE2BSidecarProvisioner, readProvisionerConfig as readE2BProvisionerConfig, } from "@corbits/e2b-sandbox-sidecar"; -import { - createDrizzleRunKeyHistoryStore, - createRunKeyHistoryListener, - createRunKeyHistoryRoutes, -} from "@corbits/run-key-history"; - import { createEventCollectorRegistry, createHubSessionLookups, @@ -619,14 +606,9 @@ export async function createHub(config: HubConfig) { : {}), }); const baseLookups = createHubSessionLookups({ db, agentRepoStore }); - // Shared with `createRunKeyHistoryListener` below: one store instance - // for the process, read here ahead of `workflow_run` and written to - // there off every `agent.deploy.ack`. - const runKeyHistoryStore = createDrizzleRunKeyHistoryStore(db); // A chat agent is a native provisioned deployment. Reconnect - // ownership is Interchange's live run + `@corbits/run-key-history`. - // Completed means dead; wake is a fresh provision, not a folded-run - // idle settle. + // ownership is Interchange's live run. Completed means dead; wake is a + // fresh provision, not a folded-run idle settle. // CL-6345: the grant-allowance gate wraps `registerSignalCorrelation` // so a parked read-only call whose resource a standing grant covers is // auto-approved right after its approval row lands — no card for a @@ -904,18 +886,6 @@ export async function createHub(config: HubConfig) { db, eventCollectors, }); - // A second, independent listener on the same `agent.deploy.ack` event - // `createHubSessionOrchestrator` already reacts to above: that vendor - // listener owns `workflow_run.public_key`'s live value, this one - // maintains a decoupled append-only history so a historical signature - // stays re-provable after a key rotation. It never reads - // `workflow_run` — only its own last-recorded entry per address — so - // it cannot race vendor's independent write to that row on the same - // event. - createRunKeyHistoryListener({ - events: sidecarRouter.events, - store: runKeyHistoryStore, - }); // CL-6225: the launch path re-reads every tool-package tarball and // rebuilds a full git pack of every attached asset on every agent // launch; both reads are pure functions of an immutable commit SHA (see @@ -1773,72 +1743,16 @@ export async function createHub(config: HubConfig) { ), }), ); - // Run key identity diagnostics: read side of the append-only - // `run_key_history` table above — per-run key lifecycle, divergence - // against `workflow_run.public_key`, and tenant-wide counts by - // identity state, so diagnosing a stranded run never again requires - // hand-comparing a sidecar's on-disk key against this table. - app.route( - `${TENANT_PREFIX}/run-key-history`, - createRunKeyHistoryRoutes({ - db, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - // Preferences: a single per-(tenant, principal) JSONB bag for small UI - // choices a surface wants to remember across reload (col2 collapse, - // theme, ...). Package-owned table, migrated at hub start like insights. - await applyPreferencesMigrations(config.databaseUrl); - const preferences = createPostgresPreferencesStore(config.databaseUrl); - app.route( - `${TENANT_PREFIX}/preferences`, - createPreferencesRoutes({ - store: preferences.store, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - // Bench model policy: what a bench will and will not spend inference on. - // Package-owned table, migrated at hub start like insights and - // preferences. A bench with no row is unconstrained, so a freshly - // connected bench needs no configuration to get an answer. + // Bench model policy migrations still run: the store itself is read + // below (tenant-session bench-model-policy read), even though the + // dedicated read/write mount is gone. await applyInferenceCatalogMigrations(config.databaseUrl); const benchModelPolicy = createPostgresBenchModelPolicyStore( config.databaseUrl, ); - app.route( - `${TENANT_PREFIX}/bench-model-policy`, - createBenchModelPolicyRoutes({ - store: benchModelPolicy.store, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); - // Resolved catalog offerings: the same ancestor-inheriting view - // `listVisibleOfferings` gives `workflowDeployer` above, exposed over - // HTTP so an out-of-process deployer (`workbench seed`) can deploy - // against exactly what the hub itself would deploy against, not just - // the offerings a tenant owns directly. - app.route( - `${TENANT_PREFIX}/catalog/resolved-offerings`, - createResolvedOfferingsRoutes({ - listOfferings: (tenantId) => listVisibleOfferings(db, tenantId), - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - }), - ); // Bench purpose/type: benches are Interchange tenants, so this is a // package-owned side-table keyed by tenant id, migrated at hub start - // like insights and preferences. + // like insights. await applyBenchMigrations(config.databaseUrl); const benchSettings = createPostgresBenchSettingsStore(config.databaseUrl); app.route( @@ -1853,8 +1767,8 @@ export async function createHub(config: HubConfig) { ); // Eval run history: read-only surface over the package-owned // `evals.run` table, migrated at hub start like insights and - // bench-settings. Eval runs aren't tenant-owned (same as - // run-key-history), so the tenant prefix here is only the grant gate. + // bench-settings. Eval runs aren't tenant-owned, so the tenant prefix + // here is only the grant gate. await applyEvalsMigrations(config.databaseUrl); const evalRuns = createPostgresEvalRunStore(config.databaseUrl); app.route( @@ -2493,7 +2407,6 @@ export async function createHub(config: HubConfig) { inboxUnsnoozeSweep.stop(); await insightsUsage.close(); await insightsLatency.close(); - await preferences.close(); await benchSettings.close(); await evalRuns.close(); await closeMailbox(); diff --git a/apps/web/package.json b/apps/web/package.json index 315ca10396..2cba4c56b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,6 @@ "@corbits/inference-settings": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/plugins-ui": "workspace:*", - "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", diff --git a/bun.lock b/bun.lock index 377c934fa8..0429359294 100644 --- a/bun.lock +++ b/bun.lock @@ -73,12 +73,10 @@ "@corbits/morning-brief-workflow": "workspace:*", "@corbits/notify": "workspace:*", "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/process-granola-call-workflow": "workspace:*", "@corbits/process-provisioner": "workspace:*", "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", - "@corbits/run-key-history": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@corbits/turn-artifacts": "workspace:*", @@ -169,7 +167,6 @@ "@corbits/inference-settings": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/plugins-ui": "workspace:*", - "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@corbits/settings-ui": "workspace:*", @@ -416,9 +413,8 @@ }, "packages/capability-tools": { "name": "@corbits/capability-tools", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { - "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", @@ -491,7 +487,6 @@ "@corbits/error-sink": "workspace:*", "@corbits/icons": "workspace:*", "@corbits/inference-settings": "workspace:*", - "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@tanstack/react-query": "catalog:", @@ -616,12 +611,11 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@corbits/connections": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", - "@workbench/templates": "workspace:*", "arktype": "catalog:", }, "devDependencies": { @@ -1106,24 +1100,6 @@ "typescript": "catalog:", }, }, - "packages/preferences": { - "name": "@corbits/preferences", - "version": "0.0.1", - "dependencies": { - "@corbits/api-query": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", - "@intx/hub-api": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/presence": { "name": "@corbits/presence", "version": "0.0.1", @@ -1179,24 +1155,6 @@ "typescript": "catalog:", }, }, - "packages/run-key-history": { - "name": "@corbits/run-key-history", - "version": "0.0.1", - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/log": "0.3.0", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/sandbox-sidecar": { "name": "@corbits/sandbox-sidecar", "version": "0.0.1", @@ -1395,9 +1353,8 @@ }, "packages/workflow-authoring-tools": { "name": "@corbits/workflow-authoring-tools", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { - "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", @@ -1736,9 +1693,11 @@ }, "devDependencies": { "@corbits/agent-directory-tools": "workspace:*", + "@corbits/connections": "workspace:*", "@corbits/connections-tools": "workspace:*", "@corbits/interaction-tools": "workspace:*", "@types/bun": "catalog:", + "@workbench/templates": "workspace:*", "typescript": "catalog:", }, }, @@ -2191,8 +2150,6 @@ "@corbits/plugins-ui": ["@corbits/plugins-ui@workspace:packages/plugins-ui"], - "@corbits/preferences": ["@corbits/preferences@workspace:packages/preferences"], - "@corbits/presence": ["@corbits/presence@workspace:packages/presence"], "@corbits/process-granola-call-workflow": ["@corbits/process-granola-call-workflow@workspace:workflows/process-granola-call"], @@ -2207,8 +2164,6 @@ "@corbits/reddit-tools": ["@corbits/reddit-tools@workspace:packages/reddit-tools"], - "@corbits/run-key-history": ["@corbits/run-key-history@workspace:packages/run-key-history"], - "@corbits/sandbox-sidecar": ["@corbits/sandbox-sidecar@workspace:packages/sandbox-sidecar"], "@corbits/scout-agent": ["@corbits/scout-agent@workspace:packages/scout-agent"], @@ -3483,9 +3438,9 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3507,9 +3462,7 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], @@ -3607,8 +3560,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/docs/package-migrations.md b/docs/package-migrations.md index 929f78ed0a..ac8aaa320b 100644 --- a/docs/package-migrations.md +++ b/docs/package-migrations.md @@ -71,7 +71,7 @@ package onto the transactional pattern — see below): 1. **Self-contained, transactional** — `@corbits/chat`, `@corbits/notify`, `@corbits/webhook-triggers`, `@corbits/insights`, - `@corbits/skills`, `@corbits/bench`, `@corbits/preferences`, + `@corbits/skills`, `@corbits/bench`, `@corbits/inference-catalog`, `@corbits/evals`. (`@corbits/workflows`'s `./deploy-source` followed this shape until CL-7591 deleted it; it is no longer wired through diff --git a/packages/bench/src/pg-store.ts b/packages/bench/src/pg-store.ts index 04efffe1b8..71114d6273 100644 --- a/packages/bench/src/pg-store.ts +++ b/packages/bench/src/pg-store.ts @@ -2,10 +2,10 @@ // bench_settings table. Created after applyBenchMigrations on the same // URL. `purpose`/`type` are plain columns, not a jsonb bag, so the // upsert's partial-patch semantics come from `COALESCE(EXCLUDED.col, -// existing.col)` rather than the `||` merge `@corbits/preferences` uses -// for its jsonb column: a patch key that's absent sends `null` for that -// column on the INSERT side, and COALESCE falls back to whatever the row -// already had rather than overwriting it with that null. +// existing.col)` rather than a `||` merge on a jsonb column: a patch key +// that's absent sends `null` for that column on the INSERT side, and +// COALESCE falls back to whatever the row already had rather than +// overwriting it with that null. import postgres from "postgres"; import type { diff --git a/packages/bench/test/migrations.test.ts b/packages/bench/test/migrations.test.ts index 49f2a1039d..2284189c60 100644 --- a/packages/bench/test/migrations.test.ts +++ b/packages/bench/test/migrations.test.ts @@ -1,7 +1,6 @@ // DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring @corbits/preferences' migrations -// test. Runs against its own scratch database, never the developer's or -// the walking-skeleton suite's. +// still runs the unit gates). Runs against its own scratch database, +// never the developer's or the walking-skeleton suite's. import { afterAll, beforeAll, expect, test } from "bun:test"; import postgres from "postgres"; diff --git a/packages/chat-ui/package.json b/packages/chat-ui/package.json index bf9ab1a24a..3f4c8d689a 100644 --- a/packages/chat-ui/package.json +++ b/packages/chat-ui/package.json @@ -29,7 +29,6 @@ "@corbits/context-menu": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/inference-settings": "workspace:*", - "@corbits/preferences": "workspace:*", "@corbits/presence": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", "@tanstack/react-query": "catalog:", diff --git a/packages/chat-ui/src/workbench-settings/notifications-api.ts b/packages/chat-ui/src/workbench-settings/notifications-api.ts deleted file mode 100644 index be232ea8f6..0000000000 --- a/packages/chat-ui/src/workbench-settings/notifications-api.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Notifications section seam onto @corbits/preferences' tenant-scoped -// per-principal preference bag: this workbench's tenant id keys the row, -// and one JSON key per workbench — "chat.notifications." — holds -// that workbench's choice, so every workbench's preference shares the same row. -import { getPreferences, patchPreferences } from "@corbits/preferences/client"; - -import type { NotificationPreference } from "./notifications-section"; - -const DEFAULT_NOTIFICATION_PREFERENCE: NotificationPreference = "all"; - -function notificationsKey(workbenchId: string): string { - return `chat.notifications.${workbenchId}`; -} - -function isNotificationPreference( - value: unknown, -): value is NotificationPreference { - return value === "all" || value === "mentions" || value === "mute"; -} - -export async function getNotificationPreference( - tenantId: string, - workbenchId: string, -): Promise { - const preferences = await getPreferences(tenantId); - const stored = preferences[notificationsKey(workbenchId)]; - return isNotificationPreference(stored) - ? stored - : DEFAULT_NOTIFICATION_PREFERENCE; -} - -export async function setNotificationPreference( - tenantId: string, - workbenchId: string, - value: NotificationPreference, -): Promise { - const preferences = await patchPreferences(tenantId, { - [notificationsKey(workbenchId)]: value, - }); - const stored = preferences[notificationsKey(workbenchId)]; - return isNotificationPreference(stored) ? stored : value; -} diff --git a/packages/chat-ui/src/workbench-settings/notifications-section.tsx b/packages/chat-ui/src/workbench-settings/notifications-section.tsx deleted file mode 100644 index 3b60d7bf03..0000000000 --- a/packages/chat-ui/src/workbench-settings/notifications-section.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// Notifications section: personal-only preference, persisted through -// @corbits/preferences (per-principal, per-workbench) keyed by this -// workbench. Saves immediately on choice, the same instant-apply shape as -// CapacitySection's switch. - -import { toast } from "@corbits/react-ui"; -import { useEffect, useState } from "react"; - -import { CHAT_STRINGS } from "../strings"; -import { - getNotificationPreference, - setNotificationPreference, -} from "./notifications-api"; - -const NOTIFICATION_CHOICES = [ - ["all", CHAT_STRINGS.workbenchSettingsNotifyAll], - ["mentions", CHAT_STRINGS.workbenchSettingsNotifyMentions], - ["mute", CHAT_STRINGS.workbenchSettingsNotifyMute], -] as const; - -export type NotificationPreference = (typeof NOTIFICATION_CHOICES)[number][0]; - -export function NotificationsSection({ - tenantId, - workbenchId, -}: { - readonly tenantId: string; - readonly workbenchId: string; -}) { - const [value, setValue] = useState("all"); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - getNotificationPreference(tenantId, workbenchId).then((preference) => { - if (cancelled) return; - setValue(preference); - }); - return () => { - cancelled = true; - }; - }, [tenantId, workbenchId]); - - function handleChange(next: NotificationPreference) { - const previous = value; - setValue(next); - setSaving(true); - setError(null); - setNotificationPreference(tenantId, workbenchId, next) - .then((saved) => { - setValue(saved); - toast(CHAT_STRINGS.workbenchSettingsSavedToast); - }) - .catch(() => { - setValue(previous); - setError(CHAT_STRINGS.workbenchSettingsNotificationsSaveError); - }) - .finally(() => setSaving(false)); - } - - return ( -
-
- {NOTIFICATION_CHOICES.map(([id, label]) => ( - - ))} -
-

- {CHAT_STRINGS.workbenchSettingsNotificationsHint} -

- {error !== null ? ( -

- {error} -

- ) : null} -
- ); -} diff --git a/packages/chat-ui/src/workbench-settings/surface.tsx b/packages/chat-ui/src/workbench-settings/surface.tsx index e363864e17..10836f3f0b 100644 --- a/packages/chat-ui/src/workbench-settings/surface.tsx +++ b/packages/chat-ui/src/workbench-settings/surface.tsx @@ -40,7 +40,6 @@ import type { WorkbenchSettingsSection, WorkbenchSettingsSectionId, } from "./model"; -import { NotificationsSection } from "./notifications-section"; type WorkbenchSettingsData = { readonly data: WorkbenchSettings; @@ -350,13 +349,6 @@ export function WorkbenchSettingsSurface({ /> ) : null} - {activeSection.id === "notifications" ? ( - - ) : null} - {activeSection.id === "danger" ? : null} diff --git a/packages/chat-ui/test/notifications-section.test.tsx b/packages/chat-ui/test/notifications-section.test.tsx deleted file mode 100644 index 5b3f213751..0000000000 --- a/packages/chat-ui/test/notifications-section.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -// CL-6119: notifications was local-draft-only — the choice vanished on -// reload. Covers the real @corbits/preferences wiring: load on mount, save -// on choice, and that the choice survives a reload (a fresh mount against -// the same fake preferences store). -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { NotificationsSection } from "../src/workbench-settings/notifications-section"; - -const TENANT_ID = "tnt_flow"; -const WORKBENCH_ID = "chn_general"; -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function stubPreferencesStore(initial: Record = {}): { - data: Record; -} { - const state = { data: { ...initial } }; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = - typeof input === "string" ? input : new URL(String(input)).pathname; - if (path !== `/api/tenants/${TENANT_ID}/preferences`) { - throw new Error(`unexpected fetch: ${path}`); - } - if (init?.method === "PATCH") { - const patch = JSON.parse(String(init.body)) as Record; - state.data = { ...state.data, ...patch }; - return Response.json({ preferences: state.data }); - } - return Response.json({ preferences: state.data }); - }) as typeof fetch; - return state; -} - -async function mount(): Promise<{ container: HTMLDivElement; root: Root }> { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - await act(async () => { - root.render( - , - ); - await Promise.resolve(); - await Promise.resolve(); - }); - return { container, root }; -} - -function choiceButton(label: string): HTMLButtonElement | null { - return Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent === label, - ) as HTMLButtonElement | null; -} - -describe("NotificationsSection", () => { - test("loads the stored preference on mount", async () => { - stubPreferencesStore({ "chat.notifications.chn_general": "mentions" }); - const { container, root } = await mount(); - try { - const button = choiceButton("Mentions only"); - expect(button?.getAttribute("aria-pressed")).toBe("true"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("defaults to all messages when nothing is stored yet", async () => { - stubPreferencesStore(); - const { container, root } = await mount(); - try { - const button = choiceButton("All messages"); - expect(button?.getAttribute("aria-pressed")).toBe("true"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("saves the choice through the preferences PATCH route", async () => { - const store = stubPreferencesStore(); - const { container, root } = await mount(); - try { - const mute = choiceButton("Mute"); - await act(async () => { - mute?.click(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(mute?.getAttribute("aria-pressed")).toBe("true"); - expect(store.data["chat.notifications.chn_general"]).toBe("mute"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("persists the choice across a reload", async () => { - const store = stubPreferencesStore(); - const first = await mount(); - await act(async () => { - choiceButton("Mentions only")?.click(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - act(() => first.root.unmount()); - first.container.remove(); - - // Same fake store, standing in for a page reload. - globalThis.fetch = (async ( - input: RequestInfo | URL, - init?: RequestInit, - ) => { - const path = - typeof input === "string" ? input : new URL(String(input)).pathname; - if (path !== `/api/tenants/${TENANT_ID}/preferences`) { - throw new Error(`unexpected fetch: ${path}`); - } - if (init?.method === "PATCH") { - const patch = JSON.parse(String(init.body)) as Record; - store.data = { ...store.data, ...patch }; - return Response.json({ preferences: store.data }); - } - return Response.json({ preferences: store.data }); - }) as typeof fetch; - - const { container, root } = await mount(); - try { - const button = choiceButton("Mentions only"); - expect(button?.getAttribute("aria-pressed")).toBe("true"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("reverts and shows an error when the save fails", async () => { - globalThis.fetch = (async ( - input: RequestInfo | URL, - init?: RequestInit, - ) => { - const path = - typeof input === "string" ? input : new URL(String(input)).pathname; - if (path !== `/api/tenants/${TENANT_ID}/preferences`) { - throw new Error(`unexpected fetch: ${path}`); - } - if (init?.method === "PATCH") { - return new Response("nope", { status: 500 }); - } - return Response.json({ preferences: {} }); - }) as typeof fetch; - - const { container, root } = await mount(); - try { - const mute = choiceButton("Mute"); - await act(async () => { - mute?.click(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(mute?.getAttribute("aria-pressed")).toBe("false"); - const alert = document.body.querySelector('[role="alert"]'); - expect(alert?.textContent).toBe( - "Couldn't save your notification setting — try again.", - ); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); -}); diff --git a/packages/evals/src/routes.ts b/packages/evals/src/routes.ts index 7d29166109..54213273bc 100644 --- a/packages/evals/src/routes.ts +++ b/packages/evals/src/routes.ts @@ -1,9 +1,9 @@ // Read-only HTTP surface over `EvalRunStore` (CL-6465): recent eval runs // across every eval, and one run's full step/scorer detail. Mounted the -// same way `@corbits/insights` and `@corbits/run-key-history` mount their -// own routes.ts — one `Hono` app gated by `requireGrant`, no -// data partition by tenant (eval runs aren't tenant-owned, same as -// `run_key_history`; the tenant middleware here is only the grant gate). +// same way `@corbits/insights` mounts its own routes.ts — one +// `Hono` app gated by `requireGrant`, no data partition by +// tenant (eval runs aren't tenant-owned; the tenant middleware here is +// only the grant gate). // // Every field below is either already human-readable (`evalName`, // `configName`, scorer `name`/`reason`) or a dedicated `id` a UI never diff --git a/packages/inference-catalog/src/index.ts b/packages/inference-catalog/src/index.ts index 01af47a3e3..2d5ecd7bdc 100644 --- a/packages/inference-catalog/src/index.ts +++ b/packages/inference-catalog/src/index.ts @@ -59,14 +59,6 @@ export { type BenchModelPolicyStore, } from "./store"; export { createPostgresBenchModelPolicyStore } from "./pg-store"; -export { - createBenchModelPolicyRoutes, - type CreateBenchModelPolicyRoutesDeps, -} from "./routes"; -export { - createResolvedOfferingsRoutes, - type CreateResolvedOfferingsRoutesDeps, -} from "./resolved-offerings-routes"; export { createWorkflowCatalogRoutes, estimateUsd, diff --git a/packages/inference-catalog/src/resolved-offerings-routes.ts b/packages/inference-catalog/src/resolved-offerings-routes.ts deleted file mode 100644 index ec1cfae2f7..0000000000 --- a/packages/inference-catalog/src/resolved-offerings-routes.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The tenant-owned `GET .../catalog/offerings` route only ever lists rows a -// tenant planted directly on itself; it has no way to show offerings a -// tenant inherits from an ancestor tenant. `listVisibleOfferings` -// (`@intx/db`) already computes that resolved, inheritance-aware view for -// the hub's own workflow deployer — this route exposes the same view over -// HTTP so an out-of-process caller (`workbench seed`, in particular) can -// deploy against exactly what the hub itself would deploy against. -// -// Takes a `listOfferings` port rather than a `db` handle, same as -// `workflow-catalog-routes.ts`, so the route is testable with literals. -import { Hono } from "hono"; -import type { ResolvedOffering } from "@intx/db"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -export type CreateResolvedOfferingsRoutesDeps = { - readonly listOfferings: ( - tenantId: string, - ) => Promise; - readonly requireGrant: RequireGrant; -}; - -export function createResolvedOfferingsRoutes( - deps: CreateResolvedOfferingsRoutesDeps, -): Hono { - const app = new Hono(); - - app.get("/", deps.requireGrant("model-offering:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const resolved = [...(await deps.listOfferings(tenant.id))].sort( - (a, b) => a.offering.priority - b.offering.priority, - ); - return c.json({ - offerings: resolved.map((entry) => ({ - id: entry.offering.id, - priority: entry.offering.priority, - modelId: entry.offering.modelId, - providerId: entry.offering.providerId, - origin: entry.origin, - })), - }); - }); - - return app; -} diff --git a/packages/inference-catalog/src/routes.ts b/packages/inference-catalog/src/routes.ts deleted file mode 100644 index 759ddec2c4..0000000000 --- a/packages/inference-catalog/src/routes.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Tenant-scoped bench model policy: what this bench will and won't spend on -// inference. Gated on the same catalog-administration grants the platform -// already uses for offerings, since that is exactly what a policy constrains. -import { Hono } from "hono"; -import { type } from "arktype"; - -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { BenchModelPolicyPatch } from "./policy"; -import type { BenchModelPolicyStore } from "./store"; - -export type CreateBenchModelPolicyRoutesDeps = { - store: BenchModelPolicyStore; - requireGrant: RequireGrant; -}; - -export function createBenchModelPolicyRoutes( - deps: CreateBenchModelPolicyRoutesDeps, -): Hono { - const app = new Hono(); - - app.get("/", deps.requireGrant("model-offering:*", "read"), async (c) => { - const tenant = c.get("tenant"); - return c.json({ policy: await deps.store.getPolicy(tenant.id) }); - }); - - app.patch("/", deps.requireGrant("model-offering:*", "manage"), async (c) => { - const tenant = c.get("tenant"); - const raw = await c.req.json().catch(() => undefined); - const patch = BenchModelPolicyPatch(raw); - if (patch instanceof type.errors) { - return c.json( - { - error: { - code: "bad_request", - message: `invalid body: ${patch.summary}`, - }, - }, - 400, - ); - } - return c.json({ policy: await deps.store.patchPolicy(tenant.id, patch) }); - }); - - return app; -} diff --git a/packages/inference-catalog/test/resolved-offerings-routes.test.ts b/packages/inference-catalog/test/resolved-offerings-routes.test.ts deleted file mode 100644 index 897e053130..0000000000 --- a/packages/inference-catalog/test/resolved-offerings-routes.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// The resolved-offerings route exists so an out-of-process caller -// (`workbench seed`, CL-7461) can deploy against exactly what the hub's -// own workflow deployer would deploy against — including offerings a -// tenant only inherits from an ancestor, which the tenant-owned -// `GET .../catalog/offerings` route never lists. -import { Hono } from "hono"; -import { describe, expect, test } from "bun:test"; - -import type { TenantEnv } from "@intx/hub-api"; -import type { ResolvedOffering } from "@intx/db"; - -import { createResolvedOfferingsRoutes } from "../src/resolved-offerings-routes"; -import { offering } from "./fixtures"; - -const TENANT = { - id: "bench-1", - name: "Bench One", - slug: "bench-one", - domain: "bench-one.example", - parentId: null, - config: null, - createdAt: new Date("2026-01-01T00:00:00.000Z"), - updatedAt: new Date("2026-01-01T00:00:00.000Z"), -}; - -function app( - listOfferings: (tenantId: string) => Promise, - allow = true, -) { - const host = new Hono(); - host.use("*", async (c, next) => { - c.set("tenant", TENANT); - await next(); - }); - host.route( - "/", - createResolvedOfferingsRoutes({ - listOfferings, - requireGrant: () => async (c, next) => { - if (!allow) { - return c.json({ error: { code: "forbidden", message: "no" } }, 403); - } - return next(); - }, - }), - ); - return host; -} - -describe("GET resolved-offerings", () => { - test("a denied grant is rejected before the offerings are read", async () => { - const response = await app(async () => { - throw new Error("must not be called when the grant is denied"); - }, false).request("/"); - expect(response.status).toBe(403); - }); - - test("lists both directly-owned and ancestor-inherited offerings, sorted by priority", async () => { - const owned = offering({ - id: "owned", - canonicalName: "thrifty", - providerName: "globex", - capabilities: ["plain-text"], - priority: 2, - }); - const inherited = offering({ - id: "inherited", - canonicalName: "lavish", - providerName: "acme", - capabilities: ["plain-text"], - priority: 1, - inherited: true, - }); - - const response = await app(async (tenantId) => { - expect(tenantId).toBe(TENANT.id); - return [owned, inherited]; - }).request("/"); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - offerings: { - id: string; - priority: number; - modelId: string; - providerId: string; - origin: { tenantId: string; direct: boolean }; - }[]; - }; - expect(body.offerings.map((o) => o.id)).toEqual(["inherited", "owned"]); - const inheritedEntry = body.offerings.find((o) => o.id === "inherited"); - expect(inheritedEntry?.origin).toEqual({ - tenantId: "parent-bench", - direct: false, - }); - const ownedEntry = body.offerings.find((o) => o.id === "owned"); - expect(ownedEntry?.origin).toEqual({ tenantId: TENANT.id, direct: true }); - }); -}); diff --git a/packages/preferences/LICENSE b/packages/preferences/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/preferences/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/preferences/README.md b/packages/preferences/README.md deleted file mode 100644 index 2d64ca4221..0000000000 --- a/packages/preferences/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# @corbits/preferences - -Per-(tenant, principal) UI preference store: a single JSONB bag persisted -across reload for small UI choices a surface wants to remember — e.g. col2 -collapse state or theme. Keys are forward-compatible free-form strings -owned by whichever surface writes them; the package has no opinion on -their shape beyond "a plain JSON object", so a new key lands with no -migration. - -## Composition - -- `routes.ts` mounts under the tenant-session prefix using `@intx/hub-api`'s - `RequireGrant`/`TenantEnv`; identity comes from the resolved - tenant/principal, never a client-supplied value. -- `pg-store.ts` is the production `PreferencesStore`, backed by Drizzle + - `postgres` against the package-owned `preferences` Postgres schema - (`schema.ts`), fully siloed from the platform's `public` schema. The - merge (`data || patch`) happens inside a Postgres upsert so concurrent - patches from the same principal never race in application code; the - patch is sent via `sql.json(...)`, not a `::jsonb`-cast string parameter, - to avoid postgres.js silently treating `||` as array-append instead of - object merge. -- `store.ts`'s `createMemoryPreferencesStore` is the in-memory fake tests - and non-Postgres hosts use against the same `PreferencesStore` contract. -- `./client` subpath — a browser fetch/parse client against - `/api/tenants/:id/preferences`, kept here rather than in `apps/web` - per AGENTS.md's "apps stay generic" rule. -- Own migration ledger (`migrations.ts`) so the package's schema history - stays extractable independent of the platform's drizzle journal. - -## Key modules - -- `routes.ts` — `createPreferencesRoutes`: GET (returns `{}` if no row) - and PATCH (shallow-merge) over the tenant's preferences bag. -- `store.ts` — `PreferencesStore` contract + `createMemoryPreferencesStore`. -- `pg-store.ts` — `createPostgresPreferencesStore`: the Postgres-backed - implementation. -- `schema.ts` — `userPreferences` Drizzle table, one JSONB row per - (tenantId, principalId). -- `migrations.ts` — `applyPreferencesMigrations`, `preferencesMigrations`. -- `client.ts` (`./client` subpath) — browser fetch client, `PreferencesApiError`. - -## Tests - -``` -cd packages/preferences && bun test -``` - -`test/migrations.test.ts` is DB-gated — skips without a reachable -Postgres, runs against its own scratch database otherwise. Set -`DATABASE_URL=postgres://localhost:5432/workbench_e2e` to exercise it. diff --git a/packages/preferences/package.json b/packages/preferences/package.json deleted file mode 100644 index d129353036..0000000000 --- a/packages/preferences/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@corbits/preferences", - "private": true, - "description": "Per-(tenant, principal) UI preference store: a single JSONB bag persisted across reload for small UI choices a surface wants to remember, e.g. col2 collapse or theme.", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts", - "./client": "./src/client.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/api-query": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", - "@intx/hub-api": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/preferences/src/client.ts b/packages/preferences/src/client.ts deleted file mode 100644 index 73736c4a3f..0000000000 --- a/packages/preferences/src/client.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Browser client for @corbits/preferences' tenant-scoped routes. Apps stay -// generic (AGENTS.md): the fetch/parse logic for hitting -// `/api/tenants/:id/preferences` is domain logic that lives here, not in -// apps/web. -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; -import { UnauthenticatedError } from "@corbits/api-query/envelope"; - -const PreferencesResponse = type({ - preferences: "Record", -}); - -export class PreferencesApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - } -} - -type Validator = (data: unknown) => T | ArkErrors; - -async function request( - path: string, - schema: Validator, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new PreferencesApiError( - cause instanceof Error ? cause.message : String(cause), - ); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - throw new PreferencesApiError( - `The hub answered ${response.status} for ${path}.`, - response.status, - ); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new PreferencesApiError( - `Unexpected response shape from ${path}: ${parsed.summary}`, - ); - } - return parsed; -} - -export function getPreferences( - tenantId: string, -): Promise> { - return request( - `/api/tenants/${tenantId}/preferences`, - PreferencesResponse, - ).then((r) => r.preferences); -} - -export function patchPreferences( - tenantId: string, - patch: Record, -): Promise> { - return request(`/api/tenants/${tenantId}/preferences`, PreferencesResponse, { - method: "PATCH", - body: JSON.stringify(patch), - }).then((r) => r.preferences); -} diff --git a/packages/preferences/src/index.ts b/packages/preferences/src/index.ts deleted file mode 100644 index 817553f3cf..0000000000 --- a/packages/preferences/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - applyPreferencesMigrations, - preferencesMigrations, - type ApplyPreferencesMigrationsReport, - type PreferencesMigration, -} from "./migrations"; -export { - preferencesSchema, - userPreferences, - type UserPreferencesRow, -} from "./schema"; -export { createMemoryPreferencesStore, type PreferencesStore } from "./store"; -export { createPostgresPreferencesStore } from "./pg-store"; -export { - createPreferencesRoutes, - type CreatePreferencesRoutesDeps, -} from "./routes"; diff --git a/packages/preferences/src/migrations.ts b/packages/preferences/src/migrations.ts deleted file mode 100644 index 55a83e7dba..0000000000 --- a/packages/preferences/src/migrations.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Package-owned migrations for @corbits/preferences. Bookkeeping uses its -// own ledger table so the package can be extracted without disentangling -// history from the platform drizzle journal. Every table this package -// owns — including its ledger — lives in its own `preferences` Postgres -// schema, never `public`; see docs/package-migrations.md. Mechanics -// (schema/ledger bootstrap, transactional apply, the advisory lock -// across concurrent hub replicas) live in @corbits/migration-runner — -// this file owns only the domain SQL. -import { - applyPackageMigrations, - type ApplyPackageMigrationsReport, - type PackageMigration, -} from "@corbits/migration-runner"; - -export type PreferencesMigration = PackageMigration; - -const SCHEMA = "preferences"; - -export const preferencesMigrations: readonly PreferencesMigration[] = [ - { - name: "0001_user_preferences", - sql: ` - CREATE TABLE IF NOT EXISTS "preferences"."user_preferences" ( - "tenant_id" text NOT NULL, - "principal_id" text NOT NULL, - "data" jsonb NOT NULL DEFAULT '{}', - "updated_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "principal_id") - ); - `, - }, -]; - -const LEDGER_TABLE = "preferences_migrations"; - -export type ApplyPreferencesMigrationsReport = ApplyPackageMigrationsReport; - -export async function applyPreferencesMigrations( - databaseUrl: string, -): Promise { - return applyPackageMigrations({ - databaseUrl, - schema: SCHEMA, - ledgerTable: LEDGER_TABLE, - migrations: preferencesMigrations, - packageLabel: "preferences", - }); -} diff --git a/packages/preferences/src/pg-store.ts b/packages/preferences/src/pg-store.ts deleted file mode 100644 index 988f16ffa5..0000000000 --- a/packages/preferences/src/pg-store.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Postgres-backed PreferencesStore against the package-owned -// user_preferences table. Created after applyPreferencesMigrations on the -// same URL. The merge itself happens in Postgres (`data || patch`) inside -// the upsert so concurrent patches from the same principal never clobber -// each other on a read-modify-write race in application code. -// -// The patch value is sent via `sql.json(...)`, not a `${...}::jsonb` cast -// on a plain string parameter — the latter reaches Postgres with the wrong -// parameter type and silently changes `||`'s behavior from an object merge -// to an array-append, observed directly against this repo's postgres.js -// version (verified with psql producing the correct merge for the same SQL -// text, isolating the bug to that parameter-binding path). -import postgres from "postgres"; - -import type { PreferencesStore } from "./store"; - -type Sql = ReturnType; - -// postgres.js's jsonb column parser is not reliable under every runtime -// this repo ships on (observed returning the raw text under bun rather -// than the parsed object) — parse defensively instead of trusting the -// driver to have deserialized `data` already. -function parseJsonbData(value: unknown): Record { - if (typeof value === "string") - return JSON.parse(value) as Record; - return (value ?? {}) as Record; -} - -/** - * Open a PreferencesStore on an already-migrated Postgres URL. Caller owns - * connection lifetime via the returned `close` handle. - */ -export function createPostgresPreferencesStore(databaseUrl: string): { - store: PreferencesStore; - close: () => Promise; -} { - const sql: Sql = postgres(databaseUrl, { - max: 4, - onnotice: () => undefined, - }); - - const store: PreferencesStore = { - async getPreferences(tenantId, principalId) { - const rows = await sql<{ data: unknown }[]>` - SELECT data FROM preferences.user_preferences - WHERE tenant_id = ${tenantId} AND principal_id = ${principalId} - LIMIT 1 - `; - const row = rows[0]; - return row === undefined ? {} : parseJsonbData(row.data); - }, - - async patchPreferences(tenantId, principalId, patch) { - const rows = await sql<{ data: unknown }[]>` - INSERT INTO preferences.user_preferences (tenant_id, principal_id, data, updated_at) - VALUES (${tenantId}, ${principalId}, ${sql.json(patch as never)}, now()) - ON CONFLICT (tenant_id, principal_id) DO UPDATE SET - data = preferences.user_preferences.data || EXCLUDED.data, - updated_at = now() - RETURNING data - `; - const row = rows[0]; - return row === undefined ? patch : parseJsonbData(row.data); - }, - }; - - return { - store, - close: async () => { - await sql.end({ timeout: 5 }); - }, - }; -} diff --git a/packages/preferences/src/routes.test.ts b/packages/preferences/src/routes.test.ts deleted file mode 100644 index 3a5f0a2e18..0000000000 --- a/packages/preferences/src/routes.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Route-level tests: request parsing, grant gating, and error-envelope -// mapping against the in-memory store. Merge semantics themselves are -// covered in store.test.ts. -import { expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; - -import type { TenantEnv } from "@intx/hub-api"; - -import { createPreferencesRoutes } from "./routes"; -import { createMemoryPreferencesStore } from "./store"; - -const TENANT = { id: "tnt_1" }; -const PRINCIPAL = { id: "prn_1" }; - -function buildApp(opts?: { - store?: ReturnType; - denyWrite?: boolean; -}): Hono { - const store = opts?.store ?? createMemoryPreferencesStore(); - const routes = createPreferencesRoutes({ - store, - requireGrant: (_resource, action) => async (c, next) => { - if (opts?.denyWrite === true && action === "write") { - return c.json({ error: { code: "forbidden", message: "no" } }, 403); - } - await next(); - }, - }); - const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT as never); - c.set("principal", PRINCIPAL as never); - await next(); - }; - const app = new Hono(); - app.use("*", asPrincipal); - app.route("/", routes); - return app; -} - -test("GET / returns {} default preferences", async () => { - const app = buildApp(); - const response = await app.request("/"); - expect(response.status).toBe(200); - const body = (await response.json()) as { preferences: unknown }; - expect(body.preferences).toEqual({}); -}); - -test("PATCH / merges and GET / reflects the patched value", async () => { - const app = buildApp(); - const patchResponse = await app.request("/", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "shell.col2Collapsed": true }), - }); - expect(patchResponse.status).toBe(200); - const patchBody = (await patchResponse.json()) as { - preferences: Record; - }; - expect(patchBody.preferences).toEqual({ "shell.col2Collapsed": true }); - - const getResponse = await app.request("/"); - const getBody = (await getResponse.json()) as { - preferences: Record; - }; - expect(getBody.preferences).toEqual({ "shell.col2Collapsed": true }); -}); - -test("PATCH / with a non-object body 400s", async () => { - const app = buildApp(); - const response = await app.request("/", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify("not an object"), - }); - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); -}); - -test("PATCH / with no body / invalid JSON 400s rather than 500", async () => { - const app = buildApp(); - const response = await app.request("/", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: "not json", - }); - expect(response.status).toBe(400); -}); - -test("GET / is gated by requireGrant", async () => { - const app = new Hono(); - const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT as never); - c.set("principal", PRINCIPAL as never); - await next(); - }; - app.use("*", asPrincipal); - app.route( - "/", - createPreferencesRoutes({ - store: createMemoryPreferencesStore(), - requireGrant: () => async (c) => - c.json({ error: { code: "forbidden", message: "no" } }, 403), - }), - ); - const response = await app.request("/"); - expect(response.status).toBe(403); -}); - -test("PATCH / is gated by requireGrant separately from GET", async () => { - const app = buildApp({ denyWrite: true }); - const response = await app.request("/", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ a: 1 }), - }); - expect(response.status).toBe(403); - - const getResponse = await app.request("/"); - expect(getResponse.status).toBe(200); -}); diff --git a/packages/preferences/src/routes.ts b/packages/preferences/src/routes.ts deleted file mode 100644 index f3553fbacc..0000000000 --- a/packages/preferences/src/routes.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Tenant-scoped Preferences API: a single JSONB bag per (tenant, principal) -// for small UI choices a surface wants to remember across reload. Keys are -// forward-compatible free-form strings owned by whichever surface writes -// them (e.g. "shell.col2Collapsed") — this package has no opinion on their -// shape beyond "a plain JSON object". -import { Hono } from "hono"; -import { type } from "arktype"; - -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import type { PreferencesStore } from "./store"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -const PatchBody = type("Record"); - -export type CreatePreferencesRoutesDeps = { - store: PreferencesStore; - requireGrant: RequireGrant; -}; - -export function createPreferencesRoutes( - deps: CreatePreferencesRoutesDeps, -): Hono { - const app = new Hono(); - - app.get("/", deps.requireGrant("preferences:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const preferences = await deps.store.getPreferences( - tenant.id, - principal.id, - ); - return c.json({ preferences }); - }); - - app.patch("/", deps.requireGrant("preferences:*", "write"), async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - - const raw = await c.req.json().catch(() => undefined); - const patch = PatchBody(raw); - if (patch instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid body: ${patch.summary}`, - }), - 400, - ); - } - - const preferences = await deps.store.patchPreferences( - tenant.id, - principal.id, - patch, - ); - return c.json({ preferences }); - }); - - return app; -} diff --git a/packages/preferences/src/schema.ts b/packages/preferences/src/schema.ts deleted file mode 100644 index d3c73ff456..0000000000 --- a/packages/preferences/src/schema.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Workbench-owned per-(tenant, principal) UI preference store: one JSONB -// row per user per bench, holding whatever small UI choices a surface wants -// to remember across reload (col2 collapse, theme, ...). Forward-compatible -// on purpose — new keys land in the same row with no migration. -// -// This table lives in its own `preferences` Postgres schema, fully siloed -// from the platform's `public` schema — see docs/package-migrations.md. -// `tenantId`/`principalId` are plain text identifiers, not foreign keys, so -// referencing platform tenant/principal ids works identically from a named -// schema. -import { - jsonb, - pgSchema, - primaryKey, - text, - timestamp, -} from "drizzle-orm/pg-core"; - -export const preferencesSchema = pgSchema("preferences"); - -export const userPreferences = preferencesSchema.table( - "user_preferences", - { - tenantId: text("tenant_id").notNull(), - principalId: text("principal_id").notNull(), - data: jsonb("data").notNull().default({}), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [primaryKey({ columns: [table.tenantId, table.principalId] })], -); - -export type UserPreferencesRow = typeof userPreferences.$inferSelect; diff --git a/packages/preferences/src/store.test.ts b/packages/preferences/src/store.test.ts deleted file mode 100644 index 45f0d1962a..0000000000 --- a/packages/preferences/src/store.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createMemoryPreferencesStore } from "./store"; - -describe("createMemoryPreferencesStore", () => { - test("returns {} for a tenant/principal with no stored preferences", async () => { - const store = createMemoryPreferencesStore(); - expect(await store.getPreferences("tnt_1", "prn_1")).toEqual({}); - }); - - test("patchPreferences upserts and returns the merged object", async () => { - const store = createMemoryPreferencesStore(); - const merged = await store.patchPreferences("tnt_1", "prn_1", { - "shell.col2Collapsed": true, - }); - expect(merged).toEqual({ "shell.col2Collapsed": true }); - expect(await store.getPreferences("tnt_1", "prn_1")).toEqual({ - "shell.col2Collapsed": true, - }); - }); - - test("a later patch shallow-merges, preserving unrelated existing keys", async () => { - const store = createMemoryPreferencesStore(); - await store.patchPreferences("tnt_1", "prn_1", { - "shell.col2Collapsed": true, - "shell.theme": "dark", - }); - const merged = await store.patchPreferences("tnt_1", "prn_1", { - "shell.col2Collapsed": false, - }); - expect(merged).toEqual({ - "shell.col2Collapsed": false, - "shell.theme": "dark", - }); - }); - - test("a patch key overwrites the prior value for that key", async () => { - const store = createMemoryPreferencesStore(); - await store.patchPreferences("tnt_1", "prn_1", { count: 1 }); - const merged = await store.patchPreferences("tnt_1", "prn_1", { - count: 2, - }); - expect(merged).toEqual({ count: 2 }); - }); - - test("tenants are isolated from each other", async () => { - const store = createMemoryPreferencesStore(); - await store.patchPreferences("tnt_1", "prn_1", { flag: true }); - expect(await store.getPreferences("tnt_2", "prn_1")).toEqual({}); - }); - - test("principals within the same tenant are isolated from each other", async () => { - const store = createMemoryPreferencesStore(); - await store.patchPreferences("tnt_1", "prn_1", { flag: true }); - expect(await store.getPreferences("tnt_1", "prn_2")).toEqual({}); - }); - - test("returned objects are copies, not live references into the store", async () => { - const store = createMemoryPreferencesStore(); - await store.patchPreferences("tnt_1", "prn_1", { flag: true }); - const read = await store.getPreferences("tnt_1", "prn_1"); - (read as Record)["flag"] = false; - expect(await store.getPreferences("tnt_1", "prn_1")).toEqual({ - flag: true, - }); - }); -}); diff --git a/packages/preferences/src/store.ts b/packages/preferences/src/store.ts deleted file mode 100644 index 35eea64beb..0000000000 --- a/packages/preferences/src/store.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type PreferencesStore = { - /** Returns the stored preferences object, or `{}` if no row exists yet. */ - getPreferences( - tenantId: string, - principalId: string, - ): Promise>; - /** - * Shallow-merges `patch` into the existing preferences object (creating a - * row if absent) and returns the merged object. Keys not present in - * `patch` are preserved; keys in `patch` overwrite the existing value. - */ - patchPreferences( - tenantId: string, - principalId: string, - patch: Record, - ): Promise>; -}; - -function key(tenantId: string, principalId: string): string { - return `${tenantId}\u0000${principalId}`; -} - -/** - * In-memory store for unit tests and local smoke. Production wiring uses a - * drizzle-backed implementation against the same interface (see pg-store.ts). - */ -export function createMemoryPreferencesStore(): PreferencesStore { - const rows = new Map>(); - - return { - async getPreferences(tenantId, principalId) { - return { ...(rows.get(key(tenantId, principalId)) ?? {}) }; - }, - async patchPreferences(tenantId, principalId, patch) { - const existing = rows.get(key(tenantId, principalId)) ?? {}; - const merged = { ...existing, ...patch }; - rows.set(key(tenantId, principalId), merged); - return { ...merged }; - }, - }; -} diff --git a/packages/preferences/test/migrations.test.ts b/packages/preferences/test/migrations.test.ts deleted file mode 100644 index 3a03da9a1e..0000000000 --- a/packages/preferences/test/migrations.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring @corbits/insights' migrations test. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyPreferencesMigrations } from "../src/migrations"; -import { createPostgresPreferencesStore } from "../src/pg-store"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_preferences_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const migrationNames = ["0001_user_preferences"]; - -describeIfDb("applyPreferencesMigrations", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("applies the table into its own schema and is idempotent on a second run", async () => { - const first = await applyPreferencesMigrations(scratchUrl); - expect(first.applied).toEqual(migrationNames); - - const second = await applyPreferencesMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied.sort()).toEqual([...migrationNames].sort()); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'preferences' AND table_name = 'user_preferences'`, - ); - expect(tables.map((row) => String(row["table_name"]))).toEqual([ - "user_preferences", - ]); - - const inPublic = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'public' AND table_name = 'user_preferences'`, - ); - expect(inPublic).toHaveLength(0); - } finally { - await sql.end(); - } - }); - - test("get/patch round-trip through the real Postgres-backed store", async () => { - const { store, close } = createPostgresPreferencesStore(scratchUrl); - try { - expect(await store.getPreferences("tnt_1", "prn_1")).toEqual({}); - - const merged1 = await store.patchPreferences("tnt_1", "prn_1", { - "shell.col2Collapsed": true, - }); - expect(merged1).toEqual({ "shell.col2Collapsed": true }); - - const merged2 = await store.patchPreferences("tnt_1", "prn_1", { - "shell.theme": "dark", - }); - expect(merged2).toEqual({ - "shell.col2Collapsed": true, - "shell.theme": "dark", - }); - - expect(await store.getPreferences("tnt_1", "prn_1")).toEqual({ - "shell.col2Collapsed": true, - "shell.theme": "dark", - }); - - expect(await store.getPreferences("tnt_1", "prn_2")).toEqual({}); - } finally { - await close(); - } - }); -}); diff --git a/packages/preferences/tsconfig.json b/packages/preferences/tsconfig.json deleted file mode 100644 index 5058326fdc..0000000000 --- a/packages/preferences/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../api-query/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - } - ] -} diff --git a/packages/preferences/tsconfig.src.json b/packages/preferences/tsconfig.src.json deleted file mode 100644 index 517ced7759..0000000000 --- a/packages/preferences/tsconfig.src.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../api-query/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - }, - { - "path": "../migration-runner/tsconfig.src.json" - } - ] -} diff --git a/packages/run-key-history/LICENSE b/packages/run-key-history/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/run-key-history/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/run-key-history/package.json b/packages/run-key-history/package.json deleted file mode 100644 index 93f2042bbd..0000000000 --- a/packages/run-key-history/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@corbits/run-key-history", - "private": true, - "description": "Append-only history of a workflow run's public key, decoupled from workflow_run's own live column, so a historical signature stays re-provable after a key rotation.", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/log": "0.3.0", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/run-key-history/src/diagnostics.ts b/packages/run-key-history/src/diagnostics.ts deleted file mode 100644 index 5257ea1278..0000000000 --- a/packages/run-key-history/src/diagnostics.ts +++ /dev/null @@ -1,209 +0,0 @@ -// Read side of `run_key_history`. The listener (./listener.ts) and the -// parked repair (./reconnect.ts, CL-6281) both write or would write -// against this table; nothing before this module ever read it back for -// a human. This is purely diagnostic — it never writes `run_key_history` -// or `workflow_run`, unlike `./reconnect.ts`, which this module does not -// import or extend. -import { and, eq, inArray, isNull } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { liveWorkflowRunStatuses, workflowRun } from "@intx/db/schema"; -import { runKeyHistory } from "./schema"; - -export type RunKeyLifecycleEntry = { - readonly publicKey: string; - readonly recordedAt: Date; - readonly supersededAt: Date | null; -}; - -/** - * The full ordered key history for one run address, oldest first. Empty - * when `./listener.ts` has never observed an `agent.deploy.ack` for this - * address — a pre-existing run, or a deploy this package predates. - */ -export async function getRunKeyLifecycle( - db: DB["db"], - runAddress: string, -): Promise { - return db - .select({ - publicKey: runKeyHistory.publicKey, - recordedAt: runKeyHistory.recordedAt, - supersededAt: runKeyHistory.supersededAt, - }) - .from(runKeyHistory) - .where(eq(runKeyHistory.runAddress, runAddress)) - .orderBy(runKeyHistory.recordedAt); -} - -/** - * A run's identity state, distinguishing the reasons a reconnect - * challenge can fail so a "failed"/"cancelled" run failing its challenge - * (correct, per `liveWorkflowRunStatuses`) never reads the same as a - * live run whose recorded key has genuinely diverged (a fault). - * - * - "retired": the platform's own liveness gate has already retired this - * run. A challenge failure here is expected behavior, not a fault. - * - "unacknowledged": the run is live but `./listener.ts` has never - * recorded an ack for its address — the platform's own deploy-to-ack - * window, or a deploy that predates this package. - * - "diverged": the run is live, an ack was recorded, and the recorded - * key disagrees with `workflow_run.public_key` — the CL-6281 gap this - * package's `./reconnect.ts` repairs but this module only reports. - * - "in_sync": the run is live and the recorded key matches - * `workflow_run.public_key`. - */ -export type RunIdentityState = - "retired" | "unacknowledged" | "diverged" | "in_sync"; - -export type RunIdentityStatus = { - readonly runAddress: string; - readonly tenantId: string; - readonly sidecarId: string | null; - readonly workflowRunStatus: string; - readonly isLive: boolean; - readonly recordedKey: string | null; - readonly platformKey: string | null; - readonly state: RunIdentityState; -}; - -function isLiveStatus(status: string): boolean { - return liveWorkflowRunStatuses.some((live) => live === status); -} - -function classify( - isLive: boolean, - recordedKey: string | null, - platformKey: string | null, -): RunIdentityState { - if (!isLive) return "retired"; - if (recordedKey === null) return "unacknowledged"; - if (recordedKey !== platformKey) return "diverged"; - return "in_sync"; -} - -async function currentKeysByAddress( - db: DB["db"], - addresses: readonly string[], -): Promise> { - if (addresses.length === 0) return new Map(); - const rows = await db - .select({ - runAddress: runKeyHistory.runAddress, - publicKey: runKeyHistory.publicKey, - }) - .from(runKeyHistory) - .where( - and( - inArray(runKeyHistory.runAddress, [...addresses]), - isNull(runKeyHistory.supersededAt), - ), - ); - return new Map(rows.map((row) => [row.runAddress, row.publicKey])); -} - -/** - * One run's identity status, or `null` when `runAddress` names no - * `workflow_run` row at all (not a run address, or the row was deleted). - */ -export async function getRunIdentityStatus( - db: DB["db"], - runAddress: string, -): Promise { - const [run] = await db - .select({ - tenantId: workflowRun.tenantId, - sidecarId: workflowRun.sidecarId, - status: workflowRun.status, - publicKey: workflowRun.publicKey, - }) - .from(workflowRun) - .where(eq(workflowRun.address, runAddress)) - .limit(1); - if (run === undefined) return null; - - const currentKeys = await currentKeysByAddress(db, [runAddress]); - const recordedKey = currentKeys.get(runAddress) ?? null; - const isLive = isLiveStatus(run.status); - return { - runAddress, - tenantId: run.tenantId, - sidecarId: run.sidecarId, - workflowRunStatus: run.status, - isLive, - recordedKey, - platformKey: run.publicKey, - state: classify(isLive, recordedKey, run.publicKey), - }; -} - -export type RunIdentityStateCounts = { - readonly retired: number; - readonly unacknowledged: number; - readonly diverged: number; - readonly inSync: number; -}; - -export type RunIdentityScope = { - readonly tenantId: string; - readonly sidecarId?: string; -}; - -/** - * Counts every addressed run in scope by `RunIdentityState`, for a - * tenant (and optionally one sidecar within it) — the "13 runs failing - * their reconnect challenge" incident, surfaced as a number instead of - * grepped log lines. - */ -export async function countRunIdentityStates( - db: DB["db"], - scope: RunIdentityScope, -): Promise { - const conditions = [eq(workflowRun.tenantId, scope.tenantId)]; - if (scope.sidecarId !== undefined) { - conditions.push(eq(workflowRun.sidecarId, scope.sidecarId)); - } - - const runs = await db - .select({ - address: workflowRun.address, - status: workflowRun.status, - publicKey: workflowRun.publicKey, - }) - .from(workflowRun) - .where(and(...conditions)); - - const addressedRuns = runs.filter( - (run): run is typeof run & { address: string } => run.address !== null, - ); - - const currentKeys = await currentKeysByAddress( - db, - addressedRuns.map((run) => run.address), - ); - - const counts = { retired: 0, unacknowledged: 0, diverged: 0, inSync: 0 }; - - for (const run of addressedRuns) { - const state = classify( - isLiveStatus(run.status), - currentKeys.get(run.address) ?? null, - run.publicKey, - ); - switch (state) { - case "retired": - counts.retired += 1; - break; - case "unacknowledged": - counts.unacknowledged += 1; - break; - case "diverged": - counts.diverged += 1; - break; - case "in_sync": - counts.inSync += 1; - break; - } - } - - return counts; -} diff --git a/packages/run-key-history/src/index.ts b/packages/run-key-history/src/index.ts deleted file mode 100644 index 1fa208d9f2..0000000000 --- a/packages/run-key-history/src/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export { - createDrizzleRunKeyHistoryStore, - type RunKeyHistoryDb, - type RunKeyHistoryStore, -} from "./store"; -export { - createRunKeyHistoryListener, - type AgentDeployAckEvent, - type CreateRunKeyHistoryListenerDeps, - type RunKeyHistoryEventBus, - type RunKeyHistoryListener, -} from "./listener"; -export { - runKeyHistory, - runKeyHistorySchema, - type RunKeyHistoryRow, -} from "./schema"; -export { lookupRunKeyHistoryReconnectKey } from "./reconnect"; -export { - countRunIdentityStates, - getRunIdentityStatus, - getRunKeyLifecycle, - type RunIdentityScope, - type RunIdentityState, - type RunIdentityStateCounts, - type RunIdentityStatus, - type RunKeyLifecycleEntry, -} from "./diagnostics"; -export { - createRunKeyHistoryRoutes, - type CreateRunKeyHistoryRoutesDeps, -} from "./routes"; diff --git a/packages/run-key-history/src/listener.ts b/packages/run-key-history/src/listener.ts deleted file mode 100644 index 21925c3142..0000000000 --- a/packages/run-key-history/src/listener.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Wires `RunKeyHistoryStore` onto the hub's own `agent.deploy.ack` -// stream, independently of `@intx/hub-sessions`' vendored listener on -// the same event. Both listeners read the SAME ack payload but never -// coordinate: this one never reads `workflow_run` at all, comparing -// only against its own last-recorded entry, so there is no race with -// vendor's independent `UPDATE workflow_run SET public_key = ...`. -import { getLogger } from "@intx/log"; -import type { RunKeyHistoryStore } from "./store"; - -/** The narrow slice of `agent.deploy.ack`'s payload this listener - * needs — matches `@intx/hub-sessions`' own `SidecarEventEmitter` - * shape for that event, without depending on the whole vendor type. */ -export type AgentDeployAckEvent = { - agentAddress: string; - publicKey: string; - allocated?: unknown; -}; - -export type RunKeyHistoryEventBus = { - on( - type: "agent.deploy.ack", - listener: (event: AgentDeployAckEvent) => void, - ): () => void; -}; - -export type CreateRunKeyHistoryListenerDeps = { - events: RunKeyHistoryEventBus; - store: RunKeyHistoryStore; -}; - -export type RunKeyHistoryListener = { - dispose(): void; -}; - -/** - * Subscribes to `agent.deploy.ack` and records every observed key - * against `RunKeyHistoryStore`, including exclusive-allocation acks - * (`allocated !== undefined`). Vendor's orchestrator listener skips - * those acks when writing `workflow_run.public_key`; the service path - * (`updateAnchorPublicKeyUnderAllocationLock`) still stamps the key, - * so history must record it too. - */ -export function createRunKeyHistoryListener( - deps: CreateRunKeyHistoryListenerDeps, -): RunKeyHistoryListener { - const log = getLogger(["run-key-history", "listener"]); - - const unsubscribe = deps.events.on("agent.deploy.ack", (event) => { - deps.store - .recordObservedKey(event.agentAddress, event.publicKey) - .catch((cause: unknown) => { - log.error`failed to record key history for ${event.agentAddress}: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - }); - - return { - dispose: unsubscribe, - }; -} diff --git a/packages/run-key-history/src/migrations.ts b/packages/run-key-history/src/migrations.ts deleted file mode 100644 index 407d2f1833..0000000000 --- a/packages/run-key-history/src/migrations.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Package-owned migrations for @corbits/run-key-history. Bookkeeping -// uses its own ledger table so the package can be extracted without -// disentangling history from the platform drizzle journal. The table -// this package owns lives in its own `run_key_history` Postgres -// schema, never `public`; see docs/package-migrations.md. -import postgres from "postgres"; - -export interface RunKeyHistoryMigration { - name: string; - sql: string; -} - -const SCHEMA = "run_key_history"; - -export const runKeyHistoryMigrations: readonly RunKeyHistoryMigration[] = [ - { - name: "0001_run_key_history", - sql: ` - CREATE TABLE IF NOT EXISTS "run_key_history"."run_key_history" ( - "id" text PRIMARY KEY, - "run_address" text NOT NULL, - "public_key" text NOT NULL, - "recorded_at" timestamptz NOT NULL DEFAULT now(), - "superseded_at" timestamptz - ); - CREATE INDEX IF NOT EXISTS "run_key_history_run_address_idx" - ON "run_key_history"."run_key_history" ("run_address"); - CREATE INDEX IF NOT EXISTS "run_key_history_current_idx" - ON "run_key_history"."run_key_history" ("run_address", "superseded_at"); - `, - }, -]; - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -function quoteQualified(schema: string, name: string): string { - return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; -} - -const LEDGER_TABLE = "run_key_history_migrations"; - -export interface ApplyRunKeyHistoryMigrationsReport { - applied: string[]; - alreadyApplied: string[]; -} - -export async function applyRunKeyHistoryMigrations( - databaseUrl: string, -): Promise { - const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(SCHEMA)}`); - - await sql.unsafe( - `CREATE TABLE IF NOT EXISTS ${quoteQualified(SCHEMA, LEDGER_TABLE)} (` + - `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - - const applied: string[] = []; - const alreadyApplied: string[] = []; - - for (const migration of runKeyHistoryMigrations) { - const existing = await sql.unsafe( - `SELECT 1 FROM ${quoteQualified(SCHEMA, LEDGER_TABLE)} WHERE name = $1`, - [migration.name], - ); - if (existing.length > 0) { - alreadyApplied.push(migration.name); - continue; - } - try { - await sql.begin(async (tx) => { - await tx.unsafe(migration.sql); - await tx.unsafe( - `INSERT INTO ${quoteQualified(SCHEMA, LEDGER_TABLE)} (name) VALUES ($1)`, - [migration.name], - ); - }); - applied.push(migration.name); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error( - `run_key_history migration ${migration.name} failed: ${message}`, - { cause: err }, - ); - } - } - - return { applied, alreadyApplied }; - } finally { - await sql.end({ timeout: 5 }); - } -} diff --git a/packages/run-key-history/src/reconnect.ts b/packages/run-key-history/src/reconnect.ts deleted file mode 100644 index 5755e28b54..0000000000 --- a/packages/run-key-history/src/reconnect.ts +++ /dev/null @@ -1,98 +0,0 @@ -// CL-6281: closes the one gap `RunKeyHistoryStore` was already -// positioned to close but did not yet act on. `workflow_run.public_key` -// and this package's own current row are both written from the exact -// same authenticated `agent.deploy.ack` event (see `./listener.ts`); -// they diverge only when one write lands and the other is lost -- -// e.g. the hub is restarting when a freshly-minted keypair's ack -// arrives, so vendor's `UPDATE workflow_run` never runs while this -// package's independent write either also misses it (nothing to -// recover: the true key is gone with the frame) or lands anyway -// (a partial failure on vendor's side of the very same event; the true -// key survives here). This module repairs the second case: when this -// package's own record disagrees with `workflow_run`, its record wins. -// -// Why this can never become a key-rotation bypass or a hijack: -// - Every row this package holds was written by `./listener.ts` from -// `agent.deploy.ack`, which only fires for a frame the hub already -// accepted over an authenticated, address-scoped sidecar connection -- -// the identical trust boundary vendor's own `workflow_run` write -// relies on. Consulting this table asserts nothing a sidecar itself -// did not already prove to the hub; it adds no new claimant. -// - `RunKeyHistoryStore.recordObservedKey` is monotonic: the row with -// `supersededAt === null` is always the LATEST key this address's -// holder has ever acked, never an older, already-superseded one. So -// "prefer this package's record on disagreement" can only ever move -// an address's effective key forward in time, matching the same -// sidecar's own later ack -- never resurrect a key a legitimate -// rotation has already retired. -// - Divergence therefore has one honest reading: the address's real -// holder is the sender of the ack this package recorded, and -// `workflow_run`'s row is the stale side. There is no reading of the -// divergence under which `workflow_run`'s key describes a still-live -// claimant this repair would evict -- that claimant, if one existed, -// would itself be the source of the later ack this package holds. -// - The reconnect signature challenge is untouched: whichever key this -// lookup returns still only unlocks a session for whoever can sign -// with the matching private key. Republishing a wrong guess costs -// nothing beyond the failed challenge that already happens today; it -// can never grant a session to a party that cannot produce that -// signature. -// - Liveness is still the platform's call, not this module's. The repair -// reads the same `liveWorkflowRunStatuses` gate -// `@intx/hub-sessions`' own `lookupPublicKey` applies, so a -// decommissioned run ("failed"/"cancelled", or a "completed" one-shot) -// stays unchallengeable exactly as it is today. Repairing a stale key -// must never widen WHICH runs may reconnect -- only which key the -// challenge targets for a run that was already allowed to. -import { and, eq, inArray } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { liveWorkflowRunStatuses, workflowRun } from "@intx/db/schema"; -import type { RunKeyHistoryStore } from "./store"; - -/** - * The hub app wires this AFTER the platform's own `lookupPublicKey`: - * the platform's answer stands whenever it has one, and this repair is - * reached only for a live run whose `workflow_run` key the platform - * could not supply or which disagrees with the last observed ack. - * - * Returns `null` when there is nothing to reconcile: this address has - * never been observed by `./listener.ts` (a pre-existing run, or one - * this package has not seen a deploy for yet), or it names no LIVE - * `workflow_run` row (not a run address, or a run the platform's own - * liveness gate has already retired). In both cases the caller's own - * next lookup in the chain decides, unchanged. - */ -export async function lookupRunKeyHistoryReconnectKey( - db: DB["db"], - store: RunKeyHistoryStore, - agentAddress: string, -): Promise { - const observed = await store.getCurrent(agentAddress); - if (observed === null) return null; - - const [row] = await db - .select({ publicKey: workflowRun.publicKey }) - .from(workflowRun) - .where( - and( - eq(workflowRun.address, agentAddress), - inArray(workflowRun.status, [...liveWorkflowRunStatuses]), - ), - ) - .limit(1); - - if (row === undefined) return null; - if (row.publicKey === observed.publicKey) return observed.publicKey; - - await db - .update(workflowRun) - .set({ publicKey: observed.publicKey }) - .where( - and( - eq(workflowRun.address, agentAddress), - inArray(workflowRun.status, [...liveWorkflowRunStatuses]), - ), - ); - - return observed.publicKey; -} diff --git a/packages/run-key-history/src/routes.ts b/packages/run-key-history/src/routes.ts deleted file mode 100644 index c4904b6be9..0000000000 --- a/packages/run-key-history/src/routes.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Operator read surface over `./diagnostics.ts`, mounted the same way -// `@corbits/insights`' `createInsightsRoutes` is (see its routes.ts): -// one `Hono` app with its own `requireGrant` gate, mounted -// under the platform's native tenant middleware by the host app. Every -// route here is read-only; nothing here ever writes `run_key_history` -// or `workflow_run`. -import { Hono } from "hono"; -import { type } from "arktype"; - -import type { DB } from "@intx/db"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import { - countRunIdentityStates, - getRunIdentityStatus, - getRunKeyLifecycle, -} from "./diagnostics"; - -const SummaryQuery = type({ - "sidecarId?": "string", -}); - -export type CreateRunKeyHistoryRoutesDeps = { - db: DB["db"]; - requireGrant: RequireGrant; -}; - -export function createRunKeyHistoryRoutes( - deps: CreateRunKeyHistoryRoutesDeps, -): Hono { - const app = new Hono(); - - /** - * Per-run identity: its full ordered key lifecycle plus the current - * comparison against `workflow_run.public_key`. Diagnosing "why is - * this run not running" starts here — no log-reading, no manually - * comparing a disk key file against a database column. - */ - app.get( - "/runs/:runAddress", - deps.requireGrant("workflow-run:*", "read"), - async (c) => { - const runAddress = c.req.param("runAddress"); - const [status, lifecycle] = await Promise.all([ - getRunIdentityStatus(deps.db, runAddress), - getRunKeyLifecycle(deps.db, runAddress), - ]); - if (status === null) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "run not found", - }), - 404, - ); - } - return c.json({ status, lifecycle }); - }, - ); - - /** - * Counts of runs by identity state for the calling tenant, optionally - * narrowed to one sidecar — the incident number ("13 runs failing - * their reconnect challenge, 1 with a diverged key") surfaced as data - * instead of inferred from log noise. - */ - app.get( - "/summary", - deps.requireGrant("workflow-run:*", "read"), - async (c) => { - const raw = SummaryQuery(c.req.query()); - if (raw instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid query: ${raw.summary}`, - }), - 400, - ); - } - const tenant = c.get("tenant"); - const scope = - raw.sidecarId === undefined - ? { tenantId: tenant.id } - : { tenantId: tenant.id, sidecarId: raw.sidecarId }; - const counts = await countRunIdentityStates(deps.db, scope); - return c.json(counts); - }, - ); - - return app; -} diff --git a/packages/run-key-history/src/schema.ts b/packages/run-key-history/src/schema.ts deleted file mode 100644 index 4d50c4439c..0000000000 --- a/packages/run-key-history/src/schema.ts +++ /dev/null @@ -1,41 +0,0 @@ -// The one table this package owns: an append-only record of every -// public key a workflow run has ever been deployed under, keyed by the -// run's own mail address. It lives in its own `run_key_history` -// Postgres schema, fully siloed from `public` — see -// docs/package-migrations.md — and never reads or writes -// `workflow_run` itself: comparing against this table's own last -// entry is what lets its listener avoid racing `@intx/hub-sessions`' -// independent `workflow_run.public_key` update on the same -// `agent.deploy.ack` event. -import { index, pgSchema, text, timestamp } from "drizzle-orm/pg-core"; - -export const runKeyHistorySchema = pgSchema("run_key_history"); - -/** - * One row per key a run address has ever carried. `supersededAt` is - * null exactly for the currently active key on record — every other - * row for the same `runAddress` has it set, in ascending `recordedAt` - * order, so the row with `supersededAt IS NULL` is always the single - * source of "what does this listener think the key is right now". - */ -export const runKeyHistory = runKeyHistorySchema.table( - "run_key_history", - { - id: text("id").primaryKey(), - runAddress: text("run_address").notNull(), - publicKey: text("public_key").notNull(), - recordedAt: timestamp("recorded_at", { withTimezone: true }) - .notNull() - .defaultNow(), - supersededAt: timestamp("superseded_at", { withTimezone: true }), - }, - (table) => [ - index("run_key_history_run_address_idx").on(table.runAddress), - index("run_key_history_current_idx").on( - table.runAddress, - table.supersededAt, - ), - ], -); - -export type RunKeyHistoryRow = typeof runKeyHistory.$inferSelect; diff --git a/packages/run-key-history/src/store.ts b/packages/run-key-history/src/store.ts deleted file mode 100644 index ed334c7ff4..0000000000 --- a/packages/run-key-history/src/store.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Persistence for `run_key_history`, kept apart from the listener that -// drives it so the transaction shape is unit-testable without a real -// event bus. `RunKeyHistoryStore` is the seam; `createDrizzleRunKeyHistoryStore` -// is its production implementation over `./schema.ts`. -import { and, eq, isNull } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { runKeyHistory } from "./schema"; - -/** - * The drizzle handle `createDrizzleRunKeyHistoryStore` operates - * against. Generic over the host's schema record, the same shape - * other packages' own drizzle handles use — the host hands in its own - * `drizzle(sql, { schema })` instance unchanged, and no cast is - * needed at the call site. - */ -export type RunKeyHistoryDb< - TSchema extends Record = Record, -> = PostgresJsDatabase; - -export interface RunKeyHistoryStore { - /** - * Records an observed `(runAddress, publicKey)` pair from an - * `agent.deploy.ack`. Compares only against this table's own current - * entry for `runAddress` — never `workflow_run` — so it never races - * `@intx/hub-sessions`' independent write to that row on the same - * event. A first sighting inserts one current entry; an unchanged - * key no-ops; a changed key supersedes the old current entry and - * inserts a new one, atomically. - */ - recordObservedKey(runAddress: string, publicKey: string): Promise; - /** The row with `supersededAt === null` for `runAddress`, or `null` - * if this address has never been observed. Test/inspection seam. */ - getCurrent(runAddress: string): Promise<{ publicKey: string } | null>; -} - -function newRowId(): string { - return `rkh_${crypto.randomUUID().replaceAll("-", "")}`; -} - -export function createDrizzleRunKeyHistoryStore< - TSchema extends Record, ->(db: RunKeyHistoryDb): RunKeyHistoryStore { - return { - async recordObservedKey(runAddress, publicKey) { - await db.transaction(async (tx) => { - const [current] = await tx - .select() - .from(runKeyHistory) - .where( - and( - eq(runKeyHistory.runAddress, runAddress), - isNull(runKeyHistory.supersededAt), - ), - ) - .limit(1); - - if (current === undefined) { - await tx.insert(runKeyHistory).values({ - id: newRowId(), - runAddress, - publicKey, - recordedAt: new Date(), - supersededAt: null, - }); - return; - } - - if (current.publicKey === publicKey) return; - - const now = new Date(); - await tx - .update(runKeyHistory) - .set({ supersededAt: now }) - .where(eq(runKeyHistory.id, current.id)); - await tx.insert(runKeyHistory).values({ - id: newRowId(), - runAddress, - publicKey, - recordedAt: now, - supersededAt: null, - }); - }); - }, - - async getCurrent(runAddress) { - const [row] = await db - .select() - .from(runKeyHistory) - .where( - and( - eq(runKeyHistory.runAddress, runAddress), - isNull(runKeyHistory.supersededAt), - ), - ) - .limit(1); - return row === undefined ? null : { publicKey: row.publicKey }; - }, - }; -} diff --git a/packages/run-key-history/test/diagnostics.drizzle.test.ts b/packages/run-key-history/test/diagnostics.drizzle.test.ts deleted file mode 100644 index 1a7a4fae78..0000000000 --- a/packages/run-key-history/test/diagnostics.drizzle.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable, mirroring -// reconnect.drizzle.test.ts's setup. Proves the read side of -// `run_key_history` against a real `workflow_run` row: the ordered -// lifecycle for one address, and the four-way classification the -// motivating incident needed — a retired run failing its challenge is -// not the same fault as a live run with a genuinely diverged key or one -// that was never acknowledged at all. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq, inArray } from "drizzle-orm"; -import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; - -import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyRunKeyHistoryMigrations } from "../src/migrations"; -import { createDrizzleRunKeyHistoryStore } from "../src/store"; -import { runKeyHistory } from "../src/schema"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import { - countRunIdentityStates, - getRunIdentityStatus, - getRunKeyLifecycle, -} from "../src/diagnostics"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const SCHEMA = "run_key_history_diagnostics_test"; -const TENANT = "tnt_run_key_history_diagnostics"; -const OTHER_TENANT = "tnt_run_key_history_diagnostics_other"; -const DEFINITION = "wfd_run_key_history_diagnostics"; -const SIDECAR = "sc_run_key_history_diagnostics"; - -describeIfDb("run-key-history diagnostics", () => { - const target = dbTargetFromUrl( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - await applyRunKeyHistoryMigrations(databaseUrl as string); - }); - - afterAll(async () => { - // `applyRunKeyHistoryMigrations` always lands `run_key_history` in - // its own fixed, global `run_key_history` schema -- unlike the - // platform tables above, it is never scoped by `SCHEMA` -- so - // dropping `SCHEMA` alone would leave this suite's rows behind for - // the next run against the same shared e2e database to collide - // with. - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db - .delete(runKeyHistory) - .where( - inArray(runKeyHistory.runAddress, [ - "run_lifecycle@run-key-history.workbench.test", - "run_in_sync@run-key-history.workbench.test", - "run_diverged@run-key-history.workbench.test", - "run_unacked@run-key-history.workbench.test", - "run_failed@run-key-history.workbench.test", - "run_cancelled@run-key-history.workbench.test", - "run_count_in_sync@run-key-history.workbench.test", - "run_sidecar_scoped@run-key-history.workbench.test", - ]), - ); - } finally { - await close(); - } - await dropSchema(target, { schema: SCHEMA }); - }); - - async function withDb( - run: (db: ReturnType["db"]) => Promise, - ) { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - return await run(db); - } finally { - await close(); - } - } - - async function seedTenant(tenantId: string) { - await withDb(async (db) => { - await db - .insert(schema.tenant) - .values({ - id: tenantId, - name: "Run Key History Diagnostics Tenant", - slug: `run-key-history-diagnostics-${tenantId}`, - domain: `${tenantId}.run-key-history.workbench.test`, - }) - .onConflictDoNothing(); - }); - } - - async function seedSidecar(sidecarId: string) { - await withDb(async (db) => { - await db - .insert(schema.sidecar) - .values({ - id: sidecarId, - tokenHashSha256: new Uint8Array(32).fill(1), - }) - .onConflictDoNothing(); - }); - } - - async function seedRun(options: { - runId: string; - address: string; - publicKey: string | null; - status: "deployed" | "running" | "completed" | "failed" | "cancelled"; - tenantId?: string; - sidecarId?: string; - }) { - const tenantId = options.tenantId ?? TENANT; - await withDb(async (db) => { - await db - .insert(schema.workflowDefinition) - .values({ - id: DEFINITION, - tenantId, - name: "run-key-history-diagnostics", - status: "deployed", - }) - .onConflictDoNothing(); - await db.insert(schema.workflowRun).values({ - id: options.runId, - definitionId: DEFINITION, - tenantId, - address: options.address, - publicKey: options.publicKey, - status: options.status, - sidecarId: options.sidecarId, - }); - }); - } - - beforeAll(async () => { - await seedTenant(TENANT); - await seedTenant(OTHER_TENANT); - await seedSidecar(SIDECAR); - }); - - test("getRunKeyLifecycle returns the full ordered history, oldest first", async () => { - const address = "run_lifecycle@run-key-history.workbench.test"; - await seedRun({ - runId: "run_lifecycle", - address, - publicKey: "key-b", - status: "running", - }); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-a"); - await store.recordObservedKey(address, "key-b"); - - const lifecycle = await getRunKeyLifecycle(db, address); - expect(lifecycle).toHaveLength(2); - expect(lifecycle[0]?.publicKey).toBe("key-a"); - expect(lifecycle[0]?.supersededAt).not.toBeNull(); - expect(lifecycle[1]?.publicKey).toBe("key-b"); - expect(lifecycle[1]?.supersededAt).toBeNull(); - }); - }); - - test("a live run with a matching recorded key is in_sync", async () => { - const address = "run_in_sync@run-key-history.workbench.test"; - await seedRun({ - runId: "run_in_sync", - address, - publicKey: "key-a", - status: "running", - }); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-a"); - - const status = await getRunIdentityStatus(db, address); - expect(status?.state).toBe("in_sync"); - expect(status?.isLive).toBe(true); - }); - }); - - test("a live run whose recorded key disagrees with workflow_run is diverged, not repaired", async () => { - const address = "run_diverged@run-key-history.workbench.test"; - await seedRun({ - runId: "run_diverged", - address, - publicKey: "key-a", - status: "deployed", - }); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-b"); - - const status = await getRunIdentityStatus(db, address); - expect(status?.state).toBe("diverged"); - expect(status?.recordedKey).toBe("key-b"); - expect(status?.platformKey).toBe("key-a"); - - // Diagnostic only: workflow_run.public_key is left untouched. - const [row] = await db - .select({ publicKey: schema.workflowRun.publicKey }) - .from(schema.workflowRun) - .where(eq(schema.workflowRun.address, address)); - expect(row?.publicKey).toBe("key-a"); - }); - }); - - test("a live run never observed by the listener is unacknowledged, not diverged", async () => { - const address = "run_unacked@run-key-history.workbench.test"; - await seedRun({ - runId: "run_unacked", - address, - publicKey: null, - status: "deployed", - }); - await withDb(async (db) => { - const status = await getRunIdentityStatus(db, address); - expect(status?.state).toBe("unacknowledged"); - expect(status?.recordedKey).toBeNull(); - }); - }); - - test("a retired run failing its challenge is retired, never diverged or unacknowledged", async () => { - const failedAddress = "run_failed@run-key-history.workbench.test"; - await seedRun({ - runId: "run_failed", - address: failedAddress, - publicKey: "key-a", - status: "failed", - }); - const cancelledAddress = "run_cancelled@run-key-history.workbench.test"; - await seedRun({ - runId: "run_cancelled", - address: cancelledAddress, - publicKey: null, - status: "cancelled", - }); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - // Even a genuinely diverged key on a retired run must not surface - // as "diverged" -- its challenge failure is correct behavior. - await store.recordObservedKey(failedAddress, "key-b"); - - const failedStatus = await getRunIdentityStatus(db, failedAddress); - expect(failedStatus?.state).toBe("retired"); - expect(failedStatus?.isLive).toBe(false); - - const cancelledStatus = await getRunIdentityStatus(db, cancelledAddress); - expect(cancelledStatus?.state).toBe("retired"); - }); - }); - - test("an address with no workflow_run row returns null", async () => { - await withDb(async (db) => { - const status = await getRunIdentityStatus( - db, - "run_never_deployed@run-key-history.workbench.test", - ); - expect(status).toBeNull(); - }); - }); - - test("countRunIdentityStates tallies a tenant's runs by state and stays scoped to it", async () => { - await seedRun({ - runId: "run_count_in_sync", - address: "run_count_in_sync@run-key-history.workbench.test", - publicKey: "key-a", - status: "running", - tenantId: OTHER_TENANT, - }); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey( - "run_count_in_sync@run-key-history.workbench.test", - "key-a", - ); - - const otherTenantCounts = await countRunIdentityStates(db, { - tenantId: OTHER_TENANT, - }); - expect(otherTenantCounts.inSync).toBe(1); - expect(otherTenantCounts.retired).toBe(0); - expect(otherTenantCounts.diverged).toBe(0); - expect(otherTenantCounts.unacknowledged).toBe(0); - - // The main TENANT fixture accumulated one of each state across the - // earlier tests in this file: run_in_sync, run_diverged, - // run_unacked, run_failed, run_cancelled. - const tenantCounts = await countRunIdentityStates(db, { - tenantId: TENANT, - }); - expect(tenantCounts.inSync).toBeGreaterThanOrEqual(1); - expect(tenantCounts.diverged).toBeGreaterThanOrEqual(1); - expect(tenantCounts.unacknowledged).toBeGreaterThanOrEqual(1); - expect(tenantCounts.retired).toBeGreaterThanOrEqual(2); - }); - }); - - test("countRunIdentityStates narrows to one sidecar within the tenant", async () => { - const address = "run_sidecar_scoped@run-key-history.workbench.test"; - await seedRun({ - runId: "run_sidecar_scoped", - address, - publicKey: "key-a", - status: "running", - sidecarId: SIDECAR, - }); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-a"); - - const scoped = await countRunIdentityStates(db, { - tenantId: TENANT, - sidecarId: SIDECAR, - }); - expect(scoped.inSync).toBe(1); - expect( - scoped.retired + - scoped.diverged + - scoped.unacknowledged + - scoped.inSync, - ).toBe(1); - }); - }); -}); diff --git a/packages/run-key-history/test/listener.test.ts b/packages/run-key-history/test/listener.test.ts deleted file mode 100644 index c263c8469b..0000000000 --- a/packages/run-key-history/test/listener.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - createRunKeyHistoryListener, - type AgentDeployAckEvent, - type RunKeyHistoryEventBus, -} from "../src/listener"; -import type { RunKeyHistoryStore } from "../src/store"; - -function createFakeEventBus(): RunKeyHistoryEventBus & { - emit(event: AgentDeployAckEvent): void; -} { - const listeners = new Set<(event: AgentDeployAckEvent) => void>(); - return { - on(_type, listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - emit(event) { - for (const listener of listeners) listener(event); - }, - }; -} - -function createFakeStore(): RunKeyHistoryStore & { - calls: { runAddress: string; publicKey: string }[]; -} { - const calls: { runAddress: string; publicKey: string }[] = []; - return { - calls, - async recordObservedKey(runAddress, publicKey) { - calls.push({ runAddress, publicKey }); - }, - async getCurrent() { - return null; - }, - }; -} - -describe("createRunKeyHistoryListener", () => { - test("records every ordinary agent.deploy.ack", () => { - const events = createFakeEventBus(); - const store = createFakeStore(); - createRunKeyHistoryListener({ events, store }); - - events.emit({ agentAddress: "run_1@ten1.test", publicKey: "key-a" }); - - expect(store.calls).toEqual([ - { runAddress: "run_1@ten1.test", publicKey: "key-a" }, - ]); - }); - - test("records an exclusive-allocation ack", () => { - const events = createFakeEventBus(); - const store = createFakeStore(); - createRunKeyHistoryListener({ events, store }); - - events.emit({ - agentAddress: "run_1@ten1.test", - publicKey: "key-a", - allocated: { - allocationId: "alloc_1", - anchorRunId: "run_0", - generation: 1, - }, - }); - - expect(store.calls).toEqual([ - { runAddress: "run_1@ten1.test", publicKey: "key-a" }, - ]); - }); - - test("dispose stops observing further acks", () => { - const events = createFakeEventBus(); - const store = createFakeStore(); - const listener = createRunKeyHistoryListener({ events, store }); - - listener.dispose(); - events.emit({ agentAddress: "run_1@ten1.test", publicKey: "key-a" }); - - expect(store.calls).toEqual([]); - }); - - // Exclusive-allocation deploys stamp `allocated` on the ack and rotate - // `workflow_run.public_key` on the service path - // (`updateAnchorPublicKeyUnderAllocationLock`), not the orchestrator - // listener. History must still record that key. - test("an exclusive-allocation ack still rotates workflow_run.public_key, so it must be recorded", () => { - const events = createFakeEventBus(); - const store = createFakeStore(); - createRunKeyHistoryListener({ events, store }); - - const workflowRun: { publicKey: string | null } = { publicKey: null }; - events.on("agent.deploy.ack", (event) => { - workflowRun.publicKey = event.publicKey; - }); - - events.emit({ - agentAddress: "run_1@ten1.test", - publicKey: "key-a", - allocated: { - allocationId: "alloc_1", - anchorRunId: "run_1", - generation: 1, - }, - }); - - expect(workflowRun.publicKey).toBe("key-a"); - expect(store.calls).toEqual([ - { runAddress: "run_1@ten1.test", publicKey: "key-a" }, - ]); - }); -}); diff --git a/packages/run-key-history/test/migrations.test.ts b/packages/run-key-history/test/migrations.test.ts deleted file mode 100644 index 0610b44b83..0000000000 --- a/packages/run-key-history/test/migrations.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// DB-gated: runs against its own scratch database, never the -// developer's or the walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyRunKeyHistoryMigrations } from "../src/migrations"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_run_key_history_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("applyRunKeyHistoryMigrations", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - async function withMaintenance(run: (sql: postgres.Sql) => Promise) { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await run(maintenance); - } finally { - await maintenance.end(); - } - } - - beforeAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await sql.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - }); - }, 20000); - - afterAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - }); - }, 20000); - - test("creates the run_key_history schema and table", async () => { - const report = await applyRunKeyHistoryMigrations(scratchUrl); - expect(report.applied).toEqual(["0001_run_key_history"]); - - const client = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const inSchema = await client.unsafe( - `SELECT 1 FROM information_schema.tables ` + - `WHERE table_schema = 'run_key_history' AND table_name = 'run_key_history'`, - ); - expect(inSchema).toHaveLength(1); - } finally { - await client.end(); - } - }, 30000); - - test("re-running the migration is a no-op", async () => { - const second = await applyRunKeyHistoryMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied).toContain("0001_run_key_history"); - }, 20000); -}); diff --git a/packages/run-key-history/test/reconnect.drizzle.test.ts b/packages/run-key-history/test/reconnect.drizzle.test.ts deleted file mode 100644 index 979554e156..0000000000 --- a/packages/run-key-history/test/reconnect.drizzle.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable. Runs the -// real platform schema (`@intx/db`'s `runMigrations`) alongside this -// package's own `applyRunKeyHistoryMigrations`, so the reconnect -// repair is proven against a real `workflow_run` row and a real -// `run_key_history` table, not a hand-rolled fake `db`. -// -// This is the crash-loop case CL-6281 exists to fix: a fresh keypair's -// `agent.deploy.ack` lands (this package records it) but the hub goes -// down before vendor's own `UPDATE workflow_run SET public_key` for -// that same ack commits. Every later reconnect challenges against the -// now-stale `workflow_run` row and fails forever. `lookupRunKeyHistoryReconnectKey` -// is the repair: it notices the disagreement and republishes this -// package's own record. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq } from "drizzle-orm"; -import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; -import { workflowRun } from "@intx/db/schema"; - -import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyRunKeyHistoryMigrations } from "../src/migrations"; -import { createDrizzleRunKeyHistoryStore } from "../src/store"; -import { lookupRunKeyHistoryReconnectKey } from "../src/reconnect"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const SCHEMA = "run_key_history_reconnect_test"; -const TENANT = "tnt_run_key_history_reconnect"; -const DEFINITION = "wfd_run_key_history_reconnect"; - -describeIfDb("lookupRunKeyHistoryReconnectKey", () => { - const target = dbTargetFromUrl( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - await applyRunKeyHistoryMigrations(databaseUrl as string); - }); - - afterAll(async () => { - await dropSchema(target, { schema: SCHEMA }); - }); - - async function withDb( - run: (db: ReturnType["db"]) => Promise, - ) { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - return await run(db); - } finally { - await close(); - } - } - - async function seedRun( - runId: string, - address: string, - publicKey: string | null, - ) { - await withDb(async (db) => { - await db - .insert(schema.tenant) - .values({ - id: TENANT, - name: "Run Key History Tenant", - slug: "run-key-history-tenant", - domain: "run-key-history.workbench.test", - }) - .onConflictDoNothing(); - await db - .insert(schema.workflowDefinition) - .values({ - id: DEFINITION, - tenantId: TENANT, - name: "run-key-history-reconnect", - status: "deployed", - }) - .onConflictDoNothing(); - await db.insert(workflowRun).values({ - id: runId, - definitionId: DEFINITION, - tenantId: TENANT, - address, - publicKey, - status: "deployed", - }); - }); - } - - test("no observed key at all defers to the caller's own lookup", async () => { - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - const result = await lookupRunKeyHistoryReconnectKey( - db, - store, - "run_never_observed@run-key-history.workbench.test", - ); - expect(result).toBeNull(); - }); - }); - - test("matching keys are a no-op", async () => { - const address = "run_matching@run-key-history.workbench.test"; - await seedRun("run_matching", address, "key-a"); - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-a"); - - const result = await lookupRunKeyHistoryReconnectKey(db, store, address); - expect(result).toBe("key-a"); - - const [row] = await db - .select({ publicKey: workflowRun.publicKey }) - .from(workflowRun) - .where(eq(workflowRun.address, address)); - expect(row?.publicKey).toBe("key-a"); - }); - }); - - test("crash-loop: a second ack lost mid-outage is repaired on the next reconnect", async () => { - const address = "run_crash_loop@run-key-history.workbench.test"; - await seedRun("run_crash_loop", address, null); - - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - - // First deploy: both this package's listener and vendor's own - // `agent.deploy.ack` handler observe the ack while the hub is up. - await store.recordObservedKey(address, "key-a"); - await db - .update(workflowRun) - .set({ publicKey: "key-a" }) - .where(eq(workflowRun.address, address)); - - // The sidecar restarts on a wiped data dir, mints a fresh keypair, - // and sends a new ack. This package's listener records it, but the - // hub goes down before vendor's own `UPDATE workflow_run` for the - // same event commits -- the exact race CL-6281 diagnoses. - await store.recordObservedKey(address, "key-b"); - // (workflow_run.public_key is deliberately left at "key-a" here.) - - // The sidecar reconnects once the hub is back. Without the repair, - // the challenge would be built from the stale "key-a" and fail - // forever against a sidecar that now only holds "key-b"'s private - // key. - const result = await lookupRunKeyHistoryReconnectKey(db, store, address); - expect(result).toBe("key-b"); - - const [row] = await db - .select({ publicKey: workflowRun.publicKey }) - .from(workflowRun) - .where(eq(workflowRun.address, address)); - expect(row?.publicKey).toBe("key-b"); - }); - }); - - test("an address with no workflow_run row is left to the caller's own lookup", async () => { - const address = "run_unrouted@run-key-history.workbench.test"; - await withDb(async (db) => { - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-a"); - - const result = await lookupRunKeyHistoryReconnectKey(db, store, address); - expect(result).toBeNull(); - }); - }); -}); diff --git a/packages/run-key-history/test/routes.drizzle.test.ts b/packages/run-key-history/test/routes.drizzle.test.ts deleted file mode 100644 index 853bedbd3b..0000000000 --- a/packages/run-key-history/test/routes.drizzle.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable, mirroring -// diagnostics.drizzle.test.ts and @corbits/insights' routes-scope.test.ts. -// Proves the HTTP surface an operator actually reaches: /runs/:runAddress -// for a single run's lifecycle + status, /summary for tenant-wide counts. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import { inArray } from "drizzle-orm"; - -import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; - -import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyRunKeyHistoryMigrations } from "../src/migrations"; -import { createDrizzleRunKeyHistoryStore } from "../src/store"; -import { runKeyHistory } from "../src/schema"; -import { createRunKeyHistoryRoutes } from "../src/routes"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -const SCHEMA = "run_key_history_routes_test"; -const TENANT = "tnt_run_key_history_routes"; -const DEFINITION = "wfd_run_key_history_routes"; - -describeIfDb("createRunKeyHistoryRoutes", () => { - const target = dbTargetFromUrl( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - await applyRunKeyHistoryMigrations(databaseUrl as string); - }); - - afterAll(async () => { - // `applyRunKeyHistoryMigrations` always lands `run_key_history` in - // its own fixed, global `run_key_history` schema -- unlike the - // platform tables above, it is never scoped by `SCHEMA` -- so - // dropping `SCHEMA` alone would leave this suite's rows behind for - // the next run against the same shared e2e database to collide - // with. - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db - .delete(runKeyHistory) - .where( - inArray(runKeyHistory.runAddress, [ - "run_diverged@run-key-history-routes.workbench.test", - "run_summary_in_sync@run-key-history-routes.workbench.test", - "run_summary_retired@run-key-history-routes.workbench.test", - "run_summary_sidecar@run-key-history-routes.workbench.test", - ]), - ); - } finally { - await close(); - } - await dropSchema(target, { schema: SCHEMA }); - }); - - async function withDb( - run: (db: ReturnType["db"]) => Promise, - ) { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - return await run(db); - } finally { - await close(); - } - } - - function appFor(db: ReturnType["db"]) { - const routes = createRunKeyHistoryRoutes({ db, requireGrant: allowAll }); - const asTenant: MiddlewareHandler = async (c, next) => { - c.set("tenant", { - id: TENANT, - name: "Run Key History Routes Tenant", - slug: "run-key-history-routes", - domain: "run-key-history-routes.localhost", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), - } as never); - await next(); - }; - const app = new Hono(); - app.use("*", asTenant); - app.route("/", routes); - return app; - } - - beforeAll(async () => { - await withDb(async (db) => { - await db - .insert(schema.tenant) - .values({ - id: TENANT, - name: "Run Key History Routes Tenant", - slug: "run-key-history-routes", - domain: "run-key-history-routes.localhost", - }) - .onConflictDoNothing(); - await db - .insert(schema.workflowDefinition) - .values({ - id: DEFINITION, - tenantId: TENANT, - name: "run-key-history-routes", - status: "deployed", - }) - .onConflictDoNothing(); - }); - }); - - test("GET /runs/:runAddress returns 404 for an address with no workflow_run row", async () => { - await withDb(async (db) => { - const app = appFor(db); - const res = await app.request( - "/runs/run_missing@run-key-history-routes.workbench.test", - ); - expect(res.status).toBe(404); - }); - }); - - test("GET /runs/:runAddress surfaces a diverged run's lifecycle and status", async () => { - const address = "run_diverged@run-key-history-routes.workbench.test"; - await withDb(async (db) => { - await db.insert(schema.workflowRun).values({ - id: "run_routes_diverged", - definitionId: DEFINITION, - tenantId: TENANT, - address, - publicKey: "key-a", - status: "running", - }); - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(address, "key-a"); - await store.recordObservedKey(address, "key-b"); - - const app = appFor(db); - const res = await app.request(`/runs/${encodeURIComponent(address)}`); - expect(res.status).toBe(200); - const body = (await res.json()) as { - status: { state: string; recordedKey: string; platformKey: string }; - lifecycle: { publicKey: string; supersededAt: string | null }[]; - }; - expect(body.status.state).toBe("diverged"); - expect(body.status.recordedKey).toBe("key-b"); - expect(body.status.platformKey).toBe("key-a"); - expect(body.lifecycle).toHaveLength(2); - expect(body.lifecycle[0]?.publicKey).toBe("key-a"); - expect(body.lifecycle[0]?.supersededAt).not.toBeNull(); - expect(body.lifecycle[1]?.publicKey).toBe("key-b"); - expect(body.lifecycle[1]?.supersededAt).toBeNull(); - }); - }); - - test("GET /summary counts the calling tenant's runs by identity state", async () => { - const inSyncAddress = - "run_summary_in_sync@run-key-history-routes.workbench.test"; - const retiredAddress = - "run_summary_retired@run-key-history-routes.workbench.test"; - await withDb(async (db) => { - await db.insert(schema.workflowRun).values([ - { - id: "run_summary_in_sync", - definitionId: DEFINITION, - tenantId: TENANT, - address: inSyncAddress, - publicKey: "key-a", - status: "running", - }, - { - id: "run_summary_retired", - definitionId: DEFINITION, - tenantId: TENANT, - address: retiredAddress, - publicKey: null, - status: "failed", - }, - ]); - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(inSyncAddress, "key-a"); - - const app = appFor(db); - const res = await app.request("/summary"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - inSync: number; - retired: number; - diverged: number; - unacknowledged: number; - }; - expect(body.inSync).toBeGreaterThanOrEqual(1); - expect(body.retired).toBeGreaterThanOrEqual(1); - }); - }); - - test("GET /summary?sidecarId= narrows to runs on that sidecar", async () => { - const sidecarId = "sc_run_key_history_routes_summary"; - const scopedAddress = - "run_summary_sidecar@run-key-history-routes.workbench.test"; - await withDb(async (db) => { - await db - .insert(schema.sidecar) - .values({ - id: sidecarId, - tokenHashSha256: new Uint8Array(32).fill(2), - }) - .onConflictDoNothing(); - await db.insert(schema.workflowRun).values({ - id: "run_summary_sidecar", - definitionId: DEFINITION, - tenantId: TENANT, - address: scopedAddress, - publicKey: "key-a", - status: "running", - sidecarId, - }); - const store = createDrizzleRunKeyHistoryStore(db); - await store.recordObservedKey(scopedAddress, "key-a"); - - const app = appFor(db); - const res = await app.request(`/summary?sidecarId=${sidecarId}`); - expect(res.status).toBe(200); - const body = (await res.json()) as { - inSync: number; - retired: number; - diverged: number; - unacknowledged: number; - }; - expect( - body.inSync + body.retired + body.diverged + body.unacknowledged, - ).toBe(1); - expect(body.inSync).toBe(1); - }); - }); -}); diff --git a/packages/run-key-history/test/store.test.ts b/packages/run-key-history/test/store.test.ts deleted file mode 100644 index 021c3831a2..0000000000 --- a/packages/run-key-history/test/store.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// DB-gated: proves `recordObservedKey`'s append-only supersession -// semantics against a real Postgres transaction, using the same -// scratch-database setup pattern every DB-gated suite in this repo -// uses. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyRunKeyHistoryMigrations } from "../src/migrations"; -import { createDrizzleRunKeyHistoryStore } from "../src/store"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_run_key_history_store_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("createDrizzleRunKeyHistoryStore", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - async function withMaintenance(run: (sql: postgres.Sql) => Promise) { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await run(maintenance); - } finally { - await maintenance.end(); - } - } - - beforeAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await sql.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - }); - await applyRunKeyHistoryMigrations(scratchUrl); - }, 20000); - - afterAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - }); - }, 20000); - - test("first ack for a run creates one current-entry row", async () => { - const client = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const store = createDrizzleRunKeyHistoryStore(drizzle(client)); - await store.recordObservedKey("run_1@ten1.workbench.test", "key-a"); - - const rows = await client.unsafe( - `SELECT public_key, superseded_at FROM run_key_history.run_key_history WHERE run_address = $1`, - ["run_1@ten1.workbench.test"], - ); - expect(rows).toHaveLength(1); - expect(rows[0]?.["public_key"]).toBe("key-a"); - expect(rows[0]?.["superseded_at"]).toBeNull(); - } finally { - await client.end(); - } - }, 20000); - - test("an ack with an unchanged key is a no-op", async () => { - const client = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const store = createDrizzleRunKeyHistoryStore(drizzle(client)); - await store.recordObservedKey("run_2@ten1.workbench.test", "key-a"); - await store.recordObservedKey("run_2@ten1.workbench.test", "key-a"); - - const rows = await client.unsafe( - `SELECT public_key FROM run_key_history.run_key_history WHERE run_address = $1`, - ["run_2@ten1.workbench.test"], - ); - expect(rows).toHaveLength(1); - } finally { - await client.end(); - } - }, 20000); - - test("an ack with a changed key supersedes the old row and inserts a new current row", async () => { - const client = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const store = createDrizzleRunKeyHistoryStore(drizzle(client)); - await store.recordObservedKey("run_3@ten1.workbench.test", "key-a"); - await store.recordObservedKey("run_3@ten1.workbench.test", "key-b"); - - const rows = await client.unsafe( - `SELECT public_key, recorded_at, superseded_at FROM run_key_history.run_key_history ` + - `WHERE run_address = $1 ORDER BY recorded_at ASC`, - ["run_3@ten1.workbench.test"], - ); - expect(rows).toHaveLength(2); - expect(rows[0]?.["public_key"]).toBe("key-a"); - expect(rows[0]?.["superseded_at"]).not.toBeNull(); - expect(rows[1]?.["public_key"]).toBe("key-b"); - expect(rows[1]?.["superseded_at"]).toBeNull(); - - const current = await store.getCurrent("run_3@ten1.workbench.test"); - expect(current?.publicKey).toBe("key-b"); - } finally { - await client.end(); - } - }, 20000); - - test("two acks with different keys leave exactly one current row and a sensible ordering", async () => { - const client = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const store = createDrizzleRunKeyHistoryStore(drizzle(client)); - await store.recordObservedKey("run_4@ten1.workbench.test", "key-a"); - await store.recordObservedKey("run_4@ten1.workbench.test", "key-b"); - await store.recordObservedKey("run_4@ten1.workbench.test", "key-c"); - - const rows = await client.unsafe( - `SELECT public_key, recorded_at, superseded_at FROM run_key_history.run_key_history ` + - `WHERE run_address = $1 ORDER BY recorded_at ASC`, - ["run_4@ten1.workbench.test"], - ); - expect(rows).toHaveLength(3); - - const currentRows = rows.filter((row) => row["superseded_at"] === null); - expect(currentRows).toHaveLength(1); - expect(currentRows[0]?.["public_key"]).toBe("key-c"); - - // Every superseded row's own supersededAt is at or after its - // recordedAt, and at or before the next row's recordedAt -- - // supersession never lands before the record it closes out, nor - // after the row that replaces it. - for (let i = 0; i < rows.length - 1; i++) { - const row = rows[i]; - const next = rows[i + 1]; - if (row === undefined || next === undefined) continue; - const recordedAt = new Date(row["recorded_at"] as string).getTime(); - const supersededAt = new Date(row["superseded_at"] as string).getTime(); - const nextRecordedAt = new Date( - next["recorded_at"] as string, - ).getTime(); - expect(supersededAt).toBeGreaterThanOrEqual(recordedAt); - expect(supersededAt).toBeLessThanOrEqual(nextRecordedAt); - } - } finally { - await client.end(); - } - }, 20000); -}); diff --git a/packages/run-key-history/tsconfig.json b/packages/run-key-history/tsconfig.json deleted file mode 100644 index d6b6da37f1..0000000000 --- a/packages/run-key-history/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - } - ] -} diff --git a/packages/run-key-history/tsconfig.src.json b/packages/run-key-history/tsconfig.src.json deleted file mode 100644 index 47f844519c..0000000000 --- a/packages/run-key-history/tsconfig.src.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - } - ] -} diff --git a/scripts/checks/browser-safe-subpaths.ts b/scripts/checks/browser-safe-subpaths.ts index 1313ae3637..7aeea175f0 100644 --- a/scripts/checks/browser-safe-subpaths.ts +++ b/scripts/checks/browser-safe-subpaths.ts @@ -54,12 +54,11 @@ export const ENTRIES: readonly BrowserSafeEntry[] = [ { package: "@corbits/insights", subpath: "./client" }, { package: "@corbits/agent-directory", subpath: "./client" }, { package: "@corbits/bench", subpath: "./client" }, - { package: "@corbits/preferences", subpath: "./client" }, { package: "@corbits/api-query", subpath: "." }, // The envelope alone (UnauthenticatedError, ApiQueryError, toAPIQuery) has // no React/JSX dependency, so packages without `jsx` configured (e.g. - // @corbits/bench, @corbits/preferences) import this subpath rather than - // the root, which drags in query-view.tsx. + // @corbits/bench) import this subpath rather than the root, which drags + // in query-view.tsx. { package: "@corbits/api-query", subpath: "./envelope" }, // CL-6099: the Inference settings section (packages/inference-settings) // imports this for its known-provider base URL seeds — plain data, no diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index f3295b8d6f..28ddf2c62e 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -103,15 +103,6 @@ const ALLOWLIST: readonly { maxOccurrences: 3, tables: ["usage_turn", "model_price", "turn_latency"], }, - { - // Append-only record of every public key a workflow run has been - // deployed under, in its own `run_key_history` Postgres schema — - // identity diagnostics the platform's `workflow_run.public_key` - // (a single mutable column) cannot answer. - relPath: "packages/run-key-history/src/schema.ts", - maxOccurrences: 1, - tables: ["run_key_history"], - }, { relPath: "packages/bench/src/schema.ts", maxOccurrences: 1, @@ -145,11 +136,6 @@ const ALLOWLIST: readonly { maxOccurrences: 1, tables: ["snooze"], }, - { - relPath: "packages/preferences/src/schema.ts", - maxOccurrences: 1, - tables: ["user_preferences"], - }, { // Eval-run history (CL-6143): one row per (eval, config) scored // run, product-owned scoring data, never tenancy. diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index 96546ac3f8..7b41839268 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -36,11 +36,9 @@ import { applyMailboxMigrations, } from "../packages/inbox/src/migrations"; import { applyInsightsMigrations } from "../packages/insights/src/migrations"; -import { applyPreferencesMigrations } from "../packages/preferences/src/migrations"; import { applyBenchMigrations } from "../packages/bench/src/migrations"; import { applyAgentDirectoryMigrations } from "../packages/agent-directory/src/migrations"; import { applyOnboardingMigrations } from "../packages/onboarding/src/migrations"; -import { applyRunKeyHistoryMigrations } from "../packages/run-key-history/src/migrations"; import { applyInferenceCatalogMigrations } from "../packages/inference-catalog/src/migrations"; const repoRoot = path.resolve(import.meta.dir, ".."); @@ -66,11 +64,9 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { // CL-7208's snooze-until table, own schema — see packages/inbox/src/schema.ts. { name: "@corbits/inbox", apply: applyInboxMigrations }, { name: "@corbits/insights", apply: applyInsightsMigrations }, - { name: "@corbits/preferences", apply: applyPreferencesMigrations }, { name: "@corbits/bench", apply: applyBenchMigrations }, { name: "@corbits/agent-directory", apply: applyAgentDirectoryMigrations }, { name: "@workbench/onboarding", apply: applyOnboardingMigrations }, - { name: "@corbits/run-key-history", apply: applyRunKeyHistoryMigrations }, { name: "@corbits/inference-catalog", apply: applyInferenceCatalogMigrations, @@ -110,6 +106,8 @@ async function applyInstalledPackageMigrations( // and grant state stays on the native grant routes. await dropRoutinesSchemaAfterDigestHandoff(databaseUrl); + await dropSchemaIfPresent(databaseUrl, "run_key_history"); + await dropSchemaIfPresent(databaseUrl, "preferences"); } // --- hub-resolved platform dependencies ------------------------------ @@ -401,6 +399,35 @@ async function dropRoutinesSchemaAfterDigestHandoff( } } +/** + * One-shot, forward-only: `@corbits/run-key-history` and + * `@corbits/preferences` (CL-8158) had zero web callers on their mounts — + * a diagnostics-only listener and a client that was built but never + * imported — so both packages, their mounts, and their schemas are gone. + * Absent schema is a no-op; safe to re-run. + */ +async function dropSchemaIfPresent( + databaseUrl: string, + schema: string, +): Promise { + const postgres = await loadPostgres(); + const target = dbTargetFromUrl(databaseUrl); + const sql = await connect(postgres, target); + try { + const existing = await sql.unsafe( + "SELECT 1 FROM information_schema.schemata WHERE schema_name = $1", + [schema], + ); + if (existing.length === 0) return; + await sql.unsafe( + `DROP SCHEMA IF EXISTS ${quoteIdentifier(schema)} CASCADE`, + ); + console.log(`db-setup: dropped ${schema} schema (CL-8158, dead mount)`); + } finally { + await sql.end(); + } +} + async function appliedMigrations( sql: SqlClient, schema: string, diff --git a/tsconfig.build.json b/tsconfig.build.json index fa02b4ebdc..1099a2775b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -127,9 +127,6 @@ { "path": "./packages/ollama-adapter/tsconfig.src.json" }, - { - "path": "./packages/preferences/tsconfig.src.json" - }, { "path": "./packages/presence/tsconfig.src.json" }, @@ -139,9 +136,6 @@ { "path": "./packages/reddit-tools/tsconfig.src.json" }, - { - "path": "./packages/run-key-history/tsconfig.src.json" - }, { "path": "./packages/sandbox-sidecar/tsconfig.src.json" },