diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index b9e2fee0a..b90f04588 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -1,33 +1,21 @@ // Deploys a hand-authored agent the same way the default worker deploys // (`worker-deploy.ts`), generalized over {name, displayName, systemPrompt}. -import { renderBundledWorkflowSourceTree } from "@corbits/workflows/client"; import { installPackage } from "./install-package"; import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; -import { - artifactToolsCredentialBinding, - artifactToolsCredentialUseRequirement, - mcpServerCredentialBinding, - mcpServerCredentialUseRequirement, - memoryToolsCredentialBinding, - memoryToolsCredentialUseRequirement, - type McpServerDeployment, -} from "@corbits/worker/workflow-ids"; +import type { ToolEffect } from "@corbits/worker/definition-json"; +import type { McpServerDeployment } from "@corbits/worker/workflow-ids"; import { ensureAgentHubCredential } from "./agent-hub-credential"; import { personMailAddress } from "./mail-address"; import { listMcpServers, resolveWorkspaceTenantId, toMcpServerDeployment } from "./mcp-servers"; import type { McpServer } from "./mcp-servers"; +import { renderWorkerSourceTree } from "./worker-deploy"; import { resolveExistingOffering } from "./onboarding/provider-connect-step"; import { isValidSlug, slugify } from "@/lib/slug"; -/** A non-default tool permission (`ask` is the stock default) to carry onto - * a redeployed run. */ -export type ToolEffect = { - readonly resource: string; - readonly effect: "allow" | "deny"; -}; +export type { ToolEffect }; export class AgentDeployError extends Error {} @@ -45,118 +33,6 @@ async function readErrorBody(response: Response): Promise { return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; } -/** The exact `WorkflowDefinition` JSON for a single-step, mail-triggered, - * unbounded-turn agent — the same shape `buildWorkerDefinitionJson` produces, - * generalized over the caller's own name and system prompt. */ -export function buildAgentDefinitionJson(args: { - slug: string; - systemPrompt: string; - triggerAddress: string; - declaredSources: readonly { readonly provider: string; readonly model: string }[]; - hubCredentialId: string; - /** Workspace-catalog servers this agent binds — the same bindings and use - * requirements Worker carries, so remote tools stay ask-gated except the - * read-only ones the server itself annotates. */ - mcpServers: readonly McpServerDeployment[]; - /** Tool permissions the person set on the previous run, re-declared so the - * new run materializes them at its first trigger. */ - toolEffects: readonly ToolEffect[]; -}): unknown { - const stepId = "run"; - return { - id: args.slug, - // `to` only feeds the deploy-time mail.address/mail.send grants; it is - // not how mail reaches this agent — that happens at its run address. - triggers: [{ type: "mail", to: args.triggerAddress }], - // Resolved at deploy into the `hub` handle the artifact and memory tools - // use, and granted to the run on the deployer's authority at its first - // trigger. One credential, one binding and one requirement per package. - credentialBindings: [ - artifactToolsCredentialBinding(args.slug), - memoryToolsCredentialBinding(args.slug), - ...args.mcpServers.map((server) => mcpServerCredentialBinding(server)), - ], - grantRequirements: [ - artifactToolsCredentialUseRequirement(args.hubCredentialId), - memoryToolsCredentialUseRequirement(args.hubCredentialId), - ...args.mcpServers.map((server) => mcpServerCredentialUseRequirement(server.credentialId)), - ...args.toolEffects.map((tool) => ({ - resource: tool.resource, - action: "invoke", - effect: tool.effect, - source: "creator", - })), - ], - steps: { - [stepId]: { - kind: "step", - id: stepId, - agent: { - id: stepId, - description: `The "${args.slug}" agent`, - systemPrompt: args.systemPrompt, - toolFactories: [], - capabilities: [], - inference: { sources: args.declaredSources.map((source) => ({ ...source })) }, - toolPackagePins: [], - }, - drainBehavior: "wait", - // No `timeout`: it stays armed across an approval park, so any - // finite value aborts a run waiting on a person to answer an - // ask-gated tool call (see packages/worker/src/index.ts). - triggers: "unbounded", - input: { from: "trigger.payload" }, - }, - }, - stepOrder: [stepId], - }; -} - -// The bundle's `buildWorkerWorkflow` is generic over which agent it builds. -async function renderAgentSourceTree( - packageName: string, - args: { - readonly slug: string; - readonly systemPrompt: string; - readonly triggerAddress: string; - readonly declaredSources: readonly { readonly provider: string; readonly model: string }[]; - readonly hubCredentialId: string; - readonly mcpServers: readonly McpServerDeployment[]; - readonly toolEffects: readonly ToolEffect[]; - }, -): Promise> { - const { WORKER_BUNDLE_BUILD_EXPORT, WORKER_DIRECTORS_BUNDLE, WORKER_WORKFLOW_BUNDLE } = - await import("@corbits/worker/bundle"); - return renderBundledWorkflowSourceTree({ - packageName, - bundle: WORKER_WORKFLOW_BUNDLE, - directorsBundle: WORKER_DIRECTORS_BUNDLE, - buildExport: WORKER_BUNDLE_BUILD_EXPORT, - buildInput: { - workflowId: args.slug, - triggerAddress: args.triggerAddress, - inferencePreferences: args.declaredSources.map((source) => ({ ...source })), - systemPrompt: args.systemPrompt, - hubCredentialId: args.hubCredentialId, - // The workspace-catalog servers the New Agent dialog (or Worker's - // create-agent card) bound to this agent; the bundle wires their tools - // behind the deferred director, ask-gated except read-only ones. - mcpServers: args.mcpServers, - }, - workflowJson: JSON.stringify( - buildAgentDefinitionJson({ - slug: args.slug, - systemPrompt: args.systemPrompt, - triggerAddress: args.triggerAddress, - declaredSources: args.declaredSources, - hubCredentialId: args.hubCredentialId, - mcpServers: args.mcpServers, - toolEffects: args.toolEffects, - }), - ), - }); -} - /** Every created agent's source asset name, `agent--source` — the one * naming convention this pipeline owns, so callers can recognize a created * agent's asset without a lookup. */ @@ -198,6 +74,8 @@ export type NewAgentInput = { /** Tool permissions to re-declare on a redeploy, as creator-sourced grant * requirements. */ readonly toolEffects?: readonly ToolEffect[]; + /** Deploy anew even when nothing changed (a restart mints a fresh run). */ + readonly redeploy?: boolean; }; /** Maps requested workspace-catalog handles to deployments. Fails closed on @@ -346,21 +224,25 @@ export async function deployAgentSource( // Minted once the asset exists: the definition binds this credential by // name and requires its use by id, so it must precede the source. files: async (assetId) => - renderAgentSourceTree(packageName, { - slug, - systemPrompt, - triggerAddress, - declaredSources: offering.declaredSources, - hubCredentialId: await ensureAgentHubCredential( - { tenantId: args.tenantId, definitionId: slug, assetId }, - fetchImpl, - ), - mcpServers, - toolEffects: args.input.toolEffects ?? [], - }), + renderWorkerSourceTree( + packageName, + { + workflowId: slug, + systemPrompt, + triggerAddress, + inferencePreferences: offering.declaredSources, + hubCredentialId: await ensureAgentHubCredential( + { tenantId: args.tenantId, definitionId: slug, assetId }, + fetchImpl, + ), + mcpServers, + }, + args.input.toolEffects ?? [], + ), entry: "./workflow.js", sourceOfferingIds: offering.sourceOfferingIds, defaultSourceOfferingId: offering.defaultSourceOfferingId, + ...(args.input.redeploy === true ? { redeploy: true } : {}), }); if (args.input.schedule !== undefined) { await scheduleAgentRun(args.tenantId, args.input.schedule, assetName, tenant.domain, fetchImpl); diff --git a/apps/web/src/install-package.ts b/apps/web/src/install-package.ts index 711447cbd..4ad99ebba 100644 --- a/apps/web/src/install-package.ts +++ b/apps/web/src/install-package.ts @@ -2,6 +2,8 @@ // asset, a short-lived git push token, the package's source pushed to // `main`, then a deployment pinned to that commit. Identical files change // nothing: `main` keeps its commit and the existing deployment is returned. +// The deploy input rides in the tree, so a changed entry or offering chain is +// a changed tree; the stock deployment listing exposes neither. import { type } from "arktype"; import { pushSourceTree } from "./git-push"; @@ -11,7 +13,11 @@ export class InstallPackageError extends Error {} const IdShape = type({ id: "string" }); const AssetListShape = type({ id: "string", name: "string" }).array(); const GitTokenShape = type({ id: "string", secret: "string" }); -const DeploymentListShape = type({ id: "string", definitionAssetId: "string" }).array(); +const DeploymentListShape = type({ + id: "string", + definitionAssetId: "string", + status: "string", +}).array(); const ErrorEnvelope = type({ error: { "userMessage?": "string", "message?": "string" } }); const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000; @@ -29,8 +35,12 @@ export type InstallPackageArgs = { entry: string; sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string; + /** Always deploy anew, even for an identical tree: a restart mints a fresh run. */ + redeploy?: boolean; }; +const INSTALL_INPUT_PATH = ".workbench-install.json"; + async function readError(response: Response): Promise { const body: unknown = await response.json().catch(() => undefined); const envelope = ErrorEnvelope(body); @@ -90,8 +100,9 @@ async function latestDeploymentId( `the deployment list came back an unexpected shape: ${parsed.summary}`, ); } - // The route lists most recent first. - return parsed.find((deployment) => deployment.definitionAssetId === assetId)?.id ?? null; + // The route lists most recent first; a released or failed one is not reusable. + const latest = parsed.find((deployment) => deployment.definitionAssetId === assetId); + return latest?.status === "deployed" ? latest.id : null; } export async function installPackage( @@ -101,7 +112,15 @@ export async function installPackage( const args = { ...input, fetch: input.fetch.bind(globalThis) }; const base = `${args.origin}/api/tenants/${encodeURIComponent(args.tenantId)}`; const assetId = await ensureWorkflowAsset(args, base); - const files = typeof args.files === "function" ? await args.files(assetId) : args.files; + const source = typeof args.files === "function" ? await args.files(assetId) : args.files; + const files = { + ...source, + [INSTALL_INPUT_PATH]: JSON.stringify({ + entry: args.entry, + sourceOfferingIds: args.sourceOfferingIds, + defaultSourceOfferingId: args.defaultSourceOfferingId, + }), + }; const minted = await args.fetch( `${base}/git-tokens`, @@ -133,7 +152,7 @@ export async function installPackage( await args.fetch(`${base}/git-tokens/${encodeURIComponent(token.id)}`, { method: "DELETE" }); } - if (!pushed.changed) { + if (!pushed.changed && args.redeploy !== true) { const existing = await latestDeploymentId(args, base, assetId); if (existing !== null) { return { assetId, commitSha: pushed.commitSha, deploymentId: existing }; diff --git a/apps/web/src/settings/worker-model-redeploy.ts b/apps/web/src/settings/worker-model-redeploy.ts index 708bb9e6e..facbb5382 100644 --- a/apps/web/src/settings/worker-model-redeploy.ts +++ b/apps/web/src/settings/worker-model-redeploy.ts @@ -4,7 +4,7 @@ // allocation service re-resolves by those exact ids, so the old offering // can only be retired once a redeploy has moved Worker onto the new one. // This is the same stock deploy path the client runs at onboarding -// (`worker-deploy.ts`'s `deployWorkerSource`, `POST /workflows/deployments`), +// (`worker-deploy.ts`'s `deployWorkerSource`), // just re-run here with one offering id swapped for another. import { type } from "arktype"; @@ -114,22 +114,9 @@ export async function redeployWorkerForModelChange( if (swapped === null) return false; const tenantDomain = await resolveTenantDomain(input.tenantId, fetchImpl); - const deploy = await deployWorkerSource( - { tenantId: input.tenantId, tenantDomain, ...swapped }, + await deployWorkerSource( + { tenantId: input.tenantId, tenantDomain, ...swapped, redeploy: true }, fetchImpl, ); - const deployed = await fetchImpl( - `/api/tenants/${encodeURIComponent(input.tenantId)}/workflows/deployments`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(deploy), - }, - ); - if (!deployed.ok) { - throw new WorkerRedeployError( - `redeploying the worker onto the new model failed: ${await readErrorBody(deployed)}`, - ); - } return true; } diff --git a/apps/web/src/workbench-create.ts b/apps/web/src/workbench-create.ts index 0fd2b24a1..49c74e28b 100644 --- a/apps/web/src/workbench-create.ts +++ b/apps/web/src/workbench-create.ts @@ -86,14 +86,13 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise { - const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ - error: { code: "string", userMessage: "string", refId: "string" }, - })(body); - return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; -} - -/** Idempotently ensures the `workflow` asset the worker's source is pushed - * into: create it, or on 409 find the existing one by name. */ -export async function ensureWorkerSourceAsset( - tenantId: string, - fetchImpl: typeof fetch = fetch, -): Promise { - const created = await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}/assets`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: WORKER_SOURCE_CONFIG.assetKind, - name: WORKER_SOURCE_CONFIG.assetName, - displayName: WORKER_SOURCE_CONFIG.displayName, - }), - }); - if (created.status === 201) { - const parsed = AssetCreatedShape(await created.json()); - if (parsed instanceof type.errors) { - throw new WorkerDeployError( - `the worker's source came back an unexpected shape: ${parsed.summary}`, - ); - } - return parsed.id; - } - if (created.status !== 409) { - throw new WorkerDeployError( - `preparing the worker's source failed: ${await readErrorBody(created)}`, - ); - } - const listed = await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=${WORKER_SOURCE_CONFIG.assetKind}&inherited=false`, - ); - if (!listed.ok) { - throw new WorkerDeployError( - `checking this workbench's setup failed: ${await readErrorBody(listed)}`, - ); - } - const parsed = AssetListShape(await listed.json()); - if (parsed instanceof type.errors) { - throw new WorkerDeployError( - `this workbench's setup list came back an unexpected shape: ${parsed.summary}`, - ); - } - const existing = parsed.find((asset) => asset.name === WORKER_SOURCE_CONFIG.assetName); - if (existing === undefined) { - throw new WorkerDeployError( - "the worker's source reported a name conflict but is not listed on this workbench", - ); - } - return existing.id; -} - -// Hand-built rather than calling `buildWorkerWorkflow` directly, since that -// pulls in a Node-bound runtime a browser bundle can't resolve. See -// docs/worker-definition-json.md. -export function buildWorkerDefinitionJson( - triggerAddress: string, - declaredSources: readonly DeclaredSource[], - hubCredentialId: string, - mcpServers: readonly McpServerDeployment[], -): unknown { - return { - id: WORKER_WORKFLOW_ID, - // Resolved at deploy into the `hub` handle the artifact and memory tools - // use, and granted to the run on the deployer's authority at its first - // trigger. One credential, one binding and one requirement per package. - credentialBindings: [ - artifactToolsCredentialBinding(WORKER_WORKFLOW_ID), - memoryToolsCredentialBinding(WORKER_WORKFLOW_ID), - ...mcpServers.map((server) => mcpServerCredentialBinding(server)), - ], - grantRequirements: [ - artifactToolsCredentialUseRequirement(hubCredentialId), - memoryToolsCredentialUseRequirement(hubCredentialId), - ...mcpServers.map((server) => mcpServerCredentialUseRequirement(server.credentialId)), - ], - // `to` only feeds the deploy-time mail.address/mail.send grants; Worker is - // actually reached at her run address, minted at deploy time. - triggers: [{ type: "mail", to: triggerAddress }], - steps: { - [WORKER_STEP_ID]: { - kind: "step", - id: WORKER_STEP_ID, - agent: { - id: WORKER_STEP_ID, - description: - "A co-worker that lives in one workbench: answers questions, " + - "drafts text, and gets things done for the team", - systemPrompt: WORKER_SYSTEM_PROMPT, - toolFactories: [], - capabilities: [], - // The probe approves exactly these `(provider, model)` pairs, so - // they must name what the deploy's offering chain resolves to. - inference: { - sources: declaredSources.map((source) => ({ ...source })), - }, - toolPackagePins: [], - }, - drainBehavior: "wait", - triggers: "unbounded", - input: { from: "trigger.payload" }, - }, - }, - stepOrder: [WORKER_STEP_ID], - }; -} +const ToolNamesShape = type({ visible: "string[]", deferred: "string[]" }); -/** Mints a push-only token scoped to `main`, runs `push` with it, and - * revokes the token afterwards whatever the push's outcome. */ -async function withPushToken( - tenantId: string, - assetId: string, - fetchImpl: typeof fetch, - push: (token: string) => Promise, -): Promise { - const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; - const minted = await fetchImpl(tokensPath, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - // Active token names are unique per user; concurrent attempts must not collide. - name: `worker-deploy-${crypto.randomUUID()}`, - resource: `asset:${assetId}`, - refPattern: "refs/heads/main", - // The ref advertisement before a push is a read, so a push-only - // token is refused at info/refs. - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), - }), - }); - if (!minted.ok) { - throw new WorkerDeployError(`minting a push token failed: ${await readErrorBody(minted)}`); - } - const token = GitTokenMintShape(await minted.json()); - if (token instanceof type.errors) { - throw new WorkerDeployError(`the push token came back an unexpected shape: ${token.summary}`); - } - try { - return await push(token.secret); - } finally { - await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { - method: "DELETE", - }); - } -} - -/** Renders the worker's built definition as a source tree and pushes it to the - * asset's `main`. Returns the commit sha the deploy pins to. */ -export async function pushWorkerSource( - tenantId: string, - assetId: string, - tenantDomain: string, - declaredSources: readonly DeclaredSource[], - hubCredentialId: string, - mcpServers: readonly McpServerDeployment[], - fetchImpl: typeof fetch = fetch, -): Promise { - const triggerAddress = `worker@${tenantDomain}`; +/** Renders a worker-harness agent's source tree: the bundled entry plus the + * one definition JSON, so a created agent and Worker cannot drift. */ +export async function renderWorkerSourceTree( + packageName: string, + input: WorkerWorkflowInput, + toolEffects: readonly ToolEffect[] = [], +): Promise> { // Half a megabyte of bundled entry text, needed only during setup — kept // out of the app's entry chunk the same way the git client is. - const { WORKER_BUNDLE_BUILD_EXPORT, WORKER_DIRECTORS_BUNDLE, WORKER_WORKFLOW_BUNDLE } = - await import("@corbits/worker/bundle"); - const tree = renderBundledWorkflowSourceTree({ - packageName: WORKER_SOURCE_CONFIG.packageName, + const { + WORKER_BUNDLE_BUILD_EXPORT, + WORKER_DIRECTORS_BUNDLE, + WORKER_WORKFLOW_BUNDLE, + WORKER_TOOL_NAMES_JSON, + } = await import("@corbits/worker/bundle"); + const toolNames: WorkerToolNames | type.errors = ToolNamesShape( + JSON.parse(WORKER_TOOL_NAMES_JSON), + ); + if (toolNames instanceof type.errors) { + throw new Error(`the worker's tool names are malformed: ${toolNames.summary}`); + } + return renderBundledWorkflowSourceTree({ + packageName, bundle: WORKER_WORKFLOW_BUNDLE, directorsBundle: WORKER_DIRECTORS_BUNDLE, buildExport: WORKER_BUNDLE_BUILD_EXPORT, - buildInput: { - workflowId: WORKER_WORKFLOW_ID, - triggerAddress, - inferencePreferences: declaredSources.map((source) => ({ ...source })), - systemPrompt: WORKER_SYSTEM_PROMPT, - hubCredentialId, - mcpServers, - }, - workflowJson: JSON.stringify( - buildWorkerDefinitionJson(triggerAddress, declaredSources, hubCredentialId, mcpServers), - ), + buildInput: input, + workflowJson: JSON.stringify(buildWorkerDefinitionJson(input, toolNames, toolEffects)), }); - const url = new URL( - `/api/tenants/${encodeURIComponent(tenantId)}/assets/${WORKER_SOURCE_CONFIG.assetKind}/${WORKER_SOURCE_CONFIG.assetName}.git`, - globalThis.location.origin, - ).toString(); - // Loaded on demand: the git client only runs during setup, so it stays - // out of the main bundle. - const { pushSourceTree } = await import("./git-push"); - return withPushToken( - tenantId, - assetId, - fetchImpl, - async (token) => - ( - await pushSourceTree({ - fetch: fetchImpl, - url, - token, - tree, - message: "Publish the worker's definition", - }) - ).commitSha, - ); } -/** The pure mapping this module exists to get right: the operator's - * offering pick plus the commit just pushed, turned into the exact - * `WorkflowDeployInput` the stock deploy route needs. */ -export function buildWorkerDeployInput(args: { - assetId: string; - commitSha: string; - sourceOfferingIds: readonly string[]; - defaultSourceOfferingId: string; -}): WorkflowDeployInput { - if (args.sourceOfferingIds.length === 0) { - throw new WorkerDeployError("at least one source offering id is required to start Worker"); - } - if (!args.sourceOfferingIds.includes(args.defaultSourceOfferingId)) { - throw new WorkerDeployError( - "the default source offering id must be one of the supplied source offering ids", - ); - } - return { - source: { - kind: "asset", - assetId: args.assetId, - package: { format: "source", commitSha: args.commitSha }, - }, - entry: WORKER_SOURCE_CONFIG.entryPath, - sourceOfferingIds: [...args.sourceOfferingIds], - defaultSourceOfferingId: args.defaultSourceOfferingId, - }; -} +export type DeployedWorker = Awaited>; -/** Orchestrates the steps above and returns the `workerDeploy` input - * ready to hand to the stock deploy route. */ export async function deployWorkerSource( args: { tenantId: string; @@ -279,34 +59,39 @@ export async function deployWorkerSource( sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string; declaredSources: readonly DeclaredSource[]; + /** Deploy anew even when nothing changed (a restart mints a fresh run). */ + redeploy?: boolean; }, fetchImpl: typeof fetch = fetch, -): Promise { - const assetId = await ensureWorkerSourceAsset(args.tenantId, fetchImpl); - // Minted before the push: the definition binds this credential by name - // and requires its use by id, so it must exist before the source does. - const hubCredentialId = await ensureAgentHubCredential( - { tenantId: args.tenantId, definitionId: WORKER_WORKFLOW_ID, assetId }, - fetchImpl, - ); +): Promise { // The catalogs come out of the stored credentials, so a redeploy never // reaches an MCP server; only adding one does. const mcpServers = (await ensureBuiltInMcpServers(args.tenantId, fetchImpl)).map( toMcpServerDeployment, ); - const commitSha = await pushWorkerSource( - args.tenantId, - assetId, - args.tenantDomain, - args.declaredSources, - hubCredentialId, - mcpServers, - fetchImpl, - ); - return buildWorkerDeployInput({ - assetId, - commitSha, + return installPackage({ + fetch: fetchImpl, + origin: globalThis.location.origin, + tenantId: args.tenantId, + assetName: WORKER_SOURCE_CONFIG.assetName, + displayName: WORKER_SOURCE_CONFIG.displayName, + // Minted once the asset exists: the definition binds this credential by + // name and requires its use by id, so it must precede the source. + files: async (assetId) => + renderWorkerSourceTree(WORKER_SOURCE_CONFIG.packageName, { + workflowId: WORKER_WORKFLOW_ID, + triggerAddress: `worker@${args.tenantDomain}`, + inferencePreferences: args.declaredSources.map((source) => ({ ...source })), + systemPrompt: WORKER_SYSTEM_PROMPT, + hubCredentialId: await ensureAgentHubCredential( + { tenantId: args.tenantId, definitionId: WORKER_WORKFLOW_ID, assetId }, + fetchImpl, + ), + mcpServers, + }), + entry: WORKER_SOURCE_CONFIG.entryPath, sourceOfferingIds: args.sourceOfferingIds, defaultSourceOfferingId: args.defaultSourceOfferingId, + ...(args.redeploy === true ? { redeploy: true } : {}), }); } diff --git a/docs/worker-definition-json.md b/docs/worker-definition-json.md deleted file mode 100644 index a2b62c7d1..000000000 --- a/docs/worker-definition-json.md +++ /dev/null @@ -1,21 +0,0 @@ -# The worker's hand-built definition.json - -`buildWorkerDefinitionJson` in `apps/web/src/worker-deploy.ts` is the -function-free projection of what `@corbits/worker`'s `buildWorkerWorkflow` -produces, written to the asset's `definition.json` for readers — the entry -itself is a bundle no reader can slice apart. - -It is hand-built here rather than by calling that function, because -`buildWorkerWorkflow` goes through `@intx/workflow`'s `defineWorkflow`/`step`, -which pull in `@intx/agent`'s Node-bound runtime (file locking) that a -browser bundle cannot resolve. - -It mirrors `defineWorkflow`'s own normalization -(`vendor/intx/workflow/src/definition/workflow.ts`'s `normalize`/ -`applyDefaultInputStep`, and `primitives.ts`'s `step`): a single step with no -`after` gets `input: { from: "trigger.payload" }`; `triggers: "unbounded"` -(not the numeric default) gets `drainBehavior: "wait"`; a bare `trigger` -becomes a one-element `triggers` array. - -`toolFactories` and `toolPackagePins` are empty on purpose: the real -factories ride the bundle, and JSON cannot carry a function. diff --git a/e2e/install/install-package.test.ts b/e2e/install/install-package.test.ts index 92d3f9de3..5c3a2030d 100644 --- a/e2e/install/install-package.test.ts +++ b/e2e/install/install-package.test.ts @@ -1,6 +1,7 @@ // Proves the client-driven install over stock routes: a package's source goes // into a fresh tenant as a workflow asset, deploys, and answers mail. A second -// install of identical files deploys nothing new. Skips without DATABASE_URL. +// install of identical input deploys nothing new; a changed offering chain or +// `redeploy: true` does. Skips without DATABASE_URL. // LightningFS, the browser fs the installer uses, needs IndexedDB. import "fake-indexeddb/auto"; import { afterAll, beforeAll, expect, test } from "bun:test"; @@ -135,7 +136,7 @@ describeIfDb("installPackage", () => { }, workflowJson: "{}", }); - const install = () => + const install = (over: { offering?: string; redeploy?: boolean } = {}) => installPackage({ fetch: call as typeof fetch, origin, @@ -144,8 +145,9 @@ describeIfDb("installPackage", () => { displayName: "Echo", files, entry: "./workflow.js", - sourceOfferingIds: [offering], - defaultSourceOfferingId: offering, + sourceOfferingIds: [over.offering ?? offering], + defaultSourceOfferingId: over.offering ?? offering, + ...(over.redeploy === true ? { redeploy: true } : {}), }); const first = await install(); @@ -172,5 +174,33 @@ describeIfDb("installPackage", () => { await (await call(`${origin}${t}/workflows/deployments`)).json(), ); expect(after).toHaveLength(1); + + const deploymentCount = async () => + Deployments.assert(await (await call(`${origin}${t}/workflows/deployments`)).json()).length; + + // The same tree under a different offering chain is a different deploy. + const modelProvider2 = await created( + await post(`${t}/catalog/providers`, { + name: "anthropic-2", + plugin: "anthropic", + baseURL: "https://api.anthropic.com", + credentialId: credential, + }), + ); + const offering2 = await created( + await post(`${t}/catalog/offerings`, { + modelId: model, + providerId: modelProvider2, + priority: 1, + }), + ); + const swapped = await install({ offering: offering2 }); + expect(swapped.deploymentId).not.toBe(first.deploymentId); + expect(await deploymentCount()).toBe(2); + + // An explicit redeploy always deploys anew, even for identical input. + const again = await install({ offering: offering2, redeploy: true }); + expect(again.deploymentId).not.toBe(swapped.deploymentId); + expect(await deploymentCount()).toBe(3); }, 120_000); }); diff --git a/packages/worker/package.json b/packages/worker/package.json index 3f0bc05ad..1d97430ef 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -21,6 +21,7 @@ ".": "./src/index.ts", "./prompt": "./src/system-prompt.ts", "./workflow-ids": "./src/workflow-ids.ts", + "./definition-json": "./src/definition-json.ts", "./bundle": "./src/bundle.ts", "./package.json": "./package.json" }, diff --git a/packages/worker/scripts/build-bundle.ts b/packages/worker/scripts/build-bundle.ts index dbc0f1cc8..0026ab489 100644 --- a/packages/worker/scripts/build-bundle.ts +++ b/packages/worker/scripts/build-bundle.ts @@ -59,6 +59,8 @@ export async function buildWorkerDirectorsBundle(): Promise { return bundleEntry("directors.ts"); } +export const WORKER_TOOL_NAMES_PATH = path.join(packageRoot, "bundle", "tool-names.json"); + export async function writeWorkerBundle(): Promise { const [workflow, directors] = await Promise.all([ buildWorkerBundle(), @@ -67,7 +69,11 @@ export async function writeWorkerBundle(): Promise { await mkdir(path.dirname(WORKER_BUNDLE_PATH), { recursive: true }); await writeFile(WORKER_BUNDLE_PATH, workflow); await writeFile(WORKER_DIRECTORS_BUNDLE_PATH, directors); - return [WORKER_BUNDLE_PATH, WORKER_DIRECTORS_BUNDLE_PATH]; + // The browser-safe definition JSON can't import the factories, so it reads + // the director's tool split from this file. + const { WORKER_TOOL_NAMES } = await import("../src/index"); + await writeFile(WORKER_TOOL_NAMES_PATH, JSON.stringify(WORKER_TOOL_NAMES)); + return [WORKER_BUNDLE_PATH, WORKER_DIRECTORS_BUNDLE_PATH, WORKER_TOOL_NAMES_PATH]; } if (import.meta.main) { diff --git a/packages/worker/src/bundle.ts b/packages/worker/src/bundle.ts index ccb11ac00..ec2610fc0 100644 --- a/packages/worker/src/bundle.ts +++ b/packages/worker/src/bundle.ts @@ -4,6 +4,7 @@ // produces it before Vite resolves these imports. import bundleText from "../bundle/workflow-bundle.js?raw"; import directorsText from "../bundle/directors-bundle.js?raw"; +import toolNamesText from "../bundle/tool-names.json?raw"; /** One self-contained ESM module exporting `WORKER_BUNDLE_BUILD_EXPORT`. */ export const WORKER_WORKFLOW_BUNDLE: string = bundleText; @@ -13,3 +14,6 @@ export const WORKER_DIRECTORS_BUNDLE: string = directorsText; /** The bundle export the rendered entry calls with per-deploy values. */ export const WORKER_BUNDLE_BUILD_EXPORT = "buildWorkerWorkflow"; + +/** The director's tool split, JSON, as the bundle build wrote it. */ +export const WORKER_TOOL_NAMES_JSON: string = toolNamesText; diff --git a/packages/worker/src/definition-json.ts b/packages/worker/src/definition-json.ts new file mode 100644 index 000000000..1dbdfa733 --- /dev/null +++ b/packages/worker/src/definition-json.ts @@ -0,0 +1,130 @@ +// The function-free projection of `buildWorkerWorkflow`, written beside the +// entry as `definition.json` for readers. Browser-safe: no `@intx/*` import, +// because `defineWorkflow` pulls in a Node-bound runtime. It mirrors that +// function's normalization (single step gets `input: trigger.payload`; +// `triggers: "unbounded"` gets `drainBehavior: "wait"`; a bare trigger becomes +// a one-element `triggers` array). Every field shared with the entry is +// derived from the same constants, so the two cannot drift. +import { + WORKER_STEP_ID, + artifactToolsCredentialBinding, + artifactToolsCredentialUseRequirement, + mcpServerCredentialBinding, + mcpServerCredentialUseRequirement, + mcpToolNamePattern, + memoryToolsCredentialBinding, + memoryToolsCredentialUseRequirement, + type McpServerDeployment, +} from "./workflow-ids"; + +/** The description the agent step carries. */ +export const WORKER_DESCRIPTION = + "A co-worker that lives in one workbench: answers questions, " + + "drafts text, and gets things done for the team"; + +/** Tool names the director splits on, read from the factories' own + * declarations by the bundle build so a renamed tool cannot fall out of both + * lists. */ +export type WorkerToolNames = { + readonly visible: readonly string[]; + readonly deferred: readonly string[]; +}; + +/** The director's tool split: what every turn carries, and what the model has + * to search for. A remote catalog can run to dozens of tools, so a whole + * server's namespace stays behind one pattern. */ +export function workerDirectorRef(toolNames: WorkerToolNames, mcpHandles: readonly string[]) { + return { + id: "@corbits/deferred-tools/director", + config: { + visible: [...toolNames.visible], + deferred: [...toolNames.deferred, ...mcpHandles.map(mcpToolNamePattern)], + }, + }; +} + +/** Everything the definition needs that is per-deployment data. */ +export interface WorkerWorkflowInput { + /** The definition id: the default `WORKER_WORKFLOW_ID`, or a created + * agent's slug — the bundle is generic over which agent it builds. */ + readonly workflowId: string; + /** The deployment's mail address; each inbound mail is one run. */ + readonly triggerAddress: string; + /** Provider/model preferences, in order; resolved at deploy time. */ + readonly inferencePreferences: readonly { + readonly provider: string; + readonly model: string; + readonly parameters?: Readonly>; + }[]; + /** The prompt this deployment runs with. */ + readonly systemPrompt: string; + /** The tenant credential holding this agent's hub token, minted by the + * deployer before the source is pushed. The definition binds it to the + * artifact and memory tools and requires its use on the deployer's + * authority. */ + readonly hubCredentialId: string; + /** The MCP servers this deployment carries, catalogs already discovered. + * Each one gets its own credential handle, binding and use requirement. */ + readonly mcpServers: readonly McpServerDeployment[]; +} + +/** A non-default tool permission (`ask` is the stock default) to carry onto + * a redeployed run. */ +export type ToolEffect = { + readonly resource: string; + readonly effect: "allow" | "deny"; +}; + +export function buildWorkerDefinitionJson( + input: WorkerWorkflowInput, + toolNames: WorkerToolNames, + /** Re-declared as grant requirements so the new run materializes them at + * its first trigger. */ + toolEffects: readonly ToolEffect[] = [], +): unknown { + return { + id: input.workflowId, + credentialBindings: [ + artifactToolsCredentialBinding(input.workflowId), + memoryToolsCredentialBinding(input.workflowId), + ...input.mcpServers.map((server) => mcpServerCredentialBinding(server)), + ], + grantRequirements: [ + artifactToolsCredentialUseRequirement(input.hubCredentialId), + memoryToolsCredentialUseRequirement(input.hubCredentialId), + ...input.mcpServers.map((server) => mcpServerCredentialUseRequirement(server.credentialId)), + ...toolEffects.map((tool) => ({ + resource: tool.resource, + action: "invoke", + effect: tool.effect, + source: "creator", + })), + ], + // `to` only feeds the deploy-time mail.address/mail.send grants; the agent + // is reached at its run address, minted at deploy time. + triggers: [{ type: "mail", to: input.triggerAddress }], + steps: { + [WORKER_STEP_ID]: { + kind: "step", + id: WORKER_STEP_ID, + agent: { + id: WORKER_STEP_ID, + description: WORKER_DESCRIPTION, + systemPrompt: input.systemPrompt, + toolFactories: [], + director: workerDirectorRef( + toolNames, + input.mcpServers.map((server) => server.handle), + ), + capabilities: [], + inference: { sources: input.inferencePreferences.map((source) => ({ ...source })) }, + toolPackagePins: [], + }, + drainBehavior: "wait", + triggers: "unbounded", + input: { from: "trigger.payload" }, + }, + }, + stepOrder: [WORKER_STEP_ID], + }; +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 3dd5496f7..6c59a953c 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,7 +8,7 @@ // directly, so the factories must be real functions here rather than // `toolPackagePins` the source lineage never resolves. -import type { AgentDefinition, AnnotatedToolFactory, InferencePreference } from "@intx/agent"; +import type { AgentDefinition, AnnotatedToolFactory } from "@intx/agent"; import { defineWorkflow, step } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; import { mail } from "@intx/tools-mail/sidecar-bundle"; @@ -18,26 +18,25 @@ import { memory } from "@corbits/memory/sidecar-bundle"; import { mcpServers } from "@corbits/mcp/sidecar-bundle"; import { toolSearch } from "@corbits/deferred-tools"; +import { + WORKER_DESCRIPTION, + workerDirectorRef, + type WorkerToolNames, + type WorkerWorkflowInput, +} from "./definition-json"; import { WORKER_STEP_ID, artifactToolsCredentialBinding, artifactToolsCredentialUseRequirement, mcpServerCredentialBinding, mcpServerCredentialUseRequirement, - mcpToolNamePattern, memoryToolsCredentialBinding, memoryToolsCredentialUseRequirement, - type McpServerDeployment, } from "./workflow-ids"; export { WORKER_SYSTEM_PROMPT } from "./system-prompt"; export { WORKER_STEP_ID, WORKER_WORKFLOW_ID } from "./workflow-ids"; - -/** The description the agent step carries; mirrored by the JSON projection - * the deploy writes beside the entry, so both must stay identical. */ -export const WORKER_DESCRIPTION = - "A co-worker that lives in one workbench: answers questions, " + - "drafts text, and gets things done for the team"; +export { WORKER_DESCRIPTION, type WorkerWorkflowInput } from "./definition-json"; // Tool packages in the shape Interchange has: mail over the agent's // transport, posix over its working tree, and artifacts and memory through @@ -51,49 +50,17 @@ export const WORKER_TOOL_FACTORIES = [ toolSearch, ] as unknown as readonly AnnotatedToolFactory[]; -/** The director's tool split, derived from the factories' own declarations so - * a renamed tool can never fall out of both lists: mail and posix are what - * every turn carries, artifacts and memory are what the model has to search - * for. */ function toolNames(factories: readonly AnnotatedToolFactory[]): string[] { return factories.flatMap((factory) => factory.definitions.map((definition) => definition.name)); } -export function workerDirector(mcpHandles: readonly string[]) { - return { - id: "@corbits/deferred-tools/director", - config: { - visible: toolNames([mail, posix] as unknown as readonly AnnotatedToolFactory[]), - deferred: [ - ...toolNames([artifacts, memory] as unknown as readonly AnnotatedToolFactory[]), - // A remote catalog can run to dozens of tools, so a whole server's - // namespace stays behind one pattern until the model searches for it. - ...mcpHandles.map(mcpToolNamePattern), - ], - }, - }; -} - -/** Everything the definition needs that is per-deployment data. */ -export interface WorkerWorkflowInput { - /** The definition id: The default `WORKER_WORKFLOW_ID`, or a created - * agent's slug — the bundle is generic over which agent it builds. */ - readonly workflowId: string; - /** The deployment's mail address; each inbound mail is one run. */ - readonly triggerAddress: string; - /** Provider/model preferences, in order; resolved at deploy time. */ - readonly inferencePreferences: readonly InferencePreference[]; - /** The prompt this deployment runs with. */ - readonly systemPrompt: string; - /** The tenant credential holding this agent's hub token, minted by the - * deployer before the source is pushed. The definition binds it to the - * artifact and memory tools and requires its use on the deployer's - * authority. */ - readonly hubCredentialId: string; - /** The MCP servers this deployment carries, catalogs already discovered. - * Each one gets its own credential handle, binding and use requirement. */ - readonly mcpServers: readonly McpServerDeployment[]; -} +/** Read from the factories' own declarations: mail and posix are what every + * turn carries, artifacts and memory are what the model has to search for. + * The bundle build writes this beside the bundle for the browser-safe JSON. */ +export const WORKER_TOOL_NAMES: WorkerToolNames = { + visible: toolNames([mail, posix] as unknown as readonly AnnotatedToolFactory[]), + deferred: toolNames([artifacts, memory] as unknown as readonly AnnotatedToolFactory[]), +}; /** * Builds the worker's definition. Exactly one step, on purpose: the single-step @@ -154,7 +121,10 @@ export function buildWorkerWorkflow(input: WorkerWorkflowInput): WorkflowDefinit })), }) as unknown as AnnotatedToolFactory, ], - director: workerDirector(input.mcpServers.map((server) => server.handle)), + director: workerDirectorRef( + WORKER_TOOL_NAMES, + input.mcpServers.map((server) => server.handle), + ), capabilities: [], inference: { sources: input.inferencePreferences }, toolPackagePins: [],