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 (;;) {