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/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/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=="], 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");