From 43a9af11bf82338ec8ad76c40f97aceda63cdf14 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:49:07 -0700 Subject: [PATCH] feat(tools)!: workflow_deploy calls the stock deploy route with the run bearer workflow_deploy no longer posts to the Workbench-specific /api/workflow-workflow-authoring/:assetId/deploy mirror. It reads the tenant's resolved inference catalog from stock GET /api/tenants/:tenantId/models, flattens it into one priority-ordered sourceOfferingIds chain, and posts an asset/source deploy to stock POST /api/tenants/:tenantId/workflows/deployments. The run bearer authenticates both; the tenant id rides on the step env, already threaded from the hub's signed deploy frame. Flattening is the one non-trivial mapping: the deleted mirror resolved the chain server-side from listVisibleOfferings sorted by priority, while the discovery route groups its offerings under each model, so the client has to restore a single global priority order. A red/green test covers it. The mirror route, its registry operation and their tests are deleted. The hub's own workflowDeployer stays: the on-demand catalog-block deploy still drives it. What is left on the /api/workflow-workflow-authoring mount is the git half alone -- author, republish, source read, deploy preview -- which no run-bearer credential can do against stock git smart-HTTP today (CL-8171). Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/hub/src/index.ts | 46 +++--- .../workflow-substrate-factory/step-env.ts | 9 +- packages/workflow-authoring-tools/README.md | 47 ++++-- .../workflow-authoring-tools/package.json | 4 +- .../src/client.test.ts | 114 +++++++++++++ .../workflow-authoring-tools/src/client.ts | 156 ++++++++++++++++-- .../workflow-authoring-tools/src/tool.test.ts | 58 ++++--- packages/workflow-authoring-tools/src/tool.ts | 6 +- packages/workflows/src/authoring/index.ts | 2 +- .../workflows/src/authoring/registry.test.ts | 107 +----------- packages/workflows/src/authoring/registry.ts | 91 ++++------ .../src/authoring/workflow-routes.test.ts | 86 +--------- .../src/authoring/workflow-routes.ts | 34 +--- workflows/assistant/src/prompt.ts | 2 +- 14 files changed, 399 insertions(+), 363 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 58f10251af..6e159fa7d3 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1602,7 +1602,7 @@ export async function createHub(config: HubConfig) { // A row's Message-ID always addresses under the row's OWN tenant's // domain, never the acting caller's — see `mailbox-fanout.ts`'s // `MailboxFanoutDeps.resolveTenantDomain` doc comment. Same - // `tenant` lookup `workflowDeployer.deploy` above uses for the + // `tenant` lookup `workflowDeployer.deploy` below uses for the // identical reason (an instance's trigger address, minted against // its own tenant's domain). resolveTenantDomain: async (tenantId) => { @@ -1777,16 +1777,18 @@ export async function createHub(config: HubConfig) { app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp); } - // CL-7361: the `deploy` half of the run-authenticated deployer this - // route's registry calls — the SAME `prepareProvisionedDeployment` the - // native `POST /workflows/deployments` route drives. Inference sources - // ride as catalog offering ids (`listVisibleOfferings`); an agent never + // The hub-side deploy seam the on-demand catalog-block route below + // drives — the SAME `prepareProvisionedDeployment` the native `POST + // /workflows/deployments` route drives. Inference sources ride as + // catalog offering ids (`listVisibleOfferings`); the caller never // supplies or sees a provider secret. The tree is already on the asset // at `commitSha`, so this does not re-populate. // - // `wf_deploy_preview` (CL-7362) is NOT wired through this - // deployer. `registry.previewDeploy` does a static, read-only render of - // the already-committed source at `commitSha` straight off `RepoStore`. + // An agent deploying its own authored workflow does not come through + // here: `@corbits/workflow-authoring-tools` calls stock `POST + // /api/tenants/:tenantId/workflows/deployments` with the run bearer + // (CL-8171), resolving the same offering chain from the stock model + // discovery route. const workflowDeployer: WorkflowDeployer = { async deploy({ tenantId, principalId, assetId, commitSha, entry }) { const tenantRow = await db.query.tenant.findFirst({ @@ -1872,17 +1874,17 @@ export async function createHub(config: HubConfig) { } }, }; - // Agent-authored workflows (CL-7360, CL-7361): an agent publishes a - // workflow codebase as a native `kind:"workflow"` asset AND deploys it, - // both through this workflow-run-authenticated surface — `deploy` - // reaches the exact same `prepareProvisionedDeployment` the - // tenant-session `/workflows/deployments` route drives (`workflowDeployer` - // above), never a second gating path. Unlike `/api/workflow-skills` - // above, every write here also runs a real `chatGrantStore` - // authorization check (`asset:*`/create, `asset:`/write, - // `workflow:*`/create) before reaching `RepoStore` or the deploy call, - // because authoring and deploying are side effects, not a markdown - // skill edit. + // Agent-authored workflows (CL-7360): an agent publishes a workflow + // codebase as a native `kind:"workflow"` asset through this + // workflow-run-authenticated surface, then deploys it through stock + // `POST /api/tenants/:tenantId/workflows/deployments` with the same run + // bearer. What is left mounted here is the git half alone — reading and + // writing the asset repo's trees — which no run-bearer credential can do + // against stock git smart-HTTP today (CL-8171). Unlike + // `/api/workflow-skills` above, every write here runs a real + // `chatGrantStore` authorization check (`asset:*`/create, + // `asset:`/write, `workflow:*`/create) before reaching `RepoStore`, + // because authoring source is a side effect, not a markdown skill edit. app.route( "/api/workflow-workflow-authoring", createWorkflowAuthorRoutes({ @@ -1893,7 +1895,6 @@ export async function createHub(config: HubConfig) { repoStore: agentRepoStore.repoStore, grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, - deployer: workflowDeployer, }), }), ); @@ -2185,9 +2186,8 @@ export async function createHub(config: HubConfig) { }, }); - // Native deploy, not a hub-local inert freeze: the same - // `workflowDeployer` the agent-authored deploy path above drives, - // so a catalog block's definition goes through the real + // Native deploy, not a hub-local inert freeze: `workflowDeployer` + // above, so a catalog block's definition goes through the real // bundle → sidecar probe → capability walk → gate → freeze // pipeline instead of a hub-side shortcut. const result = await workflowDeployer.deploy({ diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index 09d3ac9913..a13387eef9 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -452,8 +452,10 @@ export function createSidecarStepBuildEnv( hubAgentDirectoryUrl: deps.hubArtifactsUrl, hubChatUrl: deps.hubArtifactsUrl, // And under the key `@corbits/workflow-authoring-tools` declares - // (`requires: ["hubWorkflowAuthoringUrl", "sidecarToken", "address"]`) - // for `@corbits/workflows`'s `./authoring`'s run-authenticated routes. + // (`requires: ["hubWorkflowAuthoringUrl", "tenantId", "sidecarToken", + // "address"]`) for `@corbits/workflows`'s `./authoring`'s + // run-authenticated routes and for the stock deploy route it calls + // alongside them. hubWorkflowAuthoringUrl: deps.hubArtifactsUrl, // And once more under the key `@corbits/access-tools` declares // (`requires: ["hubAccessUrl", "sidecarToken", "address"]`) for its @@ -463,7 +465,8 @@ export function createSidecarStepBuildEnv( definitionId: deps.definitionId, // The run's own tenant/principal, for the tool bundles that address // stock `/api/tenants/:tenantId/*` routes with the run bearer - // (`@corbits/access-tools`, `@corbits/connections-tools`). + // (`@corbits/access-tools`, `@corbits/connections-tools`, + // `@corbits/workflow-authoring-tools`). tenantId: deps.tenantId, principalId: deps.principalId, }; diff --git a/packages/workflow-authoring-tools/README.md b/packages/workflow-authoring-tools/README.md index d3af4e514b..bc1424f0ae 100644 --- a/packages/workflow-authoring-tools/README.md +++ b/packages/workflow-authoring-tools/README.md @@ -8,9 +8,8 @@ for the contract this implements. ## The tools -None of the three is `approval: "ask"`: storing source is not a side effect. -Deploying an asset so it can run is a separate, human-approved step -(`workflow_deploy`, CL-7362), never this bundle's job. +Only `workflow_deploy` is `approval: "ask"`: storing source is not a side +effect, but making a workflow runnable is. - `workflow_author({ name, files, message? })` — creates the asset and commits the tree; returns `{ assetId, name, commitSha }`. `name` is @@ -22,6 +21,14 @@ Deploying an asset so it can run is a separate, human-approved step committed content. - `workflow_source_read({ assetId })` — every file on `refs/heads/main` plus `headSha`, as JSON. +- `wf_deploy_preview({ assetId, commitSha, entry })` — a static read of the + already-committed source: package name, file list, and any statically + declared `toolPackagePins`. Never installs, probes, gates, or freezes. +- `workflow_deploy({ assetId, commitSha, entry, packageName?, toolPackagePins? })` + — deploys the committed source through Interchange's native pipeline behind + a human approval. `packageName`/`toolPackagePins` come from a prior + `wf_deploy_preview` on the same commit and are shown on the approval card; + they are not sent to the hub. Every request carries the run's own sidecar bearer token and `x-workflow-run-address`; the hub resolves tenant and principal from the run @@ -34,11 +41,25 @@ on. ## Routes and client -| Tool | Route | Client function | -| ---------------------- | ------------------------------------------------------ | -------------------- | -| `workflow_author` | `POST /api/workflow-workflow-authoring/author` | `authorWorkflow` | -| `workflow_republish` | `POST /api/workflow-workflow-authoring/republish` | `republishWorkflow` | -| `workflow_source_read` | `GET /api/workflow-workflow-authoring/:assetId/source` | `readWorkflowSource` | +| Tool | Route | Client function | +| ---------------------- | --------------------------------------------------------------- | ----------------------- | +| `workflow_author` | `POST /api/workflow-workflow-authoring/author` | `authorWorkflow` | +| `workflow_republish` | `POST /api/workflow-workflow-authoring/republish` | `republishWorkflow` | +| `workflow_source_read` | `GET /api/workflow-workflow-authoring/:assetId/source` | `readWorkflowSource` | +| `wf_deploy_preview` | `POST /api/workflow-workflow-authoring/:assetId/deploy/preview` | `previewDeployWorkflow` | +| `workflow_deploy` | `POST /api/tenants/:tenantId/workflows/deployments` (stock) | `deployWorkflow` | + +`workflow_deploy` speaks stock Interchange routes (CL-8171): it reads the +tenant's resolved inference catalog from `GET /api/tenants/:tenantId/models`, +flattens it into one priority-ordered `sourceOfferingIds` chain, and posts an +`asset`/`source` deploy naming the commit. The run bearer authenticates both. + +The four authoring operations still call the Workbench-specific +`/api/workflow-workflow-authoring` mount. Their stock equivalent is git +smart-HTTP on the asset repo, which no run-bearer credential reaches today: +`createGitTokenAuth` accepts only an `itx_pat_`/`itx_svc_` git token, and the +stock mint route (`POST /api/tenants/:tenantId/git-tokens`) refuses a caller +with no browser session. CL-8171 carries that upstream ask. A hub refusal surfaces as `WorkflowAuthoringRequestError` (`status`, `code`, `currentHeadSha` on a conflict); the bundle lets it throw, and @@ -46,10 +67,12 @@ A hub refusal surfaces as `WorkflowAuthoringRequestError` (`status`, `code`, ## Env -`requires: ["hubWorkflowAuthoringUrl", "sidecarToken", "address"]` — -`hubWorkflowAuthoringUrl` is threaded in -`apps/sidecar/src/workflow-substrate-factory/step-env.ts` exactly like -`hubCapabilitiesUrl`. +`requires: ["hubWorkflowAuthoringUrl", "tenantId", "sidecarToken", "address"]` +— all four are threaded in +`apps/sidecar/src/workflow-substrate-factory/step-env.ts`; +`hubWorkflowAuthoringUrl` exactly like `hubCapabilitiesUrl`, `tenantId` from +the hub's signed deploy frame, as the `:tenantId` segment of the stock routes +`workflow_deploy` calls. ## Bundle id diff --git a/packages/workflow-authoring-tools/package.json b/packages/workflow-authoring-tools/package.json index 1f3c9de1c7..5b0f4b2935 100644 --- a/packages/workflow-authoring-tools/package.json +++ b/packages/workflow-authoring-tools/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/workflow-authoring-tools", - "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy_preview, workflow_deploy): an @intx/agent tool bundle calling @corbits/workflows's `./authoring`'s workflow-run-authenticated routes so an agent can write a workflow code package into a kind:\"workflow\" hub asset, read it back, preview a deploy's grant surface, and deploy it through Interchange's native source pipeline behind a human approval", - "version": "0.0.6", + "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy_preview, workflow_deploy): an @intx/agent tool bundle that writes a workflow code package into a kind:\"workflow\" hub asset, reads it back and previews a deploy's grant surface through @corbits/workflows's `./authoring`'s workflow-run-authenticated routes, and deploys it behind a human approval through Interchange's stock POST /api/tenants/:tenantId/workflows/deployments route", + "version": "0.0.7", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/workflow-authoring-tools/src/client.test.ts b/packages/workflow-authoring-tools/src/client.test.ts index fae392730f..2e481e9b5e 100644 --- a/packages/workflow-authoring-tools/src/client.test.ts +++ b/packages/workflow-authoring-tools/src/client.test.ts @@ -2,6 +2,8 @@ import { expect, test } from "bun:test"; import { authorWorkflow, + deployWorkflow, + orderedSourceOfferingIds, readWorkflowSource, republishWorkflow, WorkflowAuthoringRequestError, @@ -22,6 +24,7 @@ function capture(respond: () => Response): { return { config: { hubWorkflowAuthoringUrl: "https://hub.example.com", + tenantId: "tenant_1", sidecarToken: "sc-token", address: "run_1@workflow", fetchImpl, @@ -158,3 +161,114 @@ test("a success body of the wrong shape is rejected", async () => { authorWorkflow(config, { name: "x", files: FILES }), ).rejects.toThrow(/expected shape/); }); + +// The deleted `/api/workflow-workflow-authoring/:assetId/deploy` mirror +// resolved this chain server-side from `listVisibleOfferings`, sorted by +// priority. The stock route takes it from the caller, and the stock +// discovery route it is rebuilt from groups its offerings under each +// model — so the flattening has to restore one global priority order +// across models, not preserve the per-model grouping. +test("orderedSourceOfferingIds flattens the discovery response into one priority order across models", () => { + expect( + orderedSourceOfferingIds([ + { + offerings: [ + { offeringId: "off_slow", priority: 30 }, + { offeringId: "off_mid", priority: 20 }, + ], + }, + { offerings: [{ offeringId: "off_fast", priority: 10 }] }, + ]), + ).toEqual(["off_fast", "off_mid", "off_slow"]); +}); + +test("deployWorkflow reads the stock catalog, then posts an asset/source deploy to the stock route", async () => { + const seen: { url: string; init: RequestInit | undefined }[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return String(url).endsWith("/models") + ? new Response( + JSON.stringify([ + { offerings: [{ offeringId: "off_2", priority: 20 }] }, + { offerings: [{ offeringId: "off_1", priority: 10 }] }, + ]), + ) + : new Response( + JSON.stringify({ + id: "run_1", + tenantId: "tenant_1", + definitionAssetId: "asset_1", + status: "pending", + createdAt: "2026-01-01T00:00:00.000Z", + }), + { status: 201 }, + ); + }) as unknown as typeof fetch; + const config: WorkflowAuthoringClientConfig = { + hubWorkflowAuthoringUrl: "https://hub.example.com", + tenantId: "tenant_1", + sidecarToken: "sc-token", + address: "run_1@workflow", + fetchImpl, + }; + + const result = await deployWorkflow(config, { + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }); + + expect(result).toEqual({ + deploymentId: "run_1", + definitionAssetId: "asset_1", + status: "pending", + }); + expect(seen[0]?.url).toBe( + "https://hub.example.com/api/tenants/tenant_1/models", + ); + expect(seen[1]?.url).toBe( + "https://hub.example.com/api/tenants/tenant_1/workflows/deployments", + ); + expect(JSON.parse(String(seen[1]?.init?.body))).toEqual({ + source: { + kind: "asset", + assetId: "asset_1", + package: { format: "source", commitSha: "sha_1" }, + }, + entry: "./workflow.ts", + sourceOfferingIds: ["off_1", "off_2"], + defaultSourceOfferingId: "off_1", + }); + const headers = seen[1]?.init?.headers as Record; + expect(headers["authorization"]).toBe("Bearer sc-token"); + expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); +}); + +test("deployWorkflow surfaces the stock error envelope's message", async () => { + const fetchImpl = (async (url: string | URL) => + String(url).endsWith("/models") + ? new Response( + JSON.stringify([ + { offerings: [{ offeringId: "off_1", priority: 1 }] }, + ]), + ) + : new Response( + JSON.stringify({ + error: { code: "invalid_workflow", message: "entry not found" }, + }), + { status: 409 }, + )) as unknown as typeof fetch; + const err = await deployWorkflow( + { + hubWorkflowAuthoringUrl: "https://hub.example.com", + tenantId: "tenant_1", + sidecarToken: "sc-token", + address: "run_1@workflow", + fetchImpl, + }, + { assetId: "asset_1", commitSha: "sha_1", entry: "./workflow.ts" }, + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(WorkflowAuthoringRequestError); + expect((err as WorkflowAuthoringRequestError).code).toBe("invalid_workflow"); + expect((err as Error).message).toBe("entry not found"); +}); diff --git a/packages/workflow-authoring-tools/src/client.ts b/packages/workflow-authoring-tools/src/client.ts index 9bfaac16fd..37fa2b5386 100644 --- a/packages/workflow-authoring-tools/src/client.ts +++ b/packages/workflow-authoring-tools/src/client.ts @@ -1,9 +1,23 @@ -// A minimal client for the workflow-run-authenticated authoring surface -// (`@corbits/workflows`'s `./authoring`'s `createWorkflowAuthorRoutes`, -// mounted in `apps/hub` at `/api/workflow-workflow-authoring`). Every -// call carries the run's own sidecar bearer token and run address — -// the same two headers `@corbits/capability-tools` sends — so the hub -// resolves tenant and principal from the run, never from an argument. +// Two surfaces, one credential. +// +// `deployWorkflow` speaks the STOCK `@intx/hub-api` routes: `GET +// /api/tenants/:tenantId/models` for the tenant's resolved inference +// catalog, then `POST /api/tenants/:tenantId/workflows/deployments`. The +// run bearer authenticates both — `apps/hub/src/workflow-run-tenant-auth.ts` +// resolves the sidecar token + run address into a principal and tenant +// across the whole tenant subtree. The tenant id is a path segment on +// every stock route, so it rides in the client config; it is never +// trusted as authority, since the hub sets the acting tenant from the +// authenticated run alone. +// +// `authorWorkflow` / `republishWorkflow` / `readWorkflowSource` / +// `previewDeployWorkflow` still call the Workbench-specific +// `/api/workflow-workflow-authoring` mount. Their stock equivalent is git +// smart-HTTP on the asset repo, which no run-bearer credential can reach +// today: `createGitTokenAuth` accepts only an `itx_pat_`/`itx_svc_` git +// token, and the stock mint route refuses a caller with no browser +// session. See CL-8171 for the upstream ask that would let these four +// follow `deploy` onto stock routes. import { type } from "arktype"; import { runBearerHeaders, @@ -15,6 +29,8 @@ export interface WorkflowAuthoringClientConfig extends RunBearerClientConfig { /** The hub's plain HTTP origin, the same value every other tool * bundle's `hub*Url` env key carries. */ readonly hubWorkflowAuthoringUrl: string; + /** The run's own tenant — the `:tenantId` segment of every stock route. */ + readonly tenantId: string; } export type WorkflowSourceFiles = Readonly>; @@ -116,12 +132,24 @@ const SnapshotResponse = type({ }, }); -const DeployResponse = type({ - data: { - deploymentId: "string", - definitionAssetId: "string", - status: "string", - }, +/** The stock deploy route's response: the anchor run projected as a + * deployment record. */ +const StockDeployResponse = type({ + id: "string", + definitionAssetId: "string", + status: "string", +}); + +/** The stock model-discovery response, narrowed to the two fields the + * deploy's ordered offering chain is built from. */ +const DiscoveredModels = type({ + offerings: type({ offeringId: "string", priority: "number" }).array(), +}).array(); + +/** The stock error envelope (`{error: {code, message}}`), which the + * vendored routes use in place of the canonical `userMessage` shape. */ +const StockErrorResponse = type({ + error: { code: "string", message: "string" }, }); const DeployPreviewResponse = type({ @@ -138,6 +166,60 @@ function endpoint(config: WorkflowAuthoringClientConfig, path: string): string { return `${config.hubWorkflowAuthoringUrl}/api/workflow-workflow-authoring${path}`; } +function stockEndpoint( + config: WorkflowAuthoringClientConfig, + path: string, +): string { + return `${config.hubWorkflowAuthoringUrl}/api/tenants/${encodeURIComponent(config.tenantId)}${path}`; +} + +async function throwForStockFailure( + response: Response, + operation: string, +): Promise { + const body: unknown = await response.json().catch(() => undefined); + const parsed = StockErrorResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `${operation} failed: ${response.status} ${response.statusText}`, + ); + } + throw new WorkflowAuthoringRequestError( + response.status, + parsed.error.code, + parsed.error.message, + ); +} + +/** + * The ordered catalog offering chain a deploy hands the hub, rebuilt on + * the client from the tenant's resolved model catalog. + * + * The deleted `/api/workflow-workflow-authoring/:assetId/deploy` mirror + * resolved this server-side with `listVisibleOfferings` sorted by + * `priority`; the stock route takes it from the caller instead. The + * discovery route already applies the same inheritance, shadowing and + * disable cascade, and groups its offerings under each model, so the + * flattened list is re-sorted by priority here to restore the single + * global ordering the mirror produced. `offeringId` is deduplicated + * because the stock route rejects a chain with a repeat. + */ +export function orderedSourceOfferingIds( + models: readonly { readonly offerings: readonly OfferingOrdering[] }[], +): readonly string[] { + const flattened = models.flatMap((m) => [...m.offerings]); + flattened.sort( + (a, b) => + a.priority - b.priority || a.offeringId.localeCompare(b.offeringId), + ); + return [...new Set(flattened.map((o) => o.offeringId))]; +} + +export type OfferingOrdering = { + readonly offeringId: string; + readonly priority: number; +}; + async function throwForFailure( response: Response, operation: string, @@ -220,8 +302,33 @@ export async function deployWorkflow( input: DeployWorkflowRequest, ): Promise { const doFetch = runBearerFetch(config); + + const catalogResponse = await doFetch(stockEndpoint(config, "/models"), { + headers: runBearerHeaders(config), + }); + if (!catalogResponse.ok) { + await throwForStockFailure( + catalogResponse, + "Reading the workbench's inference catalog", + ); + } + const models = parseOrThrow( + DiscoveredModels, + await catalogResponse.json(), + "Reading the workbench's inference catalog", + ); + const sourceOfferingIds = orderedSourceOfferingIds(models); + const defaultSourceOfferingId = sourceOfferingIds[0]; + if (defaultSourceOfferingId === undefined) { + throw new WorkflowAuthoringRequestError( + 409, + "invalid", + "No catalog offerings are visible to this workbench, so a workflow cannot be deployed", + ); + } + const response = await doFetch( - endpoint(config, `/${encodeURIComponent(input.assetId)}/deploy`), + stockEndpoint(config, "/workflows/deployments"), { method: "POST", headers: { @@ -229,17 +336,30 @@ export async function deployWorkflow( "content-type": "application/json", }, body: JSON.stringify({ - commitSha: input.commitSha, + source: { + kind: "asset", + assetId: input.assetId, + package: { format: "source", commitSha: input.commitSha }, + }, entry: input.entry, + sourceOfferingIds, + defaultSourceOfferingId, }), }, ); - if (!response.ok) await throwForFailure(response, "Deploying a workflow"); - return parseOrThrow( - DeployResponse, + if (!response.ok) { + await throwForStockFailure(response, "Deploying a workflow"); + } + const deployment = parseOrThrow( + StockDeployResponse, await response.json(), "Deploying a workflow", - ).data; + ); + return { + deploymentId: deployment.id, + definitionAssetId: deployment.definitionAssetId, + status: deployment.status, + }; } export async function previewDeployWorkflow( diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index f8b29cdb7f..bec269dd81 100644 --- a/packages/workflow-authoring-tools/src/tool.test.ts +++ b/packages/workflow-authoring-tools/src/tool.test.ts @@ -14,6 +14,7 @@ import { function testEnv(): WorkflowAuthoringEnv { return { hubWorkflowAuthoringUrl: "https://hub.example.com", + tenantId: "tenant_1", sidecarToken: "sc-token", address: "run_1@workflow", } as unknown as WorkflowAuthoringEnv; @@ -47,6 +48,7 @@ test("declares the four no-approval tools with workflow_deploy alone behind appr ]); expect(workflowAuthoringTools.requires).toEqual([ "hubWorkflowAuthoringUrl", + "tenantId", "sidecarToken", "address", ]); @@ -176,25 +178,32 @@ test("workflow_source_read returns the snapshot as JSON the model can parse", as expect(JSON.parse(String(result.content))).toEqual(snapshot); }); -test("workflow_deploy posts only assetId, commitSha, and entry to the deploy route — packageName/toolPackagePins stay client-side for the approval headline", async () => { +test("workflow_deploy posts an asset/source deploy to the stock tenant route — packageName/toolPackagePins stay client-side for the approval headline", async () => { const bundle = workflowAuthoringTools(testEnv()); - let seenUrl: string | undefined; - let seenBody: unknown; + const seen: { url: string; body: unknown }[] = []; const result = await withFetch( (url, init) => { - seenUrl = url; - seenBody = - init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; - return new Response( - JSON.stringify({ - data: { - deploymentId: "run_1", - definitionAssetId: "asset_1", - status: "deployed", - }, - }), - { status: 201 }, - ); + seen.push({ + url, + body: + init?.body !== undefined ? JSON.parse(String(init.body)) : undefined, + }); + return url.endsWith("/models") + ? new Response( + JSON.stringify([ + { offerings: [{ offeringId: "off_1", priority: 10 }] }, + ]), + ) + : new Response( + JSON.stringify({ + id: "run_1", + tenantId: "tenant_1", + definitionAssetId: "asset_1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + }), + { status: 201 }, + ); }, () => bundle.run( @@ -208,15 +217,22 @@ test("workflow_deploy posts only assetId, commitSha, and entry to the deploy rou new AbortController().signal, ), ); - expect(seenUrl).toBe( - "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy", + expect(seen[1]?.url).toBe( + "https://hub.example.com/api/tenants/tenant_1/workflows/deployments", ); // `packageName`/`toolPackagePins` are carried on the approval card via // the tool call's own arguments (see @corbits/approvals' headline.ts), - // not re-sent to the hub — the deploy route only needs commitSha/entry. - expect(seenBody).toEqual({ - commitSha: "sha_1", + // not re-sent to the hub — the stock deploy route names the commit + // through its `source` instead. + expect(seen[1]?.body).toEqual({ + source: { + kind: "asset", + assetId: "asset_1", + package: { format: "source", commitSha: "sha_1" }, + }, entry: "./workflow.ts", + sourceOfferingIds: ["off_1"], + defaultSourceOfferingId: "off_1", }); expect(result.isError).toBe(false); expect(result.content).toContain("run_1"); diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index af3357bc7d..3c1c7b4176 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -39,6 +39,9 @@ export const WORKFLOW_DEPLOY_TOOL = "workflow_deploy"; * way `@corbits/capability-tools`' `hubCapabilitiesUrl` is. */ export interface WorkflowAuthoringEnv extends BaseEnv { readonly hubWorkflowAuthoringUrl: string; + /** The run's own tenant — the `:tenantId` segment of the stock deploy + * and model-discovery routes `workflow_deploy` calls. */ + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; } @@ -99,6 +102,7 @@ function clientConfig( ): WorkflowAuthoringClientConfig { return { hubWorkflowAuthoringUrl: env.hubWorkflowAuthoringUrl, + tenantId: env.tenantId, sidecarToken: env.sidecarToken, address: env.address, }; @@ -200,7 +204,7 @@ async function runDeploy( */ export const workflowAuthoringTools = defineTool({ id: "@corbits/workflow_authoring/wf", - requires: ["hubWorkflowAuthoringUrl", "sidecarToken", "address"], + requires: ["hubWorkflowAuthoringUrl", "tenantId", "sidecarToken", "address"], definitions: [ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, diff --git a/packages/workflows/src/authoring/index.ts b/packages/workflows/src/authoring/index.ts index 92718bb140..2d53a3fa5f 100644 --- a/packages/workflows/src/authoring/index.ts +++ b/packages/workflows/src/authoring/index.ts @@ -9,9 +9,9 @@ export { type WorkflowAssetSummary, type WorkflowAuthorCaller, type WorkflowAuthorRegistry, - type WorkflowAuthorRepoReads, type WorkflowDeployer, type WorkflowDeployResult, + type WorkflowAuthorRepoReads, type WorkflowSourceSnapshot, } from "./registry"; export { diff --git a/packages/workflows/src/authoring/registry.test.ts b/packages/workflows/src/authoring/registry.test.ts index fe055f9a9d..3d861568d7 100644 --- a/packages/workflows/src/authoring/registry.test.ts +++ b/packages/workflows/src/authoring/registry.test.ts @@ -12,7 +12,6 @@ import { createWorkflowAuthorRegistry, type CreateWorkflowAuthorRegistryDeps, type WorkflowAuthorRepoReads, - type WorkflowDeployer, } from "./registry"; const MANIFEST = JSON.stringify({ @@ -108,17 +107,6 @@ function fakeDb(row: AssetRow | undefined): DB["db"] { } as unknown as DB["db"]; } -function fakeDeployer( - overrides: Partial = {}, -): WorkflowDeployer { - return { - deploy: async () => { - throw new Error("deploy not stubbed"); - }, - ...overrides, - }; -} - function deps( overrides: Partial = {}, ): CreateWorkflowAuthorRegistryDeps { @@ -132,7 +120,6 @@ function deps( allowGrant("read"), ]), conditionRegistry, - deployer: fakeDeployer(), ...overrides, }; } @@ -478,98 +465,13 @@ test("readSource refuses without an asset read grant", async () => { expect((err as WorkflowAuthorError).reason).toBe("forbidden"); }); -test("deploy refuses an asset id that does not resolve in the caller's own tenant", async () => { - let deployCalled = false; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(undefined), - grantStore: fakeGrantStore([workflowGrant("create")]), - deployer: fakeDeployer({ - deploy: async () => { - deployCalled = true; - throw new Error("must not be called"); - }, - }), - }), - ); - const err = await registry - .deploy(caller, "asset_from_another_tenant", { - commitSha: "sha_1", - entry: "./workflow.ts", - }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("not_found"); - expect(deployCalled).toBe(false); -}); - -test("deploy refuses when the grant store has no matching workflow:*/create grant", async () => { - let deployCalled = false; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: fakeGrantStore([allowGrant("create")]), // asset:*/create, not workflow:*/create - deployer: fakeDeployer({ - deploy: async () => { - deployCalled = true; - throw new Error("must not be called"); - }, - }), - }), - ); - const err = await registry - .deploy(caller, "asset_1", { commitSha: "sha_1", entry: "./workflow.ts" }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("forbidden"); - expect(deployCalled).toBe(false); -}); - -test("deploy calls the injected deployer with the caller's own scope once authorized", async () => { - let seen: unknown; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: fakeGrantStore([workflowGrant("create")]), - deployer: fakeDeployer({ - deploy: async (params) => { - seen = params; - return { - deploymentId: "run_1", - definitionAssetId: "asset_1", - status: "deployed", - }; - }, - }), - }), - ); - const result = await registry.deploy(caller, "asset_1", { - commitSha: "sha_1", - entry: "./workflow.ts", - }); - expect(result).toEqual({ - deploymentId: "run_1", - definitionAssetId: "asset_1", - status: "deployed", - }); - expect(seen).toEqual({ - tenantId: "tenant_1", - principalId: "principal_1", - assetId: "asset_1", - assetName: "daily-digest", - commitSha: "sha_1", - entry: "./workflow.ts", - }); -}); - -test("previewDeploy is a static read of the committed source at commitSha: file list, package name, and declared tool pins from an inert entry, never a deploy call", async () => { +test("previewDeploy is a static read of the committed source at commitSha: file list, package name, and declared tool pins from an inert entry", async () => { const pinnedEntry = 'export default { toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }] };\n'; const blobs: Record = { oid_pkg: MANIFEST, oid_entry: pinnedEntry, }; - let deployCalled = false; const registry = createWorkflowAuthorRegistry( deps({ db: fakeDb(ownRow), @@ -591,12 +493,6 @@ test("previewDeploy is a static read of the committed source at commitSha: file } : null, }), - deployer: fakeDeployer({ - deploy: async () => { - deployCalled = true; - throw new Error("must not be called"); - }, - }), }), ); @@ -612,7 +508,6 @@ test("previewDeploy is a static read of the committed source at commitSha: file toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], packageName: "daily-digest", }); - expect(deployCalled).toBe(false); }); test("previewDeploy lists files only, with no tool pins, when the entry is not an inert object literal", async () => { diff --git a/packages/workflows/src/authoring/registry.ts b/packages/workflows/src/authoring/registry.ts index f67587093d..72d4b7d9ad 100644 --- a/packages/workflows/src/authoring/registry.ts +++ b/packages/workflows/src/authoring/registry.ts @@ -6,13 +6,10 @@ // grant-store authorization call (`asset:*`/create for a new asset, // `asset:`/write for a republish, `asset:`/read for a source read) // — and by `validateWorkflowSourceTree` before anything reaches -// `RepoStore`. `deploy` (CL-7361) is a run-authenticated mirror of -// `@intx/hub-api`'s existing source-based `POST .../workflows/deployments` -// route: it checks `workflow:*`/`create` itself, then calls a -// `WorkflowDeployer` apps/hub injects that wraps the SAME -// `prepareProvisionedDeployment` call the native route makes, with -// catalog offering ids resolved server-side, never supplied by the caller. -// No install/probe/gate/freeze logic is reimplemented here. +// `RepoStore`. Deploying is not this registry's job: an agent deploys a +// committed workflow asset through stock `POST +// /api/tenants/:tenantId/workflows/deployments` with the run bearer +// (CL-8171). // // `populateAsset` is called with `principal: { kind: "hub" }`, the same // principal `@corbits/skills`' `writeSkillMd` uses. This is deliberate, @@ -96,17 +93,37 @@ export type RepublishWorkflowInput = { readonly expectedHeadSha?: string; }; -export type DeployWorkflowInput = { - readonly commitSha: string; - readonly entry: string; -}; - export type WorkflowDeployResult = { readonly deploymentId: string; readonly definitionAssetId: string; readonly status: "deployed" | "pending"; }; +/** + * The apps/hub-supplied seam onto the same operation the native `POST + * /workflows/deployments` route drives (`prepareProvisionedDeployment`), + * with catalog offering ids resolved server-side. An agent authoring a + * workflow does not use it — it calls the stock route directly with the + * run bearer (CL-8171); the hub's own on-demand catalog-block deploy + * does. + */ +export type WorkflowDeployer = { + deploy(params: { + tenantId: string; + principalId: string; + assetId: string; + assetName: string; + commitSha: string; + entry: string; + }): Promise; +}; + +/** The commit a `previewDeploy` renders. */ +export type DeployWorkflowInput = { + readonly commitSha: string; + readonly entry: string; +}; + export type WorkflowDeployPreviewResult = { readonly commitSha: string; readonly entry: string; @@ -122,31 +139,6 @@ export type WorkflowDeployPreviewResult = { readonly packageName: string; }; -/** - * The apps/hub-supplied seam onto the same operation the native - * `POST /workflows/deployments` route drives - * (`prepareProvisionedDeployment`), with catalog offering ids resolved - * server-side. Thrown failures are `WorkflowAuthorError`s with a reason - * this registry passes straight through: `not_found` (asset/commit - * missing), `invalid` (rejected package/definition or empty catalog), - * `unavailable` (sidecar unreachable). - * - * CL-7362: this seam carries no `previewDeploy` — the preview - * (`registry.previewDeploy` below) never touches `sessionService` at all, - * so it cannot freeze anything even by accident. It is a static read of - * the already-committed source through `RepoStore` alone. - */ -export type WorkflowDeployer = { - deploy(params: { - tenantId: string; - principalId: string; - assetId: string; - assetName: string; - commitSha: string; - entry: string; - }): Promise; -}; - export type WorkflowAuthorRegistry = { author( caller: WorkflowAuthorCaller, @@ -161,11 +153,6 @@ export type WorkflowAuthorRegistry = { caller: WorkflowAuthorCaller, assetId: string, ): Promise; - deploy( - caller: WorkflowAuthorCaller, - assetId: string, - input: DeployWorkflowInput, - ): Promise; previewDeploy( caller: WorkflowAuthorCaller, assetId: string, @@ -184,7 +171,6 @@ export type CreateWorkflowAuthorRegistryDeps = { repoStore: WorkflowAuthorRepoReads; grantStore: GrantStore; conditionRegistry: ConditionRegistry; - deployer: WorkflowDeployer; }; async function requireAuthorized( @@ -450,23 +436,6 @@ export function createWorkflowAuthorRegistry( return { assetId, name: row.name, headSha, files }; }, - async deploy(caller, assetId, input) { - // Own-tenant scoping resolved BEFORE the grant check, same as every - // other write here: an asset id from another tenant reads as - // not_found, never a 403 confirming the id exists. - const row = await requireOwnWorkflowAsset(caller, assetId); - await requireAuthorized(deps, caller, "workflow:*", "create"); - - return deps.deployer.deploy({ - tenantId: caller.tenantId, - principalId: caller.principalId, - assetId, - assetName: row.name, - commitSha: input.commitSha, - entry: input.entry, - }); - }, - async previewDeploy(caller, assetId, input) { // Own-tenant scoping and the same `workflow:*`/create authorization // as `deploy`: a preview shows exactly what `deploy` would name. @@ -475,7 +444,7 @@ export function createWorkflowAuthorRegistry( // A STATIC read of the already-committed source at `commitSha` — // never install/probe/gate/freeze, so this truly cannot deploy - // anything. See `WorkflowDeployer`'s doc comment. + // anything. const reads = await repoStore.openCommittedReadsAtCommit( HUB_PRINCIPAL, { kind: WORKFLOW_ASSET_KIND, id: assetId }, diff --git a/packages/workflows/src/authoring/workflow-routes.test.ts b/packages/workflows/src/authoring/workflow-routes.test.ts index 304e1ce57b..40d8aa97c5 100644 --- a/packages/workflows/src/authoring/workflow-routes.test.ts +++ b/packages/workflows/src/authoring/workflow-routes.test.ts @@ -27,9 +27,6 @@ function fakeRegistry( readSource: async () => { throw new Error("readSource not stubbed"); }, - deploy: async () => { - throw new Error("deploy not stubbed"); - }, previewDeploy: async () => { throw new Error("previewDeploy not stubbed"); }, @@ -224,83 +221,7 @@ test("GET /:assetId/source returns the registry's snapshot for the authenticated expect(body.data.headSha).toBe("sha_head"); }); -test("POST /:assetId/deploy returns the deployment on the happy path", async () => { - let seen: { assetId: string; commitSha: string; entry: string } | undefined; - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - deploy: async (_caller, assetId, input) => { - seen = { assetId, ...input }; - return { - deploymentId: "run_1", - definitionAssetId: assetId, - status: "deployed", - }; - }, - }), - }); - const res = await app.request( - req("/asset_1/deploy", { commitSha: "sha_1", entry: "./workflow.ts" }), - ); - expect(res.status).toBe(201); - expect(seen).toEqual({ - assetId: "asset_1", - commitSha: "sha_1", - entry: "./workflow.ts", - }); - const body = (await res.json()) as { - data: { deploymentId: string; status: string }; - }; - expect(body.data.deploymentId).toBe("run_1"); - expect(body.data.status).toBe("deployed"); -}); - -test("POST /:assetId/deploy surfaces a forbidden deploy as 403, not a 500", async () => { - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - deploy: async () => { - throw new WorkflowAuthorError( - "forbidden", - 'principal principal_1 is not granted "create" on "workflow:*"', - ); - }, - }), - }); - const res = await app.request( - req("/asset_1/deploy", { commitSha: "sha_1", entry: "./workflow.ts" }), - ); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("forbidden"); -}); - -test("POST /:assetId/deploy surfaces a sidecar-unavailable deploy as 502", async () => { - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - deploy: async () => { - throw new WorkflowAuthorError("unavailable", "sidecar unreachable"); - }, - }), - }); - const res = await app.request( - req("/asset_1/deploy", { commitSha: "sha_1", entry: "./workflow.ts" }), - ); - expect(res.status).toBe(502); -}); - -test("POST /:assetId/deploy/preview returns a static read of the committed source without deploying", async () => { - let deployCalled = false; +test("POST /:assetId/deploy/preview returns a static read of the committed source", async () => { let seen: { assetId: string; commitSha: string; entry: string } | undefined; const app = createWorkflowAuthorRoutes({ authenticator: fakeAuthenticator({ @@ -308,10 +229,6 @@ test("POST /:assetId/deploy/preview returns a static read of the committed sourc principalId: "principal_1", }), registry: fakeRegistry({ - deploy: async () => { - deployCalled = true; - throw new Error("must not be called by a preview"); - }, previewDeploy: async (_caller, assetId, input) => { seen = { assetId, ...input }; return { @@ -352,5 +269,4 @@ test("POST /:assetId/deploy/preview returns a static read of the committed sourc toolPackagePins: [], packageName: "daily-digest", }); - expect(deployCalled).toBe(false); }); diff --git a/packages/workflows/src/authoring/workflow-routes.ts b/packages/workflows/src/authoring/workflow-routes.ts index 1681503a72..dc7b091a89 100644 --- a/packages/workflows/src/authoring/workflow-routes.ts +++ b/packages/workflows/src/authoring/workflow-routes.ts @@ -11,10 +11,11 @@ // never name a different tenant or write into another principal's // workflow asset. // -// `POST /:assetId/deploy` (CL-7361) extends this surface to deployment: a -// run-authenticated mirror of the native `/workflows/deployments` route, -// authorized and gated exactly the same way — see `./registry.ts`'s doc -// comment on `deploy`. +// Deployment is NOT here: `@corbits/workflow-authoring-tools` calls stock +// `POST /api/tenants/:tenantId/workflows/deployments` with the run bearer +// instead. What remains is the git half — reading and writing the asset +// repo's trees — which no run-bearer credential can do against stock git +// smart-HTTP today (CL-8171). import { type } from "arktype"; import { Hono } from "hono"; import { makeErrorEnvelope } from "@corbits/error-sink"; @@ -50,11 +51,6 @@ const RepublishBody = type({ "expectedHeadSha?": "string", }); -const DeployBody = type({ - commitSha: "string", - entry: "string", -}); - const DeployPreviewBody = type({ commitSha: "string", entry: "string", @@ -190,25 +186,5 @@ export function createWorkflowAuthorRoutes( return c.json({ data: result }); }); - app.post("/:assetId/deploy", async (c) => { - const body = DeployBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: body.summary, - }), - 400, - ); - } - const scope = c.get("workflowRunScope"); - const result = await deps.registry.deploy( - scope, - c.req.param("assetId"), - body, - ); - return c.json({ data: result }, 201); - }); - return app; } diff --git a/workflows/assistant/src/prompt.ts b/workflows/assistant/src/prompt.ts index 69accbb87c..d4aeffe583 100644 --- a/workflows/assistant/src/prompt.ts +++ b/workflows/assistant/src/prompt.ts @@ -39,7 +39,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/mcp-tools", version: "0.0.11" }, { name: "@corbits/interaction-tools", version: "0.0.9" }, { name: "@corbits/manus-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.7" }, { name: "@corbits/access-tools", version: "0.0.5" }, ];