diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index edff7ab522..959398695f 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -2002,8 +2002,8 @@ export async function createHub(config: HubConfig) { // `createWorkflowDefinitionRoutes` (`vendor/intx/hub-api/src/app.ts` // already mounts that one at this same `/workflows/definitions` // prefix): this GET is a Workbench-owned read composed over native - // rows plus `@corbits/workflows`'s `./deploy-source`, so it lives in - // `@corbits/workflows`'s `./detail`, not the vendored tree. + // rows, so it lives in `@corbits/workflows`'s `./detail`, not the + // vendored tree. app.route( `${TENANT_PREFIX}/workflows/definitions`, createWorkflowDetailRoute({ diff --git a/bun.lock b/bun.lock index 5b01c0d628..734f800138 100644 --- a/bun.lock +++ b/bun.lock @@ -1495,7 +1495,6 @@ "version": "0.0.1", "dependencies": { "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", @@ -1507,7 +1506,6 @@ "cronstrue": "^3.24.0", "drizzle-orm": "catalog:", "hono": "catalog:", - "postgres": "catalog:", }, "devDependencies": { "@corbits/connections": "workspace:*", diff --git a/docs/package-migrations.md b/docs/package-migrations.md index dde989d09a..1397cb9ca3 100644 --- a/docs/package-migrations.md +++ b/docs/package-migrations.md @@ -72,8 +72,10 @@ 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/inference-catalog`, `@corbits/evals`, `@corbits/access-policy`, - `@corbits/workflows`'s `./deploy-source`. + `@corbits/inference-catalog`, `@corbits/evals`, `@corbits/access-policy`. + (`@corbits/workflows`'s `./deploy-source` followed this shape until + CL-7591 deleted it; it is no longer wired through + `scripts/db-setup.ts`.) The package's `src/migrations.ts` owns only a literal `{ name, sql }[]` array and a thin `applyXMigrations(databaseUrl)` wrapper; the mechanics — schema/ledger bootstrap, the transactional apply loop, and the advisory diff --git a/docs/workflow-deploy-source-vs-launch-spec.md b/docs/workflow-deploy-source-vs-launch-spec.md index f3e9d7cd48..3cd8146621 100644 --- a/docs/workflow-deploy-source-vs-launch-spec.md +++ b/docs/workflow-deploy-source-vs-launch-spec.md @@ -1,5 +1,10 @@ # `workflow_deploy_source` vs `workflow_run_launch_spec` +> Superseded by CL-7591: `@corbits/workflows`'s `./deploy-source` (the +> `workflow_deploy_source` table) is deleted and the workflow detail read +> is native-only. This analysis is kept as the historical decision record +> for CL-7271; do not read it as a description of the current schema. + Analysis for CL-7271. Read against the vendored pin `a8bc06ae`. ## The claim under test diff --git a/docs/workflow-detail.md b/docs/workflow-detail.md index 3c3e9721a2..d7ff3d1340 100644 --- a/docs/workflow-detail.md +++ b/docs/workflow-detail.md @@ -11,10 +11,10 @@ useful version — read-only, no editing surface here. - **Lifecycle**: `source-only`, `pending-approval`, `deployed`, `superseded`, or `build-failed` — derived by the pure `deriveWorkflowLifecycle` (`packages/workflows/src/ -definition-lifecycle.ts`) from the asset's newest `workflow_definition` - row plus whether `@corbits/workflows`'s `./deploy-source` ever recorded a - deploy attempt for it. No new Postgres column: everything it reads is - native or already Workbench-owned. +definition-lifecycle.ts`) from the asset's `workflow_definition` rows + alone. Native-only since CL-7591: the deleted Workbench deploy-source + store is no longer a deploy-attempt signal. No new Postgres column: + everything it reads is native. - **Steps**: read from the frozen `wire_projection` (`@intx/db`'s `loadFrozenWireProjection`) in `stepOrder`, each carrying its role (`kind`), best-effort director/model/tool pins (the wire step schema is @@ -29,9 +29,9 @@ definition-lifecycle.ts`) from the asset's newest `workflow_definition` actually approved. Credential binding **names** only (`workflow_definition.credential_bindings[].handle`); no value is ever read or returned. -- **Source**: the deploying commit sha, entry module, and origin kind, from - `@corbits/workflows`'s `./deploy-source`'s per-asset deploy record — `null` when - no deploy was ever attempted. +- **Source**: always `null` since CL-7591 — the Workbench deploy-source + store is deleted, so the native-only read records no per-asset deploy + provenance. ## Authorization diff --git a/docs/workflow-model.md b/docs/workflow-model.md index 3c392bdddc..d1b917c0a8 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -55,7 +55,7 @@ separate routine table. | Approve | Native `POST /api/tenants/:tenantId/approvals/:id/approve` | A principal holding `approval:*`/`resolve` — a human; no agent holds it | This is the approval | Credentials and resolved provider secrets never enter source trees or deploy -requests recorded by `@corbits/workflows`'s `./deploy-source`. +requests. Inference sources are re-resolved from the tenant catalog at deploy and redeploy (`resolveDefinitionSources`). diff --git a/packages/workflows/README.md b/packages/workflows/README.md index f346312f78..81f169ceaf 100644 --- a/packages/workflows/README.md +++ b/packages/workflows/README.md @@ -1,11 +1,10 @@ # @corbits/workflows The workflow domain package: `./source` (the two-file source codebase -every authoring path renders/reads), `./deploy-source` (the durable -deploy-source record), `./detail` (the definition detail read a -workflow's own page uses), and `./authoring` (letting an agent author, -republish, and deploy a workflow through Interchange's native source -pipeline). +every authoring path renders/reads), `./detail` (the definition detail +read a workflow's own page uses), and `./authoring` (letting an agent +author, republish, and deploy a workflow through Interchange's native +source pipeline). Server code imports `@corbits/workflows`; browser code imports `@corbits/workflows/client` for the browser-safe subset (source diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 64f83ba70c..6c0e73cd05 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/workflows", "private": true, - "description": "The workflow domain package: the two-file source codebase every authoring path renders/reads (`./source`), the durable deploy-source record for a code-sourced deploy (`./deploy-source`), the definition detail read a workflow's own page uses (`./detail`), and letting an agent author/republish/deploy a workflow through Interchange's native source pipeline (`./authoring`)", + "description": "The workflow domain package: the two-file source codebase every authoring path renders/reads (`./source`), the definition detail read a workflow's own page uses (`./detail`), and letting an agent author/republish/deploy a workflow through Interchange's native source pipeline (`./authoring`)", "version": "0.0.1", "license": "LGPL-2.1-or-later", "type": "module", @@ -16,7 +16,6 @@ }, "dependencies": { "@corbits/error-sink": "workspace:*", - "@corbits/migration-runner": "workspace:*", "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", @@ -27,8 +26,7 @@ "arktype": "catalog:", "cronstrue": "^3.24.0", "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" + "hono": "catalog:" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/workflows/src/deploy-source/index.ts b/packages/workflows/src/deploy-source/index.ts deleted file mode 100644 index 7bca7b5623..0000000000 --- a/packages/workflows/src/deploy-source/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - createDrizzleWorkflowDeploySourceStore, - type WorkflowDeploySourceDb, - type WorkflowDeploySourceRecord, - type WorkflowDeploySourceStore, -} from "./store"; -export { workflowDeploySource, workflowDeploySourceSchema } from "./schema"; -export type { WorkflowDeploySourceRow } from "./schema"; diff --git a/packages/workflows/src/deploy-source/migrations.test.ts b/packages/workflows/src/deploy-source/migrations.test.ts deleted file mode 100644 index 05fe55fc7f..0000000000 --- a/packages/workflows/src/deploy-source/migrations.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -// DB-gated: runs against its own scratch database, never the -// developer's or the walking-skeleton suite's, mirroring -// packages/run-key-history/test/migrations.test.ts. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../../scripts/e2e/harness"; -import { applyWorkflowDeploySourceMigrations } from "./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}_workflow_deploy_source_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("applyWorkflowDeploySourceMigrations", () => { - 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 schema, table, and ledger on first run", async () => { - const report = await applyWorkflowDeploySourceMigrations(scratchUrl); - expect(report.applied).toEqual(["0001_workflow_deploy_source"]); - expect(report.alreadyApplied).toEqual([]); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'workflow_deploy_source' - ORDER BY table_name - `; - expect(tables.map((row) => row["table_name"])).toEqual([ - "workflow_deploy_source", - "workflow_deploy_source_migrations", - ]); - } finally { - await sql.end(); - } - }); - - test("is idempotent: a second run applies nothing new", async () => { - const report = await applyWorkflowDeploySourceMigrations(scratchUrl); - expect(report.applied).toEqual([]); - expect(report.alreadyApplied).toEqual(["0001_workflow_deploy_source"]); - }); -}); - -// Separate database from the suites above: two replicas racing the same -// ledger must not collide with the earlier tests' own already-applied -// rows, and must start from a schema that has never seen this migration -// set before. -describeIfDb("applyWorkflowDeploySourceMigrations concurrency", () => { - const scratchUrl = scratchUrlFor( - databaseUrl ?? "postgres://localhost:5432/unused", - ).replace( - "_workflow_deploy_source_migrations_test", - "_workflow_deploy_source_migrations_concurrent_test", - ); - 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("two replicas booting concurrently both complete without either crashing on a duplicate ledger insert", async () => { - const [first, second] = await Promise.all([ - applyWorkflowDeploySourceMigrations(scratchUrl), - applyWorkflowDeploySourceMigrations(scratchUrl), - ]); - - const appliedNames = [...first.applied, ...second.applied].sort(); - expect(new Set(appliedNames).size).toBe(appliedNames.length); - expect( - [ - ...appliedNames, - ...first.alreadyApplied, - ...second.alreadyApplied, - ].sort(), - ).toEqual(["0001_workflow_deploy_source", "0001_workflow_deploy_source"]); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const ledgerRows = await sql.unsafe( - `SELECT name FROM "workflow_deploy_source"."workflow_deploy_source_migrations" ORDER BY name`, - ); - expect(ledgerRows.map((row) => String(row["name"]))).toEqual([ - "0001_workflow_deploy_source", - ]); - } finally { - await sql.end(); - } - }, 10000); -}); diff --git a/packages/workflows/src/deploy-source/migrations.ts b/packages/workflows/src/deploy-source/migrations.ts deleted file mode 100644 index 467bc3cd5f..0000000000 --- a/packages/workflows/src/deploy-source/migrations.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Package-owned migrations for @corbits/workflows's ./deploy-source. -// 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 `workflow_deploy_source` -// 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 WorkflowDeploySourceMigration = PackageMigration; - -const SCHEMA = "workflow_deploy_source"; - -export const workflowDeploySourceMigrations: readonly WorkflowDeploySourceMigration[] = - [ - { - name: "0001_workflow_deploy_source", - sql: ` - CREATE TABLE IF NOT EXISTS "workflow_deploy_source"."workflow_deploy_source" ( - "anchor_run_id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "deployment_domain" text NOT NULL, - "source" jsonb NOT NULL, - "entry" text NOT NULL, - "pin" text, - "definition_asset_id" text NOT NULL, - "source_ref" text, - "source_authority_principal_id" text NOT NULL, - "recorded_at" timestamptz NOT NULL DEFAULT now() - ); - CREATE INDEX IF NOT EXISTS "workflow_deploy_source_tenant_idx" - ON "workflow_deploy_source"."workflow_deploy_source" ("tenant_id"); - `, - }, - ]; - -const LEDGER_TABLE = "workflow_deploy_source_migrations"; - -export type ApplyWorkflowDeploySourceMigrationsReport = - ApplyPackageMigrationsReport; - -export async function applyWorkflowDeploySourceMigrations( - databaseUrl: string, -): Promise { - return applyPackageMigrations({ - databaseUrl, - schema: SCHEMA, - ledgerTable: LEDGER_TABLE, - migrations: workflowDeploySourceMigrations, - packageLabel: "workflow_deploy_source", - }); -} diff --git a/packages/workflows/src/deploy-source/schema.ts b/packages/workflows/src/deploy-source/schema.ts deleted file mode 100644 index e1ba8ee4ed..0000000000 --- a/packages/workflows/src/deploy-source/schema.ts +++ /dev/null @@ -1,59 +0,0 @@ -// The one table this package owns: one row per anchor run naming the -// deploy-time inputs the hub needs to reissue that run's deploy call -// with no input beyond what Postgres already holds. It lives in its own -// `workflow_deploy_source` Postgres schema, fully siloed from `public` — -// see docs/package-migrations.md — and never reads `workflow_run` or -// `workflow_run_launch_spec` itself, so it never races either. -// -// `source` is `WorkflowDefinitionSource` verbatim — the same discriminated -// union `POST /workflows/deployments` already accepts (`registry` or -// `asset`). A hub-git asset is one arm of that union, not a hardcoded -// shape: nothing here assumes the bytes came from `RepoStore`, so a future -// source arm (a remote git origin, say) needs no new column, just a new -// case inside the same jsonb value. -import { index, jsonb, pgSchema, text, timestamp } from "drizzle-orm/pg-core"; - -export const workflowDeploySourceSchema = pgSchema("workflow_deploy_source"); - -export const workflowDeploySource = workflowDeploySourceSchema.table( - "workflow_deploy_source", - { - // The deployment's anchor `workflow_run.id`. One durable source record - // per deployment: a redeploy or a rotation overwrites this row rather - // than appending, so a lookup by anchor run id always answers with the - // current source. - anchorRunId: text("anchor_run_id").primaryKey(), - tenantId: text("tenant_id").notNull(), - deploymentDomain: text("deployment_domain").notNull(), - // Where the definition's bytes come from at apply time — the - // `WorkflowDefinitionSource` union (registry name@range, or an asset - // commit/tarball in the hub's git-backed store), stored as-is. - source: jsonb("source").notNull(), - // The `interchange.workflow` entry-module path the sidecar evaluates. - entry: text("entry").notNull(), - // A `name@range` spec for the definition package. Required for the - // `registry` and asset-`tarball` source variants; null for asset-`source`, - // whose member is selected by `source.package.packageName` instead. - pin: text("pin"), - // The `workflow`-kind asset the deployed definition projects a - // `workflow_definition` row over. - definitionAssetId: text("definition_asset_id").notNull(), - // WORKBENCH DELTA companion (see vendor/intx/hub-sessions - // `DeployWorkflowFromSourceParams.sourceRef`): the git ref inside the - // source asset that carries `source.package.commitSha`, when the asset - // mints a fresh tree per run rather than living on its default ref. - sourceRef: text("source_ref"), - // The principal whose grant/catalog authority a redeploy resolves fresh - // inference sources against. Never a resolved source or a credential — - // those are re-resolved at redeploy time, exactly as the exclusive - // placement's launch spec re-resolves from offering ids rather than - // persisting secrets. - sourceAuthorityPrincipalId: text("source_authority_principal_id").notNull(), - recordedAt: timestamp("recorded_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [index("workflow_deploy_source_tenant_idx").on(table.tenantId)], -); - -export type WorkflowDeploySourceRow = typeof workflowDeploySource.$inferSelect; diff --git a/packages/workflows/src/deploy-source/store.test.ts b/packages/workflows/src/deploy-source/store.test.ts deleted file mode 100644 index 9b430ad2a5..0000000000 --- a/packages/workflows/src/deploy-source/store.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -// DB-gated: proves `record`'s upsert semantics and the redeploy round trip -// against a real Postgres transaction, mirroring -// packages/run-key-history/test/store.test.ts's scratch-database setup. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../../scripts/e2e/harness"; -import { applyWorkflowDeploySourceMigrations } from "./migrations"; -import { createDrizzleWorkflowDeploySourceStore } from "./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}_workflow_deploy_source_store_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("createDrizzleWorkflowDeploySourceStore", () => { - 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 applyWorkflowDeploySourceMigrations(scratchUrl); - }, 20000); - - afterAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - }); - }, 20000); - - test("records and reads back an asset-sourced deploy", async () => { - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - const db = drizzle(sql); - const store = createDrizzleWorkflowDeploySourceStore(db); - try { - await store.record({ - anchorRunId: "run_asset_1", - tenantId: "tenant_1", - deploymentDomain: "tenant1.workbench.dev", - source: { - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "a".repeat(40) }, - }, - entry: "workflow.ts", - definitionAssetId: "asset_1", - sourceAuthorityPrincipalId: "principal_1", - }); - - const row = await store.get("run_asset_1"); - expect(row).not.toBeNull(); - expect(row?.source).toEqual({ - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "a".repeat(40) }, - }); - expect(row?.pin).toBeNull(); - expect(row?.sourceRef).toBeNull(); - } finally { - await sql.end(); - } - }); - - test("a redeploy overwrites the prior record for the same anchor run", async () => { - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - const db = drizzle(sql); - const store = createDrizzleWorkflowDeploySourceStore(db); - try { - await store.record({ - anchorRunId: "run_rotate", - tenantId: "tenant_1", - deploymentDomain: "tenant1.workbench.dev", - source: { - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "a".repeat(40) }, - }, - entry: "workflow.ts", - definitionAssetId: "asset_1", - sourceAuthorityPrincipalId: "principal_1", - }); - await store.record({ - anchorRunId: "run_rotate", - tenantId: "tenant_1", - deploymentDomain: "tenant1.workbench.dev", - source: { - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "b".repeat(40) }, - }, - entry: "workflow.ts", - definitionAssetId: "asset_1", - sourceAuthorityPrincipalId: "principal_1", - }); - - const row = await store.get("run_rotate"); - expect(row?.source).toEqual({ - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "b".repeat(40) }, - }); - } finally { - await sql.end(); - } - }); - - // The deliberate proof CL-6581 phase 1 asks for: everything a shared - // placement deploy needs to be reissued survives in Postgres alone, with - // NO input from the sidecar's local `deployment.json`. This test writes a - // record with one connection (standing in for the hub process that - // performed the original deploy), then opens a FRESH connection and store - // instance (standing in for a hub restart with no shared in-memory state) - // and reconstructs the exact `DeployWorkflowFromSourceParams` shape a - // redeploy call needs -- proving the record alone, not any sidecar file, - // is sufficient to redrive the deploy. - test("a redeploy's full source-call intent survives a fresh connection, with no sidecar record involved", async () => { - const writerSql = postgres(scratchUrl, { - max: 1, - onnotice: () => undefined, - }); - try { - const writerStore = createDrizzleWorkflowDeploySourceStore( - drizzle(writerSql), - ); - await writerStore.record({ - anchorRunId: "run_restart_proof", - tenantId: "tenant_1", - deploymentDomain: "tenant1.workbench.dev", - source: { - kind: "asset", - assetId: "asset_native_workflow", - package: { - format: "source", - commitSha: "c".repeat(40), - packageName: "@corbits/example-workflow", - }, - }, - entry: "src/workflow.ts", - definitionAssetId: "asset_native_workflow", - sourceRef: "refs/heads/runs/run_restart_proof", - sourceAuthorityPrincipalId: "principal_seed", - }); - } finally { - await writerSql.end(); - } - - // A fresh process, a fresh connection, a fresh store -- nothing carried - // over from the write above except what landed in Postgres. - const readerSql = postgres(scratchUrl, { - max: 1, - onnotice: () => undefined, - }); - try { - const readerStore = createDrizzleWorkflowDeploySourceStore( - drizzle(readerSql), - ); - const row = await readerStore.get("run_restart_proof"); - expect(row).not.toBeNull(); - if (row === null) return; - - // Everything `DeployWorkflowFromSourceParams` needs beyond a fresh - // `HarnessConfig` (re-resolved from the tenant's live inference - // catalog, never persisted here) is present and round-trips exactly. - const redeployParams = { - tenantId: row.tenantId, - anchorRunId: row.anchorRunId, - deploymentDomain: row.deploymentDomain, - source: row.source, - entry: row.entry, - definitionAssetId: row.definitionAssetId, - ...(row.pin !== null ? { pin: row.pin } : {}), - ...(row.sourceRef !== null ? { sourceRef: row.sourceRef } : {}), - }; - - expect(redeployParams).toEqual({ - tenantId: "tenant_1", - anchorRunId: "run_restart_proof", - deploymentDomain: "tenant1.workbench.dev", - source: { - kind: "asset", - assetId: "asset_native_workflow", - package: { - format: "source", - commitSha: "c".repeat(40), - packageName: "@corbits/example-workflow", - }, - }, - entry: "src/workflow.ts", - definitionAssetId: "asset_native_workflow", - sourceRef: "refs/heads/runs/run_restart_proof", - }); - } finally { - await readerSql.end(); - } - }); - - test("get returns null for an anchor run that was never recorded", async () => { - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - const db = drizzle(sql); - const store = createDrizzleWorkflowDeploySourceStore(db); - try { - expect(await store.get("run_never_seen")).toBeNull(); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/workflows/src/deploy-source/store.ts b/packages/workflows/src/deploy-source/store.ts deleted file mode 100644 index 8d85552b57..0000000000 --- a/packages/workflows/src/deploy-source/store.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Persistence for `workflow_deploy_source`, kept apart from the -// deploy-call decorator that drives it so the write shape is -// unit-testable without a real `SessionService`. `WorkflowDeploySourceStore` -// is the seam; `createDrizzleWorkflowDeploySourceStore` is its production -// implementation over `./schema.ts`. -import { eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; - -import { workflowDeploySource, type WorkflowDeploySourceRow } from "./schema"; - -/** - * The drizzle handle `createDrizzleWorkflowDeploySourceStore` operates - * against. Generic over the host's schema record, like - * `@corbits/run-key-history`'s own `RunKeyHistoryDb` — the host hands in - * its own `drizzle(sql, { schema })` instance unchanged, and no cast is - * needed at the call site. - */ -export type WorkflowDeploySourceDb< - TSchema extends Record = Record, -> = PostgresJsDatabase; - -export type WorkflowDeploySourceRecord = { - anchorRunId: string; - tenantId: string; - deploymentDomain: string; - source: WorkflowDefinitionSource; - entry: string; - pin?: string; - definitionAssetId: string; - sourceRef?: string; - sourceAuthorityPrincipalId: string; -}; - -export interface WorkflowDeploySourceStore { - /** - * Record (or overwrite, on a redeploy/rotation) the deploy source for one - * anchor run. Idempotent: recording the same anchor run twice replaces the - * row in place rather than accumulating history — the durable answer to - * "what is this deployment's source right now", not an audit log. - */ - record(entry: WorkflowDeploySourceRecord): Promise; - /** The current source record for an anchor run, or `null` if none was - * ever recorded (e.g. a deployment that predates this store). */ - get(anchorRunId: string): Promise; -} - -export function createDrizzleWorkflowDeploySourceStore< - TSchema extends Record, ->(db: WorkflowDeploySourceDb): WorkflowDeploySourceStore { - return { - async record(entry) { - await db - .insert(workflowDeploySource) - .values({ - anchorRunId: entry.anchorRunId, - tenantId: entry.tenantId, - deploymentDomain: entry.deploymentDomain, - source: entry.source, - entry: entry.entry, - pin: entry.pin ?? null, - definitionAssetId: entry.definitionAssetId, - sourceRef: entry.sourceRef ?? null, - sourceAuthorityPrincipalId: entry.sourceAuthorityPrincipalId, - recordedAt: new Date(), - }) - .onConflictDoUpdate({ - target: workflowDeploySource.anchorRunId, - set: { - tenantId: entry.tenantId, - deploymentDomain: entry.deploymentDomain, - source: entry.source, - entry: entry.entry, - pin: entry.pin ?? null, - definitionAssetId: entry.definitionAssetId, - sourceRef: entry.sourceRef ?? null, - sourceAuthorityPrincipalId: entry.sourceAuthorityPrincipalId, - recordedAt: new Date(), - }, - }); - }, - - async get(anchorRunId) { - const [row] = await db - .select() - .from(workflowDeploySource) - .where(eq(workflowDeploySource.anchorRunId, anchorRunId)) - .limit(1); - return row ?? null; - }, - }; -} diff --git a/packages/workflows/src/detail/definition-lifecycle.test.ts b/packages/workflows/src/detail/definition-lifecycle.test.ts index 362bb9d311..7192e09354 100644 --- a/packages/workflows/src/detail/definition-lifecycle.test.ts +++ b/packages/workflows/src/detail/definition-lifecycle.test.ts @@ -18,8 +18,8 @@ function row(patch: Partial): DefinitionLifecycleRow { } describe("deriveWorkflowLifecycle", () => { - test("no definition rows and no deploy attempt is source-only", () => { - const result = deriveWorkflowLifecycle([], false); + test("no definition rows is source-only — the native cutover removed the deploy-attempt signal", () => { + const result = deriveWorkflowLifecycle([]); expect(result).toEqual({ lifecycle: "source-only", currentDefinitionId: null, @@ -27,26 +27,16 @@ describe("deriveWorkflowLifecycle", () => { }); }); - test("no definition rows but a deploy attempt on record is build-failed", () => { - const result = deriveWorkflowLifecycle([], true); - expect(result).toEqual({ - lifecycle: "build-failed", - currentDefinitionId: null, - wireHash: null, - }); - }); - test("newest row lacking an approved hash is pending-approval", () => { - const result = deriveWorkflowLifecycle( - [row({ id: "wfd_2", approvedWireHash: null })], - true, - ); + const result = deriveWorkflowLifecycle([ + row({ id: "wfd_2", approvedWireHash: null }), + ]); expect(result.lifecycle).toBe("pending-approval"); expect(result.currentDefinitionId).toBe("wfd_2"); }); test("newest row approved and deployed is deployed", () => { - const result = deriveWorkflowLifecycle([row({ id: "wfd_3" })], true); + const result = deriveWorkflowLifecycle([row({ id: "wfd_3" })]); expect(result).toEqual({ lifecycle: "deployed", currentDefinitionId: "wfd_3", @@ -55,10 +45,9 @@ describe("deriveWorkflowLifecycle", () => { }); test("newest row approved but stopped is superseded", () => { - const result = deriveWorkflowLifecycle( - [row({ id: "wfd_4", status: "stopped" })], - true, - ); + const result = deriveWorkflowLifecycle([ + row({ id: "wfd_4", status: "stopped" }), + ]); expect(result.lifecycle).toBe("superseded"); }); @@ -73,7 +62,7 @@ describe("deriveWorkflowLifecycle", () => { status: "deployed", createdAt: "2026-02-01T00:00:00.000Z", }); - const result = deriveWorkflowLifecycle([older, newer], true); + const result = deriveWorkflowLifecycle([older, newer]); expect(result.lifecycle).toBe("deployed"); expect(result.currentDefinitionId).toBe("wfd_new"); }); diff --git a/packages/workflows/src/detail/definition-lifecycle.ts b/packages/workflows/src/detail/definition-lifecycle.ts index da02912c49..4b13d62355 100644 --- a/packages/workflows/src/detail/definition-lifecycle.ts +++ b/packages/workflows/src/detail/definition-lifecycle.ts @@ -5,11 +5,11 @@ // Native rows carry no "lifecycle" column of their own — `workflow_model.md` // keys `workflow_definition` on `(asset_id, wire_hash)`, so a redeploy mints // a new row rather than mutating one. What a person needs is a single -// reading of the newest row for the asset, folded against the one -// Workbench-owned signal native rows don't carry: whether a deploy was ever -// attempted at all (`@corbits/workflows`'s `./deploy-source`'s per-anchor-run -// record). Kept in its own module, with no DB import, so the four states -// below are covered by a plain unit test rather than a route fixture. +// reading of the newest row for the asset, derived from native rows alone +// (CL-7591 cut over off the deleted Workbench durability store: native +// deployments/triggers are the only deploy-attempt signal). Kept in +// its own module, with no DB import, so the states below are covered by a +// plain unit test rather than a route fixture. import { isFrozen } from "../launchable/target-rule"; export type WorkflowLifecycle = @@ -45,12 +45,14 @@ export type WorkflowLifecycleResult = { /** * Derive an asset's lifecycle from its `workflow_definition` rows (any - * order) and whether a deploy was ever attempted for it. + * order) — native rows only. * - * - No rows, no deploy attempt on record → `source-only`: nothing has ever - * tried to run this asset. - * - No rows, a deploy attempt IS on record → `build-failed`: a deploy was - * asked for and never produced a definition row at all. + * - No rows → `source-only`: nothing has ever produced a definition row + * for this asset. (The native cutover removed the Workbench + * deploy-attempt signal, so a failed deploy leaves no rows and reads + * the same as never-attempted. `build-failed` stays in the + * `WorkflowLifecycle` union only as wire/UI shape compatibility — the + * route no longer produces it.) * - Rows exist: the newest one decides. Not yet frozen (see `isFrozen`) → * `pending-approval`. Frozen and `status: "deployed"` → `deployed`. * Frozen but rolled back / replaced (`status: "stopped"`) → @@ -58,11 +60,10 @@ export type WorkflowLifecycleResult = { */ export function deriveWorkflowLifecycle( rows: readonly DefinitionLifecycleRow[], - hasDeployAttempt: boolean, ): WorkflowLifecycleResult { if (rows.length === 0) { return { - lifecycle: hasDeployAttempt ? "build-failed" : "source-only", + lifecycle: "source-only", currentDefinitionId: null, wireHash: null, }; diff --git a/packages/workflows/src/detail/detail-route.drizzle.test.ts b/packages/workflows/src/detail/detail-route.drizzle.test.ts index b7605eb173..22de14f695 100644 --- a/packages/workflows/src/detail/detail-route.drizzle.test.ts +++ b/packages/workflows/src/detail/detail-route.drizzle.test.ts @@ -9,7 +9,6 @@ import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { applyWorkflowDeploySourceMigrations } from "../deploy-source/migrations"; import { dbTargetFromUrl } from "../../../../scripts/db-setup"; import { e2eDatabaseUrl } from "../../../../scripts/e2e/harness"; @@ -60,9 +59,6 @@ describeIfDb("createWorkflowDetailRoute", () => { beforeAll(async () => { await runMigrations(target, { schema: SCHEMA }); - await applyWorkflowDeploySourceMigrations( - databaseUrl ?? "postgres://localhost:5432/unused", - ); }, 30000); afterAll(async () => { @@ -165,4 +161,59 @@ describeIfDb("createWorkflowDetailRoute", () => { await close(); } }); + + // CL-7591 (native cutover): the detail read is native-only — no + // Workbench durability store exists anymore, so a deployed + // definition reports `source: null` with the lifecycle derived from + // native rows alone. + test("a deployed definition reports a null source on the native-only read", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + await db.insert(schema.tenant).values(TENANT).onConflictDoNothing(); + await db.insert(schema.principal).values(PRINCIPAL).onConflictDoNothing(); + await db.insert(schema.asset).values({ + id: "asset_native_cutover_wf", + tenantId: TENANT.id, + kind: "workflow", + name: "native-cutover", + displayName: "Native Cutover", + }); + await db.insert(schema.workflowDefinition).values({ + id: "wfd_native_cutover_1", + tenantId: TENANT.id, + assetId: "asset_native_cutover_wf", + wireHash: "hash_cutover", + name: "native-cutover", + description: "Cut over to native deployments", + status: "deployed", + currentVersion: "1", + grantRequirements: [], + }); + await db.insert(schema.workflowDefinitionVersion).values({ + id: "wfdv_native_cutover_1", + definitionId: "wfd_native_cutover_1", + version: "1", + status: "active", + approvedWireHash: "hash_cutover", + grantSnapshot: { perStep: [], grantRequirements: [] }, + wireProjection: { + id: "native-cutover", + triggers: [], + stepOrder: [], + steps: {}, + }, + }); + + const app = mount( + createWorkflowDetailRoute({ db, requireGrant: allowAll }), + ); + const res = await app.request("/asset_native_cutover_wf/detail"); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.source).toBeNull(); + expect(body.lifecycle).toBe("deployed"); + } finally { + await close(); + } + }); }); diff --git a/packages/workflows/src/detail/detail-route.ts b/packages/workflows/src/detail/detail-route.ts index 2907f4d3f1..8e0c4b493b 100644 --- a/packages/workflows/src/detail/detail-route.ts +++ b/packages/workflows/src/detail/detail-route.ts @@ -5,15 +5,17 @@ // approved grants, credential binding names — never a value). Mounted // alongside the vendored `createWorkflowDefinitionRoutes` at // `${TENANT_PREFIX}/workflows/definitions` (`apps/hub/src/index.ts`), not -// inside it: this is a Workbench-owned read composed over native rows plus -// `@corbits/workflows`'s `./deploy-source`'s deploy-attempt record, not something -// `vendor/intx/hub-api` knows about. +// inside it: this is a Workbench-owned read composed over native rows, +// not something `vendor/intx/hub-api` knows about. // // Every field is read-only and native: `workflow_definition` / // `workflow_definition_version` (via `@intx/db`'s `loadFrozenGrantSnapshot` // / `loadFrozenWireProjection`, the same freeze-transaction reads the run // path uses) and `asset` for the display name. `workflow.json` is never -// read (see docs/workflow-model.md's retirement). +// read (see docs/workflow-model.md's retirement). Since CL-7591 the read +// is native-only: the deleted Workbench durability store is gone, +// so `source` is always null and the lifecycle derives from native rows +// alone (native deployments/triggers are the deploy-attempt signal). import { and, desc, eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import type { DB } from "@intx/db"; @@ -25,8 +27,6 @@ import { } from "@intx/db"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import { idResource } from "@intx/hub-api"; -import type { WorkflowDeploySourceDb } from "../deploy-source/store"; -import { workflowDeploySource } from "../deploy-source/schema"; import { deriveWorkflowLifecycle } from "./definition-lifecycle"; import type { WorkflowDefinitionDetail } from "./definition-detail"; @@ -86,26 +86,6 @@ function projectStep( }; } -/** The `WorkflowDefinitionAssetSource` `package.commitSha`, when the deploy - * source names a source-tree package at a pinned commit — `""` for every - * other source shape (a registry pin, or a tarball package, neither of - * which carries a commit sha). Read defensively: `source` is a jsonb - * column typed as `WorkflowDefinitionSource` only by convention, not - * re-validated here. */ -function commitShaFromSource(source: unknown): string { - if (source === null || typeof source !== "object") return ""; - const pkg = (source as Record).package; - if (pkg === null || typeof pkg !== "object") return ""; - const commitSha = (pkg as Record).commitSha; - return typeof commitSha === "string" ? commitSha : ""; -} - -function originFromSource(source: unknown): string { - if (source === null || typeof source !== "object") return "unknown"; - const kind = (source as Record).kind; - return typeof kind === "string" ? kind : "unknown"; -} - export function createWorkflowDetailRoute({ db, requireGrant, @@ -164,21 +144,6 @@ export function createWorkflowDetailRoute({ ]), ); - const deploySourceRows = await ( - db as unknown as WorkflowDeploySourceDb> - ) - .select() - .from(workflowDeploySource) - .where( - and( - eq(workflowDeploySource.definitionAssetId, definitionAssetId), - eq(workflowDeploySource.tenantId, tenantCtx.id), - ), - ) - .orderBy(desc(workflowDeploySource.recordedAt)) - .limit(1); - const deploySource = deploySourceRows[0] ?? null; - const { lifecycle, currentDefinitionId, wireHash } = deriveWorkflowLifecycle( definitionRows.map((row) => ({ @@ -194,7 +159,6 @@ export function createWorkflowDetailRoute({ status: row.status, createdAt: row.createdAt.toISOString(), })), - deploySource !== null, ); const current = definitionRows.find( @@ -243,15 +207,10 @@ export function createWorkflowDetailRoute({ lifecycle, ...(currentDefinitionId !== null ? { currentDefinitionId } : {}), ...(wireHash !== null ? { wireHash } : {}), - ...(deploySource !== null - ? { - source: { - commitSha: commitShaFromSource(deploySource.source), - entry: deploySource.entry, - origin: originFromSource(deploySource.source), - }, - } - : { source: null }), + // CL-7591 native cutover: the Workbench durability store is + // deleted, so `source` is always null — the detail surface reads + // native rows only. + source: null, steps, grants: { declared: declaredGrantNames, diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 499914e5ee..16119a467b 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -1,9 +1,8 @@ // @corbits/workflows server entry — everything: the source-tree -// renderer/reader, the deploy-source durability layer, the definition -// detail route, and agent-authored-workflow authoring. Browser code -// imports `@corbits/workflows/client` instead (see ./client.ts). +// renderer/reader, the definition detail route, and +// agent-authored-workflow authoring. Browser code imports +// `@corbits/workflows/client` instead (see ./client.ts). export * from "./source"; -export * from "./deploy-source/index"; export * from "./detail/index"; export * from "./authoring/index"; export { diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index d53941f883..0866b84df3 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -111,20 +111,6 @@ const ALLOWLIST: readonly { maxOccurrences: 1, tables: ["run_key_history"], }, - { - // A native workflow's deploy source (an npm name@range pin or a - // hub-asset commit) is a real external pin nothing native records. - // RepoStore holds the source bytes; workflow_definition_version holds - // the body projection (wireProjection, grantSnapshot, - // approvedWireHash). Neither stores the deploy PARAMETERS — entry, - // pin, sourceRef, deploymentDomain, and the WorkflowDefinitionSource - // union itself — that select and apply those bytes. This table is - // that missing record, for shared placement (CL-6581 phase 1; - // exclusive placement already has workflow_run_launch_spec). - relPath: "packages/workflows/src/deploy-source/schema.ts", - maxOccurrences: 1, - tables: ["workflow_deploy_source.workflow_deploy_source"], - }, { relPath: "packages/bench/src/schema.ts", maxOccurrences: 1, diff --git a/scripts/checks/replaced-paths.txt b/scripts/checks/replaced-paths.txt index 44b9f7e4c6..289d2a50e2 100644 --- a/scripts/checks/replaced-paths.txt +++ b/scripts/checks/replaced-paths.txt @@ -31,3 +31,4 @@ packages/workflow-deploy-source packages/workflow-freeze packages/workflow-host-actions packages/workflow-source +packages/workflows/src/deploy-source diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index 1cfa934ab4..6a46e7254d 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -44,7 +44,6 @@ import { applyAgentDirectoryMigrations } from "../packages/agent-directory/src/m import { applyOnboardingMigrations } from "../packages/onboarding/src/migrations"; import { applyAccessPolicyMigrations } from "../packages/access-policy/src/migrations"; import { applyRunKeyHistoryMigrations } from "../packages/run-key-history/src/migrations"; -import { applyWorkflowDeploySourceMigrations } from "../packages/workflows/src/deploy-source/migrations"; import { applyInferenceCatalogMigrations } from "../packages/inference-catalog/src/migrations"; const repoRoot = path.resolve(import.meta.dir, ".."); @@ -77,10 +76,6 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { { name: "@workbench/onboarding", apply: applyOnboardingMigrations }, { name: "@workbench/access-policy", apply: applyAccessPolicyMigrations }, { name: "@corbits/run-key-history", apply: applyRunKeyHistoryMigrations }, - { - name: "@corbits/workflows (deploy-source)", - apply: applyWorkflowDeploySourceMigrations, - }, { name: "@corbits/inference-catalog", apply: applyInferenceCatalogMigrations,