diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 3dbafe6ba..96177a33f 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -2397,7 +2397,8 @@ select:disabled, .insights-grid > .insights-panel { border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .insights-grid > .insights-panel:last-child { diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index a4d1dc35c..6e5983d74 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -1,12 +1,9 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; + +import { spyOnReactUiToast } from "../../../test/react-ui-toast-mock"; import type { ContextMenuEntry } from "@corbits/context-menu"; -const toastMock = mock(() => undefined); -const actualReactUi = await import("@corbits/react-ui"); -mock.module("@corbits/react-ui", () => ({ - ...actualReactUi, - toast: toastMock, -})); +const toastMock = spyOnReactUiToast(); import { shellContextMenuFor } from "./items"; import type { ShellContextMenuActions } from "./items"; diff --git a/apps/web/test/canvas-column.test.tsx b/apps/web/test/canvas-column.test.tsx index 8632e929a..1a5f7a354 100644 --- a/apps/web/test/canvas-column.test.tsx +++ b/apps/web/test/canvas-column.test.tsx @@ -4,15 +4,12 @@ // Mention action already used (CL-6019). import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +import { spyOnReactUiToast } from "./react-ui-toast-mock"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -const toastMock = mock((_message: string) => undefined); -const actualReactUi = await import("@corbits/react-ui"); -mock.module("@corbits/react-ui", () => ({ - ...actualReactUi, - toast: toastMock, -})); +const toastMock = spyOnReactUiToast(); let ensureProfileDmResult: Promise< { kind: "ready"; workbenchId: string } | { kind: "error"; message: string } diff --git a/apps/web/test/login-routing.test.tsx b/apps/web/test/login-routing.test.tsx index 070295e2c..00cd284fe 100644 --- a/apps/web/test/login-routing.test.tsx +++ b/apps/web/test/login-routing.test.tsx @@ -42,7 +42,11 @@ const user: SessionUser = { * `LoginForm` submission end to end. */ let capturedHandleSignedIn: ((user: SessionUser) => void) | null = null; -function TestRoot({ initialSession }: { readonly initialSession: SessionState }) { +function TestRoot({ + initialSession, +}: { + readonly initialSession: SessionState; +}) { const [path, setPath] = useState(window.location.pathname); useEffect(() => { const onPopState = () => setPath(window.location.pathname); diff --git a/apps/web/test/react-ui-toast-mock.ts b/apps/web/test/react-ui-toast-mock.ts new file mode 100644 index 000000000..82a24694b --- /dev/null +++ b/apps/web/test/react-ui-toast-mock.ts @@ -0,0 +1,46 @@ +// Observing `@corbits/react-ui`'s `toast` from a test means `mock.module`, +// which rewrites the module registry for the whole process rather than for +// the calling file — and bun offers no way to take that back. A stub +// installed by one test file is therefore still installed when every later +// file loads, and `toast-single-system.test.tsx` renders the real toaster +// and asserts on the DOM: under a plain stub it observes nothing and fails +// for reasons that have nothing to do with toasts. +// +// So the spy DELEGATES rather than replaces. Callers get the call record +// they assert on, and any file that renders a real `` still sees +// real toasts, whichever order bun happens to load the suites in. + +import { mock } from "bun:test"; +import { toast as sonnerToast } from "sonner"; + +const actualReactUi = await import("@corbits/react-ui"); +const realToast = actualReactUi.toast; + +type ToastFn = typeof actualReactUi.toast; + +/** + * Installs a delegating spy over `toast` and returns it. Call once at module + * scope; `mockClear()` it between tests the way any other spy is cleared. + */ +export function spyOnReactUiToast(): ReturnType> { + const spy = mock(((...args: Parameters) => + realToast(...args)) as ToastFn); + // `toast` carries its own variants (`toast.error` and friends); the spy + // stands in for the whole callable, so it must carry them too. + Object.assign(spy, realToast); + mock.module("@corbits/react-ui", () => ({ + ...actualReactUi, + toast: spy, + })); + return spy; +} + +/** + * Empties sonner's toast store. The store is global and outlives any one + * `` mount or test file, so a suite that counts rendered toasts + * starts here. `@corbits/react-ui`'s `toast` is a raise-only wrapper with no + * dismiss of its own, so the clear goes to sonner directly. + */ +export function clearToasts(): void { + sonnerToast.dismiss(); +} diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index 4467a9b6c..59fdbc33d 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -9,16 +9,13 @@ // workbench in scope, this workbench's existing Myra workbench; never a // newly minted one. -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { spyOnReactUiToast } from "./react-ui-toast-mock"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -const toastMock = mock((_message: string) => undefined); -const actualReactUi = await import("@corbits/react-ui"); -mock.module("@corbits/react-ui", () => ({ - ...actualReactUi, - toast: toastMock, -})); +const toastMock = spyOnReactUiToast(); const { BenchProvider } = await import("../src/bench-context"); const { NavigationProvider } = await import("../src/navigation"); diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx index 38202463c..6d36a4f47 100644 --- a/apps/web/test/toast-single-system.test.tsx +++ b/apps/web/test/toast-single-system.test.tsx @@ -9,13 +9,14 @@ // the house styling, and clears itself. import { toast, Toaster } from "@corbits/react-ui"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { BenchProvider } from "../src/bench-context"; import { NavigationProvider } from "../src/navigation"; import { NewWorkbenchPickerRoute } from "../src/pages/new-workbench-picker"; +import { clearToasts } from "./react-ui-toast-mock"; import { TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; @@ -116,6 +117,14 @@ async function renderPickerWithToaster(): Promise { } describe("the one toast system (CL-6372)", () => { + // The store outlives this file too: a sibling suite that raised a toast + // before bun loaded this one leaves it queued, and it would render into + // the first `` mounted here. Start every test from an empty + // surface so the count below is this test's own toasts and nothing else. + beforeEach(() => { + clearToasts(); + }); + test("a failed workbench create fires exactly one toast", async () => { stubFailingCreate(); await renderPickerWithToaster(); diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index ef1de5f1c..0b10bb46c 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -12,7 +12,6 @@ import { createAgentLifecycle } from "@corbits/agent-lifecycle"; import { createCryptoProviderCache, domainOf, - findFoldedRunById, launchFoldedRun, mintFoldedRun, readDefinitionProjection, @@ -64,7 +63,6 @@ import type { import type { InferencePreference } from "@intx/agent"; import { formatRunAddress } from "@intx/types"; import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; -import { type } from "arktype"; import { AgentUnreachableError, type ChatWorkbenchEvent, @@ -818,6 +816,13 @@ export function createHubChatPlatform( return live?.run.definitionId ?? undefined; }, + async resolveDefinitionAssetId(definitionId): Promise { + const row = await deps.db.query.workflowDefinition.findFirst({ + where: eq(workflowDefinition.id, definitionId), + }); + return row?.assetId ?? undefined; + }, + async refreshAgentInstanceFromDefinition( tenantId, _workbenchId, @@ -855,7 +860,7 @@ export function createHubChatPlatform( // The stable id names the room's participant; the run it resolves // to is whichever one is alive right now, which is a different // run (and a different address) after every relaunch. - const { binding, run } = await requireLive(input.workbenchId); + const { binding } = await requireLive(input.workbenchId); const liveAddress = binding.liveAddress; // Wake before send: a sleeping instance (the lifecycle package's diff --git a/packages/chat/src/platform-port.ts b/packages/chat/src/platform-port.ts index a3152055f..6f63518b7 100644 --- a/packages/chat/src/platform-port.ts +++ b/packages/chat/src/platform-port.ts @@ -103,6 +103,17 @@ export interface WorkbenchLauncher { */ resolveDefinitionIdByAddress(address: string): Promise; + /** + * Resolves a definition id to the workflow asset it projects over — + * the agent's stable identity. A code-sourced deploy projects a fresh + * `workflow_definition` row per frozen wire projection, so one agent + * accumulates many definition ids over its life while its asset stays + * the same; anything asking "is this the same agent?" compares assets, + * never rows. Returns undefined for a definition this tenant has no + * row for. + */ + resolveDefinitionAssetId(definitionId: string): Promise; + /** * Recomputes an already-invited instance's folded launch body from * its definition's CURRENT asset content, and persists it so the diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index e6102ce32..a9e57fb10 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -875,6 +875,11 @@ const MoveWorkbenchBody = type({ * Matches forward, by the `chat/definitionId` every agent chat has * carried in its settings since this landed, and falls back to * `matchesLegacyAgentChat` for a chat minted before that key existed. + * The comparison is on the definition's ASSET, not the row id: a + * code-sourced deploy projects a new `workflow_definition` row per + * frozen wire projection, so the id a chat recorded at creation and the + * id the picker offers later are routinely different rows over the one + * asset that IS the agent. * More than one match (duplicates this same gap already let through) * resolves to the oldest by its workbench-tenancy `createdAt` — the * original conversation, not whichever the caller happens to hit first — @@ -887,12 +892,14 @@ export async function findExistingAgentChat( definitionId: string, ): Promise { const chats = await deps.store.listWorkbenchSettings(tenantId, "chat"); + const assetId = await deps.platform.resolveDefinitionAssetId(definitionId); const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = []; for (const row of chats) { const storedDefinitionId = row.settings["chat/definitionId"]; const isMatch = storedDefinitionId !== undefined - ? storedDefinitionId === definitionId + ? typeof storedDefinitionId === "string" && + (await sameAgent(deps, storedDefinitionId, definitionId, assetId)) : await matchesLegacyAgentChat(deps, row, definitionId); if (!isMatch) continue; const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId); @@ -903,6 +910,25 @@ export async function findExistingAgentChat( return matches[0]?.row; } +/** + * Whether two definition ids name the same agent: the same row, or two + * rows projected over the same workflow asset. An unresolvable asset (a + * definition row that no longer exists) never matches by asset, so a + * stale recorded id falls back to plain id equality alone. + */ +async function sameAgent( + deps: Pick, + storedDefinitionId: string, + definitionId: string, + assetId: string | undefined, +): Promise { + if (storedDefinitionId === definitionId) return true; + if (assetId === undefined) return false; + const storedAssetId = + await deps.platform.resolveDefinitionAssetId(storedDefinitionId); + return storedAssetId === assetId; +} + /** * A chat minted before `chat/definitionId` was recorded at creation * carries no forward marker naming its agent — the only way back to its diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 9ead009a9..fabc49135 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -563,6 +563,38 @@ describe("POST /workbenches — reuseExisting: true reopens the land-hop's chat, expect(chats).toHaveLength(1); }); + test("reuses the chat when the agent's definition was re-projected under a new id over the same asset", async () => { + const deps = buildDeps({ + platform: fakePlatform({ + invitable: [ + { id: "wfd_echo_v1", name: "Echo" }, + { id: "wfd_echo_v2", name: "Echo" }, + ], + resolveDefinitionAssetId: async (definitionId: string) => + definitionId.startsWith("wfd_echo") ? "ast_echo" : undefined, + }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const first = await createWorkbench(app, { + kind: "chat", + definitionId: "wfd_echo_v1", + reuseExisting: true, + }); + expect(first.response.status).toBe(201); + + const second = await createWorkbench(app, { + kind: "chat", + definitionId: "wfd_echo_v2", + reuseExisting: true, + }); + + expect(second.response.status).toBe(200); + expect(second.body.id).toBe(first.body.id); + const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); + expect(chats).toHaveLength(1); + }); + test("a new agent chat records its definitionId for future dedup", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), diff --git a/packages/chat/test/test-support.ts b/packages/chat/test/test-support.ts index 1c026eacd..f821b9b9d 100644 --- a/packages/chat/test/test-support.ts +++ b/packages/chat/test/test-support.ts @@ -59,6 +59,9 @@ export function fakePlatform( workbenchId: string, blobId: string, ) => Promise; + resolveDefinitionAssetId?: ( + definitionId: string, + ) => Promise; resolveDefinitionIdByAddress?: ( address: string, ) => Promise; @@ -138,6 +141,12 @@ export function fakePlatform( async listInvitableDefinitions() { return opts.invitable ?? []; }, + async resolveDefinitionAssetId(definitionId: string) { + if (opts.resolveDefinitionAssetId !== undefined) { + return opts.resolveDefinitionAssetId(definitionId); + } + return undefined; + }, async resolveDefinitionIdByAddress(address) { if (opts.resolveDefinitionIdByAddress !== undefined) { return opts.resolveDefinitionIdByAddress(address); diff --git a/packages/cli/test/seed.test.ts b/packages/cli/test/seed.test.ts index a35136543..4f2e6e562 100644 --- a/packages/cli/test/seed.test.ts +++ b/packages/cli/test/seed.test.ts @@ -33,7 +33,10 @@ function deps(overrides: Partial & Pick): SeedDeps { const { log } = collector(); return { config: CONFIG, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), publishToolRegistry: async () => undefined, log, ...overrides, @@ -279,7 +282,10 @@ describe("runSeed", () => { await runSeed( deps({ api: fakeAPI(handler), - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log, sleep: async () => {}, runStartTimeoutMs: 3, @@ -472,7 +478,10 @@ describe("runSeed", () => { await runSeed( deps({ api: fakeAPI(handler), - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log, sleep: async () => {}, runStartTimeoutMs: 3, diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index 3b1fcdde0..ae8f404e1 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -109,7 +109,10 @@ describe("POST /complete-setup", () => { "/api/onboarding", createOnboardingRoutes({ hubUrl: "https://bench.example.com", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), }), @@ -132,7 +135,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), }), @@ -182,7 +188,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), ensureSeededFn: async () => { @@ -227,7 +236,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), }), @@ -263,7 +275,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, ensureSeededFn: async (args) => { @@ -347,7 +362,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }), @@ -391,7 +409,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }), @@ -429,7 +450,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, ensureSeededFn: async () => { @@ -582,7 +606,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }), @@ -637,7 +664,10 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, ensureSeededFn: async () => ({ diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts index e7e81328c..c19a81db3 100644 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ b/packages/onboarding/test/huggingface-connect-routes.test.ts @@ -196,7 +196,9 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", - pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), + pushWorkflow: + overrides.pushWorkflow ?? + (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), log: overrides.log ?? (() => undefined), pendingSeedStore: overrides.pendingSeedStore ?? diff --git a/packages/onboarding/test/openrouter-connect-routes.test.ts b/packages/onboarding/test/openrouter-connect-routes.test.ts index 302f058d4..34374e6f9 100644 --- a/packages/onboarding/test/openrouter-connect-routes.test.ts +++ b/packages/onboarding/test/openrouter-connect-routes.test.ts @@ -201,7 +201,9 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", - pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), + pushWorkflow: + overrides.pushWorkflow ?? + (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), log: overrides.log ?? (() => undefined), pendingSeedStore: overrides.pendingSeedStore ?? diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index f93b0e849..98c5040e8 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -22,7 +22,10 @@ const MODEL = { apiKey: "sk-test", }; -const noopPush: WorkflowPusher = async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }); +const noopPush: WorkflowPusher = async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), +}); const noopPublishToolRegistry: ToolRegistryPublisher = async () => undefined; function collector() { diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index 0325321f7..19f860e73 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -42,7 +42,10 @@ describe("POST /provision", () => { // Port 0 on loopback refuses every connection immediately, so the // underlying fetch throws deterministically without a live hub. hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: (line) => lines.push(line), pendingSeedStore, }); @@ -83,7 +86,10 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -120,7 +126,10 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -143,7 +152,10 @@ describe("POST /provision", () => { // first login). const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -179,7 +191,10 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -219,7 +234,10 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -251,7 +269,10 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -276,7 +297,10 @@ describe("POST /provision", () => { test("an anonymous request is rejected before provisioning runs", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -295,7 +319,10 @@ describe("POST /complete", () => { test("an anonymous request is rejected before anything is seeded", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -319,7 +346,10 @@ describe("POST /complete", () => { test("a missing provider is rejected with a specific message, no network call made", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, }); @@ -348,7 +378,10 @@ describe("POST /complete", () => { providerHealth.report("tnt_own", "anthropic", "credential_failure"); const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, providerHealth, @@ -376,7 +409,10 @@ describe("POST /complete", () => { providerHealth.report("tnt_own", "anthropic", "credential_failure"); const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, providerHealth, @@ -408,7 +444,10 @@ describe("POST /complete", () => { test("a sidecar-unavailable deploy completes onboarding with a pending-agents response and writes a retry row", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, completeCredentialSetupFn: async () => ({ @@ -465,7 +504,10 @@ describe("POST /complete", () => { test("a non-sidecar failure during setup still fails loudly with the existing 500 envelope", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, pendingSeedStore, completeCredentialSetupFn: async () => { @@ -495,7 +537,10 @@ describe("POST /complete", () => { const lines: string[] = []; const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), log: () => undefined, logError: (line) => lines.push(line), pendingSeedStore, diff --git a/packages/task-planner/src/spawn.test.ts b/packages/task-planner/src/spawn.test.ts index ee0e11bca..ba5b9afa1 100644 --- a/packages/task-planner/src/spawn.test.ts +++ b/packages/task-planner/src/spawn.test.ts @@ -70,6 +70,32 @@ const AGENT_WORKFLOW_JSON = { }, }; +/** + * The frozen inert wire projection the launch path reads a definition's + * body out of (`loadFrozenWireProjection` -> `readFoldedBody`). The live + * `AGENT_WORKFLOW_JSON` above is the asset's authored shape; this is the + * projected one, where the inference chain is flattened to + * `modelSources`. + */ +const AGENT_WIRE_PROJECTION = { + id: "wfd_agent", + triggers: [], + stepOrder: ["agent"], + steps: { + agent: { + kind: "step", + agent: { + systemPrompt: "You summarize incidents.", + toolPackagePins: [], + modelSources: [ + { provider: "anthropic", model: "declared-default-model" }, + ], + }, + }, + }, + credentialBindings: [], +}; + const DEFINITION_ROW = { id: "wfd_agent", tenantId: "tnt_1", @@ -100,6 +126,16 @@ function createFakeDb() { workflowDefinition: { findFirst: async () => DEFINITION_ROW }, tenant: { findFirst: async () => TENANT_ROW }, }, + select() { + return { + from: () => ({ + where: () => ({ + limit: () => + Promise.resolve([{ wireProjection: AGENT_WIRE_PROJECTION }]), + }), + }), + }; + }, insert(table: unknown) { return { values: (values: unknown) => insertOn(table, values) }; }, diff --git a/packages/task-planner/src/workflow-dispatch-routes.test.ts b/packages/task-planner/src/workflow-dispatch-routes.test.ts index 4a5bdaf2c..69e553d41 100644 --- a/packages/task-planner/src/workflow-dispatch-routes.test.ts +++ b/packages/task-planner/src/workflow-dispatch-routes.test.ts @@ -52,6 +52,32 @@ const AGENT_WORKFLOW_JSON = { }, }; +/** + * The frozen inert wire projection the launch path reads a definition's + * body out of (`loadFrozenWireProjection` -> `readFoldedBody`). The live + * `AGENT_WORKFLOW_JSON` above is the asset's authored shape; this is the + * projected one, where the inference chain is flattened to + * `modelSources`. + */ +const AGENT_WIRE_PROJECTION = { + id: "wfd_agent", + triggers: [], + stepOrder: ["agent"], + steps: { + agent: { + kind: "step", + agent: { + systemPrompt: "You summarize incidents.", + toolPackagePins: [], + modelSources: [ + { provider: "anthropic", model: "declared-default-model" }, + ], + }, + }, + }, + credentialBindings: [], +}; + const DEFINITION_ROW = { id: "wfd_agent", tenantId: "tnt_1", @@ -82,6 +108,16 @@ function createFakeDb() { workflowDefinition: { findFirst: async () => DEFINITION_ROW }, tenant: { findFirst: async () => TENANT_ROW }, }, + select() { + return { + from: () => ({ + where: () => ({ + limit: () => + Promise.resolve([{ wireProjection: AGENT_WIRE_PROJECTION }]), + }), + }), + }; + }, insert(table: unknown) { return { values: (values: unknown) => insertOn(table, values) }; }, diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index 840c4f6fc..2faa21252 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -41,7 +41,7 @@ const ALLOWLIST: readonly { }[] = [ { relPath: "packages/chat/src/schema.ts", - maxOccurrences: 15, + maxOccurrences: 16, tables: [ // Created as channel_settings et al.; renamed to workbench_* by // 0018_rename_channel_to_workbench (CL-6260) — see migrations.ts. @@ -66,6 +66,10 @@ const ALLOWLIST: readonly { // workbench data, held here rather than read back out of the // anchor run's mailbox — see room-messages.ts. "workbench_messages", + // The turn projection (CL-6329): one row per agent turn, so a room + // answers "which run produced this reply, and how did that turn + // end" from its own rows — see agentTurns in schema.ts. + "agent_turns", ], }, { diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts index 819ad7bc9..20439d4ab 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -288,14 +288,16 @@ async function countMatching(page: Page, selector: string): Promise { // --- the walkthrough ----------------------------------------------------- +/** The picker row that mints a plain Myra room (`workbench-templates.ts`). */ +const BLANK_TEMPLATE_TITLE = "Just start talking"; + /** - * Clicks the sidebar's "+ New workbench" control — the one creation verb - * (CL-6138, superseding the CL-6081/CL-6089 picker-and-dialog design): one - * click mints a fresh Myra workbench against the account's default setup - * template and navigates straight into it, no dialog, no agent picker, no - * describe composer. Waits for the URL to actually land on a fresh - * `/w/:id` distinct from wherever the click started, rather than assuming - * a fixed delay covers the mint + navigate round trip. + * Drives the one creation flow a person actually walks: the sidebar's "+" + * opens the new-workbench picker (CL-6342 — "+" no longer mints on the + * spot), a kind is chosen, and "Create workbench" mints it and navigates + * in. Waits for the URL to actually land on a fresh `/w/:id` distinct from + * wherever the click started, rather than assuming a fixed delay covers + * the mint + navigate round trip. */ async function createMyraChat(page: Page): Promise { const before = await page.evaluate(() => window.location.pathname); @@ -306,13 +308,36 @@ async function createMyraChat(page: Page): Promise { let landed = false; for (let attempt = 0; attempt < 3 && !landed; attempt += 1) { await clickStable(page, 'button[aria-label="New workbench"]'); + await page.waitForSelector( + '[role="radiogroup"][aria-label="Workbench kind"]', + { + timeout: 15_000, + }, + ); + const picked = await page.evaluate((title: string) => { + const rows = Array.from( + document.querySelectorAll('[role="radio"]'), + ); + const row = rows.find((candidate) => + (candidate.textContent ?? "").includes(title), + ); + if (row === undefined) return false; + row.click(); + return true; + }, BLANK_TEMPLATE_TITLE); + if (!picked) { + throw new Error( + `the new-workbench picker offered no "${BLANK_TEMPLATE_TITLE}" row`, + ); + } + await clickStable(page, ".new-workbench-picker-foot button"); landed = await page .waitForFunction( (previous: string) => { const current = window.location.pathname; return current.startsWith("/w/") && current !== previous; }, - { timeout: 8_000 }, + { timeout: 30_000 }, before, ) .then(() => true) @@ -320,7 +345,7 @@ async function createMyraChat(page: Page): Promise { } if (!landed) { throw new Error( - "the + control never minted a fresh workbench after 3 clicks", + "the picker never minted a fresh workbench after 3 attempts", ); } } @@ -671,17 +696,17 @@ async function run(): Promise { }, ); - // --- Step 3: CL-6138 — the sidebar's "+" is the same one creation - // verb the bare-root land-hop used above: every click mints a fresh - // Myra workbench and navigates straight into it. The second create - // must land somewhere NEW, distinct from the auto-minted first one; - // only the initial land-hop ever reopens an existing conversation. + // --- Step 3: CL-6342 — the sidebar's "+" opens the new-workbench + // picker, and a workbench is minted only once a kind is chosen and + // "Create workbench" is pressed. The create must land somewhere NEW, + // distinct from the auto-minted first one; only the initial land-hop + // ever reopens an existing conversation. await step( () => page, "05-second-create-mints-new-workbench", async () => { // The sidebar (and its "+ New workbench" affordance) is always - // present — no navigation needed before clicking it again. + // present — no navigation needed before opening the picker again. await createMyraChat(page); await page.waitForFunction( (previous: string) => { diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts index 99bbef797..e137b9931 100644 --- a/scripts/e2e/chat.test.ts +++ b/scripts/e2e/chat.test.ts @@ -48,7 +48,8 @@ import { expectStatus, freePort, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, type ApiResult, @@ -301,7 +302,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { ); expectStatus("mint echo git token", echoGitToken, 201); - await pushWorkflowJson({ + const echoPushed = await pushWorkflowSource({ baseUrl: hub.baseUrl, tenantId, assetName: "echo", @@ -331,19 +332,15 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { echoDeployed = await api( "POST", `/api/tenants/${tenantId}/workflows/deployments`, - { + workflowDeployBody({ assetId: echoAssetId, - sources: [ - { - id: "src-echo-e2e", - provider: "anthropic", - baseURL: "https://inference.invalid", - apiKey: "e2e-placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "src-echo-e2e", - }, + commitSha: echoPushed.commitSha, + sourceId: "src-echo-e2e", + provider: "anthropic", + baseURL: "https://inference.invalid", + apiKey: "e2e-placeholder", + model: "claude-sonnet-5", + }), user1.cookies, ); if (echoDeployed.status !== 502) break; @@ -387,7 +384,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { if (Date.now() > deadline) { throw new Error( `workbench never became launchable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, + `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}\nsidecar output:\n${sidecar.output()}`, ); } await Bun.sleep(1000); @@ -904,7 +901,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { `no workbench.membership-changed event on the timeline: ${JSON.stringify(items)}`, ); } - }); + }, 30_000); // Every chat/folded run above (the workbench anchor, the mentioned // second workbench, the invited echo agent, the auto-invited chat diff --git a/scripts/e2e/folded-run-backfill.test.ts b/scripts/e2e/folded-run-backfill.test.ts index 4f267a801..5d061096e 100644 --- a/scripts/e2e/folded-run-backfill.test.ts +++ b/scripts/e2e/folded-run-backfill.test.ts @@ -184,18 +184,20 @@ describeIfDb("folded-run backfill", () => { } await sql.unsafe( - `INSERT INTO "chat"."workbench_launch" ("tenant_id", "instance_id", "folded_body") VALUES ($1, $2, $3)`, + `INSERT INTO "chat"."workbench_launch" ("tenant_id", "instance_id", "current_run_id", "folded_body") VALUES ($1, $2, $3, $4)`, [ TENANT, "run_workbench_host_old", + "run_workbench_host_old", JSON.stringify({ systemPrompt: "host" }), ], ); await sql.unsafe( - `INSERT INTO "chat"."workbench_launch" ("tenant_id", "instance_id", "folded_body") VALUES ($1, $2, $3)`, + `INSERT INTO "chat"."workbench_launch" ("tenant_id", "instance_id", "current_run_id", "folded_body") VALUES ($1, $2, $3, $4)`, [ TENANT, "run_invited_agent_old", + "run_invited_agent_old", JSON.stringify({ systemPrompt: "invited" }), ], ); diff --git a/scripts/e2e/harness.ts b/scripts/e2e/harness.ts index f15b52a47..33ea0c1ef 100644 --- a/scripts/e2e/harness.ts +++ b/scripts/e2e/harness.ts @@ -5,10 +5,11 @@ // real the suite fails and says which hop, it never fakes the result. import { afterAll } from "bun:test"; -import { spawn } from "node:child_process"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { createGitWorkflowPusher } from "../../packages/hub-client/src/index.ts"; +import { WORKFLOW_SOURCE_ENTRY } from "../../packages/workflow-source/src/index.ts"; export const REPO_ROOT = path.resolve(import.meta.dir, "..", ".."); const HUB_DIR = path.join(REPO_ROOT, "apps", "hub"); @@ -567,117 +568,59 @@ export function expectStepCompleted(events: RunEvent[], stepId: string): void { // --- workflow asset content over git smart-HTTP ----------------------- -interface GitResult { - status: number; - stdout: string; - stderr: string; -} - -function runGit( - args: string[], - cwd: string, - env: Record, -): Promise { - return new Promise((resolveRun, reject) => { - const child = spawn("git", args, { - cwd, - env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Uint8Array) => { - stdout += new TextDecoder().decode(chunk); - }); - child.stderr.on("data", (chunk: Uint8Array) => { - stderr += new TextDecoder().decode(chunk); - }); - child.on("error", reject); - child.on("close", (code) => { - resolveRun({ status: code ?? -1, stdout, stderr }); - }); - }); -} - /** - * Commit `workflow.json` into a workflow asset over the platform's - * asset smart-HTTP route — the only surface that writes asset tree - * content — using the system git binary with a bearer-token askpass - * shim, exactly as the platform's own tooling does. + * Publishes a workflow definition into its asset repo in the one shape + * a `workflow`-kind asset accepts: the source codebase + * `@corbits/workflow-source` renders. Delegates to the platform's own + * pusher so the suite exercises the same publication path the seed and + * the product use, and returns the commit a code-sourced deploy pins. */ -export async function pushWorkflowJson(options: { +export async function pushWorkflowSource(options: { baseUrl: string; tenantId: string; assetName: string; tokenSecret: string; workflowJson: string; -}): Promise { - const work = await mkdtemp(path.join(tmpdir(), "e2e-workflow-push-")); - try { - const askpass = path.join(work, "askpass.sh"); - await writeFile( - askpass, - `#!/bin/sh\nprintf '%s\\n' '${options.tokenSecret.replace(/'/g, "'\\''")}'\n`, - "utf-8", - ); - await chmod(askpass, 0o755); - const env: Record = { - ...osEnv(), - GIT_ASKPASS: askpass, - GIT_TERMINAL_PROMPT: "0", - GIT_AUTHOR_NAME: "Walking Skeleton", - GIT_AUTHOR_EMAIL: "e2e@workbench.invalid", - GIT_COMMITTER_NAME: "Walking Skeleton", - GIT_COMMITTER_EMAIL: "e2e@workbench.invalid", - }; - const remote = new URL( - `${options.baseUrl}/api/tenants/${options.tenantId}/assets/workflow/${options.assetName}.git`, - ); - remote.username = "x-access-token"; - remote.password = encodeURIComponent(options.tokenSecret); - const repoDir = path.join(work, "repo"); - - const clone = await runGit( - ["-c", "credential.helper=", "clone", remote.toString(), repoDir], - work, - env, - ); - if (clone.status !== 0) { - throw new Error(`git clone of workflow asset failed: ${clone.stderr}`); - } +}): Promise<{ commitSha: string }> { + const pushed = await createGitWorkflowPusher()({ + remoteUrl: `${options.baseUrl}/api/tenants/${options.tenantId}/assets/workflow/${options.assetName}.git`, + tokenSecret: options.tokenSecret, + workflowJson: options.workflowJson, + packageName: options.assetName, + }); + return { commitSha: pushed.commitSha }; +} - await writeFile( - path.join(repoDir, "workflow.json"), - options.workflowJson, - "utf-8", - ); - for (const step of [ - { label: "add workflow.json", args: ["add", "workflow.json"] }, - { - label: "commit workflow.json", - args: [ - "-c", - "user.name=Walking Skeleton", - "-c", - "user.email=e2e@workbench.invalid", - "commit", - "-m", - "Add echo workflow definition", - ], - }, +/** + * The deploy body a code-sourced asset deployment takes: the pushed + * commit is the definition's pin, and the entry names the + * `interchange.workflow` module the sidecar evaluates. + */ +export function workflowDeployBody(options: { + assetId: string; + commitSha: string; + sourceId: string; + provider: string; + baseURL: string; + apiKey: string; + model: string; +}): Record { + return { + source: { + kind: "asset", + assetId: options.assetId, + package: { format: "source", commitSha: options.commitSha }, + }, + entry: WORKFLOW_SOURCE_ENTRY, + sources: [ { - label: "push workflow.json", - args: ["-c", "credential.helper=", "push", "origin", "HEAD:main"], + id: options.sourceId, + provider: options.provider, + baseURL: options.baseURL, + apiKey: options.apiKey, + model: options.model, }, - ]) { - const result = await runGit(step.args, repoDir, env); - if (result.status !== 0) { - throw new Error( - `git ${step.label} failed: ${result.stderr || result.stdout}`, - ); - } - } - } finally { - await rm(work, { recursive: true, force: true }); - } + ], + defaultSource: options.sourceId, + }; } diff --git a/scripts/e2e/heartbeat.test.ts b/scripts/e2e/heartbeat.test.ts index cfeaa8dd0..f53998ffc 100644 --- a/scripts/e2e/heartbeat.test.ts +++ b/scripts/e2e/heartbeat.test.ts @@ -28,7 +28,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, waitForRunCompletion, @@ -152,46 +153,49 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { }); const assetName = "heartbeat"; - const assetId = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - user.cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); + const { assetId, commitSha } = await hop( + "workflow asset publication", + async () => { + const created = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/assets`, + { kind: "workflow", name: assetName }, + user.cookies, + ); + expectStatus("create workflow asset", created, 201); + const id = stringField(created.data, "id", "create workflow asset"); - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-heartbeat-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - user.cookies, - ); - expectStatus("mint git token", minted, 201); + const minted = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/git-tokens`, + { + name: "e2e-heartbeat-push", + resource: "asset:*", + refPattern: "**", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + user.cookies, + ); + expectStatus("mint git token", minted, 201); - const definition = buildHeartbeatWorkflow({ - triggerAddress: `heartbeat@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: "noop" }], - turnTimeoutMs: 30_000, - }); - await pushWorkflowJson({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeHeartbeatWorkflow(definition), - }); - return id; - }); + const definition = buildHeartbeatWorkflow({ + triggerAddress: `heartbeat@${slug}.localhost`, + inferencePreferences: [{ provider: "anthropic", model: "noop" }], + turnTimeoutMs: 30_000, + }); + const pushed = await pushWorkflowSource({ + baseUrl: hub.baseUrl, + tenantId, + assetName, + tokenSecret: stringField(minted.data, "secret", "mint git token"), + workflowJson: serializeHeartbeatWorkflow(definition), + }); + return { assetId: id, commitSha: pushed.commitSha }; + }, + ); // The deploy's source is the hub's own, really-reachable // noop-inference endpoint — not a placeholder like the walking @@ -201,19 +205,15 @@ describe.skipIf(databaseUrl === undefined)("heartbeat workflow", () => { // noop-inference answers it locally without reaching a real model. const deploymentId = await hop("workflow deploy", async () => { const sourceId = "src-heartbeat-e2e"; - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: `${hub.baseUrl}/api/chat/noop-inference`, - apiKey: "noop", - model: "noop", - }, - ], - defaultSource: sourceId, - }; + commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: `${hub.baseUrl}/api/chat/noop-inference`, + apiKey: "noop", + model: "noop", + }); const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 7c92bbfb6..7c4b87a13 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -932,9 +932,18 @@ describe.skipIf(databaseUrl === undefined)( definitionsBefore, 200, ); - const definitionCountBefore = ( - definitionsBefore.data as { data: unknown[] } - ).data.length; + // Agent identity is the definition NAME, not the row id: a + // code-sourced deploy re-projects a definition row per frozen + // wire projection, so Myra's own planning run adds a row under + // her existing "assistant" name every time she runs. A failed + // dispatch deploying the agent it was going to create would + // show up as a name that was not there before, which is what + // this gate is about. + const definitionNamesBefore = new Set( + (definitionsBefore.data as { data: { name: string }[] }).data.map( + (row) => row.name, + ), + ); const inboxBefore = await api( hub.baseUrl, @@ -982,10 +991,12 @@ describe.skipIf(databaseUrl === undefined)( definitionsAfter, 200, ); - const definitionCountAfter = ( - definitionsAfter.data as { data: unknown[] } - ).data.length; - expect(definitionCountAfter).toBe(definitionCountBefore); + const agentsAdded = ( + definitionsAfter.data as { data: { name: string }[] } + ).data + .map((row) => row.name) + .filter((name) => !definitionNamesBefore.has(name)); + expect(JSON.stringify(agentsAdded)).toBe("[]"); const inboxAfter = await api( hub.baseUrl, diff --git a/scripts/e2e/recurring-task-routine.test.ts b/scripts/e2e/recurring-task-routine.test.ts index 2fc24200f..1391544a5 100644 --- a/scripts/e2e/recurring-task-routine.test.ts +++ b/scripts/e2e/recurring-task-routine.test.ts @@ -42,7 +42,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, type HubHandle, @@ -108,7 +109,7 @@ async function deployWorkflow(args: { ); expectStatus(`mint git token for ${args.assetName}`, minted, 201); - await pushWorkflowJson({ + const pushed = await pushWorkflowSource({ baseUrl: args.hubBaseUrl, tenantId: args.tenantId, assetName: args.assetName, @@ -118,19 +119,15 @@ async function deployWorkflow(args: { const sourceId = `src-recurring-task-e2e-${args.assetName}`; assertNeverRealProvider(args.noopBaseUrl, "workflow deploy source baseURL"); - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: args.noopBaseUrl, - apiKey: "noop", - model: "noop", - }, - ], - defaultSource: sourceId, - }; + commitSha: pushed.commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: args.noopBaseUrl, + apiKey: "noop", + model: "noop", + }); const deadline = Date.now() + 60_000; for (;;) { if (args.sidecar.exited()) { diff --git a/scripts/e2e/routine-repeat.test.ts b/scripts/e2e/routine-repeat.test.ts index 04cdef573..d466058ec 100644 --- a/scripts/e2e/routine-repeat.test.ts +++ b/scripts/e2e/routine-repeat.test.ts @@ -22,7 +22,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, type HubHandle, @@ -192,64 +193,63 @@ describe.skipIf(databaseUrl === undefined)("routine repeat fires", () => { }); const assetName = "heartbeat"; - const assetId = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - user.cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); + const { assetId, commitSha } = await hop( + "workflow asset publication", + async () => { + const created = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/assets`, + { kind: "workflow", name: assetName }, + user.cookies, + ); + expectStatus("create workflow asset", created, 201); + const id = stringField(created.data, "id", "create workflow asset"); - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-routine-repeat-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - user.cookies, - ); - expectStatus("mint git token", minted, 201); + const minted = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/git-tokens`, + { + name: "e2e-routine-repeat-push", + resource: "asset:*", + refPattern: "**", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + user.cookies, + ); + expectStatus("mint git token", minted, 201); - const definition = buildHeartbeatWorkflow({ - triggerAddress: `heartbeat@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: "noop" }], - turnTimeoutMs: 30_000, - }); - await pushWorkflowJson({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeHeartbeatWorkflow(definition), - }); - return id; - }); + const definition = buildHeartbeatWorkflow({ + triggerAddress: `heartbeat@${slug}.localhost`, + inferencePreferences: [{ provider: "anthropic", model: "noop" }], + turnTimeoutMs: 30_000, + }); + const pushed = await pushWorkflowSource({ + baseUrl: hub.baseUrl, + tenantId, + assetName, + tokenSecret: stringField(minted.data, "secret", "mint git token"), + workflowJson: serializeHeartbeatWorkflow(definition), + }); + return { assetId: id, commitSha: pushed.commitSha }; + }, + ); // Materializes the workflow definition row a routine binds to. The // sidecar must be dial-in complete first, so poll through the 502s. const definitionId = await hop("workflow deploy", async () => { const sourceId = "src-routine-repeat-e2e"; - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: `${hub.baseUrl}/api/chat/noop-inference`, - apiKey: "noop", - model: "noop", - }, - ], - defaultSource: sourceId, - }; + commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: `${hub.baseUrl}/api/chat/noop-inference`, + apiKey: "noop", + model: "noop", + }); const deadline = Date.now() + 60_000; for (;;) { if (sidecar.exited()) { diff --git a/scripts/e2e/routine-trigger-input.test.ts b/scripts/e2e/routine-trigger-input.test.ts index 1ec6c6d7c..270fbf5c9 100644 --- a/scripts/e2e/routine-trigger-input.test.ts +++ b/scripts/e2e/routine-trigger-input.test.ts @@ -35,7 +35,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, type HubHandle, @@ -251,63 +252,62 @@ describe.skipIf(databaseUrl === undefined)( }); const assetName = "heartbeat"; - const assetId = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); - - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-routine-trigger-input-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - cookies, - ); - expectStatus("mint git token", minted, 201); + const { assetId, commitSha } = await hop( + "workflow asset publication", + async () => { + const created = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/assets`, + { kind: "workflow", name: assetName }, + cookies, + ); + expectStatus("create workflow asset", created, 201); + const id = stringField(created.data, "id", "create workflow asset"); - const definition = buildHeartbeatWorkflow({ - triggerAddress: `heartbeat@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: "noop" }], - turnTimeoutMs: 30_000, - }); - await pushWorkflowJson({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeHeartbeatWorkflow(definition), - }); - return id; - }); + const minted = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/git-tokens`, + { + name: "e2e-routine-trigger-input-push", + resource: "asset:*", + refPattern: "**", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + cookies, + ); + expectStatus("mint git token", minted, 201); + + const definition = buildHeartbeatWorkflow({ + triggerAddress: `heartbeat@${slug}.localhost`, + inferencePreferences: [{ provider: "anthropic", model: "noop" }], + turnTimeoutMs: 30_000, + }); + const pushed = await pushWorkflowSource({ + baseUrl: hub.baseUrl, + tenantId, + assetName, + tokenSecret: stringField(minted.data, "secret", "mint git token"), + workflowJson: serializeHeartbeatWorkflow(definition), + }); + return { assetId: id, commitSha: pushed.commitSha }; + }, + ); const definitionId = await hop("workflow deploy", async () => { const sourceId = "src-routine-trigger-input-e2e"; assertNeverRealProvider(noopBaseUrl, "workflow deploy source baseURL"); - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: noopBaseUrl, - apiKey: "noop", - model: "noop", - }, - ], - defaultSource: sourceId, - }; + commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: noopBaseUrl, + apiKey: "noop", + model: "noop", + }); const deadline = Date.now() + 60_000; for (;;) { if (sidecar.exited()) { diff --git a/scripts/e2e/smoke-webhook.test.ts b/scripts/e2e/smoke-webhook.test.ts index 73f4e17dc..c7746496a 100644 --- a/scripts/e2e/smoke-webhook.test.ts +++ b/scripts/e2e/smoke-webhook.test.ts @@ -36,7 +36,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, type HubHandle, @@ -208,63 +209,62 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { }); const assetName = "heartbeat"; - const assetId = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); + const { assetId, commitSha } = await hop( + "workflow asset publication", + async () => { + const created = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/assets`, + { kind: "workflow", name: assetName }, + cookies, + ); + expectStatus("create workflow asset", created, 201); + const id = stringField(created.data, "id", "create workflow asset"); - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-smoke-webhook-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - cookies, - ); - expectStatus("mint git token", minted, 201); + const minted = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/git-tokens`, + { + name: "e2e-smoke-webhook-push", + resource: "asset:*", + refPattern: "**", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + cookies, + ); + expectStatus("mint git token", minted, 201); - const definition = buildHeartbeatWorkflow({ - triggerAddress: `heartbeat@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: "noop" }], - turnTimeoutMs: 30_000, - }); - await pushWorkflowJson({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeHeartbeatWorkflow(definition), - }); - return id; - }); + const definition = buildHeartbeatWorkflow({ + triggerAddress: `heartbeat@${slug}.localhost`, + inferencePreferences: [{ provider: "anthropic", model: "noop" }], + turnTimeoutMs: 30_000, + }); + const pushed = await pushWorkflowSource({ + baseUrl: hub.baseUrl, + tenantId, + assetName, + tokenSecret: stringField(minted.data, "secret", "mint git token"), + workflowJson: serializeHeartbeatWorkflow(definition), + }); + return { assetId: id, commitSha: pushed.commitSha }; + }, + ); const definitionId = await hop("workflow deploy", async () => { const sourceId = "src-smoke-webhook-e2e"; assertNeverRealProvider(noopBaseUrl, "workflow deploy source baseURL"); - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: noopBaseUrl, - apiKey: "noop", - model: "noop", - }, - ], - defaultSource: sourceId, - }; + commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: noopBaseUrl, + apiKey: "noop", + model: "noop", + }); const deadline = Date.now() + 60_000; for (;;) { if (sidecar.exited()) { diff --git a/scripts/e2e/walking-skeleton.test.ts b/scripts/e2e/walking-skeleton.test.ts index 384c29807..feeb26def 100644 --- a/scripts/e2e/walking-skeleton.test.ts +++ b/scripts/e2e/walking-skeleton.test.ts @@ -34,7 +34,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, type ApiResult, @@ -171,52 +172,55 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { }); // Hop: workflow asset. The echo workflow definition, built by its - // own package, published as a workflow asset whose workflow.json + // own package, published as a workflow asset whose source tree // arrives over the platform's git smart-HTTP surface — the only // surface that writes asset tree content. const assetName = "echo"; - const assetId = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - user.cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); + const { assetId, commitSha } = await hop( + "workflow asset publication", + async () => { + const created = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/assets`, + { kind: "workflow", name: assetName }, + user.cookies, + ); + expectStatus("create workflow asset", created, 201); + const id = stringField(created.data, "id", "create workflow asset"); - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-workflow-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - user.cookies, - ); - expectStatus("mint git token", minted, 201); + const minted = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/git-tokens`, + { + name: "e2e-workflow-push", + resource: "asset:*", + refPattern: "**", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + user.cookies, + ); + expectStatus("mint git token", minted, 201); - const definition = buildEchoWorkflow({ - triggerAddress: `echo@${slug}.localhost`, - inferencePreferences: [ - { provider: "anthropic", model: "claude-sonnet-5" }, - ], - turnTimeoutMs: 60_000, - }); - await pushWorkflowJson({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeEchoWorkflow(definition), - }); - return id; - }); + const definition = buildEchoWorkflow({ + triggerAddress: `echo@${slug}.localhost`, + inferencePreferences: [ + { provider: "anthropic", model: "claude-sonnet-5" }, + ], + turnTimeoutMs: 60_000, + }); + const pushed = await pushWorkflowSource({ + baseUrl: hub.baseUrl, + tenantId, + assetName, + tokenSecret: stringField(minted.data, "secret", "mint git token"), + workflowJson: serializeEchoWorkflow(definition), + }); + return { assetId: id, commitSha: pushed.commitSha }; + }, + ); // Hop: workflow deploy via the native deploy API. Retries while // the hub still answers 502 (the sidecar's dial-in may not have @@ -224,19 +228,15 @@ describe.skipIf(databaseUrl === undefined)("walking skeleton", () => { // source is a placeholder — deployment does not call inference. const deploymentId = await hop("workflow deploy", async () => { const sourceId = "src-echo-e2e"; - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: "https://inference.invalid", - apiKey: "e2e-placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: sourceId, - }; + commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: "https://inference.invalid", + apiKey: "e2e-placeholder", + model: "claude-sonnet-5", + }); const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) { diff --git a/scripts/e2e/workbench-digest.test.ts b/scripts/e2e/workbench-digest.test.ts index e7d9472f7..f00586af1 100644 --- a/scripts/e2e/workbench-digest.test.ts +++ b/scripts/e2e/workbench-digest.test.ts @@ -28,7 +28,8 @@ import { freePort, hop, provisionSidecar, - pushWorkflowJson, + pushWorkflowSource, + workflowDeployBody, startHub, startSidecar, waitForRunCompletion, @@ -147,46 +148,49 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { }); const assetName = "workbench-digest"; - const assetId = await hop("workflow asset publication", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/assets`, - { kind: "workflow", name: assetName }, - user.cookies, - ); - expectStatus("create workflow asset", created, 201); - const id = stringField(created.data, "id", "create workflow asset"); + const { assetId, commitSha } = await hop( + "workflow asset publication", + async () => { + const created = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/assets`, + { kind: "workflow", name: assetName }, + user.cookies, + ); + expectStatus("create workflow asset", created, 201); + const id = stringField(created.data, "id", "create workflow asset"); - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - name: "e2e-workbench-digest-push", - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), - }, - user.cookies, - ); - expectStatus("mint git token", minted, 201); + const minted = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenantId}/git-tokens`, + { + name: "e2e-workbench-digest-push", + resource: "asset:*", + refPattern: "**", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), + }, + user.cookies, + ); + expectStatus("mint git token", minted, 201); - const definition = buildWorkbenchDigestWorkflow({ - triggerAddress: `workbench-digest@${slug}.localhost`, - inferencePreferences: [{ provider: "anthropic", model: "noop" }], - turnTimeoutMs: 30_000, - }); - await pushWorkflowJson({ - baseUrl: hub.baseUrl, - tenantId, - assetName, - tokenSecret: stringField(minted.data, "secret", "mint git token"), - workflowJson: serializeWorkbenchDigestWorkflow(definition), - }); - return id; - }); + const definition = buildWorkbenchDigestWorkflow({ + triggerAddress: `workbench-digest@${slug}.localhost`, + inferencePreferences: [{ provider: "anthropic", model: "noop" }], + turnTimeoutMs: 30_000, + }); + const pushed = await pushWorkflowSource({ + baseUrl: hub.baseUrl, + tenantId, + assetName, + tokenSecret: stringField(minted.data, "secret", "mint git token"), + workflowJson: serializeWorkbenchDigestWorkflow(definition), + }); + return { assetId: id, commitSha: pushed.commitSha }; + }, + ); // The deploy's source is the hub's own, really-reachable // noop-inference endpoint — not a placeholder like the walking @@ -196,19 +200,15 @@ describe.skipIf(databaseUrl === undefined)("workbench-digest workflow", () => { // noop-inference answers it locally without reaching a real model. const deploymentId = await hop("workflow deploy", async () => { const sourceId = "src-workbench-digest-e2e"; - const body = { + const body = workflowDeployBody({ assetId, - sources: [ - { - id: sourceId, - provider: "anthropic", - baseURL: `${hub.baseUrl}/api/chat/noop-inference`, - apiKey: "noop", - model: "noop", - }, - ], - defaultSource: sourceId, - }; + commitSha, + sourceId: sourceId, + provider: "anthropic", + baseURL: `${hub.baseUrl}/api/chat/noop-inference`, + apiKey: "noop", + model: "noop", + }); const deadline = Date.now() + 60_000; let res: ApiResult; for (;;) {