From 973d2666b4981b4ea05d52ddbc6581429423a605 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 23:24:02 -0700 Subject: [PATCH 1/2] feat(workflows): source trees carry a definition.json projection readers parse (CL-8457) A bundled entry is code no reader can slice back apart, so every rendered tree now writes the definition's JSON projection beside workflow.js and readers parse that file instead of the entry module. --- apps/web/src/agent-source-read.ts | 11 ++- .../agent-directory/src/definition-asset.ts | 12 +-- packages/agent-directory/src/index.ts | 4 +- packages/agent-directory/src/routes.test.ts | 18 ++-- packages/agent-directory/src/routes.ts | 8 +- packages/agent-directory/src/source-tree.ts | 18 ++-- .../src/workflow-capability-routes.test.ts | 4 +- .../src/workflow-skill-pin-routes.test.ts | 4 +- packages/workflows/src/source.test.ts | 49 +++++----- packages/workflows/src/source.ts | 93 ++++++++++++------- packages/workflows/src/validate-push.test.ts | 3 +- 11 files changed, 130 insertions(+), 94 deletions(-) diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts index a62a81889e..6ffba3ff63 100644 --- a/apps/web/src/agent-source-read.ts +++ b/apps/web/src/agent-source-read.ts @@ -4,7 +4,10 @@ // There is no stock file-read route for a workflow asset (only // package-registry tarballs get one), so this fetches `main` over the // asset's smart-HTTP git remote with a short-lived read-only token. -import { parseWorkflowSourceEntry, WORKFLOW_SOURCE_ENTRY_PATH } from "@corbits/workflows/client"; +import { + parseWorkflowSourceDefinition, + WORKFLOW_SOURCE_DEFINITION_PATH, +} from "@corbits/workflows/client"; import { type } from "arktype"; import { fetchSourceFile } from "./git-fetch"; @@ -78,12 +81,12 @@ export async function readAgentSource( globalThis.location.origin, ).toString(); try { - const entryModule = await fetchSourceFile({ + const definitionFile = await fetchSourceFile({ url, token: token.secret, - filepath: WORKFLOW_SOURCE_ENTRY_PATH, + filepath: WORKFLOW_SOURCE_DEFINITION_PATH, }); - const workflowJson = parseWorkflowSourceEntry(entryModule, assetId); + const workflowJson = parseWorkflowSourceDefinition(definitionFile, assetId); const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson)); if (parsed instanceof type.errors) { throw new AgentSourceReadError( diff --git a/packages/agent-directory/src/definition-asset.ts b/packages/agent-directory/src/definition-asset.ts index 6098fc7670..9cc1a07670 100644 --- a/packages/agent-directory/src/definition-asset.ts +++ b/packages/agent-directory/src/definition-asset.ts @@ -2,12 +2,12 @@ // here: the source codebase, never the retired `workflow.json` envelope. import { - parseWorkflowSourceEntry, + parseWorkflowSourceDefinition, readWorkflowSourceDefinition, renderWorkflowSourceTree, RetiredWorkflowEnvelopeError, + WORKFLOW_SOURCE_DEFINITION_PATH, WORKFLOW_SOURCE_ENTRY, - WORKFLOW_SOURCE_ENTRY_PATH, type WorkflowSourceBlobReader, type WorkflowSourceTree, WorkflowAuthorError, @@ -18,7 +18,7 @@ import type { AssetService } from "@intx/hub-sessions"; export { RetiredWorkflowEnvelopeError, - WORKFLOW_SOURCE_ENTRY_PATH as AGENT_DEFINITION_ENTRY_PATH, + WORKFLOW_SOURCE_DEFINITION_PATH as AGENT_DEFINITION_JSON_PATH, WORKFLOW_SOURCE_ENTRY as AGENT_DEFINITION_ENTRY, }; @@ -43,9 +43,9 @@ export function readAgentDefinitionWorkflowJson( return readWorkflowSourceDefinition(reader, assetId); } -/** The serialized workflow inside entry-module bytes read at a past commit. */ -export function parseAgentDefinitionEntry(entryModule: Uint8Array, assetId: string): string { - return parseWorkflowSourceEntry(new TextDecoder().decode(entryModule), assetId); +/** The serialized workflow inside definition bytes read at a past commit. */ +export function parseAgentDefinitionJson(definitionJson: Uint8Array, assetId: string): string { + return parseWorkflowSourceDefinition(new TextDecoder().decode(definitionJson), assetId); } /** The `WorkflowDeployer` seam this package needs — just the one deploy diff --git a/packages/agent-directory/src/index.ts b/packages/agent-directory/src/index.ts index a40cc15464..6d6cd75527 100644 --- a/packages/agent-directory/src/index.ts +++ b/packages/agent-directory/src/index.ts @@ -25,9 +25,9 @@ export { } from "./tool-package-version"; export { agentDefinitionSourceTree, - parseAgentDefinitionEntry, + parseAgentDefinitionJson, readAgentDefinitionWorkflowJson, - AGENT_DEFINITION_ENTRY_PATH, + AGENT_DEFINITION_JSON_PATH, RetiredWorkflowEnvelopeError, } from "./definition-asset"; export { diff --git a/packages/agent-directory/src/routes.test.ts b/packages/agent-directory/src/routes.test.ts index 7854f497bf..e1567cf79c 100644 --- a/packages/agent-directory/src/routes.test.ts +++ b/packages/agent-directory/src/routes.test.ts @@ -25,7 +25,7 @@ import { } from "./agent-workflow"; import { agentDefinitionSourceTree, - AGENT_DEFINITION_ENTRY_PATH, + AGENT_DEFINITION_JSON_PATH, readAgentDefinitionWorkflowJson, RetiredWorkflowEnvelopeError, } from "./definition-asset"; @@ -150,7 +150,7 @@ function liveDefinitionAsset(initial: Uint8Array): AssetService { return current; }, populateAsset: async (params) => { - const entry = params.tree.files[AGENT_DEFINITION_ENTRY_PATH]; + const entry = params.tree.files[AGENT_DEFINITION_JSON_PATH]; if (typeof entry !== "string") { throw new Error("populateAsset wrote no entry module"); } @@ -180,7 +180,7 @@ function storedDefinitionBytes(systemPrompt = "You are a careful research assist }), ), }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + return new TextEncoder().encode(tree[AGENT_DEFINITION_JSON_PATH]); } /** A stored definition that already pins a model — the state a person @@ -198,7 +198,7 @@ function storedDefinitionBytesWithModel(model: string): Uint8Array { }), ), }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + return new TextEncoder().encode(tree[AGENT_DEFINITION_JSON_PATH]); } /** The model the one step agent resolves against, or `undefined` when it @@ -965,7 +965,7 @@ test("GET /:definitionId answers 409, never a 500, for an asset still on the ret error: { code: string; userMessage: string }; }; expect(body.error.code).toBe("conflict"); - expect(body.error.userMessage).toContain("workflow.json"); + expect(body.error.userMessage).toContain("definition.json"); }); test("PUT /:definitionId answers 409 and writes nothing for an asset still on the retired envelope", async () => { @@ -1070,14 +1070,14 @@ test("PUT instructions after a newer tarball lands keeps the stored pin version" handle: "research-buddy", workflowJson: pinned, }); - let current = new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + let current = new TextEncoder().encode(tree[AGENT_DEFINITION_JSON_PATH]); let writtenEntry: string | undefined; let listCalls = 0; const app = buildApp( fakeAssetService({ readAssetBlob: async () => current, populateAsset: (params) => { - const entry = params.tree.files[AGENT_DEFINITION_ENTRY_PATH]; + const entry = params.tree.files[AGENT_DEFINITION_JSON_PATH]; if (typeof entry !== "string") { throw new Error("populateAsset wrote no entry module"); } @@ -1107,7 +1107,7 @@ test("PUT instructions after a newer tarball lands keeps the stored pin version" }); expect(response.status).toBe(200); expect(listCalls).toBe(0); - expect(pinsFrom(definitionFrom({ [AGENT_DEFINITION_ENTRY_PATH]: writtenEntry ?? "" }))).toEqual([ + expect(pinsFrom(definitionFrom({ [AGENT_DEFINITION_JSON_PATH]: writtenEntry ?? "" }))).toEqual([ { name: "@corbits/memory-tools", version: "1.4.0" }, ]); }); @@ -1716,7 +1716,7 @@ test("re-adding an already-pinned tool package keeps its stored version after a ), { name: "@corbits/memory-tools", version: "1.4.0" }, ), - })[AGENT_DEFINITION_ENTRY_PATH] as string, + })[AGENT_DEFINITION_JSON_PATH] as string, ), ), populateAsset: (params) => { diff --git a/packages/agent-directory/src/routes.ts b/packages/agent-directory/src/routes.ts index eec4641fe2..3f5a5374b0 100644 --- a/packages/agent-directory/src/routes.ts +++ b/packages/agent-directory/src/routes.ts @@ -31,8 +31,8 @@ import { commitLatestAgentAssetSnapshot } from "./asset-write"; import { commitAgentCapabilityAdd } from "./capability-add"; import { agentDefinitionSourceTree, - AGENT_DEFINITION_ENTRY_PATH, - parseAgentDefinitionEntry, + AGENT_DEFINITION_JSON_PATH, + parseAgentDefinitionJson, readAgentDefinitionWorkflowJson, RetiredWorkflowEnvelopeError, statusForAgentDefinitionDeployError, @@ -391,7 +391,7 @@ export function createAgentDefinitionRoutes({ const entryBytes = await history.readBlobAtCommit({ assetId: row.assetId, - path: AGENT_DEFINITION_ENTRY_PATH, + path: AGENT_DEFINITION_JSON_PATH, commitSha: body.commitSha, }); if (entryBytes === null) { @@ -403,7 +403,7 @@ export function createAgentDefinitionRoutes({ 404, ); } - const restoredWorkflowJson = parseAgentDefinitionEntry(entryBytes, row.assetId); + const restoredWorkflowJson = parseAgentDefinitionJson(entryBytes, row.assetId); // Pins live in the asset's own stanza (reindexed on every write), // so restoring a prior commit restores that revision's pins with diff --git a/packages/agent-directory/src/source-tree.ts b/packages/agent-directory/src/source-tree.ts index be5984b705..db324727d4 100644 --- a/packages/agent-directory/src/source-tree.ts +++ b/packages/agent-directory/src/source-tree.ts @@ -4,9 +4,9 @@ // routes use. import { - AGENT_DEFINITION_ENTRY_PATH, + AGENT_DEFINITION_JSON_PATH, agentDefinitionSourceTree, - parseAgentDefinitionEntry, + parseAgentDefinitionJson, } from "./definition-asset"; import { buildAgentDefinitionWorkflow, @@ -14,16 +14,16 @@ import { serializeAgentDefinitionWorkflow, } from "./agent-workflow"; -/** The two files a definition's asset tree carries, in render order. */ -export const SOURCE_TREE_PATHS = ["package.json", AGENT_DEFINITION_ENTRY_PATH]; +/** The files a definition's asset tree carries, in render order. */ +export const SOURCE_TREE_PATHS = ["package.json", "workflow.js", AGENT_DEFINITION_JSON_PATH]; /** The serialized definition a written source tree carries. */ export function definitionFrom(files: Record | undefined): string { - const entry = files?.[AGENT_DEFINITION_ENTRY_PATH]; - if (typeof entry !== "string") { - throw new Error("the written tree carries no entry module"); + const definitionJson = files?.[AGENT_DEFINITION_JSON_PATH]; + if (typeof definitionJson !== "string") { + throw new Error("the written tree carries no definition projection"); } - return parseAgentDefinitionEntry(new TextEncoder().encode(entry), "ast_1"); + return parseAgentDefinitionJson(new TextEncoder().encode(definitionJson), "ast_1"); } /** A stored definition that already pins skills — the state every @@ -44,5 +44,5 @@ export function storedDefinitionBytesWithSkills(...names: string[]): Uint8Array names.map((name) => ({ name, description: `What ${name} does.` })), ), }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + return new TextEncoder().encode(tree[AGENT_DEFINITION_JSON_PATH]); } diff --git a/packages/agent-directory/src/workflow-capability-routes.test.ts b/packages/agent-directory/src/workflow-capability-routes.test.ts index 12ea6f18b8..47f3d02924 100644 --- a/packages/agent-directory/src/workflow-capability-routes.test.ts +++ b/packages/agent-directory/src/workflow-capability-routes.test.ts @@ -21,7 +21,7 @@ import { type WorkflowCapabilityRunScope, type WorkflowRunAuthenticator, } from "./workflow-capability-routes"; -import { agentDefinitionSourceTree, AGENT_DEFINITION_ENTRY_PATH } from "./definition-asset"; +import { agentDefinitionSourceTree, AGENT_DEFINITION_JSON_PATH } from "./definition-asset"; import type { PinnedSkillIndexResolver } from "./routes"; import { definitionFrom, SOURCE_TREE_PATHS } from "./source-tree"; import type { CapabilityInventoryProvider } from "./capability-inventory"; @@ -60,7 +60,7 @@ function storedDefinitionBytes(): Uint8Array { }), ), }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + return new TextEncoder().encode(tree[AGENT_DEFINITION_JSON_PATH]); } /** A `readAssetBlob` that always answers the definition's entry module diff --git a/packages/agent-directory/src/workflow-skill-pin-routes.test.ts b/packages/agent-directory/src/workflow-skill-pin-routes.test.ts index d63eaf03e1..703e588148 100644 --- a/packages/agent-directory/src/workflow-skill-pin-routes.test.ts +++ b/packages/agent-directory/src/workflow-skill-pin-routes.test.ts @@ -20,7 +20,7 @@ import { type WorkflowSkillPinRunScope, type WorkflowRunAuthenticator, } from "./workflow-skill-pin-routes"; -import { agentDefinitionSourceTree, AGENT_DEFINITION_ENTRY_PATH } from "./definition-asset"; +import { agentDefinitionSourceTree, AGENT_DEFINITION_JSON_PATH } from "./definition-asset"; import type { PinnedSkillIndexResolver } from "./routes"; import { definitionFrom, SOURCE_TREE_PATHS, storedDefinitionBytesWithSkills } from "./source-tree"; @@ -47,7 +47,7 @@ function storedDefinitionBytes(): Uint8Array { }), ), }); - return new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + return new TextEncoder().encode(tree[AGENT_DEFINITION_JSON_PATH]); } function readAssetBlobFor(workflowBytes: Uint8Array): AssetService["readAssetBlob"] { diff --git a/packages/workflows/src/source.test.ts b/packages/workflows/src/source.test.ts index e02f78410d..243470241a 100644 --- a/packages/workflows/src/source.test.ts +++ b/packages/workflows/src/source.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test"; import { - parseWorkflowSourceEntry, readWorkflowSourceDefinition, + renderBundledWorkflowSourceTree, renderWorkflowSourceTree, RetiredWorkflowEnvelopeError, WORKFLOW_SOURCE_ENTRY, @@ -10,13 +10,23 @@ import { const WORKFLOW_JSON = JSON.stringify({ id: "wf_agent_research-buddy" }); -test("the rendered tree is a manifest naming the entry plus the entry itself", () => { +function readerFor(tree: Readonly>) { + return { + readAssetBlob: (params: { assetId: string; path: string }) => { + const content = tree[params.path]; + if (content === undefined) return Promise.reject(new Error(`no blob at ${params.path}`)); + return Promise.resolve(new TextEncoder().encode(content)); + }, + }; +} + +test("the rendered tree is a manifest, the entry, and the definition projection", () => { const tree = renderWorkflowSourceTree({ packageName: "@workbench-agent/research-buddy", workflowJson: WORKFLOW_JSON, }); - expect(Object.keys(tree).sort()).toEqual(["package.json", "workflow.js"]); + expect(Object.keys(tree).sort()).toEqual(["definition.json", "package.json", "workflow.js"]); const manifest = JSON.parse(tree["package.json"] as string) as { name: string; interchange: { workflow: string }; @@ -25,28 +35,23 @@ test("the rendered tree is a manifest naming the entry plus the entry itself", ( expect(manifest.interchange.workflow).toBe(WORKFLOW_SOURCE_ENTRY); }); -test("the definition round-trips through the entry module", () => { - const tree = renderWorkflowSourceTree({ +test("a bundled entry evaluates the bundle's build export and still projects the definition", async () => { + const tree = renderBundledWorkflowSourceTree({ packageName: "@workbench-agent/research-buddy", + bundle: "export function build(input) { return { id: input.id }; }", + buildExport: "build", + buildInput: { id: "wf_agent_research-buddy" }, workflowJson: WORKFLOW_JSON, }); - expect(parseWorkflowSourceEntry(tree["workflow.js"] as string, "ast_1")).toBe(WORKFLOW_JSON); -}); - -test("a bare workflow.json envelope parses as the named retirement error", () => { - expect(() => parseWorkflowSourceEntry(WORKFLOW_JSON, "ast_1")).toThrow( - RetiredWorkflowEnvelopeError, - ); + // The entry is code the platform evaluates, so the projection — not a + // slice of the entry — is what readers get back. + expect(tree["workflow.js"]).toContain('export default build({"id":"wf_agent_research-buddy"});'); + expect(await readWorkflowSourceDefinition(readerFor(tree), "ast_1")).toBe(WORKFLOW_JSON); }); -test("an asset with no entry module reads as the named retirement error", async () => { - const reader = { - readAssetBlob: (params: { assetId: string; path: string }) => - Promise.reject(new Error(`asset ${params.assetId} has no blob at ${params.path}`)), - }; - - await expect(readWorkflowSourceDefinition(reader, "ast_1")).rejects.toThrow( +test("an asset with no definition projection reads as the named retirement error", async () => { + await expect(readWorkflowSourceDefinition(readerFor({}), "ast_1")).rejects.toThrow( RetiredWorkflowEnvelopeError, ); }); @@ -56,10 +61,6 @@ test("reading a source-form asset answers its serialized definition", async () = packageName: "@workbench-agent/research-buddy", workflowJson: WORKFLOW_JSON, }); - const reader = { - readAssetBlob: (params: { assetId: string; path: string }) => - Promise.resolve(new TextEncoder().encode(tree[params.path] as string)), - }; - expect(await readWorkflowSourceDefinition(reader, "ast_1")).toBe(WORKFLOW_JSON); + expect(await readWorkflowSourceDefinition(readerFor(tree), "ast_1")).toBe(WORKFLOW_JSON); }); diff --git a/packages/workflows/src/source.ts b/packages/workflows/src/source.ts index 0a74153925..7a642ec47d 100644 --- a/packages/workflows/src/source.ts +++ b/packages/workflows/src/source.ts @@ -1,6 +1,7 @@ // The one shape a `workflow`-kind asset may carry: a source codebase, per -// upstream's push validator. `renderWorkflowSourceTree` and -// `parseWorkflowSourceEntry` are its one producer and consumer. +// upstream's push validator. Every tree carries the executable entry the +// platform evaluates plus a `definition.json` projection readers parse — +// a bundled entry is code, so slicing it back apart is not an option. /** The entry module's path inside the asset tree. */ export const WORKFLOW_SOURCE_ENTRY_PATH = "workflow.js"; @@ -8,42 +9,69 @@ export const WORKFLOW_SOURCE_ENTRY_PATH = "workflow.js"; export const WORKFLOW_SOURCE_ENTRY = `./${WORKFLOW_SOURCE_ENTRY_PATH}`; /** The manifest's path inside the asset tree. */ export const WORKFLOW_SOURCE_MANIFEST_PATH = "package.json"; +/** The JSON projection of the definition, for readers. */ +export const WORKFLOW_SOURCE_DEFINITION_PATH = "definition.json"; /** The path a pre-retirement asset carried its definition at. */ export const RETIRED_WORKFLOW_ENVELOPE_PATH = "workflow.json"; -const ENTRY_PREFIX = "export default "; -const ENTRY_SUFFIX = ";\n"; - export type WorkflowSourceTree = Readonly>; -/** The two-file source tree a serialized definition renders into. */ +function manifestFor(packageName: string): string { + return `${JSON.stringify( + { + name: packageName, + version: "0.0.0", + private: true, + type: "module", + interchange: { workflow: WORKFLOW_SOURCE_ENTRY }, + }, + null, + 2, + )}\n`; +} + +/** The source tree a serialized, function-free definition renders into: + * the entry re-exports the JSON verbatim. */ export function renderWorkflowSourceTree(args: { packageName: string; workflowJson: string; }): WorkflowSourceTree { - const packageJson = { - name: args.packageName, - version: "0.0.0", - private: true, - type: "module", - interchange: { workflow: WORKFLOW_SOURCE_ENTRY }, + return { + [WORKFLOW_SOURCE_MANIFEST_PATH]: manifestFor(args.packageName), + [WORKFLOW_SOURCE_ENTRY_PATH]: `export default ${args.workflowJson};\n`, + [WORKFLOW_SOURCE_DEFINITION_PATH]: `${args.workflowJson}\n`, }; +} + +/** The source tree a bundled entry renders into. `bundle` is one + * self-contained ESM module exporting `buildExport`; the trailing call + * supplies the per-deploy values and is what the platform evaluates. + * `workflowJson` is the same definition's function-free projection. */ +export function renderBundledWorkflowSourceTree(args: { + packageName: string; + bundle: string; + buildExport: string; + buildInput: unknown; + workflowJson: string; +}): WorkflowSourceTree { + const call = `${args.buildExport}(${JSON.stringify(args.buildInput)})`; return { - [WORKFLOW_SOURCE_MANIFEST_PATH]: `${JSON.stringify(packageJson, null, 2)}\n`, - [WORKFLOW_SOURCE_ENTRY_PATH]: `${ENTRY_PREFIX}${args.workflowJson}${ENTRY_SUFFIX}`, + [WORKFLOW_SOURCE_MANIFEST_PATH]: manifestFor(args.packageName), + [WORKFLOW_SOURCE_ENTRY_PATH]: `${args.bundle}\nexport default ${call};\n`, + [WORKFLOW_SOURCE_DEFINITION_PATH]: `${args.workflowJson}\n`, }; } -/** Thrown when an asset still carries the retired bare `workflow.json` - * envelope, so a route boundary can answer it as a client-visible conflict. */ +/** Thrown when an asset carries no readable `definition.json`, so a route + * boundary can answer it as a client-visible conflict. */ export class RetiredWorkflowEnvelopeError extends Error { readonly assetId: string; constructor(assetId: string, options?: { cause?: unknown }) { super( - `Asset "${assetId}" still carries the retired ${RETIRED_WORKFLOW_ENVELOPE_PATH} envelope ` + - `instead of a ${WORKFLOW_SOURCE_ENTRY_PATH} source entry. Re-author and re-deploy the ` + - `definition to write its source tree; nothing can read or edit it until then.`, + `Asset "${assetId}" carries no ${WORKFLOW_SOURCE_DEFINITION_PATH} beside its ` + + `${WORKFLOW_SOURCE_ENTRY_PATH} source entry. Re-author and re-deploy the definition ` + + `to write its source tree; nothing can read or edit it until then.`, options, ); this.name = "RetiredWorkflowEnvelopeError"; @@ -51,13 +79,16 @@ export class RetiredWorkflowEnvelopeError extends Error { } } -/** Recovers the serialized definition from the exact bytes - * `renderWorkflowSourceTree` emits — a strict slice, never an evaluation. */ -export function parseWorkflowSourceEntry(entryModule: string, assetId: string): string { - if (!entryModule.startsWith(ENTRY_PREFIX) || !entryModule.endsWith(ENTRY_SUFFIX)) { - throw new RetiredWorkflowEnvelopeError(assetId); +/** Validates the bytes at `definition.json` are the serialized definition + * and answers them verbatim, so callers keep byte-faithful JSON. */ +export function parseWorkflowSourceDefinition(definitionJson: string, assetId: string): string { + const trimmed = definitionJson.trim(); + try { + JSON.parse(trimmed); + } catch (cause) { + throw new RetiredWorkflowEnvelopeError(assetId, { cause }); } - return entryModule.slice(ENTRY_PREFIX.length, entryModule.length - ENTRY_SUFFIX.length); + return trimmed; } /** The blob read a source-form asset needs, declared structurally so this @@ -66,20 +97,20 @@ export type WorkflowSourceBlobReader = { readAssetBlob(params: { assetId: string; path: string }): Promise; }; -/** Reads a source-form asset's serialized definition. A missing entry - * module surfaces as `RetiredWorkflowEnvelopeError`, not a generic not-found. */ +/** Reads a source-form asset's serialized definition. A missing projection + * surfaces as `RetiredWorkflowEnvelopeError`, not a generic not-found. */ export async function readWorkflowSourceDefinition( reader: WorkflowSourceBlobReader, assetId: string, ): Promise { - let entryBytes: Uint8Array; + let bytes: Uint8Array; try { - entryBytes = await reader.readAssetBlob({ + bytes = await reader.readAssetBlob({ assetId, - path: WORKFLOW_SOURCE_ENTRY_PATH, + path: WORKFLOW_SOURCE_DEFINITION_PATH, }); } catch (cause) { throw new RetiredWorkflowEnvelopeError(assetId, { cause }); } - return parseWorkflowSourceEntry(new TextDecoder().decode(entryBytes), assetId); + return parseWorkflowSourceDefinition(new TextDecoder().decode(bytes), assetId); } diff --git a/packages/workflows/src/validate-push.test.ts b/packages/workflows/src/validate-push.test.ts index fecc66e74e..698e0dfb21 100644 --- a/packages/workflows/src/validate-push.test.ts +++ b/packages/workflows/src/validate-push.test.ts @@ -75,7 +75,8 @@ test("the renderer never leaves an envelope-valid workflow.json beside the packa workflowJson: WORKFLOW_JSON, }); - // The renderer's only two paths are package.json and workflow.js; the + // The renderer's only paths are package.json, workflow.js and + // definition.json; the // retired workflow.json envelope path never appears in its output, so the // ambiguous-tree rejection has no way to fire against what we emit. expect(Object.keys(tree)).not.toContain("workflow.json"); From ee8f462877f06fe279f61efa9b708c8a1102a36d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 23:24:08 -0700 Subject: [PATCH 2/2] feat(myra): deploy source is a bundled entry carrying her tool factories (CL-8457) The stock deployments route passes no toolPackagePins, so the sidecar's source lineage resolves no manifest and runs only the step agent's own evaluated toolFactories - which were empty, leaving Myra with no tools at all. Her workflow entry now imports the eight pinned tool packages directly and is bundled into one self-contained ESM module the deploy pushes as workflow.js, with a trailing call supplying the per-deploy trigger address, prompt and inference sources. --- .gitignore | 3 + agents/myra/package.json | 19 ++-- agents/myra/scripts/build-bundle.ts | 56 ++++++++++ agents/myra/src/boundary.test.ts | 49 --------- agents/myra/src/bundle.ts | 11 ++ agents/myra/src/definition.test.ts | 100 ++++-------------- agents/myra/src/index.ts | 155 +++++++++++----------------- agents/myra/src/raw-module.d.ts | 6 ++ apps/web/src/myra-deploy.ts | 43 +++++--- apps/web/vite.config.ts | 23 ++++- bun.lock | 24 ++--- 11 files changed, 222 insertions(+), 267 deletions(-) create mode 100644 agents/myra/scripts/build-bundle.ts delete mode 100644 agents/myra/src/boundary.test.ts create mode 100644 agents/myra/src/bundle.ts create mode 100644 agents/myra/src/raw-module.d.ts diff --git a/.gitignore b/.gitignore index 903d12c703..e9deeb98eb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ e2e/browser/shots/ # `bun pm pack` / `bun run release` tarballs — built on demand, never # committed (CL-8167). *.tgz + +# Generated Myra workflow bundle (built on demand, never committed) +agents/myra/bundle/ diff --git a/agents/myra/package.json b/agents/myra/package.json index fa1509fcca..12ed4c019c 100644 --- a/agents/myra/package.json +++ b/agents/myra/package.json @@ -12,6 +12,7 @@ "src", "!src/**/*.test.ts", "!src/**/*.test.tsx", + "bundle", "LICENSE", "README.md" ], @@ -20,22 +21,28 @@ ".": "./src/index.ts", "./prompt": "./src/system-prompt.ts", "./tool-packages": "./src/tool-packages.ts", - "./workflow-ids": "./src/workflow-ids.ts" + "./workflow-ids": "./src/workflow-ids.ts", + "./bundle": "./src/bundle.ts" }, "publishConfig": { "access": "public" }, "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "build": "bun run scripts/build-bundle.ts" }, "dependencies": { - "@corbits/catalog-tools": "0.1.1", - "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3" - }, - "devDependencies": { + "@corbits/access-tools": "workspace:*", "@corbits/agent-directory-tools": "workspace:*", + "@corbits/capability-tools": "workspace:*", + "@corbits/catalog-tools": "workspace:*", "@corbits/interaction-tools": "workspace:*", + "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", + "@corbits/skills-tools": "workspace:*", + "@corbits/workflow-authoring-tools": "workspace:*" + }, + "devDependencies": { "@types/bun": "catalog:", "typescript": "catalog:" }, diff --git a/agents/myra/scripts/build-bundle.ts b/agents/myra/scripts/build-bundle.ts new file mode 100644 index 0000000000..709c4f8ec3 --- /dev/null +++ b/agents/myra/scripts/build-bundle.ts @@ -0,0 +1,56 @@ +// Bundles Myra's workflow entry into one self-contained ESM module. +// +// The sidecar's source deploy evaluates the pushed `workflow.js` as a frozen +// closure with nothing to resolve bare imports against, so every `@intx/*` +// and tool-package import has to be inlined here. +import { mkdir, writeFile } from "node:fs/promises"; +import { isBuiltin } from "node:module"; +import path from "node:path"; + +const packageRoot = path.resolve(import.meta.dir, ".."); + +export const MYRA_BUNDLE_FILE = "workflow-bundle.js"; +export const MYRA_BUNDLE_PATH = path.join(packageRoot, "bundle", MYRA_BUNDLE_FILE); + +/** The name the bundle exports, which the rendered entry calls. */ +export const MYRA_BUNDLE_BUILD_EXPORT = "buildMyraWorkflow"; + +export async function buildMyraBundle(): Promise { + const built = await Bun.build({ + entrypoints: [path.join(packageRoot, "src", "index.ts")], + target: "bun", + format: "esm", + minify: false, + sourcemap: "none", + throw: true, + }); + const artifact = built.outputs[0]; + if (artifact === undefined) { + throw new Error("buildMyraBundle: Bun.build produced no output"); + } + const code = await artifact.text(); + // A package specifier left in the bundle is a silent runtime failure inside + // the sidecar's closure, so it fails here instead. Node builtins stay + // external on purpose: the closure is evaluated by bun, which resolves them. + const unresolved = [...code.matchAll(/(?:^|\n)\s*import\s[^\n]*?from\s*"([^"]+)"/g)] + .map((match) => match[1] ?? "") + .filter((specifier) => !specifier.startsWith(".") && !isBuiltin(specifier)); + if (unresolved.length > 0) { + throw new Error( + `buildMyraBundle: bundle still carries unresolved imports: ${[...new Set(unresolved)].join(", ")}`, + ); + } + return code; +} + +export async function writeMyraBundle(): Promise { + const code = await buildMyraBundle(); + await mkdir(path.dirname(MYRA_BUNDLE_PATH), { recursive: true }); + await writeFile(MYRA_BUNDLE_PATH, code); + return MYRA_BUNDLE_PATH; +} + +if (import.meta.main) { + const written = await writeMyraBundle(); + console.log(`[myra] bundled workflow entry -> ${written}`); +} diff --git a/agents/myra/src/boundary.test.ts b/agents/myra/src/boundary.test.ts deleted file mode 100644 index cb7c5bcb48..0000000000 --- a/agents/myra/src/boundary.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// This package is installable data on the native workflow contract: -// its shipped sources import only published platform packages, so the -// source tree plus the package manifest deploys on any Interchange -// instance without a wrapper contract. - -import { readFile, readdir } from "node:fs/promises"; -import path from "node:path"; -import { expect, test } from "bun:test"; - -const ALLOWED_IMPORT_PREFIXES = ["@intx/", "./", "../"]; - -async function listFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const files: string[] = []; - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...(await listFiles(full))); - } else { - files.push(full); - } - } - return files; -} - -async function shippedFiles(): Promise { - const packageRoot = path.join(import.meta.dir, ".."); - return [ - ...(await listFiles(path.join(packageRoot, "src"))), - path.join(packageRoot, "package.json"), - ]; -} - -test("shipped sources import only published platform packages", async () => { - const importPattern = /from\s+"([^"]+)"/g; - const violations: string[] = []; - for (const file of await shippedFiles()) { - if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue; - const content = await readFile(file, "utf8"); - for (const match of content.matchAll(importPattern)) { - const specifier = match[1] ?? ""; - const allowed = ALLOWED_IMPORT_PREFIXES.some((prefix) => specifier.startsWith(prefix)); - if (!allowed) { - violations.push(`${path.basename(file)}: ${specifier}`); - } - } - } - expect(violations).toEqual([]); -}); diff --git a/agents/myra/src/bundle.ts b/agents/myra/src/bundle.ts new file mode 100644 index 0000000000..82f9dd63c6 --- /dev/null +++ b/agents/myra/src/bundle.ts @@ -0,0 +1,11 @@ +// The built bundle's text, handed to the client as a string so the deploy +// can render it into the asset's `workflow.js`. `bundle/` is generated by +// `scripts/build-bundle.ts` and gitignored; the web build produces it before +// Vite resolves this import. +import bundleText from "../bundle/workflow-bundle.js?raw"; + +/** One self-contained ESM module exporting `MYRA_BUNDLE_BUILD_EXPORT`. */ +export const MYRA_WORKFLOW_BUNDLE: string = bundleText; + +/** The bundle export the rendered entry calls with per-deploy values. */ +export const MYRA_BUNDLE_BUILD_EXPORT = "buildMyraWorkflow"; diff --git a/agents/myra/src/definition.test.ts b/agents/myra/src/definition.test.ts index 164d72aa68..c74cd89144 100644 --- a/agents/myra/src/definition.test.ts +++ b/agents/myra/src/definition.test.ts @@ -1,23 +1,17 @@ -// Tests for this package's own contract: the shape our factory -// commits to, its serialization guarantees, and its boundary. The -// platform's own normalization and validation are its business, not -// re-proven here. +// The two facts about Myra's entry that a deploy silently depends on: she +// carries real tool factories (the source lineage resolves no pins, so an +// empty list means an agent with no tools), and she stays single-step. import { expect, test } from "bun:test"; import type { StepPrimitive, WorkflowDefinition } from "@intx/workflow"; -import { - ASSISTANT_STEP_ID, - ASSISTANT_SYSTEM_PROMPT, - ASSISTANT_WORKFLOW_ID, - buildAssistantWorkflow, - serializeAssistantWorkflow, -} from "./index"; +import { ASSISTANT_STEP_ID, buildMyraWorkflow } from "./index"; const INPUT = { triggerAddress: "ins_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "claude-test" }], turnTimeoutMs: 600000, + systemPrompt: "You are Myra.", } as const; function assistantStep(definition: WorkflowDefinition): StepPrimitive { @@ -28,79 +22,23 @@ function assistantStep(definition: WorkflowDefinition): StepPrimitive { return primitive; } +test("the agent carries its tool factories inline, not as pins", () => { + const agent = assistantStep(buildMyraWorkflow(INPUT)).agent; + expect(agent.toolFactories.length).toBeGreaterThan(0); + // Every factory must be callable and namespaced: the sidecar reads + // `factory.id` as the tool package name for the source lineage. + for (const factory of agent.toolFactories) { + expect(typeof factory).toBe("function"); + expect(factory.id).toMatch(/^@?[^/]+\/.+/); + } + expect(agent.toolPackagePins).toEqual([]); +}); + test("the definition has exactly one step, so a deployment stays conversational", () => { // A single-step deployment keeps one warm agent with durable memory // across runs; a second step would silently trade that memory away. - // This assertion is the tripwire against that regression. - const definition = buildAssistantWorkflow(INPUT); + const definition = buildMyraWorkflow(INPUT); expect(definition.stepOrder).toEqual([ASSISTANT_STEP_ID]); - expect(Object.keys(definition.steps)).toEqual([ASSISTANT_STEP_ID]); -}); - -test("the step carries an explicit per-turn timeout", () => { - const definition = buildAssistantWorkflow(INPUT); + expect(assistantStep(definition).triggers).toBe("unbounded"); expect(assistantStep(definition).timeout).toBe(INPUT.turnTimeoutMs); }); - -test("the step is unbounded: it re-arms after every reply instead of completing after the first", () => { - // The platform's step primitive defaults `triggers` to 1 (batch). A - // conversation is the long-lived interactive agent that must never - // self-complete — without this, the run ends after the greeting and - // every later message is rejected as sent to a terminal run. - expect(assistantStep(buildAssistantWorkflow(INPUT)).triggers).toBe("unbounded"); -}); - -test("the workflow is triggered by mail to the given deployment address", () => { - const definition = buildAssistantWorkflow(INPUT); - expect(definition.id).toBe(ASSISTANT_WORKFLOW_ID); - expect(definition.triggers).toEqual([{ type: "mail", to: INPUT.triggerAddress }]); -}); - -test("the agent carries the assistant prompt, the preferences, and inlines no tools", () => { - const agent = assistantStep(buildAssistantWorkflow(INPUT)).agent; - expect(agent.systemPrompt).toBe(ASSISTANT_SYSTEM_PROMPT); - expect(agent.inference.sources).toEqual([...INPUT.inferencePreferences]); - // Tools arrive as packages on the deploy, never inlined here: an - // inline factory is a function-valued field the asset cannot carry. - expect(agent.toolFactories).toEqual([]); -}); - -test("the definition survives the workflow-asset JSON round-trip", () => { - const definition = buildAssistantWorkflow(INPUT); - const revived: unknown = JSON.parse(serializeAssistantWorkflow(definition)); - expect(revived).toEqual(definition); -}); - -test("serialization fails loud on a function-valued field, naming its path", () => { - const poisoned = { - id: ASSISTANT_WORKFLOW_ID, - triggers: [{ type: "manual" }], - stepOrder: [ASSISTANT_STEP_ID], - steps: { - assistant: { - kind: "step", - id: ASSISTANT_STEP_ID, - drainBehavior: "cancel", - agent: { - id: ASSISTANT_STEP_ID, - systemPrompt: ASSISTANT_SYSTEM_PROMPT, - toolFactories: [() => []], - capabilities: [], - inference: { sources: [] }, - }, - }, - }, - } as unknown as WorkflowDefinition; - expect(() => serializeAssistantWorkflow(poisoned)).toThrow( - /steps\.assistant\.agent\.toolFactories\[0\]/, - ); -}); - -test("an empty trigger address is rejected", () => { - expect(() => buildAssistantWorkflow({ ...INPUT, triggerAddress: "" })).toThrow(/triggerAddress/); -}); - -test("a non-positive or fractional turn timeout is rejected", () => { - expect(() => buildAssistantWorkflow({ ...INPUT, turnTimeoutMs: 0 })).toThrow(/turnTimeoutMs/); - expect(() => buildAssistantWorkflow({ ...INPUT, turnTimeoutMs: 0.5 })).toThrow(/turnTimeoutMs/); -}); diff --git a/agents/myra/src/index.ts b/agents/myra/src/index.ts index 4ece7928de..b6dae64eb9 100644 --- a/agents/myra/src/index.ts +++ b/agents/myra/src/index.ts @@ -1,73 +1,85 @@ -// The assistant workflow: a single-step, mail-triggered conversational -// definition whose agent is a general-purpose assistant for a team -// workspace — it answers questions, drafts text, and reasons through -// problems, rather than repeating what it is told. +// Myra's workflow entry: a single-step, mail-triggered conversational +// definition whose agent carries its tool factories inline. // -// This package is installable data. It imports only published platform -// packages, and nothing imports it statically: a host publishes the -// serialized definition as a workflow asset and deploys it through the -// platform's deploy machinery; the execution host materializes it at -// runtime from the deploy alone. -// -// Tool-package pins: `@intx/agent`'s `defineAgent` -// still does not accept a `toolPackagePins` field on its authoring-time -// config — it is vendored, read-only source for this change — so the -// agent below is built directly against `AgentDefinition`'s own type, -// which already carries the field, matching -// `workflows/collateral-generation`'s precedent. `@corbits/memory` -// is pinned so this deployment can search, add, and list the -// tenant's firm memory (`memory_search`/`memory_add`/`memory_list`) — a -// published dependency, not a workbench-built tool package, resolved -// through its own `interchange.tools` surface the same way any other -// pin is; whether the pin *resolves* at deploy time still depends on an -// operator publishing it to a registry the host's tool-package resolver -// can reach (see `apps/hub/src/index.ts`'s `toolPackageRegistries` -// wiring). +// This module is never imported by a browser bundle. It is the entrypoint +// `scripts/build-bundle.ts` bundles into one self-contained ESM file that +// the deploy pushes as the asset's `workflow.js`; the sidecar's +// source-deploy path evaluates that closure and runs `req.agent.toolFactories` +// directly, so the factories must be real functions here rather than +// `toolPackagePins` the source lineage never resolves. -import type { AgentDefinition, InferencePreference } from "@intx/agent"; +import type { AgentDefinition, AnnotatedToolFactory, InferencePreference } from "@intx/agent"; import { defineWorkflow, step } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; +import { memoryAdd, memoryList, memorySearch } from "@corbits/memory/tools"; +import { accessTools } from "@corbits/access-tools"; +import { agentDirectoryTools } from "@corbits/agent-directory-tools"; +import { capabilityTools } from "@corbits/capability-tools"; +import { catalogTools } from "@corbits/catalog-tools"; +import { interactionTools } from "@corbits/interaction-tools"; +import { skillsManageTools, skillsQueryTools } from "@corbits/skills-tools"; +import { workflowAuthoringTools } from "@corbits/workflow-authoring-tools"; -import { ASSISTANT_SYSTEM_PROMPT } from "./system-prompt"; -import { ASSISTANT_TOOL_PACKAGE_PINS } from "./tool-packages"; import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "./workflow-ids"; export { ASSISTANT_SYSTEM_PROMPT } from "./system-prompt"; export { ASSISTANT_TOOL_PACKAGE_PINS } from "./tool-packages"; export { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "./workflow-ids"; -/** - * Everything the definition needs that is per-deployment data. The - * trigger address names a specific deployment's inbox, so a definition - * built here is per-deployment by construction. - */ -export interface AssistantWorkflowInput { +/** The description the agent step carries; mirrored by the JSON projection + * the deploy writes beside the entry, so both must stay identical. */ +export const ASSISTANT_DESCRIPTION = + "A general-purpose assistant that answers questions, drafts " + + "text, and reasons through problems for the team"; + +// Each factory declares its own env requirements, so the union has no +// common `EnvReq`; the runtime `validateEnv` is the load-bearing check. +export const MYRA_TOOL_FACTORIES = [ + memoryAdd, + memorySearch, + memoryList, + capabilityTools, + agentDirectoryTools, + catalogTools, + skillsQueryTools, + skillsManageTools, + interactionTools, + workflowAuthoringTools, + accessTools, +] as unknown as readonly AnnotatedToolFactory[]; + +/** Everything the definition needs that is per-deployment data. */ +export interface MyraWorkflowInput { /** 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[]; /** Per-turn timeout in milliseconds, enforced on the single step. */ readonly turnTimeoutMs: number; + /** The prompt this deployment runs with. */ + readonly systemPrompt: string; } /** - * Builds the assistant definition. Exactly one step, on purpose: the - * single-step shape is what makes a deployment conversational (the - * execution host keeps one warm agent with durable memory across - * runs). A second step would silently trade that memory away, so the - * step count is contract, not style. + * Builds Myra's definition. Exactly one step, on purpose: the single-step + * shape is what makes a deployment conversational (the execution host keeps + * one warm agent with durable memory across runs). A second step would + * silently trade that memory away, so the step count is contract, not style. * * The step always sets an explicit `timeout` — the singular `agent:` - * shorthand sets none, and a wedged inference call would then hang a - * run forever. Tools are never inlined on the definition: they arrive - * as packages on the deploy, keeping the definition pure data. + * shorthand sets none, and a wedged inference call would then hang a run + * forever. `toolPackagePins` is empty because the source lineage resolves + * no manifest: the factories above are the whole tool surface. */ -export function buildAssistantWorkflow(input: AssistantWorkflowInput): WorkflowDefinition { +export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition { if (input.triggerAddress === "") { - throw new Error("buildAssistantWorkflow requires a non-empty triggerAddress"); + throw new Error("buildMyraWorkflow requires a non-empty triggerAddress"); + } + if (input.systemPrompt === "") { + throw new Error("buildMyraWorkflow requires a non-empty systemPrompt"); } if (!Number.isInteger(input.turnTimeoutMs) || input.turnTimeoutMs <= 0) { - throw new Error("buildAssistantWorkflow requires turnTimeoutMs to be a positive integer"); + throw new Error("buildMyraWorkflow requires turnTimeoutMs to be a positive integer"); } return defineWorkflow({ id: ASSISTANT_WORKFLOW_ID, @@ -76,14 +88,12 @@ export function buildAssistantWorkflow(input: AssistantWorkflowInput): WorkflowD assistant: step({ agent: { id: ASSISTANT_STEP_ID, - description: - "A general-purpose assistant that answers questions, drafts " + - "text, and reasons through problems for the team", - systemPrompt: ASSISTANT_SYSTEM_PROMPT, - toolFactories: [], + description: ASSISTANT_DESCRIPTION, + systemPrompt: input.systemPrompt, + toolFactories: MYRA_TOOL_FACTORIES, capabilities: [], inference: { sources: input.inferencePreferences }, - toolPackagePins: ASSISTANT_TOOL_PACKAGE_PINS, + toolPackagePins: [], } satisfies AgentDefinition, timeout: input.turnTimeoutMs, triggers: "unbounded", @@ -91,48 +101,3 @@ export function buildAssistantWorkflow(input: AssistantWorkflowInput): WorkflowD }, }); } - -/** - * Serializes a definition to the JSON a workflow asset carries. The - * definition must survive the asset round-trip byte-faithfully, so - * anything JSON would silently drop or mangle — functions, undefined, - * symbols, bigints, non-finite numbers, class instances — is a loud - * error naming the offending path instead of a corrupted asset. - */ -export function serializeAssistantWorkflow(definition: WorkflowDefinition): string { - assertJsonPortable(definition, "definition"); - return JSON.stringify(definition); -} - -function assertJsonPortable(value: unknown, path: string): void { - if (value === null) return; - switch (typeof value) { - case "string": - case "boolean": - return; - case "number": - if (!Number.isFinite(value)) { - throw new Error(`${path} is a non-finite number; JSON drops it`); - } - return; - case "object": - break; - default: - throw new Error( - `${path} is a ${typeof value}, which does not survive JSON ` + "serialization", - ); - } - if (Array.isArray(value)) { - value.forEach((element, index) => { - assertJsonPortable(element, `${path}[${index}]`); - }); - return; - } - const proto: unknown = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - throw new Error(`${path} is a non-plain object; JSON would flatten it lossily`); - } - for (const [key, entry] of Object.entries(value)) { - assertJsonPortable(entry, `${path}.${key}`); - } -} diff --git a/agents/myra/src/raw-module.d.ts b/agents/myra/src/raw-module.d.ts new file mode 100644 index 0000000000..28936c3b94 --- /dev/null +++ b/agents/myra/src/raw-module.d.ts @@ -0,0 +1,6 @@ +// Vite's `?raw` suffix yields a module's source text; nothing in the +// toolchain declares it for this package. +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index b40c99542e..713a0b6c5c 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -3,9 +3,8 @@ // token, push the rendered source tree into the asset's git repo, revoke // the token, and hand back the `WorkflowDeployInput` pinned to that commit. import { ASSISTANT_SYSTEM_PROMPT } from "@corbits/myra/prompt"; -import { ASSISTANT_TOOL_PACKAGE_PINS } from "@corbits/myra/tool-packages"; import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "@corbits/myra/workflow-ids"; -import { renderWorkflowSourceTree } from "@corbits/workflows/client"; +import { renderBundledWorkflowSourceTree } from "@corbits/workflows/client"; import { type } from "arktype"; import { MYRA_SOURCE_CONFIG } from "./myra-source"; @@ -79,21 +78,21 @@ export async function ensureMyraSourceAsset( const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; /** - * The exact `WorkflowDefinition` JSON `@corbits/myra`'s - * `buildAssistantWorkflow` would produce for a single-step, mail-triggered, - * unbounded-turn assistant — hand-built here rather than calling that - * function, because `buildAssistantWorkflow` goes through `@intx/workflow`'s + * The function-free projection of what `@corbits/myra`'s `buildMyraWorkflow` + * produces, written to the asset's `definition.json` for readers — the entry + * itself is a bundle no reader can slice apart. Hand-built here rather than + * by calling that function, because it goes through `@intx/workflow`'s * `defineWorkflow`/`step`, which pull in `@intx/agent`'s Node-bound runtime - * (file locking) that a browser bundle cannot resolve (confirmed by a failed - * `apps/web` build importing `@corbits/myra` directly). - * `apps/web/src/myra-deploy.test.ts` and `agents/myra`'s own - * `validate-push.test.ts`-style round-trip both guard this shape against - * drift from `defineWorkflow`'s own normalization + * (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. + * bare `trigger` becomes a one-element `triggers` array. `toolFactories` is + * empty and `toolPackagePins` is empty on purpose: the real factories ride + * the bundle, and JSON cannot carry a function. */ export function buildMyraDefinitionJson( triggerAddress: string, @@ -119,7 +118,7 @@ export function buildMyraDefinitionJson( inference: { sources: declaredSources.map((source) => ({ ...source })), }, - toolPackagePins: ASSISTANT_TOOL_PACKAGE_PINS, + toolPackagePins: [], }, drainBehavior: "wait", timeout: ASSISTANT_TURN_TIMEOUT_MS, @@ -179,11 +178,21 @@ export async function pushMyraSource( declaredSources: readonly DeclaredSource[], fetchImpl: typeof fetch = fetch, ): Promise { - const tree = renderWorkflowSourceTree({ + const triggerAddress = `assistant@${tenantDomain}`; + // 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 { MYRA_BUNDLE_BUILD_EXPORT, MYRA_WORKFLOW_BUNDLE } = await import("@corbits/myra/bundle"); + const tree = renderBundledWorkflowSourceTree({ packageName: MYRA_SOURCE_CONFIG.packageName, - workflowJson: JSON.stringify( - buildMyraDefinitionJson(`assistant@${tenantDomain}`, declaredSources), - ), + bundle: MYRA_WORKFLOW_BUNDLE, + buildExport: MYRA_BUNDLE_BUILD_EXPORT, + buildInput: { + triggerAddress, + inferencePreferences: declaredSources.map((source) => ({ ...source })), + turnTimeoutMs: ASSISTANT_TURN_TIMEOUT_MS, + systemPrompt: ASSISTANT_SYSTEM_PROMPT, + }, + workflowJson: JSON.stringify(buildMyraDefinitionJson(triggerAddress, declaredSources)), }); const url = new URL( `/api/tenants/${encodeURIComponent(tenantId)}/assets/${MYRA_SOURCE_CONFIG.assetKind}/${MYRA_SOURCE_CONFIG.assetName}.git`, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index c2e7b37792..89f2865c1a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,11 +4,30 @@ // // `vite dev` proxies /api to a locally running hub so the interface can be // developed against real data without a build step. +import { execFileSync } from "node:child_process"; import path from "node:path"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; + +const myraDir = path.resolve(__dirname, "..", "..", "agents", "myra"); + +// Myra's deploy source is a bundle of her workflow entry, generated (and +// gitignored) rather than committed; it has to exist before Vite resolves +// `@corbits/myra/bundle`. Built in a `bun` subprocess because `Bun.build` +// is unavailable in the Node process Vite itself runs in. +function myraWorkflowBundle(): Plugin { + return { + name: "myra-workflow-bundle", + buildStart() { + execFileSync("bun", ["run", path.join(myraDir, "scripts", "build-bundle.ts")], { + cwd: myraDir, + stdio: "inherit", + }); + }, + }; +} function manualChunks(id: string): string | undefined { if (!id.includes("node_modules")) return undefined; @@ -24,7 +43,7 @@ function manualChunks(id: string): string | undefined { const hubOrigin = process.env.BASE_URL ?? "http://localhost:3000"; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [myraWorkflowBundle(), react(), tailwindcss()], resolve: { alias: { "@": path.resolve(__dirname, "src"), diff --git a/bun.lock b/bun.lock index c24877c953..86f0a1e4aa 100644 --- a/bun.lock +++ b/bun.lock @@ -36,12 +36,16 @@ "name": "@corbits/myra", "version": "0.0.3", "dependencies": { - "@corbits/catalog-tools": "0.1.1", + "@corbits/access-tools": "workspace:*", + "@corbits/agent-directory-tools": "workspace:*", + "@corbits/capability-tools": "workspace:*", + "@corbits/catalog-tools": "workspace:*", + "@corbits/interaction-tools": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", + "@corbits/skills-tools": "workspace:*", + "@corbits/workflow-authoring-tools": "workspace:*", }, "devDependencies": { - "@corbits/agent-directory-tools": "workspace:*", - "@corbits/interaction-tools": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -2039,14 +2043,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#5309a15", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-5309a15", "sha512-zGHkq++juclUrOd0O8/Z5ziTldmJZ6gqyH/c9cs5S10UPU4gqn8CHwq174SiJXY0ujJ3cNpJXnrr+0fcqlfQ1w=="], - - "@workbench/hub/@corbits/codex-provider": ["@corbits/codex-provider@github:corbitsdev/corbits-codex-provider#a1f475f", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-a1f475f", "sha512-D6d0lblWx/4KudfLwQoQGrLB8GKRLP/HVp1cx08L2/hOGZAgH6txLPpeEtBqjl+R520qXSSPfM0kDC+3XNaAfw=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#e74da20", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-e74da20", "sha512-/uA9xTcF7XwMXgnR8OZamierzkLG4g4mhw3u9Fl3Uj6ait8pH3wcoQ6V32on1nMdaqtPRdYVFnxof5O+4ZacGA=="], - "@workbench/hub/@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#9f2d4ba", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-9f2d4ba", "sha512-ZjvKxGcp+QzCh8zeXHg7kNbtkpR/itZkmQAcdG1aOAOjHaNwfGVpkPc/c/XtwPNsQyaDjF8W9CHb4rH1xI2uCA=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -2141,14 +2139,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], - - "@workbench/hub/@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], - - "@workbench/hub/@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], - - "@workbench/hub/@corbits/xai-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],