From 85218b030cce3390a942fd8b02e5f4ad46bda2e1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 17:49:29 -0700 Subject: [PATCH 1/2] Add tests for hub-side projection freeze of inert workflow definitions Hub-authored agents used to land with a NULL wire projection and stay unlaunchable. These tests lock the reify-hash-walk sequence, the call-site freezer wiring, and the consumer-language recovery guidance for a missing launch body. --- .../test/routes.integration.test.ts | 45 ++++- packages/agent-directory/test/routes.test.ts | 45 +++++ .../test/workflow-capability-routes.test.ts | 27 +++ .../test/workflow-create-routes.test.ts | 20 ++ .../test/workflow-skill-pin-routes.test.ts | 27 +++ packages/chat/test/routes.test.ts | 6 +- packages/folded-runs/test/definition.test.ts | 4 +- packages/workflow-freeze/src/index.test.ts | 99 ++++++++++ .../test/freeze.drizzle.test.ts | 180 ++++++++++++++++++ 9 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 packages/workflow-freeze/src/index.test.ts create mode 100644 packages/workflow-freeze/test/freeze.drizzle.test.ts diff --git a/packages/agent-directory/test/routes.integration.test.ts b/packages/agent-directory/test/routes.integration.test.ts index 3e18c4832..7deacce15 100644 --- a/packages/agent-directory/test/routes.integration.test.ts +++ b/packages/agent-directory/test/routes.integration.test.ts @@ -25,7 +25,11 @@ import { eq, inArray } from "drizzle-orm"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; -import { createDB } from "@intx/db"; +import { + createDB, + loadFrozenGrantSnapshot, + loadFrozenWireProjection, +} from "@intx/db"; import { asset as assetTable, principal as principalTable, @@ -39,6 +43,7 @@ import { dbTargetFromUrl } from "../../../scripts/db-setup"; import { applyAgentDirectoryMigrations } from "../src/migrations"; import { definitionSkills } from "../src/schema"; import { createAgentDefinitionRoutes } from "../src/routes"; +import { createDefinitionFreezer } from "@corbits/workflow-freeze"; import type { PinnedSkillIndexResolver } from "../src/routes"; import { createDrizzleDefinitionSkillsStore } from "../src/skills-store"; import type { DefinitionAssetHistory } from "../src/definition-history"; @@ -138,6 +143,7 @@ describeIfDb("agent-directory routes against a real assetService", () => { history: fakeHistory, capabilityInventory: fakeCapabilityInventory, requireGrant: allowAllRequireGrant, + definitionFreezer: createDefinitionFreezer(db), }); const asPrincipal: MiddlewareHandler = async (c, next) => { c.set("tenant", TENANT); @@ -205,4 +211,41 @@ describeIfDb("agent-directory routes against a real assetService", () => { const gotAfterBody = (await gotAfter.json()) as { skills: string[] }; expect(gotAfterBody.skills).toEqual([]); }); + + test("a created definition is launch-resolvable: its projection and grant snapshot are frozen (CL-6447)", async () => { + const handle = `launchable-${suffix}`; + const created = await post(app, { + name: "Launchable", + handle, + systemPrompt: "You answer launch checks.", + skills: [], + }); + expect(created.status).toBe(201); + const { id: definitionId } = (await created.json()) as { id: string }; + + // The exact reads the chat invite path (`readDefinitionProjection`) + // and the mail-triggered turn path (`loadFrozenGrantSnapshot`) fail + // closed on: both must be frozen at create or the agent 409s + // `not_launchable` forever. + const projection = await loadFrozenWireProjection(db, definitionId); + expect(projection).not.toBeNull(); + expect(JSON.stringify(projection)).toContain("You answer launch checks."); + expect(await loadFrozenGrantSnapshot(db, definitionId)).not.toBeNull(); + + // An instructions save re-freezes in place: the frozen projection + // follows the edit under the same definition id. + const updated = await app.request(`/${definitionId}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Launchable", + systemPrompt: "You answer edited launch checks.", + }), + }); + expect(updated.status).toBe(200); + const refrozen = await loadFrozenWireProjection(db, definitionId); + expect(JSON.stringify(refrozen)).toContain( + "You answer edited launch checks.", + ); + }); }); diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index e6ce6b591..92429bca7 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -263,6 +263,25 @@ const allowAllRequireGrant: RequireGrant = () => async (_c, next) => { await next(); }; +/** Records freeze/re-freeze calls instead of running the real + * `@corbits/workflow-freeze` machinery (whose own suites cover the DB + * half); routes here are asserted to invoke it on every content write. */ +function recordingDefinitionFreezer() { + const freezes: { assetId: string; workflowJson: string }[] = []; + const refreezes: { definitionId: string; workflowJson: string }[] = []; + return { + freezes, + refreezes, + freeze: (input: { assetId: string; workflowJson: string }) => { + freezes.push(input); + return Promise.resolve({ definitionId: "def_new", wireHash: "hash_1" }); + }, + refreeze: (input: { definitionId: string; workflowJson: string }) => { + refreezes.push(input); + return Promise.resolve({ wireHash: "hash_2" }); + }, + }; +} function buildApp( assetService: AssetService, db: DB["db"] = fakeDb(), @@ -270,6 +289,9 @@ function buildApp( history: DefinitionAssetHistory = fakeHistory(), capabilityInventory: CapabilityInventoryProvider = fakeCapabilityInventory, skillsStore: DefinitionSkillsStore = createInMemoryDefinitionSkillsStore(), + definitionFreezer: ReturnType< + typeof recordingDefinitionFreezer + > = recordingDefinitionFreezer(), ): Hono { const routes = createAgentDefinitionRoutes({ db, @@ -279,6 +301,7 @@ function buildApp( history, capabilityInventory, requireGrant, + definitionFreezer, }); const asPrincipal: MiddlewareHandler = async (c, next) => { c.set("tenant", TENANT); @@ -447,6 +470,7 @@ function fakeCreateDb(): DB["db"] { test("a create request with skills writes the definition source tree to the asset and records skills in the skills store", async () => { let writtenFiles: Record | undefined; const skillsStore = createInMemoryDefinitionSkillsStore(); + const freezer = recordingDefinitionFreezer(); const app = buildApp( fakeAssetService({ createAsset: () => @@ -470,6 +494,7 @@ test("a create request with skills writes the definition source tree to the asse fakeHistory(), fakeCapabilityInventory, skillsStore, + freezer, ); const response = await post(app, { name: "Research Buddy", @@ -480,6 +505,11 @@ test("a create request with skills writes the definition source tree to the asse expect(response.status).toBe(201); expect(writtenFiles).toBeDefined(); expect(Object.keys(writtenFiles ?? {})).toEqual(SOURCE_TREE_PATHS); + // The projection freeze receives the exact source the asset carries — + // this is what makes the created definition launchable (CL-6447). + expect(freezer.freezes).toHaveLength(1); + expect(freezer.freezes[0]?.assetId).toBe("ast_1"); + expect(freezer.freezes[0]?.workflowJson).toBe(definitionFrom(writtenFiles)); expect(await skillsStore.getSkills("ast_1")).toEqual([ "web-research", "long-form-write", @@ -851,6 +881,7 @@ test("PUT /:definitionId writes the new system prompt in a single source-tree co assetId: "ast_1", name: "research-buddy", }); + const freezer = recordingDefinitionFreezer(); const app = buildApp( fakeAssetService({ readAssetBlob: () => @@ -863,6 +894,11 @@ test("PUT /:definitionId writes the new system prompt in a single source-tree co }, }), db, + allowAllRequireGrant, + fakeHistory(), + fakeCapabilityInventory, + createInMemoryDefinitionSkillsStore(), + freezer, ); const response = await put(app, "/def_1", { name: "Research Buddy", @@ -873,6 +909,14 @@ test("PUT /:definitionId writes the new system prompt in a single source-tree co expect(promptFrom(definitionFrom(writtenFiles))).toBe( "You are now a blunt, no-nonsense researcher.", ); + // Saving instructions re-freezes the definition's projection in + // place, so the next launch answers with the edit — and a legacy + // definition frozen without a projection is healed by the same save. + expect(freezer.refreezes).toHaveLength(1); + expect(freezer.refreezes[0]?.definitionId).toBe("def_1"); + expect(promptFrom(freezer.refreezes[0]?.workflowJson ?? "")).toBe( + "You are now a blunt, no-nonsense researcher.", + ); expect(db.updateCalls).toEqual([ { description: "Research Buddy", updatedAt: expect.any(Date) }, { displayName: "Research Buddy", updatedAt: expect.any(Date) }, @@ -1293,6 +1337,7 @@ test("pinning a skill the registry cannot resolve is a 400, not a 500", async () requireGrant: () => async (_c, next) => { await next(); }, + definitionFreezer: recordingDefinitionFreezer(), }); const app = new Hono(); app.use("*", async (c, next) => { diff --git a/packages/agent-directory/test/workflow-capability-routes.test.ts b/packages/agent-directory/test/workflow-capability-routes.test.ts index 684cc06f6..a73a2d5f2 100644 --- a/packages/agent-directory/test/workflow-capability-routes.test.ts +++ b/packages/agent-directory/test/workflow-capability-routes.test.ts @@ -130,12 +130,32 @@ const authenticateAsOwnRun: WorkflowRunAuthenticator = { ), }; +/** Records freeze/re-freeze calls instead of running the real + * `@corbits/workflow-freeze` machinery (whose own suites cover the DB + * half); routes here are asserted to invoke it on every content write. */ +function recordingDefinitionFreezer() { + const freezes: { assetId: string; workflowJson: string }[] = []; + const refreezes: { definitionId: string; workflowJson: string }[] = []; + return { + freezes, + refreezes, + freeze: (input: { assetId: string; workflowJson: string }) => { + freezes.push(input); + return Promise.resolve({ definitionId: "def_new", wireHash: "hash_1" }); + }, + refreeze: (input: { definitionId: string; workflowJson: string }) => { + refreezes.push(input); + return Promise.resolve({ wireHash: "hash_2" }); + }, + }; +} function buildApp(opts: { assetService?: AssetService; db?: DB["db"]; authenticator?: WorkflowRunAuthenticator; capabilityInventory?: CapabilityInventoryProvider; skillsStore?: DefinitionSkillsStore; + definitionFreezer?: ReturnType; }): Hono { return createWorkflowCapabilityRoutes({ db: opts.db ?? fakeDb(), @@ -144,6 +164,7 @@ function buildApp(opts: { skillsStore: opts.skillsStore ?? createInMemoryDefinitionSkillsStore(), capabilityInventory: opts.capabilityInventory ?? fakeCapabilityInventory, authenticator: opts.authenticator ?? authenticateAsOwnRun, + definitionFreezer: opts.definitionFreezer ?? recordingDefinitionFreezer(), }) as unknown as Hono; } @@ -211,6 +232,7 @@ test("a run targeting another definition's capabilities is a 403", async () => { test("a run may add a capability to its own definition without any grant check", async () => { let writtenFiles: Record | undefined; let writtenMessage: string | undefined; + const freezer = recordingDefinitionFreezer(); const app = buildApp({ assetService: fakeAssetService({ readAssetBlob: readAssetBlobFor(storedDefinitionBytes()), @@ -220,12 +242,17 @@ test("a run may add a capability to its own definition without any grant check", return Promise.resolve({ commitSha: "deadbeef" }); }, }), + definitionFreezer: freezer, }); const response = await postCapability(app, OWN_DEFINITION_ID, { kind: "toolPackage", name: "@corbits/capability-tools", }); expect(response.status).toBe(200); + // The rewrite re-freezes the definition's projection so the next + // launch carries the added capability (CL-6447). + expect(freezer.refreezes).toHaveLength(1); + expect(freezer.refreezes[0]?.definitionId).toBe(OWN_DEFINITION_ID); expect(Object.keys(writtenFiles ?? {})).toEqual(SOURCE_TREE_PATHS); expect(writtenMessage).toBe( "Add @corbits/capability-tools to research-buddy", diff --git a/packages/agent-directory/test/workflow-create-routes.test.ts b/packages/agent-directory/test/workflow-create-routes.test.ts index 2d826820d..af56a773d 100644 --- a/packages/agent-directory/test/workflow-create-routes.test.ts +++ b/packages/agent-directory/test/workflow-create-routes.test.ts @@ -148,6 +148,25 @@ const authenticateAsRun: WorkflowRunAuthenticator = { ), }; +/** Records freeze/re-freeze calls instead of running the real + * `@corbits/workflow-freeze` machinery (whose own suites cover the DB + * half); routes here are asserted to invoke it on every content write. */ +function recordingDefinitionFreezer() { + const freezes: { assetId: string; workflowJson: string }[] = []; + const refreezes: { definitionId: string; workflowJson: string }[] = []; + return { + freezes, + refreezes, + freeze: (input: { assetId: string; workflowJson: string }) => { + freezes.push(input); + return Promise.resolve({ definitionId: "def_new", wireHash: "hash_1" }); + }, + refreeze: (input: { definitionId: string; workflowJson: string }) => { + refreezes.push(input); + return Promise.resolve({ wireHash: "hash_2" }); + }, + }; +} function buildApp( opts: Partial = {}, ): Hono { @@ -158,6 +177,7 @@ function buildApp( skillsStore: opts.skillsStore ?? createInMemoryDefinitionSkillsStore(), capabilityInventory: opts.capabilityInventory ?? fakeCapabilityInventory, authenticator: opts.authenticator ?? authenticateAsRun, + definitionFreezer: opts.definitionFreezer ?? recordingDefinitionFreezer(), ...(opts.tenantDefaultModel !== undefined ? { tenantDefaultModel: opts.tenantDefaultModel } : {}), diff --git a/packages/agent-directory/test/workflow-skill-pin-routes.test.ts b/packages/agent-directory/test/workflow-skill-pin-routes.test.ts index 67b2f6c28..1f71fe449 100644 --- a/packages/agent-directory/test/workflow-skill-pin-routes.test.ts +++ b/packages/agent-directory/test/workflow-skill-pin-routes.test.ts @@ -138,11 +138,31 @@ const authenticateAsTenant1: WorkflowRunAuthenticator = { ), }; +/** Records freeze/re-freeze calls instead of running the real + * `@corbits/workflow-freeze` machinery (whose own suites cover the DB + * half); routes here are asserted to invoke it on every content write. */ +function recordingDefinitionFreezer() { + const freezes: { assetId: string; workflowJson: string }[] = []; + const refreezes: { definitionId: string; workflowJson: string }[] = []; + return { + freezes, + refreezes, + freeze: (input: { assetId: string; workflowJson: string }) => { + freezes.push(input); + return Promise.resolve({ definitionId: "def_new", wireHash: "hash_1" }); + }, + refreeze: (input: { definitionId: string; workflowJson: string }) => { + refreezes.push(input); + return Promise.resolve({ wireHash: "hash_2" }); + }, + }; +} function buildApp(opts: { assetService?: AssetService; db?: DB["db"]; authenticator?: WorkflowRunAuthenticator; skillsStore?: DefinitionSkillsStore; + definitionFreezer?: ReturnType; }): Hono { return createWorkflowSkillPinRoutes({ db: opts.db ?? fakeDbWithRows([]), @@ -150,6 +170,7 @@ function buildApp(opts: { skillIndex: fakeSkillIndex, skillsStore: opts.skillsStore ?? createInMemoryDefinitionSkillsStore(), authenticator: opts.authenticator ?? authenticateAsTenant1, + definitionFreezer: opts.definitionFreezer ?? recordingDefinitionFreezer(), }) as unknown as Hono; } @@ -237,6 +258,7 @@ test("pins a skill onto another definition in the same tenant and re-indexes its let writtenFiles: Record | undefined; let writtenMessage: string | undefined; const skillsStore = createInMemoryDefinitionSkillsStore(); + const freezer = recordingDefinitionFreezer(); const app = buildApp({ db: fakeDbWithRows([ { @@ -254,12 +276,17 @@ test("pins a skill onto another definition in the same tenant and re-indexes its }, }), skillsStore, + definitionFreezer: freezer, }); const response = await postPin(app, { definitionId: TARGET_DEFINITION_ID, skillName: "research", }); expect(response.status).toBe(200); + // The pin's rewrite re-freezes the projection so the next launch + // advertises the pinned skill (CL-6447). + expect(freezer.refreezes).toHaveLength(1); + expect(freezer.refreezes[0]?.definitionId).toBe(TARGET_DEFINITION_ID); expect(Object.keys(writtenFiles ?? {})).toEqual(SOURCE_TREE_PATHS); expect(writtenMessage).toBe("Pin research skill to research-buddy"); expect(await skillsStore.getSkills("ast_1")).toEqual(["research"]); diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 5ad6943e9..f82ed2d61 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -414,7 +414,7 @@ describe("POST /workbenches", () => { // whose approval never completed) — it answers a named 4xx with // consumer-language guidance, and still compensates the orphaned // tenant/settings exactly as every other agent-mint failure does. - test("a definition with no stored launch body answers 409 with re-deploy guidance, not 500, and still compensates", async () => { + test("a definition with no stored launch body answers 409 with recovery guidance, not 500, and still compensates", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -436,7 +436,7 @@ describe("POST /workbenches", () => { error: { code: string; message: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toMatch(/re-deploy it/); + expect(errorBody.error.message).toMatch(/save its instructions/); const tenancy = deps.tenancy as ReturnType< typeof createInMemoryWorkbenchTenancyStore @@ -1028,7 +1028,7 @@ describe("POST /workbenches/:id/invite", () => { error: { code: string; message: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toMatch(/re-deploy it/); + expect(errorBody.error.message).toMatch(/save its instructions/); }); }); diff --git a/packages/folded-runs/test/definition.test.ts b/packages/folded-runs/test/definition.test.ts index 9108a7304..ac86fa08e 100644 --- a/packages/folded-runs/test/definition.test.ts +++ b/packages/folded-runs/test/definition.test.ts @@ -244,10 +244,10 @@ describe("resolveNewestProjectedDefinition", () => { ).rejects.toThrow(DefinitionProjectionMissingError); }); - test("raises with consumer-language re-deploy guidance when there are no candidates at all", async () => { + test("raises with consumer-language recovery guidance when there are no candidates at all", async () => { await expect( resolveNewestProjectedDefinition({} as never, []), - ).rejects.toThrow(/re-deploy it/); + ).rejects.toThrow(/save its instructions/); }); }); diff --git a/packages/workflow-freeze/src/index.test.ts b/packages/workflow-freeze/src/index.test.ts new file mode 100644 index 000000000..778db907d --- /dev/null +++ b/packages/workflow-freeze/src/index.test.ts @@ -0,0 +1,99 @@ +// Tests for the pure half of the freeze: the reify-hash-walk sequence +// over a serialized hub-authored definition. The DB half (ensure + +// stamp, and the in-place re-freeze) is covered by +// `../test/freeze.drizzle.test.ts` against real Postgres. +import { expect, test } from "bun:test"; +import { defineAgent } from "@intx/agent"; +import { defineWorkflow, step } from "@intx/workflow"; +import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; +import { type } from "arktype"; + +import { projectAndWalkInertDefinition } from "./index"; + +const STEP_ID = "agent"; + +function serializedAgentDefinition(input?: { + systemPrompt?: string; + model?: string; +}): string { + const agent = defineAgent({ + id: STEP_ID, + description: "A test agent", + systemPrompt: input?.systemPrompt ?? "You are a careful test agent.", + tools: [], + capabilities: [], + inference: { + sources: + input?.model !== undefined + ? [{ provider: "catalog", model: input.model }] + : [], + }, + }); + const definition = defineWorkflow({ + id: "wf_agent_freeze_test", + trigger: { type: "mail", to: "freeze-test@example.test" }, + steps: { + [STEP_ID]: step({ agent, timeout: 60_000, triggers: "unbounded" }), + }, + }); + return JSON.stringify(definition); +} + +test("the frozen hash is computed over the inert projection, not the raw JSON", async () => { + const workflowJson = serializedAgentDefinition({ model: "claude-test" }); + const frozen = await projectAndWalkInertDefinition(workflowJson); + expect(frozen.wireHash).toBe( + await computeWireDefinitionHash(frozen.projection), + ); + // The raw serialized definition is a different preimage (the projector + // flattens the inference chain), so hashing it would freeze a hash + // that addresses content no launch-time reader can recover. + expect(frozen.wireHash).not.toBe( + await computeWireDefinitionHash(JSON.parse(workflowJson)), + ); +}); + +test("the projection carries the launch body the folded reader needs", async () => { + const frozen = await projectAndWalkInertDefinition( + serializedAgentDefinition({ + systemPrompt: "Answer briefly.", + model: "claude-test", + }), + ); + const projected = type({ + kind: "'step'", + agent: { + systemPrompt: "string", + modelSources: type({ provider: "string", model: "string" }).array(), + }, + }).assert(frozen.projection.steps[STEP_ID]); + expect(projected.agent.systemPrompt).toBe("Answer briefly."); + expect(projected.agent.modelSources).toEqual([ + { provider: "catalog", model: "claude-test" }, + ]); +}); + +test("the grant snapshot preserves per-step grouping and the grants flatten to their sorted union", async () => { + const frozen = await projectAndWalkInertDefinition( + serializedAgentDefinition(), + ); + expect(frozen.grantSnapshot.perStep.map((s) => s.stepId)).toEqual([STEP_ID]); + const union = [ + ...new Set(frozen.grantSnapshot.perStep.flatMap((s) => s.grants)), + ].sort(); + expect([...frozen.grants]).toEqual(union); + expect(frozen.grantSnapshot.grantRequirements).toEqual([]); +}); + +test("the mail trigger's authority lands in the walked grant set", async () => { + const frozen = await projectAndWalkInertDefinition( + serializedAgentDefinition(), + ); + expect(frozen.grants.some((grant) => grant.startsWith("mail."))).toBe(true); +}); + +test("a serialization missing its load-bearing top level fails loud", async () => { + await expect( + projectAndWalkInertDefinition(JSON.stringify({ id: "wf_broken" })), + ).rejects.toThrow("malformed"); +}); diff --git a/packages/workflow-freeze/test/freeze.drizzle.test.ts b/packages/workflow-freeze/test/freeze.drizzle.test.ts new file mode 100644 index 000000000..f13dbd9d7 --- /dev/null +++ b/packages/workflow-freeze/test/freeze.drizzle.test.ts @@ -0,0 +1,180 @@ +// DB-gated coverage for the freeze's persistence half: a create-path +// freeze leaves no half-frozen row (CL-6447's disease — a definition +// whose version row has NULL `wire_projection` is permanently +// unlaunchable), and the in-place re-freeze both follows an edit and +// heals a legacy row frozen before the projection was recorded. +// Runs against its own scratch database, never the developer's. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import postgres from "postgres"; +import { eq } from "drizzle-orm"; + +import { + createDB, + loadFrozenGrantSnapshot, + loadFrozenWireProjection, + type DB, +} from "@intx/db"; +import { asset, tenant, principal, workflowDefinition } from "@intx/db/schema"; +import { ensureWorkflowDefinitionForAsset } from "@intx/hub-sessions"; +import { generateId } from "@intx/hub-common"; + +import { defineAgent } from "@intx/agent"; +import { defineWorkflow, step } from "@intx/workflow"; + +import { setupDatabase, dbTargetFromUrl } from "../../../scripts/db-setup"; +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { + freezeInertWorkflowDefinition, + refreezeWorkflowDefinitionProjection, +} from "../src/index"; + +function scratchUrlFor(e2eUrl: string): string { + const url = new URL(e2eUrl); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_workflow_freeze_test`; + return url.toString(); +} + +function agentWorkflowJson(systemPrompt: string): string { + const agent = defineAgent({ + id: "agent", + description: "", + systemPrompt, + tools: [], + capabilities: [], + inference: { sources: [{ provider: "catalog", model: "m-test" }] }, + }); + return JSON.stringify( + defineWorkflow({ + id: "wf_agent_freeze_db_test", + trigger: { type: "mail", to: "freeze-db-test@example.test" }, + steps: { + agent: step({ agent, timeout: 60_000, triggers: "unbounded" }), + }, + }), + ); +} + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = databaseUrl === undefined ? describe.skip : describe; + +describeIfDb("freezeInertWorkflowDefinition against Postgres", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); + + let db: DB["db"]; + let close: () => Promise; + const tenantId = generateId("tenant"); + const principalId = generateId("principal"); + + 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}"`); + } finally { + await maintenance.end(); + } + await setupDatabase(scratchUrl); + + const handle = createDB(dbTargetFromUrl(scratchUrl)); + db = handle.db; + close = handle.close; + + await db.insert(tenant).values({ + id: tenantId, + name: "Freeze Test", + slug: `freeze-test-${tenantId.slice(-8)}`, + domain: `freeze-test-${tenantId.slice(-8)}.example`, + }); + await db.insert(principal).values({ + id: principalId, + tenantId, + kind: "user", + refId: `usr_${tenantId.slice(-8)}`, + status: "active", + }); + }, 60_000); + + afterAll(async () => { + await close?.(); + }); + + async function insertAsset(name: string): Promise { + const assetId = generateId("asset"); + await db.insert(asset).values({ + id: assetId, + tenantId, + kind: "workflow", + name, + displayName: name, + creatorPrincipalId: principalId, + }); + return assetId; + } + + test("a create-path freeze leaves no half-frozen row", async () => { + const assetId = await insertAsset("freeze-create"); + const { definitionId, wireHash } = await freezeInertWorkflowDefinition(db, { + assetId, + workflowJson: agentWorkflowJson("Be helpful."), + }); + + const projection = await loadFrozenWireProjection(db, definitionId); + expect(projection).not.toBeNull(); + const snapshot = await loadFrozenGrantSnapshot(db, definitionId); + expect(snapshot).not.toBeNull(); + + const row = await db.query.workflowDefinition.findFirst({ + where: eq(workflowDefinition.id, definitionId), + }); + expect(row?.wireHash).toBe(wireHash); + }); + + test("a re-freeze follows an edit in place — same definition, new content", async () => { + const assetId = await insertAsset("freeze-edit"); + const created = await freezeInertWorkflowDefinition(db, { + assetId, + workflowJson: agentWorkflowJson("First instructions."), + }); + + const refrozen = await refreezeWorkflowDefinitionProjection(db, { + definitionId: created.definitionId, + workflowJson: agentWorkflowJson("Edited instructions."), + }); + expect(refrozen.wireHash).not.toBe(created.wireHash); + + const row = await db.query.workflowDefinition.findFirst({ + where: eq(workflowDefinition.id, created.definitionId), + }); + expect(row?.wireHash).toBe(refrozen.wireHash); + + const projection = await loadFrozenWireProjection(db, created.definitionId); + expect(JSON.stringify(projection)).toContain("Edited instructions."); + }); + + test("a re-freeze heals a legacy row frozen before the projection was recorded", async () => { + const assetId = await insertAsset("freeze-legacy"); + const workflowJson = agentWorkflowJson("Legacy instructions."); + // The pre-fix write: a bare ensure, no stamp — the permanently + // unlaunchable state CL-6447 reproduced. + const { definitionId } = await ensureWorkflowDefinitionForAsset(db, { + assetId, + wireHash: "legacy-raw-json-hash", + }); + expect(await loadFrozenWireProjection(db, definitionId)).toBeNull(); + + await refreezeWorkflowDefinitionProjection(db, { + definitionId, + workflowJson, + }); + expect(await loadFrozenWireProjection(db, definitionId)).not.toBeNull(); + expect(await loadFrozenGrantSnapshot(db, definitionId)).not.toBeNull(); + }); +}); From de7f2ab4a63e1865f8f77186abdca5c3420ae45b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 17:49:39 -0700 Subject: [PATCH 2/2] CL-6447: freeze hub-authored inert definitions via @corbits/workflow-freeze A definition the hub authors itself is already inert JSON, so no sidecar probe exists to ride. @corbits/workflow-freeze is the probe's hub-local counterpart: project, walk, and stamp wire_projection / approved_wire_hash / grant_snapshot so those rows are launchable. --- apps/hub/package.json | 1 + apps/hub/src/index.ts | 32 ++- bun.lock | 38 +++ packages/agent-directory/package.json | 1 + .../agent-directory/src/agent-workflow.ts | 22 +- packages/agent-directory/src/routes.ts | 42 +++- .../src/workflow-capability-routes.ts | 9 + .../src/workflow-create-routes.ts | 2 + .../src/workflow-skill-pin-routes.ts | 9 + packages/folded-runs/src/definition.ts | 4 +- packages/workflow-freeze/package.json | 31 +++ packages/workflow-freeze/src/index.ts | 231 ++++++++++++++++++ packages/workflow-freeze/tsconfig.json | 7 + 13 files changed, 398 insertions(+), 31 deletions(-) create mode 100644 packages/workflow-freeze/package.json create mode 100644 packages/workflow-freeze/src/index.ts create mode 100644 packages/workflow-freeze/tsconfig.json diff --git a/apps/hub/package.json b/apps/hub/package.json index bf3f76218..6b133e612 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -47,6 +47,7 @@ "@corbits/url-path": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workflow-catalog": "workspace:*", + "@corbits/workflow-freeze": "workspace:*", "@corbits/workflow-source": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 5c2a579d8..90597f7c3 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -166,6 +166,10 @@ import { import { createConnectGithubRoutes } from "@corbits/workflow-catalog/connect-github-routes"; import { createTemplateBlockRoutes } from "@corbits/workflow-catalog/template-block-routes"; import { renderWorkflowSourceTree } from "@corbits/workflow-source"; +import { + createDefinitionFreezer, + freezeInertWorkflowDefinition, +} from "@corbits/workflow-freeze"; import { createDrizzleDraftStore, createDrizzleRoutineStore, @@ -222,7 +226,6 @@ import { createWorkflowAllocationService, createWorkflowDispatchService, DEFAULT_ASSET_REF, - ensureWorkflowDefinitionForAsset, type AgentRepoStore, type EventCollectorRegistry, type WsHandle, @@ -231,7 +234,6 @@ import { createLaunchCaches } from "./launch-caches"; import { wireMailRedelivery } from "./mail-redelivery"; import { getLogger, setup } from "@intx/log"; import { hexEncode } from "@intx/types"; -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { createNeedsYouRoutes, createToolAllowanceRegistry, @@ -1682,11 +1684,13 @@ export async function createHub(config: HubConfig) { }, }; + const definitionFreezer = createDefinitionFreezer(db); app.route( `${TENANT_PREFIX}/agent-definitions`, createAgentDefinitionRoutes({ db, assetService, + definitionFreezer, skillIndex: skills.skillIndex, skillsStore: definitionSkillsStore, history: createDefinitionAssetHistory({ @@ -1716,6 +1720,7 @@ export async function createHub(config: HubConfig) { createWorkflowAgentCreateRoutes({ db, assetService, + definitionFreezer, skillIndex: skills.skillIndex, skillsStore: definitionSkillsStore, capabilityInventory, @@ -1737,6 +1742,7 @@ export async function createHub(config: HubConfig) { createWorkflowCapabilityRoutes({ db, assetService, + definitionFreezer, skillIndex: skills.skillIndex, skillsStore: definitionSkillsStore, capabilityInventory, @@ -1753,6 +1759,7 @@ export async function createHub(config: HubConfig) { createWorkflowSkillPinRoutes({ db, assetService, + definitionFreezer, skillIndex: skills.skillIndex, skillsStore: definitionSkillsStore, authenticator: createWorkflowRunAuthenticator({ db }), @@ -2058,7 +2065,7 @@ export async function createHub(config: HubConfig) { // `deployBlockWorkflow` port lands here — the same source-form // materialization `deployAgentDefinition` below runs for a // participant agent (asset + `@corbits/workflow-source` tree + - // `ensureWorkflowDefinitionForAsset`), applied to a template's + // `freezeInertWorkflowDefinition`), applied to a template's // referenced block definition (`code-review` today). app.route( `${TENANT_PREFIX}/template-blocks`, @@ -2128,12 +2135,12 @@ export async function createHub(config: HubConfig) { }, }); - const wireHash = await computeWireDefinitionHash( - JSON.parse(workflowJson), - ); - const { definitionId } = await ensureWorkflowDefinitionForAsset(db, { + // Freeze, not a bare ensure: without the frozen wire projection + // the block's definition can never launch (CL-6439, the same + // disease CL-6447 fixed for the Agents page create path). + const { definitionId } = await freezeInertWorkflowDefinition(db, { assetId, - wireHash, + workflowJson, }); return { id: definitionId, created: true }; }, @@ -2911,7 +2918,7 @@ export async function createHub(config: HubConfig) { * Wraps the same sequence `@corbits/agent-directory`'s `POST /` * handler runs (`buildAgentDefinitionWorkflow` → `reindexPinnedSkills` * when skills are present → `createAsset` + `populateAsset` → - * `ensureWorkflowDefinitionForAsset`), reusing the exact `db`, + * `freezeInertWorkflowDefinition`), reusing the exact `db`, * `assetService`, and `skills.skillIndex` already in scope — never a * second instance of any of them. The one addition beyond that route's * own input is `toolPackagePins`, which the REST boundary deliberately @@ -2994,10 +3001,11 @@ export async function createHub(config: HubConfig) { }); await definitionSkillsStore.setSkills(created.id, input.skills); - const wireHash = await computeWireDefinitionHash(JSON.parse(workflowJson)); - const { definitionId } = await ensureWorkflowDefinitionForAsset(db, { + // Freeze, not a bare ensure — see `createAgentDefinitionCore`'s own + // freeze call for the why (CL-6447). + const { definitionId } = await freezeInertWorkflowDefinition(db, { assetId: created.id, - wireHash, + workflowJson, }); return { definitionId }; } diff --git a/bun.lock b/bun.lock index 79353ea47..1e49173f8 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "@corbits/url-path": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workflow-catalog": "workspace:*", + "@corbits/workflow-freeze": "workspace:*", "@corbits/workflow-source": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", @@ -200,6 +201,7 @@ "@corbits/chat": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/workflow-catalog": "workspace:*", + "@corbits/workflow-freeze": "workspace:*", "@corbits/workflow-source": "workspace:*", "@intx/agent": "0.3.0", "@intx/db": "workspace:*", @@ -1403,6 +1405,26 @@ "typescript": "catalog:", }, }, + "packages/workflow-freeze": { + "name": "@corbits/workflow-freeze", + "version": "0.0.1", + "dependencies": { + "@intx/agent": "0.3.0", + "@intx/db": "workspace:*", + "@intx/hub-sessions": "workspace:*", + "@intx/types": "0.3.0", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + }, + "devDependencies": { + "@intx/hub-common": "0.3.0", + "@types/bun": "catalog:", + "postgres": "catalog:", + "typescript": "catalog:", + }, + }, "packages/workflow-host-actions": { "name": "@corbits/workflow-host-actions", "version": "0.0.1", @@ -2050,6 +2072,8 @@ "@corbits/workflow-catalog": ["@corbits/workflow-catalog@workspace:packages/workflow-catalog"], + "@corbits/workflow-freeze": ["@corbits/workflow-freeze@workspace:packages/workflow-freeze"], + "@corbits/workflow-host-actions": ["@corbits/workflow-host-actions@workspace:packages/workflow-host-actions"], "@corbits/workflow-source": ["@corbits/workflow-source@workspace:packages/workflow-source"], @@ -3316,6 +3340,18 @@ "@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/bench-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + + "@corbits/tasks-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + "@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=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3342,6 +3378,8 @@ "@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/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#a3932c8", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-a3932c8", "sha512-Rvkeok319tIHzy95QO0BNbjS6j9CcfpKTxEdaf86etGxGbpigqZDX7NleF4tmcaPSEipuJ2G4tUDezy7QsjuJg=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/agent-directory/package.json b/packages/agent-directory/package.json index a209ea0f2..d3009da1e 100644 --- a/packages/agent-directory/package.json +++ b/packages/agent-directory/package.json @@ -17,6 +17,7 @@ "@corbits/chat": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/workflow-catalog": "workspace:*", + "@corbits/workflow-freeze": "workspace:*", "@corbits/workflow-source": "workspace:*", "@intx/agent": "0.3.0", "@intx/db": "workspace:*", diff --git a/packages/agent-directory/src/agent-workflow.ts b/packages/agent-directory/src/agent-workflow.ts index 3205d1334..d7aebb967 100644 --- a/packages/agent-directory/src/agent-workflow.ts +++ b/packages/agent-directory/src/agent-workflow.ts @@ -19,13 +19,9 @@ import type { CredentialBinding } from "@intx/types"; import { and, eq } from "drizzle-orm"; import type { DB } from "@intx/db"; import { asset, workflowDefinition } from "@intx/db/schema"; -import { - AssetServiceError, - DEFAULT_ASSET_REF, - ensureWorkflowDefinitionForAsset, -} from "@intx/hub-sessions"; +import { AssetServiceError, DEFAULT_ASSET_REF } from "@intx/hub-sessions"; import type { AssetService } from "@intx/hub-sessions"; -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; +import type { DefinitionFreezer } from "@corbits/workflow-freeze"; import { withAvailableSkills, type PinnedSkillIndexEntry, @@ -390,6 +386,10 @@ export function serializeAgentDefinitionWorkflow( export type CreateAgentDefinitionCoreDeps = { readonly db: DB["db"]; readonly assetService: AssetService; + /** Freezes the definition's wire projection at create; the + * composition root binds `@corbits/workflow-freeze`'s + * `createDefinitionFreezer` to its own `db`. */ + readonly definitionFreezer: Pick; readonly skillIndex: { resolve( tenantId: string, @@ -561,10 +561,14 @@ export async function createAgentDefinitionCore( }); await deps.skillsStore.setSkills(assetId, input.skills); - const wireHash = await computeWireDefinitionHash(JSON.parse(workflowJson)); - const { definitionId } = await ensureWorkflowDefinitionForAsset(deps.db, { + // Freeze, not a bare ensure: `ensureWorkflowDefinitionForAsset` alone + // leaves the version row's `wire_projection` NULL, and a definition + // without a frozen projection can never launch (CL-6447's 409 + // `not_launchable`). The freeze projects, walks, and stamps in one + // transaction — the same machinery the sidecar probe deploy rides. + const { definitionId } = await deps.definitionFreezer.freeze({ assetId, - wireHash, + workflowJson, }); const row = await deps.db.query.workflowDefinition.findFirst({ diff --git a/packages/agent-directory/src/routes.ts b/packages/agent-directory/src/routes.ts index 2f5262cb6..2203693a6 100644 --- a/packages/agent-directory/src/routes.ts +++ b/packages/agent-directory/src/routes.ts @@ -30,6 +30,7 @@ import { type PinnedSkillIndexEntry, } from "@corbits/skills"; import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; +import type { DefinitionFreezer } from "@corbits/workflow-freeze"; import { createAgentDefinitionCore, @@ -90,6 +91,11 @@ export type CreateAgentDefinitionRoutesDeps = { history: DefinitionAssetHistory; capabilityInventory: CapabilityInventoryProvider; requireGrant: RequireGrant; + /** Freezes/re-freezes the definition's wire projection on every + * content write; the composition root binds + * `@corbits/workflow-freeze`'s `createDefinitionFreezer` to its own + * `db`. */ + definitionFreezer: DefinitionFreezer; tenantDefaultModel?: CreateAgentDefinitionCoreDeps["tenantDefaultModel"]; }; @@ -134,6 +140,7 @@ export function createAgentDefinitionRoutes({ history, capabilityInventory, requireGrant, + definitionFreezer, tenantDefaultModel, }: CreateAgentDefinitionRoutesDeps): Hono { const app = new Hono(); @@ -203,6 +210,7 @@ export function createAgentDefinitionRoutes({ assetService, skillIndex, skillsStore, + definitionFreezer, ...(tenantDefaultModel !== undefined ? { tenantDefaultModel } : {}), }, coreInput, @@ -456,6 +464,10 @@ export function createAgentDefinitionRoutes({ message: `Restore agent ${row.name} to ${body.commitSha.slice(0, 8)}`, }, }); + await definitionFreezer.refreeze({ + definitionId: row.id, + workflowJson: restoredWorkflowJson, + }); const capabilities = readAgentCapabilities(restoredWorkflowJson); const skills = await skillsStore.getSkills(row.assetId); @@ -557,6 +569,10 @@ export function createAgentDefinitionRoutes({ message, }, }); + await definitionFreezer.refreeze({ + definitionId: row.id, + workflowJson: nextWorkflowJson, + }); if (nextSkills !== null) { await skillsStore.setSkills(row.assetId, nextSkills); } @@ -608,6 +624,10 @@ export function createAgentDefinitionRoutes({ // safely if they fail after this succeeds (see the catch below) — // the reverse order would leave a renamed row pointing at // instructions that were never actually written. + const nextWorkflowJson = withAgentSystemPrompt( + workflowJson, + body.systemPrompt, + ); await assetService.populateAsset({ assetId: row.assetId, ref: DEFAULT_ASSET_REF, @@ -615,14 +635,15 @@ export function createAgentDefinitionRoutes({ tree: { files: agentDefinitionSourceTree({ handle: row.name, - workflowJson: withAgentSystemPrompt( - workflowJson, - body.systemPrompt, - ), + workflowJson: nextWorkflowJson, }), message: `Update agent instructions for ${row.name}`, }, }); + await definitionFreezer.refreeze({ + definitionId: row.id, + workflowJson: nextWorkflowJson, + }); const now = new Date(); try { @@ -814,6 +835,10 @@ export function createAgentDefinitionRoutes({ row.assetId, ); + const nextWorkflowJson = reindexPinnedSkills( + workflowJson, + await skillIndex.resolve(tenant.id, principal.id, body.skills), + ); await assetService.populateAsset({ assetId: row.assetId, ref: DEFAULT_ASSET_REF, @@ -821,14 +846,15 @@ export function createAgentDefinitionRoutes({ tree: { files: agentDefinitionSourceTree({ handle: row.name, - workflowJson: reindexPinnedSkills( - workflowJson, - await skillIndex.resolve(tenant.id, principal.id, body.skills), - ), + workflowJson: nextWorkflowJson, }), message: `Update agent skills for ${row.name}`, }, }); + await definitionFreezer.refreeze({ + definitionId: row.id, + workflowJson: nextWorkflowJson, + }); await skillsStore.setSkills(row.assetId, body.skills); return c.json({ skills: body.skills }); diff --git a/packages/agent-directory/src/workflow-capability-routes.ts b/packages/agent-directory/src/workflow-capability-routes.ts index 9f46b77d5..3d9c29329 100644 --- a/packages/agent-directory/src/workflow-capability-routes.ts +++ b/packages/agent-directory/src/workflow-capability-routes.ts @@ -48,6 +48,7 @@ import { DEFAULT_ASSET_REF } from "@intx/hub-sessions"; import type { AssetService } from "@intx/hub-sessions"; import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; +import type { DefinitionFreezer } from "@corbits/workflow-freeze"; import { reindexPinnedSkills, @@ -125,6 +126,10 @@ export type CreateWorkflowCapabilityRoutesDeps = { skillsStore: DefinitionSkillsStore; capabilityInventory: CapabilityInventoryProvider; authenticator: WorkflowRunAuthenticator; + /** Re-freezes the definition's wire projection after the rewrite; the + * composition root binds `@corbits/workflow-freeze`'s + * `createDefinitionFreezer` to its own `db`. */ + definitionFreezer: Pick; }; export function createWorkflowCapabilityRoutes( @@ -275,6 +280,10 @@ export function createWorkflowCapabilityRoutes( message, }, }); + await deps.definitionFreezer.refreeze({ + definitionId: row.id, + workflowJson: nextWorkflowJson, + }); if (nextSkills !== null) { await deps.skillsStore.setSkills(row.assetId, nextSkills); } diff --git a/packages/agent-directory/src/workflow-create-routes.ts b/packages/agent-directory/src/workflow-create-routes.ts index eebc97f97..b81c3f724 100644 --- a/packages/agent-directory/src/workflow-create-routes.ts +++ b/packages/agent-directory/src/workflow-create-routes.ts @@ -108,6 +108,7 @@ export type CreateWorkflowAgentCreateRoutesDeps = { readonly skillsStore: CreateAgentDefinitionCoreDeps["skillsStore"]; readonly capabilityInventory: CapabilityInventoryProvider; readonly authenticator: WorkflowRunAuthenticator; + readonly definitionFreezer: CreateAgentDefinitionCoreDeps["definitionFreezer"]; readonly tenantDefaultModel?: CreateAgentDefinitionCoreDeps["tenantDefaultModel"]; }; @@ -219,6 +220,7 @@ export function createWorkflowAgentCreateRoutes( assetService: deps.assetService, skillIndex: deps.skillIndex, skillsStore: deps.skillsStore, + definitionFreezer: deps.definitionFreezer, ...(deps.tenantDefaultModel !== undefined ? { tenantDefaultModel: deps.tenantDefaultModel } : {}), diff --git a/packages/agent-directory/src/workflow-skill-pin-routes.ts b/packages/agent-directory/src/workflow-skill-pin-routes.ts index 5dd36d288..62bcb4170 100644 --- a/packages/agent-directory/src/workflow-skill-pin-routes.ts +++ b/packages/agent-directory/src/workflow-skill-pin-routes.ts @@ -32,6 +32,7 @@ import { DEFAULT_ASSET_REF } from "@intx/hub-sessions"; import type { AssetService } from "@intx/hub-sessions"; import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; +import type { DefinitionFreezer } from "@corbits/workflow-freeze"; import { reindexPinnedSkills } from "./agent-workflow"; import { @@ -94,6 +95,10 @@ export type CreateWorkflowSkillPinRoutesDeps = { skillIndex: PinnedSkillIndexResolver; skillsStore: DefinitionSkillsStore; authenticator: WorkflowRunAuthenticator; + /** Re-freezes the definition's wire projection after the rewrite; the + * composition root binds `@corbits/workflow-freeze`'s + * `createDefinitionFreezer` to its own `db`. */ + definitionFreezer: Pick; }; export function createWorkflowSkillPinRoutes( @@ -181,6 +186,10 @@ export function createWorkflowSkillPinRoutes( message: `Pin ${body.skillName} skill to ${row.name}`, }, }); + await deps.definitionFreezer.refreeze({ + definitionId: row.id, + workflowJson: nextWorkflowJson, + }); await deps.skillsStore.setSkills(row.assetId, nextSkills); return c.json({ skills: nextSkills }); diff --git a/packages/folded-runs/src/definition.ts b/packages/folded-runs/src/definition.ts index 53167ac04..f850492fa 100644 --- a/packages/folded-runs/src/definition.ts +++ b/packages/folded-runs/src/definition.ts @@ -36,8 +36,8 @@ export class DefinitionProjectionMissingError extends Error { readonly guidance: string; constructor(definitionName: string) { const guidance = - "This agent was deployed before the hub started recording its " + - "launch body — re-deploy it (run seed / republish) and try again."; + "This agent isn't finished setting up. Open it in Agents, save " + + "its instructions, and try again — or recreate it."; super( `No stored launch body for definition "${definitionName}" (${guidance})`, ); diff --git a/packages/workflow-freeze/package.json b/packages/workflow-freeze/package.json new file mode 100644 index 000000000..920c110c8 --- /dev/null +++ b/packages/workflow-freeze/package.json @@ -0,0 +1,31 @@ +{ + "name": "@corbits/workflow-freeze", + "private": true, + "description": "Hub-side projection freeze for hub-authored inert workflow definitions — the declarative counterpart of the sidecar probe-and-freeze deploy", + "version": "0.0.1", + "license": "LGPL-2.1-or-later", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@intx/agent": "0.3.0", + "@intx/db": "workspace:*", + "@intx/hub-sessions": "workspace:*", + "@intx/types": "0.3.0", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:" + }, + "devDependencies": { + "@intx/hub-common": "0.3.0", + "@types/bun": "catalog:", + "postgres": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/workflow-freeze/src/index.ts b/packages/workflow-freeze/src/index.ts new file mode 100644 index 000000000..31403e01a --- /dev/null +++ b/packages/workflow-freeze/src/index.ts @@ -0,0 +1,231 @@ +// Hub-side projection freeze for hub-authored inert workflow definitions. +// +// A code-sourced deploy freezes its definition through the sidecar +// probe: the sidecar projects the live definition to inert plain data, +// walks its capability surface, and the hub gates and freezes the +// result onto the definition's version row +// (`@intx/hub-sessions`' workflow-probe-gate). A definition the hub +// authors itself — an agent from the Agents page, a template block — +// is already inert JSON, so no sidecar round-trip exists to ride, and +// the bare `ensureWorkflowDefinitionForAsset` those paths used to call +// left `approved_wire_hash`/`grant_snapshot`/`wire_projection` NULL: +// permanently unlaunchable rows (CL-6447, CL-6439). +// +// This module is the probe's hub-local counterpart, built from the +// same platform primitives: `projectLiveToInert` reifies the parsed +// definition exactly as the probe child does, `walkCapabilities` walks +// the same grant surface against the same built-in director registry a +// closure without `interchange.directors` gets, and +// `createDbFrozenApprovalWriter` persists the same all-or-nothing +// freeze. The approval policy is the self-approve analogue the probe +// gate documents for live-authored definitions: the hub authored the +// content, so the grant surface the walk reports IS the approved set, +// and the hash is computed and frozen in the same process — there is +// no shipped hash to tamper-check. + +import { createDefaultDirectorRegistry } from "@intx/agent"; +import type { DBExecutor } from "@intx/db"; +import { workflowDefinition, workflowDefinitionVersion } from "@intx/db/schema"; +import { createDbFrozenApprovalWriter } from "@intx/hub-sessions"; +import type { GrantWalkSnapshot } from "@intx/types"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; +import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; +import { projectLiveToInert } from "@intx/workflow"; +import type { WorkflowDefinition } from "@intx/workflow"; +import { walkCapabilities } from "@intx/workflow-deploy"; +import type { CapabilityWalkResult } from "@intx/workflow-deploy"; +import { and, eq } from "drizzle-orm"; +import { type } from "arktype"; + +// The version `ensureWorkflowDefinitionForAsset` projects for a fresh +// definition — the row every freeze targets. Hand-coupled to +// `@intx/hub-sessions`' own `FROZEN_VERSION` the same way the probe +// gate documents. +const FROZEN_VERSION = "1"; + +// The load-bearing top-level fields a serialized definition must carry +// for the projector and the walk to operate. The producers are this +// hub's own builders, so this is a sanity gate that fails loud on a +// malformed serialization, not a full re-validation of a shape the +// projector already fails closed on step by step. +const SerializedWorkflowDefinition = type({ + id: "string", + stepOrder: "string[]", + steps: "object", +}); + +/** Everything a freeze persists, computed without touching the DB. */ +export type InertDefinitionFreeze = { + readonly projection: WorkflowProjectionDefinition; + readonly wireHash: string; + readonly grants: readonly string[]; + readonly grantSnapshot: GrantWalkSnapshot; +}; + +function collectDeploymentGrants(walk: CapabilityWalkResult): string[] { + const grants = new Set(); + for (const declarations of walk.perStep.values()) { + for (const grant of declarations.grants) { + grants.add(grant); + } + } + return [...grants].sort(); +} + +function buildGrantWalkSnapshot( + walk: CapabilityWalkResult, + grantRequirements: WorkflowDefinition["grantRequirements"], +): GrantWalkSnapshot { + const perStep = [...walk.perStep].map(([stepId, declarations]) => ({ + stepId, + grants: [...declarations.grants], + grantEffects: Object.fromEntries(declarations.grantEffects), + })); + return { + perStep, + grantRequirements: [...(grantRequirements ?? [])], + }; +} + +/** + * Project a serialized hub-authored definition to its inert wire form + * and walk its capability surface — the same reify-hash-walk sequence + * the sidecar probe child runs, executed hub-locally over a definition + * that carries no author code. Fails loud on an unresolvable director: + * the runtime does not re-gate `director:`, so a freeze whose grant + * set silently omitted one would approve an incomplete manifest. + */ +export async function projectAndWalkInertDefinition( + workflowJson: string, +): Promise { + const parsed = SerializedWorkflowDefinition(JSON.parse(workflowJson)); + if (parsed instanceof type.errors) { + throw new Error( + `workflow-freeze: serialized definition is malformed: ${parsed.summary}`, + ); + } + // The sanity gate above proves the load-bearing top level; the + // projector and the walk fail closed on any step whose shape lies. + // The producer is the hub's own builder, never external input. + const definition = parsed as unknown as WorkflowDefinition; + + const projection = WorkflowProjectionDefinition.assert( + projectLiveToInert(definition), + ); + const wireHash = await computeWireDefinitionHash(projection); + + const walk = walkCapabilities(definition, createDefaultDirectorRegistry()); + if (walk.unresolvedDirectors.length > 0) { + throw new Error( + `workflow-freeze: definition ${definition.id} names unresolvable ` + + `director(s): ${walk.unresolvedDirectors.join(", ")}`, + ); + } + + return { + projection, + wireHash, + grants: collectDeploymentGrants(walk), + grantSnapshot: buildGrantWalkSnapshot(walk, definition.grantRequirements), + }; +} + +/** + * Freeze a hub-authored inert definition onto a first-class + * `workflow_definition` keyed by `(assetId, wireHash)` — the create + * path. Persists through `createDbFrozenApprovalWriter`, so the ensure + * and the stamp are one transaction and the row can never exist in the + * half-frozen state a bare `ensureWorkflowDefinitionForAsset` leaves. + */ +export async function freezeInertWorkflowDefinition( + db: DBExecutor, + input: { readonly assetId: string; readonly workflowJson: string }, +): Promise<{ definitionId: string; wireHash: string }> { + const frozen = await projectAndWalkInertDefinition(input.workflowJson); + const persist = createDbFrozenApprovalWriter(db); + const { definitionId } = await persist({ + assetId: input.assetId, + approvedWireHash: frozen.wireHash, + approvedGrants: frozen.grants, + grantSnapshot: frozen.grantSnapshot, + projection: frozen.projection, + }); + return { definitionId, wireHash: frozen.wireHash }; +} + +/** + * The freeze surface a definition-authoring package consumes without + * carrying the DB executor itself: the composition root binds both + * halves to its one `db` via `createDefinitionFreezer`, and a unit test + * substitutes a recording stub instead of emulating drizzle's chains. + */ +export type DefinitionFreezer = { + freeze(input: { + readonly assetId: string; + readonly workflowJson: string; + }): Promise<{ definitionId: string; wireHash: string }>; + refreeze(input: { + readonly definitionId: string; + readonly workflowJson: string; + }): Promise<{ wireHash: string }>; +}; + +/** Bind both freeze halves to one executor. */ +export function createDefinitionFreezer(db: DBExecutor): DefinitionFreezer { + return { + freeze: (input) => freezeInertWorkflowDefinition(db, input), + refreeze: (input) => refreezeWorkflowDefinitionProjection(db, input), + }; +} + +/** + * Re-freeze an existing definition in place after its asset content + * changed — the mutable-edit counterpart of + * `freezeInertWorkflowDefinition`. Workbench treats a hand-authored + * agent as one definition whose content evolves (instructions edits, + * skill re-pins), so an edit updates the definition's own `wireHash` + * and restamps its frozen version row rather than minting a sibling + * definition per content hash. Also heals rows frozen before the + * projection was recorded: any save re-runs the full freeze. + */ +export async function refreezeWorkflowDefinitionProjection( + db: DBExecutor, + input: { readonly definitionId: string; readonly workflowJson: string }, +): Promise<{ wireHash: string }> { + const frozen = await projectAndWalkInertDefinition(input.workflowJson); + await db.transaction(async (tx) => { + const updated = await tx + .update(workflowDefinition) + .set({ wireHash: frozen.wireHash }) + .where(eq(workflowDefinition.id, input.definitionId)) + .returning({ id: workflowDefinition.id }); + if (updated.length !== 1) { + throw new Error( + `workflow-freeze: expected exactly one definition row for ` + + `${input.definitionId}, updated ${String(updated.length)}`, + ); + } + const stamped = await tx + .update(workflowDefinitionVersion) + .set({ + approvedWireHash: frozen.wireHash, + grantSnapshot: frozen.grantSnapshot, + wireProjection: frozen.projection, + }) + .where( + and( + eq(workflowDefinitionVersion.definitionId, input.definitionId), + eq(workflowDefinitionVersion.version, FROZEN_VERSION), + ), + ) + .returning({ id: workflowDefinitionVersion.id }); + if (stamped.length !== 1) { + throw new Error( + `workflow-freeze: expected exactly one ${FROZEN_VERSION} version ` + + `row for definition ${input.definitionId}, stamped ` + + `${String(stamped.length)}`, + ); + } + }); + return { wireHash: frozen.wireHash }; +} diff --git a/packages/workflow-freeze/tsconfig.json b/packages/workflow-freeze/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/workflow-freeze/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +}