From e60cd0b7ebb0b1adfa26098fdb3fb4399a09a991 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 10:02:17 -0700 Subject: [PATCH 1/3] feat(web): one installer puts any workflow package into a tenant over stock routes (CL-9526) installPackage composes the stock asset, git-token, git push and deployments routes; identical files make no new commit and no new deployment. deployAgentSource now calls it. A hub-level e2e installs a minimal echo workflow into a fresh tenant, mails it, and checks a run is listed. --- apps/web/package.json | 1 + apps/web/src/agent-deploy.ts | 174 +++++----------------------- apps/web/src/git-push.ts | 106 ++++++++++++++--- apps/web/src/install-package.ts | 166 ++++++++++++++++++++++++++ apps/web/src/myra-deploy.ts | 16 ++- apps/web/src/skill-source.ts | 17 +-- e2e/install/echo-directors.ts | 2 + e2e/install/echo-workflow.ts | 29 +++++ e2e/install/install-package.test.ts | 174 ++++++++++++++++++++++++++++ package.json | 2 + 10 files changed, 512 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/install-package.ts create mode 100644 e2e/install/echo-directors.ts create mode 100644 e2e/install/echo-workflow.ts create mode 100644 e2e/install/install-package.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0e8bb27af1..5ae4c7898d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "buffer": "^6.0.3", "isomorphic-git": "catalog:", "lucide-react": "^1.49.0", + "memfs": "^4.80.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-joyride": "^2.9.3" diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index 2b0f4de3aa..04529a7900 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -1,8 +1,8 @@ // Deploys a hand-authored agent the same way Myra deploys herself // (`myra-deploy.ts`), generalized over {name, displayName, systemPrompt}. import { renderBundledWorkflowSourceTree } from "@corbits/workflows/client"; +import { installPackage } from "./install-package"; import { type } from "arktype"; -import { WorkflowDeploymentResponse } from "@intx/types"; import { reportError } from "@corbits/error-sink"; import { @@ -24,17 +24,12 @@ import { isValidSlug, slugify } from "@/lib/slug"; export class AgentDeployError extends Error {} -const AssetCreatedShape = type({ id: "string" }); -const AssetListShape = type({ id: "string", name: "string" }).array(); -const GitTokenMintShape = type({ id: "string", secret: "string" }); const TenantDomainShape = type({ domain: "string" }); // Same shape `session.ts`'s `fetchSession` parses; a person's refId is // their better-auth user id, exactly what `threads-api.ts` builds a // principal's mailbox address from. const SessionUserShape = type({ user: { id: "string" } }); -const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000; - async function readErrorBody(response: Response): Promise { const body: unknown = await response.json().catch(() => undefined); const envelope = type({ @@ -43,57 +38,6 @@ async function readErrorBody(response: Response): Promise { return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; } -/** The `workflow`-kind asset a given agent's source pushes into, derived - * from its name — idempotent create-or-find, mirroring `ensureMyraSourceAsset` - * but keyed on a caller-supplied name rather than Myra's fixed one. */ -export async function ensureAgentSourceAsset( - tenantId: string, - assetName: string, - displayName: string, - fetchImpl: typeof fetch = fetch, -): Promise { - const created = await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}/assets`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "workflow", name: assetName, displayName }), - }); - if (created.status === 201) { - const parsed = AssetCreatedShape(await created.json()); - if (parsed instanceof type.errors) { - throw new AgentDeployError( - `this agent's source came back an unexpected shape: ${parsed.summary}`, - ); - } - return parsed.id; - } - if (created.status !== 409) { - throw new AgentDeployError( - `preparing this agent's source failed: ${await readErrorBody(created)}`, - ); - } - const listed = await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=workflow&inherited=false`, - ); - if (!listed.ok) { - throw new AgentDeployError( - `checking this workbench's agents failed: ${await readErrorBody(listed)}`, - ); - } - const parsed = AssetListShape(await listed.json()); - if (parsed instanceof type.errors) { - throw new AgentDeployError( - `this workbench's agent list came back an unexpected shape: ${parsed.summary}`, - ); - } - const existing = parsed.find((asset) => asset.name === assetName); - if (existing === undefined) { - throw new AgentDeployError( - "this agent's source reported a name conflict but is not listed on this workbench", - ); - } - return existing.id; -} - /** The exact `WorkflowDefinition` JSON for a single-step, mail-triggered, * unbounded-turn agent — the same shape `buildMyraDefinitionJson` produces, * generalized over the caller's own name and system prompt. */ @@ -152,44 +96,8 @@ export function buildAgentDefinitionJson(args: { }; } -async function withPushToken( - tenantId: string, - assetId: string, - fetchImpl: typeof fetch, - push: (token: string) => Promise, -): Promise { - const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; - const minted = await fetchImpl(tokensPath, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: `agent-deploy-${crypto.randomUUID()}`, - resource: `asset:${assetId}`, - refPattern: "refs/heads/main", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), - }), - }); - if (!minted.ok) { - throw new AgentDeployError(`minting a push token failed: ${await readErrorBody(minted)}`); - } - const token = GitTokenMintShape(await minted.json()); - if (token instanceof type.errors) { - throw new AgentDeployError(`the push token came back an unexpected shape: ${token.summary}`); - } - try { - return await push(token.secret); - } finally { - await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); - } -} - // The bundle's `buildMyraWorkflow` is generic over which agent it builds. -// Returns the commit sha the deploy pins to. -export async function pushAgentSource( - tenantId: string, - assetId: string, - assetName: string, +async function renderAgentSourceTree( packageName: string, args: { readonly slug: string; @@ -199,11 +107,10 @@ export async function pushAgentSource( readonly hubCredentialId: string; readonly mcpServers: readonly McpServerDeployment[]; }, - fetchImpl: typeof fetch = fetch, -): Promise { +): Promise> { const { MYRA_BUNDLE_BUILD_EXPORT, MYRA_DIRECTORS_BUNDLE, MYRA_WORKFLOW_BUNDLE } = await import("@corbits/myra/bundle"); - const tree = renderBundledWorkflowSourceTree({ + return renderBundledWorkflowSourceTree({ packageName, bundle: MYRA_WORKFLOW_BUNDLE, directorsBundle: MYRA_DIRECTORS_BUNDLE, @@ -230,14 +137,6 @@ export async function pushAgentSource( }), ), }); - const url = new URL( - `/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`, - globalThis.location.origin, - ).toString(); - const { pushSourceTree } = await import("./git-push"); - return withPushToken(tenantId, assetId, fetchImpl, (token) => - pushSourceTree({ url, token, tree, message: `Publish ${assetName}'s definition` }), - ); } /** Every created agent's source asset name, `agent--source` — the one @@ -368,7 +267,7 @@ async function scheduleAgentRun( } } -export type DeployedAgent = typeof WorkflowDeploymentResponse.infer; +export type DeployedAgent = Awaited>; // Fails closed when no provider is connected yet — there is no offering // to deploy against. @@ -404,13 +303,6 @@ export async function deployAgentSource( throw new AgentDeployError("connect a model provider in Settings before deploying an agent"); } - const assetId = await ensureAgentSourceAsset(args.tenantId, assetName, name, fetchImpl); - // Minted before the push: the definition binds this credential by name - // and requires its use by id, so it must exist before the source does. - const hubCredentialId = await ensureAgentHubCredential( - { tenantId: args.tenantId, definitionId: slug, assetId }, - fetchImpl, - ); // The chosen handles bind workspace-catalog servers by ancestor walk, the // same as Myra's Exa; an empty choice deploys a server-less agent. const requestedHandles = args.input.mcpHandles ?? []; @@ -424,42 +316,30 @@ export async function deployAgentSource( // Grant configuration only, not a routable address: the hub mints the // agent's real address (its run address) at deploy time. const triggerAddress = `${slug}@${tenant.domain}`; - const commitSha = await pushAgentSource( - args.tenantId, - assetId, + const parsed = await installPackage({ + fetch: fetchImpl, + origin: globalThis.location.origin, + tenantId: args.tenantId, assetName, - packageName, - { - slug, - systemPrompt, - triggerAddress, - declaredSources: offering.declaredSources, - hubCredentialId, - mcpServers, - }, - fetchImpl, - ); - - const deployed = await fetchImpl( - `/api/tenants/${encodeURIComponent(args.tenantId)}/workflows/deployments`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - source: { kind: "asset", assetId, package: { format: "source", commitSha } }, - entry: "./workflow.js", - sourceOfferingIds: offering.sourceOfferingIds, - defaultSourceOfferingId: offering.defaultSourceOfferingId, + displayName: name, + // Minted once the asset exists: the definition binds this credential by + // name and requires its use by id, so it must precede the source. + files: async (assetId) => + renderAgentSourceTree(packageName, { + slug, + systemPrompt, + triggerAddress, + declaredSources: offering.declaredSources, + hubCredentialId: await ensureAgentHubCredential( + { tenantId: args.tenantId, definitionId: slug, assetId }, + fetchImpl, + ), + mcpServers, }), - }, - ); - if (!deployed.ok) { - throw new AgentDeployError(`deploying this agent failed: ${await readErrorBody(deployed)}`); - } - const parsed = WorkflowDeploymentResponse(await deployed.json()); - if (parsed instanceof type.errors) { - throw new AgentDeployError(`this deployment came back an unexpected shape: ${parsed.summary}`); - } + entry: "./workflow.js", + sourceOfferingIds: offering.sourceOfferingIds, + defaultSourceOfferingId: offering.defaultSourceOfferingId, + }); if (args.input.schedule !== undefined) { await scheduleAgentRun(args.tenantId, args.input.schedule, assetName, tenant.domain, fetchImpl); } diff --git a/apps/web/src/git-push.ts b/apps/web/src/git-push.ts index 58fd6aada1..71ac794ee7 100644 --- a/apps/web/src/git-push.ts +++ b/apps/web/src/git-push.ts @@ -1,14 +1,18 @@ // The receive-pack wire exchange is spoken directly because the hub // answers `report-status` as raw pkt-lines isomorphic-git's push can't read. -import LightningFS from "@isomorphic-git/lightning-fs"; +// Every request goes through the caller's `fetch`, so the same code runs in +// the browser and against an in-process hub. import { Buffer } from "buffer"; -import git from "isomorphic-git"; +import git, { type GitHttpRequest, type GitHttpResponse, type HttpClient } from "isomorphic-git"; +import { createFsFromVolume, Volume } from "memfs"; // isomorphic-git reads the Node `Buffer` global; browsers do not ship one. globalThis.Buffer ??= Buffer; export class GitPushError extends Error {} +// A fixed author and a caller-independent clock keep a commit's identity a +// pure function of its tree and parent. const COMMIT_AUTHOR = { name: "Workbench", email: "workbench@corbits.dev" }; const MAIN_REF = "refs/heads/main"; const ZERO_OID = "0".repeat(40); @@ -34,8 +38,12 @@ function readPktLines(body: Uint8Array): string[] { return lines; } -async function advertisedMainSha(url: string, token: string): Promise { - const response = await fetch(`${url}/info/refs?service=git-receive-pack`, { +async function advertisedMainSha( + fetchImpl: typeof fetch, + url: string, + token: string, +): Promise { + const response = await fetchImpl(`${url}/info/refs?service=git-receive-pack`, { headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) { @@ -50,39 +58,99 @@ async function advertisedMainSha(url: string, token: string): Promise { return ZERO_OID; } +// isomorphic-git's own web client calls the global `fetch`; this one routes +// through the caller's. +function httpThrough(fetchImpl: typeof fetch): HttpClient { + return { + async request(req: GitHttpRequest): Promise { + const chunks: Uint8Array[] = []; + if (req.body !== undefined) for await (const chunk of req.body) chunks.push(chunk); + const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const body = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.length; + } + const response = await fetchImpl(req.url, { + method: req.method ?? "GET", + headers: req.headers ?? {}, + ...(length > 0 ? { body } : {}), + }); + const bytes = new Uint8Array(await response.arrayBuffer()); + return { + url: req.url, + method: req.method ?? "GET", + statusCode: response.status, + statusMessage: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: (async function* () { + yield bytes; + })(), + }; + }, + }; +} + /** Commits `tree` on top of the asset's current `main` and pushes it. - * Returns the new commit sha. */ + * When `main` already holds exactly this tree nothing is pushed and + * `changed` is false. Returns the commit `main` points at afterwards. */ export async function pushSourceTree(args: { + fetch: typeof fetch; url: string; token: string; tree: Readonly>; message: string; -}): Promise { - const fs = new LightningFS(`workbench-push-${crypto.randomUUID()}`, { wipe: true }); +}): Promise<{ commitSha: string; changed: boolean }> { + const fs = createFsFromVolume(new Volume()); const dir = "/repo"; await fs.promises.mkdir(dir); await git.init({ fs, dir, defaultBranch: "main" }); + + const entries = []; for (const [filepath, contents] of Object.entries(args.tree)) { if (filepath.includes("/")) { throw new GitPushError(`pushSourceTree writes a flat tree; got ${JSON.stringify(filepath)}`); } - await fs.promises.writeFile(`${dir}/${filepath}`, contents, "utf8"); - await git.add({ fs, dir, filepath }); + const oid = await git.writeBlob({ fs, dir, blob: new TextEncoder().encode(contents) }); + entries.push({ mode: "100644", path: filepath, oid, type: "blob" as const }); } - const oldSha = await advertisedMainSha(args.url, args.token); - const sha = await git.commit({ + const treeOid = await git.writeTree({ fs, dir, tree: entries }); + + const oldSha = await advertisedMainSha(args.fetch, args.url, args.token); + if (oldSha !== ZERO_OID) { + await git.addRemote({ fs, dir, remote: "origin", url: args.url }); + // The hub's git server advertises no `shallow` capability, so fetch the + // full branch. + await git.fetch({ + fs, + http: httpThrough(args.fetch), + dir, + remote: "origin", + ref: MAIN_REF, + singleBranch: true, + tags: false, + headers: { Authorization: `Bearer ${args.token}` }, + }); + const { commit: current } = await git.readCommit({ fs, dir, oid: oldSha }); + if (current.tree === treeOid) return { commitSha: oldSha, changed: false }; + } + + const sha = await git.writeCommit({ fs, dir, - message: args.message, - author: COMMIT_AUTHOR, - parent: oldSha === ZERO_OID ? [] : [oldSha], + commit: { + message: args.message, + tree: treeOid, + parent: oldSha === ZERO_OID ? [] : [oldSha], + author: { ...COMMIT_AUTHOR, timestamp: Math.floor(Date.now() / 1000), timezoneOffset: 0 }, + committer: { ...COMMIT_AUTHOR, timestamp: Math.floor(Date.now() / 1000), timezoneOffset: 0 }, + }, }); - const { commit } = await git.readCommit({ fs, dir, oid: sha }); - const { tree } = await git.readTree({ fs, dir, oid: commit.tree }); const { packfile } = await git.packObjects({ fs, dir, - oids: [sha, commit.tree, ...tree.map((entry) => entry.oid)], + oids: [sha, treeOid, ...entries.map((entry) => entry.oid)], }); if (packfile === undefined) throw new GitPushError("packObjects returned no packfile"); @@ -91,7 +159,7 @@ export async function pushSourceTree(args: { body.set(command, 0); body.set(new TextEncoder().encode("0000"), command.length); body.set(packfile, command.length + 4); - const response = await fetch(`${args.url}/git-receive-pack`, { + const response = await args.fetch(`${args.url}/git-receive-pack`, { method: "POST", headers: { Authorization: `Bearer ${args.token}`, @@ -112,5 +180,5 @@ export async function pushSourceTree(args: { if (!report.some((line) => line.trim() === `ok ${MAIN_REF}`)) { throw new GitPushError(`git push reported no result for ${MAIN_REF}`); } - return sha; + return { commitSha: sha, changed: true }; } diff --git a/apps/web/src/install-package.ts b/apps/web/src/install-package.ts new file mode 100644 index 0000000000..d81940bc7d --- /dev/null +++ b/apps/web/src/install-package.ts @@ -0,0 +1,166 @@ +// The one client-driven install path over stock routes: a `workflow`-kind +// asset, a short-lived git push token, the package's source pushed to +// `main`, then a deployment pinned to that commit. Identical files change +// nothing: `main` keeps its commit and the existing deployment is returned. +import { type } from "arktype"; + +import { pushSourceTree } from "./git-push"; + +export class InstallPackageError extends Error {} + +const IdShape = type({ id: "string" }); +const AssetListShape = type({ id: "string", name: "string" }).array(); +const GitTokenShape = type({ id: "string", secret: "string" }); +const DeploymentListShape = type({ id: "string", definitionAssetId: "string" }).array(); +const ErrorEnvelope = type({ error: { "userMessage?": "string", "message?": "string" } }); + +const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000; + +export type InstallPackageArgs = { + fetch: typeof fetch; + /** Absolute origin the hub is served from; git remotes need absolute URLs. */ + origin: string; + tenantId: string; + assetName: string; + displayName: string; + /** The package's flat source tree, or a function of the asset id for a + * package whose files name resources created against that asset. */ + files: Readonly> | ((assetId: string) => Promise>); + entry: string; + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; +}; + +async function readError(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = ErrorEnvelope(body); + return envelope instanceof type.errors + ? `HTTP ${response.status}` + : (envelope.error.userMessage ?? envelope.error.message ?? `HTTP ${response.status}`); +} + +function json(method: string, body: unknown): RequestInit { + return { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }; +} + +async function ensureWorkflowAsset(args: InstallPackageArgs, base: string): Promise { + const created = await args.fetch( + `${base}/assets`, + json("POST", { kind: "workflow", name: args.assetName, displayName: args.displayName }), + ); + if (created.status === 201) { + const parsed = IdShape(await created.json()); + if (parsed instanceof type.errors) { + throw new InstallPackageError(`the asset came back an unexpected shape: ${parsed.summary}`); + } + return parsed.id; + } + if (created.status !== 409) { + throw new InstallPackageError(`creating the asset failed: ${await readError(created)}`); + } + const listed = await args.fetch(`${base}/assets?kind=workflow&inherited=false`); + if (!listed.ok) { + throw new InstallPackageError(`listing assets failed: ${await readError(listed)}`); + } + const parsed = AssetListShape(await listed.json()); + if (parsed instanceof type.errors) { + throw new InstallPackageError( + `the asset list came back an unexpected shape: ${parsed.summary}`, + ); + } + const existing = parsed.find((asset) => asset.name === args.assetName); + if (existing === undefined) { + throw new InstallPackageError(`asset ${args.assetName} conflicts but is not listed`); + } + return existing.id; +} + +async function latestDeploymentId( + args: InstallPackageArgs, + base: string, + assetId: string, +): Promise { + const listed = await args.fetch(`${base}/workflows/deployments`); + if (!listed.ok) { + throw new InstallPackageError(`listing deployments failed: ${await readError(listed)}`); + } + const parsed = DeploymentListShape(await listed.json()); + if (parsed instanceof type.errors) { + throw new InstallPackageError( + `the deployment list came back an unexpected shape: ${parsed.summary}`, + ); + } + // The route lists most recent first. + return parsed.find((deployment) => deployment.definitionAssetId === assetId)?.id ?? null; +} + +export async function installPackage( + args: InstallPackageArgs, +): Promise<{ assetId: string; commitSha: string; deploymentId: string }> { + const base = `${args.origin}/api/tenants/${encodeURIComponent(args.tenantId)}`; + const assetId = await ensureWorkflowAsset(args, base); + const files = typeof args.files === "function" ? await args.files(assetId) : args.files; + + const minted = await args.fetch( + `${base}/git-tokens`, + json("POST", { + name: `install-${crypto.randomUUID()}`, + resource: `asset:${assetId}`, + refPattern: "refs/heads/main", + actions: ["can_read", "can_push"], + expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), + }), + ); + if (!minted.ok) { + throw new InstallPackageError(`minting a push token failed: ${await readError(minted)}`); + } + const token = GitTokenShape(await minted.json()); + if (token instanceof type.errors) { + throw new InstallPackageError(`the push token came back an unexpected shape: ${token.summary}`); + } + let pushed: { commitSha: string; changed: boolean }; + try { + pushed = await pushSourceTree({ + fetch: args.fetch, + url: `${base}/assets/workflow/${args.assetName}.git`, + token: token.secret, + tree: files, + message: `Publish ${args.assetName}`, + }); + } finally { + await args.fetch(`${base}/git-tokens/${encodeURIComponent(token.id)}`, { method: "DELETE" }); + } + + if (!pushed.changed) { + const existing = await latestDeploymentId(args, base, assetId); + if (existing !== null) { + return { assetId, commitSha: pushed.commitSha, deploymentId: existing }; + } + } + + const deployed = await args.fetch( + `${base}/workflows/deployments`, + json("POST", { + source: { + kind: "asset", + assetId, + package: { format: "source", commitSha: pushed.commitSha }, + }, + entry: args.entry, + sourceOfferingIds: args.sourceOfferingIds, + defaultSourceOfferingId: args.defaultSourceOfferingId, + }), + ); + if (!deployed.ok) { + throw new InstallPackageError( + `deploying ${args.assetName} failed: ${await readError(deployed)}`, + ); + } + const parsed = IdShape(await deployed.json()); + if (parsed instanceof type.errors) { + throw new InstallPackageError( + `the deployment came back an unexpected shape: ${parsed.summary}`, + ); + } + return { assetId, commitSha: pushed.commitSha, deploymentId: parsed.id }; +} diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index bf0d13e508..05556c7f41 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -220,8 +220,20 @@ export async function pushMyraSource( // Loaded on demand: the git client only runs during setup, so it stays // out of the main bundle. const { pushSourceTree } = await import("./git-push"); - return withPushToken(tenantId, assetId, fetchImpl, (token) => - pushSourceTree({ url, token, tree, message: "Publish Myra's definition" }), + return withPushToken( + tenantId, + assetId, + fetchImpl, + async (token) => + ( + await pushSourceTree({ + fetch: fetchImpl, + url, + token, + tree, + message: "Publish Myra's definition", + }) + ).commitSha, ); } diff --git a/apps/web/src/skill-source.ts b/apps/web/src/skill-source.ts index 2d9a095fb9..7eb07ffbaf 100644 --- a/apps/web/src/skill-source.ts +++ b/apps/web/src/skill-source.ts @@ -54,12 +54,15 @@ export async function writeSkillSource( actions: ["can_read", "can_push"], lifetimeMs: PUSH_TOKEN_LIFETIME_MS, fetchImpl, - use: (token) => - pushSourceTree({ - url: skillAssetUrl(tenantId, assetName), - token, - tree: { [SKILL_SOURCE_PATH]: content }, - message: "Update SKILL.md", - }), + use: async (token) => + ( + await pushSourceTree({ + fetch: fetchImpl, + url: skillAssetUrl(tenantId, assetName), + token, + tree: { [SKILL_SOURCE_PATH]: content }, + message: "Update SKILL.md", + }) + ).commitSha, }); } diff --git a/e2e/install/echo-directors.ts b/e2e/install/echo-directors.ts new file mode 100644 index 0000000000..c3f4e2d171 --- /dev/null +++ b/e2e/install/echo-directors.ts @@ -0,0 +1,2 @@ +// The `interchange.directors` entry every installed package must export. +export { deferredDirector } from "../../packages/deferred-tools/src"; diff --git a/e2e/install/echo-workflow.ts b/e2e/install/echo-workflow.ts new file mode 100644 index 0000000000..8a3a72223b --- /dev/null +++ b/e2e/install/echo-workflow.ts @@ -0,0 +1,29 @@ +// The smallest mail-triggered workflow: one step, no tools. Bundled by the +// install test into the source tree it installs. +import type { AgentDefinition, InferencePreference } from "@intx/agent"; +import { defineWorkflow, step } from "@intx/workflow"; +import type { WorkflowDefinition } from "@intx/workflow"; + +export function buildEchoWorkflow(input: { + triggerAddress: string; + inferencePreferences: readonly InferencePreference[]; +}): WorkflowDefinition { + return defineWorkflow({ + id: "echo", + trigger: { type: "mail", to: input.triggerAddress }, + steps: { + echo: step({ + agent: { + id: "echo", + description: "Replies with the text it received", + systemPrompt: "Reply with the exact text of the message you received.", + toolFactories: [], + capabilities: [], + inference: { sources: input.inferencePreferences }, + toolPackagePins: [], + } satisfies AgentDefinition, + triggers: "unbounded", + }), + }, + }); +} diff --git a/e2e/install/install-package.test.ts b/e2e/install/install-package.test.ts new file mode 100644 index 0000000000..060998e9c0 --- /dev/null +++ b/e2e/install/install-package.test.ts @@ -0,0 +1,174 @@ +// Proves the client-driven install over stock routes: a package's source goes +// into a fresh tenant as a workflow asset, deploys, and answers mail. A second +// install of identical files deploys nothing new. Skips without DATABASE_URL. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import path from "node:path"; +import { type } from "arktype"; +import { renderBundledWorkflowSourceTree } from "../../packages/workflows/src/client"; +import { installPackage } from "../../apps/web/src/install-package"; +import { dbGate } from "../lib/db-gate"; +import { bootHub } from "../lib/hub"; + +const describeIfDb = dbGate(process.env["DATABASE_URL"] ?? "", import.meta.path); + +const Id = type({ id: "string" }); +const Deployments = type({ id: "string" }).array(); +const RunList = type({ runIds: "string[]" }); + +async function bundle(entry: string): Promise { + const built = await Bun.build({ + entrypoints: [entry], + target: "bun", + format: "esm", + throw: true, + }); + return (await built.outputs[0]?.text()) ?? ""; +} + +describeIfDb("installPackage", () => { + let server: ReturnType | undefined; + beforeAll(() => { + // Bound before the hub boots so BASE_URL names the real port. + server = Bun.serve({ port: 0, fetch: () => new Response("booting", { status: 503 }) }); + }); + // The in-process sidecar dials the hub's own port back. + const booted = bootHub({ + baseUrl: () => `http://localhost:${String(server?.port)}`, + port: () => server?.port ?? 0, + }); + + afterAll(async () => { + await server?.stop(true); + }); + + test("installs echo once, mails it, and reinstalls as a no-op", async () => { + const hub = booted(); + if (server === undefined) throw new Error("server not bound"); + server.reload({ + websocket: hub.websocket as Bun.WebSocketHandler, + fetch: (req, srv) => hub.fetch(req, srv), + }); + const origin = `http://localhost:${String(server.port)}`; + const suffix = crypto.randomUUID().slice(0, 8); + + const signUp = await fetch(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify({ + email: `alice-${suffix}@example.com`, + password: "password123", + name: "alice", + }), + }); + expect(signUp.status).toBe(200); + const cookie = signUp.headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + const call = (input: string | URL | Request, init: RequestInit = {}) => + fetch(input, { + ...init, + headers: { origin, cookie, ...(init.headers as Record | undefined) }, + }); + const post = (route: string, body: unknown) => + call(`${origin}${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + async function created(res: Response): Promise { + expect(res.status).toBe(201); + return Id.assert(await res.json()).id; + } + + const tenantId = await created( + await post("/api/tenants", { name: "Workspace", slug: `ws-${suffix}` }), + ); + + // A model offering is the one thing a deployment needs from the tenant. + const t = `/api/tenants/${tenantId}`; + const provider = await created( + await post(`${t}/providers`, { + name: "anthropic", + plugin: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + }), + ); + const credential = await created( + await post(`${t}/credentials`, { + providerId: provider, + name: "anthropic-key", + type: "api_key", + secret: "sk-ant-placeholder", + }), + ); + const modelProvider = await created( + await post(`${t}/catalog/providers`, { + name: "anthropic", + plugin: "anthropic", + baseURL: "https://api.anthropic.com", + credentialId: credential, + }), + ); + const model = await created( + await post(`${t}/catalog/models`, { canonicalName: "claude-sonnet-4-5" }), + ); + const offering = await created( + await post(`${t}/catalog/offerings`, { + modelId: model, + providerId: modelProvider, + priority: 0, + }), + ); + + const workflow = await bundle(path.join(import.meta.dir, "echo-workflow.ts")); + const files = renderBundledWorkflowSourceTree({ + packageName: "@workbench-e2e/echo", + bundle: workflow, + directorsBundle: await bundle(path.join(import.meta.dir, "echo-directors.ts")), + buildExport: "buildEchoWorkflow", + buildInput: { + triggerAddress: `echo@ws-${suffix}.localhost`, + inferencePreferences: [{ provider: "anthropic", model: "claude-sonnet-4-5" }], + }, + workflowJson: "{}", + }); + const install = () => + installPackage({ + fetch: call as typeof fetch, + origin, + tenantId, + assetName: "echo", + displayName: "Echo", + files, + entry: "./workflow.js", + sourceOfferingIds: [offering], + defaultSourceOfferingId: offering, + }); + + const first = await install(); + const listed = Deployments.assert( + await (await call(`${origin}${t}/workflows/deployments`)).json(), + ); + expect(listed.map((d) => d.id)).toEqual([first.deploymentId]); + + const mailed = await post(`${t}/workflows/${first.deploymentId}/mail`, { content: "hello" }); + expect(mailed.status).toBe(202); + // The run commits its first events asynchronously after the mail is accepted. + let runIds: string[] = []; + for (let attempt = 0; attempt < 40 && runIds.length === 0; attempt++) { + runIds = RunList.assert( + await (await call(`${origin}${t}/workflows/${first.deploymentId}/runs`)).json(), + ).runIds; + if (runIds.length === 0) await Bun.sleep(500); + } + expect(runIds.length).toBeGreaterThan(0); + + const second = await install(); + expect(second).toEqual(first); + const after = Deployments.assert( + await (await call(`${origin}${t}/workflows/deployments`)).json(), + ); + expect(after).toHaveLength(1); + }, 120_000); +}); diff --git a/package.json b/package.json index b8aa1ff6c5..1998e7a796 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@intx/hub-sessions": "workspace:*", "@intx/hub-common": "0.4.0", "@intx/hub-api": "workspace:*", + "@intx/agent": "0.4.0", + "@intx/workflow": "0.4.0", "hono": "catalog:", "drizzle-orm": "catalog:" }, From 6c16e43e3cdba37b90597cd8660a9130fa98752e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 10:37:59 -0700 Subject: [PATCH 2/3] fix(web): the installer's git push runs in the browser (CL-9526) Use LightningFS instead of memfs (which needs Node's Buffer at import) and call the injected fetch unbound (window.fetch throws Illegal invocation as a method). The install e2e provides IndexedDB via fake-indexeddb. --- apps/web/package.json | 1 - apps/web/src/git-push.ts | 12 +++++++----- apps/web/src/install-package.ts | 4 +++- bun.lock | 3 +++ e2e/install/install-package.test.ts | 2 ++ package.json | 23 ++++++++++++----------- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 5ae4c7898d..0e8bb27af1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,7 +35,6 @@ "buffer": "^6.0.3", "isomorphic-git": "catalog:", "lucide-react": "^1.49.0", - "memfs": "^4.80.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-joyride": "^2.9.3" diff --git a/apps/web/src/git-push.ts b/apps/web/src/git-push.ts index 71ac794ee7..bbb1753aa8 100644 --- a/apps/web/src/git-push.ts +++ b/apps/web/src/git-push.ts @@ -4,7 +4,7 @@ // the browser and against an in-process hub. import { Buffer } from "buffer"; import git, { type GitHttpRequest, type GitHttpResponse, type HttpClient } from "isomorphic-git"; -import { createFsFromVolume, Volume } from "memfs"; +import LightningFS from "@isomorphic-git/lightning-fs"; // isomorphic-git reads the Node `Buffer` global; browsers do not ship one. globalThis.Buffer ??= Buffer; @@ -102,7 +102,9 @@ export async function pushSourceTree(args: { tree: Readonly>; message: string; }): Promise<{ commitSha: string; changed: boolean }> { - const fs = createFsFromVolume(new Volume()); + // `window.fetch` throws "Illegal invocation" when called as a method of `args`. + const fetchImpl = args.fetch.bind(globalThis); + const fs = new LightningFS(`workbench-push-${crypto.randomUUID()}`, { wipe: true }); const dir = "/repo"; await fs.promises.mkdir(dir); await git.init({ fs, dir, defaultBranch: "main" }); @@ -117,14 +119,14 @@ export async function pushSourceTree(args: { } const treeOid = await git.writeTree({ fs, dir, tree: entries }); - const oldSha = await advertisedMainSha(args.fetch, args.url, args.token); + const oldSha = await advertisedMainSha(fetchImpl, args.url, args.token); if (oldSha !== ZERO_OID) { await git.addRemote({ fs, dir, remote: "origin", url: args.url }); // The hub's git server advertises no `shallow` capability, so fetch the // full branch. await git.fetch({ fs, - http: httpThrough(args.fetch), + http: httpThrough(fetchImpl), dir, remote: "origin", ref: MAIN_REF, @@ -159,7 +161,7 @@ export async function pushSourceTree(args: { body.set(command, 0); body.set(new TextEncoder().encode("0000"), command.length); body.set(packfile, command.length + 4); - const response = await args.fetch(`${args.url}/git-receive-pack`, { + const response = await fetchImpl(`${args.url}/git-receive-pack`, { method: "POST", headers: { Authorization: `Bearer ${args.token}`, diff --git a/apps/web/src/install-package.ts b/apps/web/src/install-package.ts index d81940bc7d..711447cbd5 100644 --- a/apps/web/src/install-package.ts +++ b/apps/web/src/install-package.ts @@ -95,8 +95,10 @@ async function latestDeploymentId( } export async function installPackage( - args: InstallPackageArgs, + input: InstallPackageArgs, ): Promise<{ assetId: string; commitSha: string; deploymentId: string }> { + // `window.fetch` throws "Illegal invocation" when called as a method of `args`. + const args = { ...input, fetch: input.fetch.bind(globalThis) }; const base = `${args.origin}/api/tenants/${encodeURIComponent(args.tenantId)}`; const assetId = await ensureWorkflowAsset(args, base); const files = typeof args.files === "function" ? await args.files(assetId) : args.files; diff --git a/bun.lock b/bun.lock index bb6f3086dd..ca5d1db7bd 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "@types/bun": "1.3.14", "@typescript/native-preview": "7.0.0-dev.20260707.2", "drizzle-orm": "catalog:", + "fake-indexeddb": "^6.2.5", "hono": "catalog:", "jiti": "^2.7.0", "oxfmt": "0.68.0", @@ -1286,6 +1287,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-text-encoding": ["fast-text-encoding@1.0.6", "", {}, "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w=="], diff --git a/e2e/install/install-package.test.ts b/e2e/install/install-package.test.ts index 060998e9c0..92d3f9de3a 100644 --- a/e2e/install/install-package.test.ts +++ b/e2e/install/install-package.test.ts @@ -1,6 +1,8 @@ // Proves the client-driven install over stock routes: a package's source goes // into a fresh tenant as a workflow asset, deploys, and answers mail. A second // install of identical files deploys nothing new. Skips without DATABASE_URL. +// LightningFS, the browser fs the installer uses, needs IndexedDB. +import "fake-indexeddb/auto"; import { afterAll, beforeAll, expect, test } from "bun:test"; import path from "node:path"; import { type } from "arktype"; diff --git a/package.json b/package.json index 1998e7a796..e02b429d46 100644 --- a/package.json +++ b/package.json @@ -28,28 +28,29 @@ "@copilotkit/aimock": "^1.43.0", "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#4a2a3c22187991a6ce598faf794908fedd252e98", "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider#a1f475f341261c05fa8dade0c82f1246c175b5b3", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/ollama-adapter": "github:corbitsdev/corbits-ollama-adapter#0387eb17bee5ac87530e9c48bb3bffd9a6998ded", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider#9f2d4bac40ea075df092fef807404a13c638cf14", - "@intx/db": "workspace:*", + "@intx/agent": "0.4.0", "@intx/crypto": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/hub-common": "0.4.0", + "@intx/hub-sessions": "workspace:*", "@intx/log": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@intx/mime": "0.4.0", "@intx/types": "0.4.0", + "@intx/workflow": "0.4.0", "@types/bun": "1.3.14", "@typescript/native-preview": "7.0.0-dev.20260707.2", + "drizzle-orm": "catalog:", + "fake-indexeddb": "^6.2.5", + "hono": "catalog:", "jiti": "^2.7.0", - "oxlint": "1.83.0", "oxfmt": "0.68.0", + "oxlint": "1.83.0", "puppeteer-core": "^25.7.0", - "typescript": "6.0.3", - "@intx/hub-sessions": "workspace:*", - "@intx/hub-common": "0.4.0", - "@intx/hub-api": "workspace:*", - "@intx/agent": "0.4.0", - "@intx/workflow": "0.4.0", - "hono": "catalog:", - "drizzle-orm": "catalog:" + "typescript": "6.0.3" }, "engines": { "bun": ">=1.2.0" From c01909085e2db886f17e0da636852689b27f879f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 10:40:04 -0700 Subject: [PATCH 3/3] chore(web): refresh bun.lock for the installer (CL-9526) --- bun.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index ca5d1db7bd..01795acd7e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/ollama-adapter": "github:corbitsdev/corbits-ollama-adapter#0387eb17bee5ac87530e9c48bb3bffd9a6998ded", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider#9f2d4bac40ea075df092fef807404a13c638cf14", + "@intx/agent": "0.4.0", "@intx/crypto": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", @@ -22,6 +23,7 @@ "@intx/log": "workspace:*", "@intx/mime": "0.4.0", "@intx/types": "0.4.0", + "@intx/workflow": "0.4.0", "@types/bun": "1.3.14", "@typescript/native-preview": "7.0.0-dev.20260707.2", "drizzle-orm": "catalog:",