diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 7f095e5a5..bf105564c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -49,6 +49,9 @@ import { WorkflowDefinitionInvalidError, } from "@intx/workflow-deploy"; import type { HarnessConfig } from "@intx/types/runtime"; +// CL-7362: computes the preview's wire hash from the probed-but-unapproved +// projection `installAndApproveWorkflowSource` returns on `grants_not_approved` +// — the gate itself only stamps this hash on the `ok:true` arm. import { createAgentDefinitionRoutes, @@ -1891,6 +1894,15 @@ export async function createHub(config: HubConfig) { // considered at this step, matching `agent-definitions`' identical // tenant-default resolution above; deploy always resolves against the // tenant's default/first-preference model. + // + // `wf_deploy_preview` (CL-7362) is NOT wired through this + // deployer, and is not a probe-without-freeze call into native + // `sessionService` — a reviewed vendored delta that would have enabled + // that was reverted (see VENDORED.md). Instead `registry.previewDeploy` + // (packages/agent-workflow-authoring) does a static, read-only render of + // the already-committed source at `commitSha` straight off `RepoStore`, + // parsing `package.json` and the entry module text; it never touches + // install/probe/gate/freeze, so it truly cannot deploy anything. const workflowDeployer: WorkflowDeployer = { async deploy({ tenantId, principalId, assetId, commitSha, entry }) { const tenantRow = await db.query.tenant.findFirst({ diff --git a/docs/workflow-model.md b/docs/workflow-model.md index 262575d6a..c186fe91b 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -65,22 +65,31 @@ redeploy (`resolveDefinitionSources`). ### Deploy approval for agent-authored workflows Upstream's deploy route freezes with `approvals: { mode: "approve-probed" }` -(`vendor/intx/hub-sessions/src/session-service.ts`); the `ApprovalSet` -gate exists as a policy type but has no pending-approval record. The only -native pending-approval store is the runtime `approval` resource that an -`approval: "ask"` tool call parks on. Workbench composes those two seams and -adds no approval table: - -1. Myra calls a preview operation that runs the native probe with an empty - `ApprovalSet` and returns the walked grant surface plus the wire hash. No - freeze. +(`vendor/intx/hub-sessions/src/session-service.ts`), unmodified — no +vendored delta grants a caller-supplied approval policy or a +probe-without-freeze entry point (one was prototyped for CL-7362 and +reverted; see VENDORED.md). The only native pending-approval store is the +runtime `approval` resource that an `approval: "ask"` tool call parks on. +Workbench composes what exists, with no vendored delta and no approval +table: + +1. Myra calls `wf_deploy_preview`, a STATIC, read-only render of the + already-committed source at `commitSha` — package name, file list, and + any `toolPackagePins` a plain `export default {...}` entry declares. + Never installs, probes, gates, or freezes anything, so it truly cannot + deploy. 2. Myra calls `workflow_deploy` (`approval: "ask"`) with the asset id, - commit sha, expected wire hash, and that grant list. The tool call parks; - the human sees exactly what will be approved. -3. On approval the tool posts to the native deployments route. The native - probe re-runs; a wire hash that differs from the approved one fails - closed. Rejection leaves the source intact and the definition - unlaunchable. + commit sha, entry, and the preview's `packageName`/`toolPackagePins` + carried along on the call. The tool call parks; the approval headline + reads "Deploy workflow \ @ \ — tools: \" — the committed source the human is approving, not yet the + grants/capabilities the deploy will freeze (no no-freeze probe seam + exists to preview those; see the vendored-delta revert above). +3. On approval the tool posts to the native deployments route, which runs + the real install + probe + gate + freeze under the default + `approve-probed` policy. A rejection there leaves the source intact and + the definition unlaunchable; runtime tool calls against the deployed + definition remain approval-gated regardless. Myra cannot resolve approvals: `approval:*`/`resolve` is never minted for an agent principal. diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index aa8fe704b..fcb6c3961 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -33,15 +33,15 @@ not what an agent authors by hand. ## The operations, in order -| Step | Operation | Authorized as | Returns | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| 1 | `POST /api/workflow-workflow-authoring/author` (`@corbits/agent-workflow-authoring`) → `AssetService.createAsset` + `populateAsset` | Run bearer + run address → tenant/principal; `asset:*`/`create` | `{ assetId, name, commitSha }` | -| 1' | `.../republish` → `populateAsset` on `refs/heads/main` | `asset:`/`write`, own-tenant row check first | `{ assetId, name, commitSha }` | -| 1'' | `GET .../:assetId/source` → `RepoStore.resolveRef` + `openCommittedReads` on `refs/heads/main` | `asset:`/`read`, own-tenant row check first | `{ assetId, name, headSha, files }` | -| 2 | Preview: native probe with empty `ApprovalSet`, no freeze (CL-7362, not yet built) | Same run scope | `{ wireHash, grants[] }` or an invalid-package error | -| 3 | `POST /api/workflow-workflow-authoring/:assetId/deploy` (CL-7361) → same `sessionService.deployWorkflowFromSource` call the native `POST /api/tenants/:tenantId/workflows/deployments` route drives, with `sources` resolved server-side from the tenant catalog (`modelRequirements: null` — a per-workflow model requirement, if the package ever declares one, is NOT considered at this step; resolution always targets the tenant's default/first-preference model) | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"`, and the approval card currently shows only the tool args (asset/commit/entry) — not the probed grant surface, see CL-7362 below | `{ deploymentId, definitionAssetId, status }` | -| 4 | Human resolves the parked approval (native `approvals` route) | `approval:*`/`resolve` | Deploy continues or is rejected | -| 5 | `workflow_definition` row frozen; appears in routine target discovery | — | Launchable | +| Step | Operation | Authorized as | Returns | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 1 | `POST /api/workflow-workflow-authoring/author` (`@corbits/agent-workflow-authoring`) → `AssetService.createAsset` + `populateAsset` | Run bearer + run address → tenant/principal; `asset:*`/`create` | `{ assetId, name, commitSha }` | +| 1' | `.../republish` → `populateAsset` on `refs/heads/main` | `asset:`/`write`, own-tenant row check first | `{ assetId, name, commitSha }` | +| 1'' | `GET .../:assetId/source` → `RepoStore.resolveRef` + `openCommittedReads` on `refs/heads/main` | `asset:`/`read`, own-tenant row check first | `{ assetId, name, headSha, files }` | +| 2 | `POST .../:assetId/deploy/preview` (CL-7362) — a STATIC, read-only render of the already-committed source at `commitSha` off `RepoStore` alone; never installs, probes, gates, or freezes | Same run scope | `{ commitSha, entry, files[], toolPackagePins[], packageName }` or an invalid-package error | +| 3 | `POST /api/workflow-workflow-authoring/:assetId/deploy` (CL-7361) → same `sessionService.deployWorkflowFromSource` call the native `POST /api/tenants/:tenantId/workflows/deployments` route drives, with `sources` resolved server-side from the tenant catalog (`modelRequirements: null` — a per-workflow model requirement, if the package ever declares one, is NOT considered at this step; resolution always targets the tenant's default/first-preference model) | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"`, and the approval card shows the package name and any statically-declared tool pins from step 2 — not the grants/capabilities the deploy will freeze, see CL-7362 below | `{ deploymentId, definitionAssetId, status }` | +| 4 | Human resolves the parked approval (native `approvals` route) | `approval:*`/`resolve` | Deploy continues or is rejected | +| 5 | `workflow_definition` row frozen; appears in routine target discovery | — | Launchable | The deploy body is the same one `packages/hub-client/src/seed.ts` sends: @@ -115,14 +115,13 @@ sequenceDiagram A->>A: authorize asset:*/create, validate paths + package A->>S: createAsset + populateAsset (hub-signed commit) S-->>M: { assetId, commitSha } - M->>A: deploy preview { assetId, commitSha } - A->>P: probe (empty ApprovalSet, no freeze) - P-->>M: { wireHash, grants } - M->>M: workflow_deploy (approval: ask) parks - H->>H: inspects asset, commit, grants; approves + M->>A: deploy preview { assetId, commitSha, entry } + A->>S: RepoStore.openCommittedReadsAtCommit (static read, no probe) + S-->>M: { packageName, files, toolPackagePins } + M->>M: workflow_deploy (approval: ask) parks, carrying packageName/toolPackagePins + H->>H: inspects the committed source's package + tools; approves M->>D: POST { source: asset/source/commitSha, entry } - D->>P: bundle, probe, capability walk - P-->>D: wireHash must equal approved; else fail closed + D->>P: bundle, probe, capability walk, gate under approve-probed D->>D: freeze workflow_definition (approved_wire_hash, grant_snapshot) D-->>M: deployment { definitionAssetId } R-->>H: definition selectable as routine target @@ -148,12 +147,16 @@ index.ts` as a `WorkflowDeployer` wrapping the same ## Seams that do not exist yet (and where they go) -- A preview operation returning the probed capability surface before - `workflow_deploy` parks (step 2 in "Deploy approval for agent-authored - workflows", `workflow-model.md`): CL-7362. Until then, the parked - approval's snapshot is `workflow_deploy`'s own tool-call arguments - (asset id, commit, entry) — a human sees what will be deployed, not yet - the grants it will hold. +- A preview operation returning the probed capability surface (grants) + before `workflow_deploy` parks: needs a caller-supplied approval policy + or a probe-without-freeze entry point on native `sessionService` — a + vendored delta prototyping this was reverted (see VENDORED.md, CL-7362). + Until upstream exposes that seam, `wf_deploy_preview` stays a + STATIC read of the already-committed source (package name, files, any + statically-declared `toolPackagePins`) and the parked approval's + snapshot is that plus `workflow_deploy`'s own tool-call arguments — a + human sees the real committed package and its declared tools, not yet + the grants/capabilities the deploy will freeze. - Deleting a file from an authored asset (a `writeTreeDelta`-backed republish, or a `clearPrefix` the substrate accepts at the root). - A compare-and-set republish (`expectedHeadSha` enforced under the repo diff --git a/packages/agent-workflow-authoring/src/registry.test.ts b/packages/agent-workflow-authoring/src/registry.test.ts index 2b6dfa6f3..fe055f9a9 100644 --- a/packages/agent-workflow-authoring/src/registry.test.ts +++ b/packages/agent-workflow-authoring/src/registry.test.ts @@ -561,3 +561,108 @@ test("deploy calls the injected deployer with the caller's own scope once author 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 () => { + 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), + grantStore: fakeGrantStore([workflowGrant("create")]), + repoStore: fakeRepoStore({ + openCommittedReadsAtCommit: async (_p, _r, commitSha) => + commitSha === "sha_1" + ? { + listDir: async (dir) => + dir === "" + ? [ + { name: "package.json", oid: "oid_pkg", type: "blob" }, + { name: "workflow.ts", oid: "oid_entry", type: "blob" }, + ] + : [], + readBlobByOid: async (oid) => + new TextEncoder().encode(blobs[oid] ?? ""), + treeOid: async () => null, + } + : null, + }), + deployer: fakeDeployer({ + deploy: async () => { + deployCalled = true; + throw new Error("must not be called"); + }, + }), + }), + ); + + const result = await registry.previewDeploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "workflow.ts", + }); + + expect(result).toEqual({ + commitSha: "sha_1", + entry: "workflow.ts", + files: ["package.json", "workflow.ts"], + 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 () => { + const blobs: Record = { oid_pkg: MANIFEST, oid_entry: ENTRY }; + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([workflowGrant("create")]), + repoStore: fakeRepoStore({ + openCommittedReadsAtCommit: async () => ({ + listDir: async (dir) => + dir === "" + ? [ + { name: "package.json", oid: "oid_pkg", type: "blob" }, + { name: "workflow.ts", oid: "oid_entry", type: "blob" }, + ] + : [], + readBlobByOid: async (oid) => + new TextEncoder().encode(blobs[oid] ?? ""), + treeOid: async () => null, + }), + }), + }), + ); + + const result = await registry.previewDeploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "workflow.ts", + }); + expect(result.toolPackagePins).toEqual([]); + expect(result.files).toEqual(["package.json", "workflow.ts"]); +}); + +test("previewDeploy is not_found when the commit does not exist", async () => { + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([workflowGrant("create")]), + repoStore: fakeRepoStore({ + openCommittedReadsAtCommit: async () => null, + }), + }), + ); + + const err = await registry + .previewDeploy(caller, "asset_1", { + commitSha: "sha_missing", + entry: "workflow.ts", + }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(WorkflowAuthorError); + expect((err as WorkflowAuthorError).reason).toBe("not_found"); +}); diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index 8417fbd40..7e0d4e975 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -46,9 +46,11 @@ import { import type { DB } from "@intx/db"; import { asset as assetTable } from "@intx/db/schema"; import { and, eq } from "drizzle-orm"; +import { type } from "arktype"; +import { PackageJSON } from "@intx/types/package-json"; import { WorkflowAuthorError } from "./errors"; -import { validateWorkflowSourceTree } from "./source-tree"; +import { PACKAGE_JSON_PATH, validateWorkflowSourceTree } from "./source-tree"; const WORKFLOW_ASSET_KIND = "workflow"; const HUB_PRINCIPAL = { kind: "hub" } as const; @@ -102,6 +104,21 @@ export type WorkflowDeployResult = { readonly status: "deployed" | "pending"; }; +export type WorkflowDeployPreviewResult = { + readonly commitSha: string; + readonly entry: string; + /** Every repo-relative file path in the committed tree at `commitSha`. */ + readonly files: readonly string[]; + /** The `toolPackagePins` an inert `export default {...}` entry declares; + * empty when the entry isn't a plain object literal (a folded/built + * workflow — pins aren't statically knowable there without execution). */ + readonly toolPackagePins: readonly { + readonly name: string; + readonly version: string; + }[]; + readonly packageName: string; +}; + /** * The apps/hub-supplied seam onto the same operation the native * `POST /workflows/deployments` route drives (`sessionService. @@ -110,6 +127,11 @@ export type WorkflowDeployResult = { * failures are `WorkflowAuthorError`s with a reason this registry passes * straight through: `not_found` (asset/commit missing), `invalid` * (rejected package/definition), `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: { @@ -141,6 +163,11 @@ export type WorkflowAuthorRegistry = { assetId: string, input: DeployWorkflowInput, ): Promise; + previewDeploy( + caller: WorkflowAuthorCaller, + assetId: string, + input: DeployWorkflowInput, + ): Promise; }; export type WorkflowAuthorRepoReads = Pick< @@ -244,6 +271,64 @@ async function collectTree( } } +/** + * CL-7362: a best-effort, read-only render of an inert `export default + * {...}` object literal in an entry module — the shape a folded/single-step + * workflow package's entry commonly takes. Deliberately NOT a JS parser or + * evaluator (the source is untrusted agent output and must never be + * executed): strips the `export default` prefix and a trailing `;`, then + * accepts the remainder only if `JSON.parse` on it (after quoting bare + * object keys, the one common non-JSON literal shape) succeeds. Any import, + * function call, or other executable construct fails this and the caller + * falls back to listing files only. + */ +function tryReadInertDefaultExport(source: string): unknown { + const trimmed = source.trim(); + const match = /^export\s+default\s+([\s\S]*?);?\s*$/.exec(trimmed); + if (match === null || match[1] === undefined) return undefined; + const quotedKeys = match[1].replace( + /([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)/g, + '$1"$2"$3', + ); + try { + return JSON.parse(quotedKeys); + } catch { + // report-error-ignore: a non-JSON entry (real code, not an inert + // literal) is the expected, common case for a folded/multi-step + // workflow — falling back to listing files only, not an error. + return undefined; + } +} + +function extractToolPackagePins( + literal: unknown, +): readonly { readonly name: string; readonly version: string }[] { + if ( + literal === undefined || + literal === null || + typeof literal !== "object" + ) { + return []; + } + const pins = (literal as Record).toolPackagePins; + if (!Array.isArray(pins)) return []; + const out: { readonly name: string; readonly version: string }[] = []; + for (const pin of pins) { + if ( + pin !== null && + typeof pin === "object" && + typeof (pin as Record).name === "string" && + typeof (pin as Record).version === "string" + ) { + out.push({ + name: (pin as { name: string }).name, + version: (pin as { version: string }).version, + }); + } + } + return out; +} + export function createWorkflowAuthorRegistry( deps: CreateWorkflowAuthorRegistryDeps, ): WorkflowAuthorRegistry { @@ -378,5 +463,70 @@ export function createWorkflowAuthorRegistry( 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. + await requireOwnWorkflowAsset(caller, assetId); + await requireAuthorized(deps, caller, "workflow:*", "create"); + + // 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. + const reads = await repoStore.openCommittedReadsAtCommit( + HUB_PRINCIPAL, + { kind: WORKFLOW_ASSET_KIND, id: assetId }, + input.commitSha, + ); + if (reads === null) { + throw new WorkflowAuthorError( + "not_found", + `workflow asset ${assetId} has no commit ${input.commitSha}`, + ); + } + const files: Record = {}; + await collectTree(reads, "", files); + if (!(input.entry in files)) { + throw new WorkflowAuthorError( + "invalid", + `entry ${JSON.stringify(input.entry)} names no file in commit ${input.commitSha}`, + ); + } + const manifestSource = files[PACKAGE_JSON_PATH]; + if (manifestSource === undefined) { + throw new WorkflowAuthorError( + "invalid", + `commit ${input.commitSha} has no top-level ${PACKAGE_JSON_PATH}`, + ); + } + let manifestJson: unknown; + try { + manifestJson = JSON.parse(manifestSource); + } catch (cause) { + throw new WorkflowAuthorError( + "invalid", + `${PACKAGE_JSON_PATH} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + const manifest = PackageJSON(manifestJson); + if (manifest instanceof type.errors) { + throw new WorkflowAuthorError( + "invalid", + `${PACKAGE_JSON_PATH} failed validation: ${manifest.summary}`, + ); + } + const packageName = manifest.name; + const entrySource = files[input.entry] ?? ""; + const inertLiteral = tryReadInertDefaultExport(entrySource); + const toolPackagePins = extractToolPackagePins(inertLiteral); + + return { + commitSha: input.commitSha, + entry: input.entry, + files: Object.keys(files), + toolPackagePins, + packageName, + }; + }, }; } diff --git a/packages/agent-workflow-authoring/src/workflow-routes.test.ts b/packages/agent-workflow-authoring/src/workflow-routes.test.ts index 143bebcd9..304e1ce57 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.test.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.test.ts @@ -30,6 +30,9 @@ function fakeRegistry( deploy: async () => { throw new Error("deploy not stubbed"); }, + previewDeploy: async () => { + throw new Error("previewDeploy not stubbed"); + }, ...overrides, }; } @@ -295,3 +298,59 @@ test("POST /:assetId/deploy surfaces a sidecar-unavailable deploy as 502", async ); expect(res.status).toBe(502); }); + +test("POST /:assetId/deploy/preview returns a static read of the committed source without deploying", async () => { + let deployCalled = false; + let seen: { assetId: string; commitSha: string; entry: string } | undefined; + const app = createWorkflowAuthorRoutes({ + authenticator: fakeAuthenticator({ + tenantId: "tenant_1", + 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 { + commitSha: input.commitSha, + entry: input.entry, + files: ["package.json", "workflow.ts"], + toolPackagePins: [], + packageName: "daily-digest", + }; + }, + }), + }); + const res = await app.request( + req("/asset_1/deploy/preview", { + commitSha: "sha_1", + entry: "./workflow.ts", + }), + ); + expect(res.status).toBe(200); + expect(seen).toEqual({ + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }); + const body = (await res.json()) as { + data: { + commitSha: string; + entry: string; + files: string[]; + toolPackagePins: { name: string; version: string }[]; + packageName: string; + }; + }; + expect(body.data).toEqual({ + commitSha: "sha_1", + entry: "./workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [], + packageName: "daily-digest", + }); + expect(deployCalled).toBe(false); +}); diff --git a/packages/agent-workflow-authoring/src/workflow-routes.ts b/packages/agent-workflow-authoring/src/workflow-routes.ts index 0d54474c9..f7ae72305 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.ts @@ -55,6 +55,11 @@ const DeployBody = type({ entry: "string", }); +const DeployPreviewBody = type({ + commitSha: "string", + entry: "string", +}); + function statusFor( reason: WorkflowAuthorError["reason"], ): 400 | 403 | 404 | 409 | 502 { @@ -160,6 +165,31 @@ export function createWorkflowAuthorRoutes( return c.json({ data: snapshot }); }); + // CL-7362: a preview of `/:assetId/deploy` — a STATIC read of the + // already-committed source at `commitSha` (package name, entry, file + // list, any statically-declared tool pins). Never calls install/probe/ + // gate/freeze, so it cannot deploy anything; a human approves the real + // `workflow_deploy` call with this committed source already visible. + app.post("/:assetId/deploy/preview", async (c) => { + const body = DeployPreviewBody(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.previewDeploy( + scope, + c.req.param("assetId"), + body, + ); + 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) { diff --git a/packages/approvals/src/headline.test.ts b/packages/approvals/src/headline.test.ts index 6a7aebd91..46ad7e12f 100644 --- a/packages/approvals/src/headline.test.ts +++ b/packages/approvals/src/headline.test.ts @@ -47,3 +47,46 @@ test("ignores a blank or non-string title rather than rendering an empty quote", ); expect(headlineFor({ name: "send_email" }, { title: 42 })).toBe("send_email"); }); + +test("workflow_deploy renders the package name, short sha, and declared tool pins directly, ignoring the tool's own description", () => { + expect( + headlineFor( + { name: "workflow_deploy", description: "Deploy a workflow asset..." }, + { + assetId: "asset_daily_digest", + commitSha: "abcdef1234567890", + entry: "./workflow.ts", + packageName: "daily-digest", + toolPackagePins: [ + { name: "@corbits/email-tools", version: "1.2.3" }, + { name: "@corbits/http-tools", version: "0.4.0" }, + ], + }, + ), + ).toBe( + "Deploy workflow daily-digest @ abcdef1 — tools: @corbits/email-tools@1.2.3, @corbits/http-tools@0.4.0", + ); +}); + +test("workflow_deploy with no declared pins reads as 'none declared' rather than an empty list", () => { + expect( + headlineFor( + { name: "workflow_deploy" }, + { + assetId: "asset_daily_digest", + commitSha: "abcdef1234567890", + packageName: "daily-digest", + toolPackagePins: [], + }, + ), + ).toBe("Deploy workflow daily-digest @ abcdef1 — tools: none declared"); +}); + +test("workflow_deploy falls back to the bare assetId when packageName is missing", () => { + expect( + headlineFor( + { name: "workflow_deploy" }, + { assetId: "asset_daily_digest", commitSha: "abcdef1234567890" }, + ), + ).toBe("Deploy workflow asset_daily_digest @ abcdef1 — tools: none declared"); +}); diff --git a/packages/approvals/src/headline.ts b/packages/approvals/src/headline.ts index 48d0cc3e8..ae32f4358 100644 --- a/packages/approvals/src/headline.ts +++ b/packages/approvals/src/headline.ts @@ -11,6 +11,49 @@ function stringField(source: object, field: string): string | undefined { return typeof value === "string" && value.trim() !== "" ? value : undefined; } +function toolPackagePinsField( + source: object, + field: string, +): readonly { readonly name: string; readonly version: string }[] { + if (!(field in source)) return []; + const value = (source as Record)[field]; + if (!Array.isArray(value)) return []; + return value.filter( + (pin): pin is { name: string; version: string } => + pin !== null && + typeof pin === "object" && + typeof (pin as Record).name === "string" && + typeof (pin as Record).version === "string", + ); +} + +/** + * CL-7362: `workflow_deploy` (`@corbits/workflow-authoring-tools`) parks an + * approval whose arguments carry the packageName/toolPackagePins a prior + * `wf_deploy_preview` call (a static read of the committed source) + * reported, passed through. This renders that directly rather than + * falling back to the tool's generic description, so the approval card + * names the real package and tools instead of a bare asset id. It does + * NOT show grants/capabilities: those are stamped by the native + * install+probe+gate `workflow_deploy` itself runs, which has no + * no-freeze preview yet (CL-7362). + */ +function workflowDeployHeadline(toolArguments: object): string | undefined { + const commitSha = stringField(toolArguments, "commitSha"); + if (commitSha === undefined) return undefined; + const packageName = + stringField(toolArguments, "packageName") ?? + stringField(toolArguments, "assetId"); + if (packageName === undefined) return undefined; + const sha7 = commitSha.slice(0, 7); + const pins = toolPackagePinsField(toolArguments, "toolPackagePins"); + const toolsText = + pins.length > 0 + ? pins.map((pin) => `${pin.name}@${pin.version}`).join(", ") + : "none declared"; + return `Deploy workflow ${packageName} @ ${sha7} — tools: ${toolsText}`; +} + /** * Builds the headline for an approval. Prefers the tool's own * `description` — written by the tool's author to be human-readable — @@ -24,6 +67,19 @@ export function headlineFor( toolDefinition: unknown, toolArguments: unknown, ): string { + const toolName = + typeof toolDefinition === "object" && toolDefinition !== null + ? stringField(toolDefinition, "name") + : undefined; + if ( + toolName === "workflow_deploy" && + typeof toolArguments === "object" && + toolArguments !== null + ) { + const deployHeadline = workflowDeployHeadline(toolArguments); + if (deployHeadline !== undefined) return deployHeadline; + } + const base = typeof toolDefinition === "object" && toolDefinition !== null ? (stringField(toolDefinition, "description") ?? diff --git a/packages/workflow-authoring-tools/package.json b/packages/workflow-authoring-tools/package.json index 88d03e5c8..c02548d68 100644 --- a/packages/workflow-authoring-tools/package.json +++ b/packages/workflow-authoring-tools/package.json @@ -1,8 +1,8 @@ { "name": "@corbits/workflow-authoring-tools", "private": true, - "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy): an @intx/agent tool bundle calling @corbits/agent-workflow-authoring's workflow-run-authenticated routes so an agent can write a workflow code package into a kind:\"workflow\" hub asset, read it back, and deploy it through Interchange's native source pipeline behind a human approval", - "version": "0.0.2", + "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/agent-workflow-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.3", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/workflow-authoring-tools/src/client.ts b/packages/workflow-authoring-tools/src/client.ts index 2875867ef..d76a6c7a1 100644 --- a/packages/workflow-authoring-tools/src/client.ts +++ b/packages/workflow-authoring-tools/src/client.ts @@ -50,12 +50,31 @@ export type DeployWorkflowRequest = { readonly entry: string; }; +export type DeployWorkflowPreviewRequest = { + readonly assetId: string; + readonly commitSha: string; + readonly entry: string; +}; + export type WorkflowDeployResult = { readonly deploymentId: string; readonly definitionAssetId: string; readonly status: string; }; +export type ToolPackagePin = { + readonly name: string; + readonly version: string; +}; + +export type WorkflowDeployPreviewResult = { + readonly commitSha: string; + readonly entry: string; + readonly files: readonly string[]; + readonly toolPackagePins: readonly ToolPackagePin[]; + readonly packageName: string; +}; + /** The hub refused the request with a canonical error envelope. `code` * is the envelope's code (`invalid`, `forbidden`, `not_found`, * `conflict`, ...); `currentHeadSha` is set on a republish `conflict` so @@ -104,6 +123,16 @@ const DeployResponse = type({ }, }); +const DeployPreviewResponse = type({ + data: { + commitSha: "string", + entry: "string", + files: "string[]", + toolPackagePins: type({ name: "string", version: "string" }).array(), + packageName: "string", + }, +}); + function authHeaders( config: WorkflowAuthoringClientConfig, ): Record { @@ -212,6 +241,32 @@ export async function deployWorkflow( ).data; } +export async function previewDeployWorkflow( + config: WorkflowAuthoringClientConfig, + input: DeployWorkflowPreviewRequest, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch( + endpoint(config, `/${encodeURIComponent(input.assetId)}/deploy/preview`), + { + method: "POST", + headers: { ...authHeaders(config), "content-type": "application/json" }, + body: JSON.stringify({ + commitSha: input.commitSha, + entry: input.entry, + }), + }, + ); + if (!response.ok) { + await throwForFailure(response, "Previewing a workflow deploy"); + } + return parseOrThrow( + DeployPreviewResponse, + await response.json(), + "Previewing a workflow deploy", + ).data; +} + export async function readWorkflowSource( config: WorkflowAuthoringClientConfig, assetId: string, diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index bcdebd465..f8b29cdb7 100644 --- a/packages/workflow-authoring-tools/src/tool.test.ts +++ b/packages/workflow-authoring-tools/src/tool.test.ts @@ -4,6 +4,7 @@ import type { ToolCall } from "@intx/types/runtime"; import { workflowAuthoringTools, WORKFLOW_AUTHOR_TOOL, + WORKFLOW_DEPLOY_PREVIEW_TOOL, WORKFLOW_DEPLOY_TOOL, WORKFLOW_REPUBLISH_TOOL, WORKFLOW_SOURCE_READ_TOOL, @@ -36,11 +37,12 @@ async function withFetch( } } -test("declares the three source tools with no approval gate and workflow_deploy behind approval: ask", () => { +test("declares the four no-approval tools with workflow_deploy alone behind approval: ask", () => { expect(workflowAuthoringTools.definitions).toEqual([ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, { name: WORKFLOW_SOURCE_READ_TOOL }, + { name: WORKFLOW_DEPLOY_PREVIEW_TOOL }, { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, ]); expect(workflowAuthoringTools.requires).toEqual([ @@ -174,7 +176,7 @@ 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 assetId, commitSha, and entry to the deploy route", async () => { +test("workflow_deploy posts only assetId, commitSha, and entry to the deploy route — packageName/toolPackagePins stay client-side for the approval headline", async () => { const bundle = workflowAuthoringTools(testEnv()); let seenUrl: string | undefined; let seenBody: unknown; @@ -200,6 +202,8 @@ test("workflow_deploy posts assetId, commitSha, and entry to the deploy route", assetId: "asset_1", commitSha: "sha_1", entry: "./workflow.ts", + packageName: "daily-digest", + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], }), new AbortController().signal, ), @@ -207,13 +211,19 @@ test("workflow_deploy posts assetId, commitSha, and entry to the deploy route", expect(seenUrl).toBe( "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy", ); - expect(seenBody).toEqual({ commitSha: "sha_1", entry: "./workflow.ts" }); + // `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", + entry: "./workflow.ts", + }); expect(result.isError).toBe(false); expect(result.content).toContain("run_1"); expect(result.content).toContain("asset_1"); }); -test("workflow_deploy rejects a call missing commitSha without calling the hub", async () => { +test("workflow_deploy rejects a call missing required fields without calling the hub", async () => { const bundle = workflowAuthoringTools(testEnv()); await withFetch( () => { @@ -233,6 +243,52 @@ test("workflow_deploy rejects a call missing commitSha without calling the hub", ); }); +test("wf_deploy_preview posts assetId, commitSha, and entry to the preview route and never approval-gates", async () => { + const bundle = workflowAuthoringTools(testEnv()); + let seenUrl: string | undefined; + const result = await withFetch( + (url) => { + seenUrl = url; + return new Response( + JSON.stringify({ + data: { + commitSha: "sha_1", + entry: "./workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], + packageName: "daily-digest", + }, + }), + ); + }, + () => + bundle.run( + call(WORKFLOW_DEPLOY_PREVIEW_TOOL, { + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }), + new AbortController().signal, + ), + ); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy/preview", + ); + expect(result.isError).toBe(false); + expect(JSON.parse(String(result.content))).toEqual({ + commitSha: "sha_1", + entry: "./workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], + packageName: "daily-digest", + }); + expect( + workflowAuthoringTools.definitions.find( + (d) => d.name === WORKFLOW_DEPLOY_PREVIEW_TOOL, + )?.approval, + ).toBeUndefined(); +}); + test("an unknown tool name rejects loudly, never a silent no-op", async () => { const bundle = workflowAuthoringTools(testEnv()); await expect( diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index 0205796f6..af3357bc7 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -21,6 +21,7 @@ import { type } from "arktype"; import { authorWorkflow, deployWorkflow, + previewDeployWorkflow, readWorkflowSource, republishWorkflow, type WorkflowAuthoringClientConfig, @@ -29,6 +30,7 @@ import { export const WORKFLOW_AUTHOR_TOOL = "workflow_author"; export const WORKFLOW_REPUBLISH_TOOL = "workflow_republish"; export const WORKFLOW_SOURCE_READ_TOOL = "workflow_source_read"; +export const WORKFLOW_DEPLOY_PREVIEW_TOOL = "wf_deploy_preview"; export const WORKFLOW_DEPLOY_TOOL = "workflow_deploy"; /** Env this bundle needs beyond `BaseEnv`: the hub origin under its own @@ -58,10 +60,20 @@ const RepublishInput = type({ const SourceReadInput = type({ assetId: "string > 0" }); +const DeployPreviewInput = type({ + assetId: "string > 0", + commitSha: "string > 0", + entry: "string > 0", +}); + +const ToolPackagePinInput = type({ name: "string > 0", version: "string > 0" }); + const DeployInput = type({ assetId: "string > 0", commitSha: "string > 0", entry: "string > 0", + "packageName?": "string > 0", + "toolPackagePins?": ToolPackagePinInput.array(), }); const PACKAGE_SHAPE_DESCRIPTION = @@ -144,6 +156,18 @@ async function runSourceRead( return textResult(call.id, JSON.stringify(snapshot)); } +async function runDeployPreview( + env: WorkflowAuthoringEnv, + call: ToolCall, +): Promise { + const input = DeployPreviewInput(call.arguments); + if (input instanceof type.errors) { + throw invalidInput(WORKFLOW_DEPLOY_PREVIEW_TOOL, input); + } + const result = await previewDeployWorkflow(clientConfig(env), input); + return textResult(call.id, JSON.stringify(result)); +} + async function runDeploy( env: WorkflowAuthoringEnv, call: ToolCall, @@ -152,7 +176,11 @@ async function runDeploy( if (input instanceof type.errors) { throw invalidInput(WORKFLOW_DEPLOY_TOOL, input); } - const result = await deployWorkflow(clientConfig(env), input); + const result = await deployWorkflow(clientConfig(env), { + assetId: input.assetId, + commitSha: input.commitSha, + entry: input.entry, + }); return textResult( call.id, `Deployed workflow asset ${result.definitionAssetId} as deployment ${result.deploymentId} (status: ${result.status}). ` + @@ -177,6 +205,7 @@ export const workflowAuthoringTools = defineTool({ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, { name: WORKFLOW_SOURCE_READ_TOOL }, + { name: WORKFLOW_DEPLOY_PREVIEW_TOOL }, { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, ], factory: (env) => ({ @@ -264,6 +293,40 @@ export const workflowAuthoringTools = defineTool({ required: ["assetId"], }, }, + { + name: WORKFLOW_DEPLOY_PREVIEW_TOOL, + description: + "Read what deploying a workflow asset's committed source would " + + "run, WITHOUT deploying it: a static read of the already-" + + "committed source at commitSha (never installs, probes, gates, " + + "or freezes anything). Returns the package name, the file list, " + + "and any toolPackagePins statically declared in the entry " + + "module. Call this BEFORE workflow_deploy and pass its " + + "packageName and toolPackagePins on that call, so the human " + + "approving the deploy sees the real package and tools it names.", + inputSchema: { + type: "object", + properties: { + assetId: { + type: "string", + description: "The workflow asset id to preview a deploy of.", + }, + commitSha: { + type: "string", + description: + "The exact commit to preview — the commitSha from " + + "workflow_author, workflow_republish, or " + + "workflow_source_read's headSha.", + }, + entry: { + type: "string", + description: + 'The interchange.workflow entry module path, e.g. "./workflow.ts".', + }, + }, + required: ["assetId", "commitSha", "entry"], + }, + }, { name: WORKFLOW_DEPLOY_TOOL, description: @@ -271,13 +334,15 @@ export const workflowAuthoringTools = defineTool({ "Interchange's native deploy pipeline (install, probe, capability " + "walk, gate, freeze), making it selectable as a routine target. " + "A human must approve this before it runs: the approval card " + - "shows the asset, commit, and entry this call names — it does " + - "not yet show the grants/capabilities the deploy would freeze " + - "(CL-7362), so say so explicitly if you explain this approval " + - "to a human. Inference sources come from the workbench's own " + - "catalog — never pass a model or credential. If the deploy " + - "fails because the probed capability surface changed, re-read " + - "the source and retry.", + "shows the package and tools this call names, sourced from a " + + "prior wf_deploy_preview call on the same commit — call " + + "that FIRST and pass its packageName and toolPackagePins here " + + "so the approval reflects the real committed source, not just " + + "an asset id. Grants/capabilities are stamped by the native " + + "install+probe+gate this call runs, which the human does not " + + "see a preview of yet (CL-7362); say so if you explain this " + + "approval to a human. Inference sources come from the " + + "workbench's own catalog — never pass a model or credential.", inputSchema: { type: "object", properties: { @@ -297,6 +362,26 @@ export const workflowAuthoringTools = defineTool({ description: 'The interchange.workflow entry module path, e.g. "./workflow.ts".', }, + packageName: { + type: "string", + description: + "The packageName returned by wf_deploy_preview for " + + "this same asset/commit, shown on the approval card.", + }, + toolPackagePins: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + required: ["name", "version"], + }, + description: + "The toolPackagePins returned by wf_deploy_preview " + + "for this same asset/commit, shown on the approval card.", + }, }, required: ["assetId", "commitSha", "entry"], }, @@ -310,6 +395,8 @@ export const workflowAuthoringTools = defineTool({ return runRepublish(env, call); case WORKFLOW_SOURCE_READ_TOOL: return runSourceRead(env, call); + case WORKFLOW_DEPLOY_PREVIEW_TOOL: + return runDeployPreview(env, call); case WORKFLOW_DEPLOY_TOOL: return runDeploy(env, call); default: diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 77f7636a6..3315b300e 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -54,7 +54,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/mcp-tools", version: "0.0.10" }, { name: "@corbits/interaction-tools", version: "0.0.4" }, { name: "@corbits/manus-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.2" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.3" }, ]; /**