Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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:<id>`/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:<id>`/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({
Expand All @@ -1893,7 +1895,6 @@ export async function createHub(config: HubConfig) {
repoStore: agentRepoStore.repoStore,
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
deployer: workflowDeployer,
}),
}),
);
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 6 additions & 3 deletions apps/sidecar/src/workflow-substrate-factory/step-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
Expand Down
47 changes: 35 additions & 12 deletions packages/workflow-authoring-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -34,22 +41,38 @@ 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
`@intx/agent`'s tool runner turns the message into an `isError` result.

## 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

Expand Down
4 changes: 2 additions & 2 deletions packages/workflow-authoring-tools/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
114 changes: 114 additions & 0 deletions packages/workflow-authoring-tools/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { expect, test } from "bun:test";

import {
authorWorkflow,
deployWorkflow,
orderedSourceOfferingIds,
readWorkflowSource,
republishWorkflow,
WorkflowAuthoringRequestError,
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, string>;
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");
});
Loading
Loading