diff --git a/.gitignore b/.gitignore index 8f9ef0e5..4da12245 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ artifacts/ dist-web/ apps/desktop/native/.build*/ apps/desktop/native/.swiftpm/ +celld.json +.celld/ diff --git a/.lane/memory/packages/channel/src/log.ts/01M5FHS8DZHENAVBR7RPWT8Q1A-the-poisoned-session-error-h.md b/.lane/memory/packages/channel/src/log.ts/01M5FHS8DZHENAVBR7RPWT8Q1A-the-poisoned-session-error-h.md new file mode 100644 index 00000000..75b85a68 --- /dev/null +++ b/.lane/memory/packages/channel/src/log.ts/01M5FHS8DZHENAVBR7RPWT8Q1A-the-poisoned-session-error-h.md @@ -0,0 +1,12 @@ +--- +id: 01M5FHS8DZHENAVBR7RPWT8Q1A +anchor: function failure +created: 2026-10-21T15:37:54Z +norm: '1' +sig: f1231fd34544d478 +body_hash: f0ddb33d9820c29c +raw_hash: 691df1159bc16114 +lines: 15-20 +--- + +the poisoned-session error hides its real failure in cause, which JSON drops diff --git a/.lane/memory/services/celld.ts/01M5FHS8DGB54T3DSJSMJQCYEQ-celld-refuses-wrangler-keys.md b/.lane/memory/services/celld.ts/01M5FHS8DGB54T3DSJSMJQCYEQ-celld-refuses-wrangler-keys.md new file mode 100644 index 00000000..fd335aef --- /dev/null +++ b/.lane/memory/services/celld.ts/01M5FHS8DGB54T3DSJSMJQCYEQ-celld-refuses-wrangler-keys.md @@ -0,0 +1,12 @@ +--- +id: 01M5FHS8DGB54T3DSJSMJQCYEQ +anchor: '@file' +created: 2026-10-21T15:37:54Z +norm: '1' +sig: 37297e6494f690e4 +body_hash: 342828b49bb29be7 +raw_hash: 4de2c420dc4c4f31 +lines: 1-47 +--- + +celld refuses wrangler keys it doesn't implement (observability), so the generated config must drop them diff --git a/docs/architecture.md b/docs/architecture.md index dc0e5e6a..f937cc97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,8 +18,8 @@ access to it; see [the team](#the-team). | `packages/channel` | inside a channel worker | The channel: chats, agents, lanes, and the client protocol types | | `apps/host` | on every machine (Bun) | The `ace` CLI, channel workers, the channel catalog, local transport | | `apps/desktop` | on macOS | The desktop client and packaging for Ace Helper | -| `services/channel` | Cloudflare Workers | Hosted channels: one Durable Object per channel | -| `services/directory` | Cloudflare Workers | The team's list of hosts and where each channel lives, and lobbies | +| `services/channel` | Cloudflare or celld | Hosted channels: one Durable Object per channel | +| `services/directory` | Cloudflare or celld | The team's list of hosts and where each channel lives, and lobbies | Clients (the CLI and desktop app) attach to channels; a channel is not a client. @@ -317,7 +317,7 @@ its outcome, and `task` and `call` on model responses and tool calls. Channels l responses with usage and provider errors, and tool calls with durations; message text is not logged, but tool arguments are, clipped to 2,000 characters. `debug` lines (listings, watches, directory syncs, workspace calls) are written only with `ACE_DEBUG=1`. Hosted services log the same -records to Workers Logs. +records to Workers Logs on Cloudflare, or to the node's output on celld. ## Desktop diff --git a/docs/setup.md b/docs/setup.md index 31f080d3..f0791538 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,8 +56,9 @@ Whoever sets up the team deploys the services once from a checkout; everyone els privately. `Troubleshooting → Refresh status` syncs at once. From a checkout, `printf %s "$SECRET" | bun ace key set ACE_SECRET` then `bun ace directory ` does the same. -To deploy the channel and directory services to your Cloudflare account, give the Workers the same -secret and your tailnet's MagicDNS suffix. The services then serve only that tailnet; see +The services are cells, so they run on Cloudflare Workers or on [celld](#on-celld) on machines the +team runs. To deploy them to your Cloudflare account, give the Workers the same secret and your +tailnet's MagicDNS suffix. The services then serve only that tailnet; see [the team](architecture.md#the-team) for what that scope does and doesn't protect. ```sh @@ -81,6 +82,34 @@ picks them up: printf %s https://ace-channel..workers.dev | bunx wrangler secret put HOSTED ``` +### On celld + +celld runs the same services on any machine with an S3, R2, GCS, Tigris, or Azure Blob bucket. +Until [denoland/celld#277](https://github.com/denoland/celld/issues/277) lands, install the +[githubnext/celld](https://github.com/githubnext/celld) build, which fixes runs failing with +"Session is poisoned": + +```sh +curl -fsSL https://celld.dev/install.sh | CELLD_RELEASES_URL=https://github.com/githubnext/celld/releases sh +``` + +Put each service's vars in a file (`ACE_SECRET`, `TAILNET`, model keys for `channel`, and `HOSTED`, +`APP`, or `COMPUTE` for `directory`), deploy each under its own prefix, and run a node per prefix. +celld has no secret store: the vars sit in the bucket as plain text, so restrict who can read it. + +```sh +bun services/celld.ts channel deploy channel.env --bucket s3://my-bucket/channel +bun services/celld.ts directory deploy directory.env --bucket s3://my-bucket/directory +celld --bucket s3://my-bucket/channel --listen 127.0.0.1:8788 # one node per service +celld --bucket s3://my-bucket/directory --listen 127.0.0.1:8787 +tailscale serve --bg --https 8443 http://127.0.0.1:8788 # celld serves no TLS +tailscale serve --bg --https 443 http://127.0.0.1:8787 +``` + +Hosts then use `https://..ts.net` as the directory and +`https://..ts.net:8443` as `HOSTED`. Redeploying updates running nodes without a +restart. Compute still needs Cloudflare. + To try the services locally, put `ACE_SECRET`, `TAILNET`, and a model key in `services/channel/.dev.vars`, then: @@ -90,3 +119,6 @@ bun --filter @ace/directory dev --port 8788 # point hosts at http: printf %s "$SECRET" | bun ace key set ACE_SECRET # the value in .dev.vars bun ace new --hosted http://localhost:8787 --project ~/code/my-repo ``` + +`bun services/celld.ts dev --port ` runs a service on celld instead, reading +its `.dev.vars`. diff --git a/packages/channel/src/log.ts b/packages/channel/src/log.ts index 2498c182..7a8ff540 100644 --- a/packages/channel/src/log.ts +++ b/packages/channel/src/log.ts @@ -11,11 +11,12 @@ export function scoped(log: Log, base: Fields): Log { return (level, event, fields) => log(level, event, { ...base, ...fields }); } -/** An error's message and stack, which JSON drops. */ +/** An error's message, stack, and cause, which JSON drops. */ export function failure(error: unknown): Fields { - return error instanceof Error - ? { error: error.message, stack: error.stack } - : { error: String(error) }; + if (!(error instanceof Error)) return { error: String(error) }; + const fields: Fields = { error: error.message, stack: error.stack }; + if (error.cause !== undefined) fields.cause = failure(error.cause); + return fields; } const ARGS = 2000; diff --git a/services/celld.ts b/services/celld.ts new file mode 100644 index 00000000..1c6af962 --- /dev/null +++ b/services/celld.ts @@ -0,0 +1,47 @@ +import { rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +const USAGE = `bun services/celld.ts dev [celld dev options] +bun services/celld.ts deploy --bucket [celld deploy options]`; + +const [service, command, ...rest] = process.argv.slice(2); +if (!service || !["channel", "directory"].includes(service)) throw new Error(USAGE); +if (command !== "dev" && command !== "deploy") throw new Error(USAGE); + +const root = join(import.meta.dir, service); +const config: Record = (await import(join(root, "wrangler.jsonc"))).default; +// celld refuses keys it doesn't implement, and Workers Logs is Cloudflare's. +delete config.observability; + +let args = rest; +if (command === "deploy") { + const [file, ...options] = rest; + if (!file) throw new Error(USAGE); + // celld has no secret store: these become plain vars in the fleet bucket's deployment. + config.vars = parse(await Bun.file(file).text()); + args = options; +} + +// Wrangler's own esbuild keeps the celld and Cloudflare bundles alike. +const wrangler = dirname(Bun.resolveSync("wrangler/package.json", root)); +const generated = join(root, "celld.json"); +await Bun.write(generated, JSON.stringify(config, null, "\t")); +try { + const child = Bun.spawn(["celld", command, generated, ...args], { + cwd: root, + env: { ...process.env, CELLD_ESBUILD: Bun.resolveSync("esbuild/bin/esbuild", wrangler) }, + stdio: ["inherit", "inherit", "inherit"], + }); + process.exitCode = await child.exited; +} finally { + await rm(generated, { force: true }); +} + +function parse(text: string): Record { + const vars: Record = {}; + for (const line of text.split("\n")) { + const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); + if (match) vars[match[1]!] = match[2]!.trim().replace(/^(["'])(.*)\1$/, "$2"); + } + return vars; +}