@jami-studio/core 0.92.13 → 0.92.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1453 +1,1458 @@
1
- /**
2
- * `agent-native deploy` — build and deploy every app in a workspace to a
3
- * single origin. Each app is served from `/<app-name>/*`, so:
4
- *
5
- * https://your-agents.com/mail/* → apps/mail
6
- * https://your-agents.com/calendar/* → apps/calendar
7
- *
8
- * Benefits of same-origin deploy:
9
- * - Shared auth cookie → log in once, every app is signed in
10
- * - Cross-app A2A is a same-origin fetch (no CORS, no JWT for siblings)
11
- * - One DNS record, one TLS cert, one CDN cache
12
- *
13
- * Per-app independent deploy is still supported — just cd into the app and
14
- * run `agent-native build` as before. This orchestrator is for teams that
15
- * want the whole workspace behind one domain.
16
- */
17
- import { execFileSync } from "child_process";
18
- import fs from "fs";
19
- import path from "path";
20
-
21
- import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
22
- import { findWorkspaceRoot } from "../scripts/utils.js";
23
- import {
24
- DEFAULT_WORKSPACE_APP_AUDIENCE,
25
- normalizeWorkspaceAppAudience,
26
- normalizeWorkspaceAppPathList,
27
- workspaceAppAudienceFromPackageJson,
28
- workspaceAppRouteAccessFromPackageJson,
29
- type WorkspaceAppRouteAccess,
30
- type WorkspaceAppAudience,
31
- } from "../shared/workspace-app-audience.js";
32
- import { DISPATCH_WORKSPACE_ROOT_REDIRECTS } from "../shared/workspace-app-id.js";
33
- import {
34
- collectImmutableAssetPaths,
35
- IMMUTABLE_ASSET_CACHE_HEADERS,
36
- } from "./immutable-assets.js";
37
-
38
- export type WorkspaceDeployPreset = "cloudflare_pages" | "netlify" | "vercel";
39
-
40
- const NETLIFY_WORKSPACE_STATIC_DIR = "_workspace_static";
41
- const NETLIFY_PUBLIC_ASSET_EXTENSIONS = new Set([
42
- "avif",
43
- "css",
44
- "gif",
45
- "ico",
46
- "jpeg",
47
- "jpg",
48
- "js",
49
- "json",
50
- "map",
51
- "mp4",
52
- "pdf",
53
- "png",
54
- "svg",
55
- "txt",
56
- "wasm",
57
- "webm",
58
- "webmanifest",
59
- "webp",
60
- "xml",
61
- ]);
62
- const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
63
- const WORKSPACE_APPS_MANIFEST_DIR = ".agent-native";
64
- const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
65
- const VERCEL_OUTPUT_DIR = ".vercel/output";
66
-
67
- interface WorkspaceAppManifestEntry {
68
- id: string;
69
- name: string;
70
- description: string;
71
- path: string;
72
- url?: string;
73
- isDispatch: boolean;
74
- audience: WorkspaceAppAudience;
75
- publicPaths: string[];
76
- protectedPaths: string[];
77
- }
78
-
79
- interface WorkspaceAppManifestOverride {
80
- id: string;
81
- url?: string;
82
- audience?: WorkspaceAppAudience;
83
- publicPaths?: string[];
84
- protectedPaths?: string[];
85
- }
86
-
87
- export interface WorkspaceDeployOptions {
88
- args?: string[];
89
- /** Override the workspace root (defaults to walking up from cwd). */
90
- workspaceRoot?: string;
91
- /** Only build — don't invoke the deploy platform CLI. */
92
- buildOnly?: boolean;
93
- /** Target preset. Defaults to `cloudflare_pages`. */
94
- preset?: WorkspaceDeployPreset;
95
- /** @internal Override process execution in tests. */
96
- execFile?: typeof execFileSync;
97
- }
98
-
99
- export async function runWorkspaceDeploy(
100
- opts: WorkspaceDeployOptions = {},
101
- ): Promise<void> {
102
- const workspaceRoot =
103
- opts.workspaceRoot ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
104
- const appsDir = path.join(workspaceRoot, "apps");
105
- if (!fs.existsSync(appsDir)) {
106
- throw new Error(
107
- `No apps/ directory found at ${workspaceRoot}. Run this inside an agent-native workspace.`,
108
- );
109
- }
110
-
111
- const rawArgs = opts.args ?? [];
112
- const args = new Set(rawArgs);
113
- const buildOnly = opts.buildOnly ?? args.has("--build-only");
114
-
115
- const apps = fs
116
- .readdirSync(appsDir, { withFileTypes: true })
117
- .filter((e) => e.isDirectory())
118
- .map((e) => e.name)
119
- .filter((n) => fs.existsSync(path.join(appsDir, n, "package.json")))
120
- .sort(compareWorkspaceAppIds);
121
-
122
- if (apps.length === 0) {
123
- throw new Error(
124
- `Workspace has no apps. Run \`agent-native add-app\` to add one.`,
125
- );
126
- }
127
- assertNoReservedWorkspaceAppIds(apps);
128
- const workspaceApps = readWorkspaceAppManifest(workspaceRoot, apps);
129
-
130
- const preset = resolvePreset(opts.preset, rawArgs);
131
- assertWorkspaceDeployProductionEnv({ buildOnly, preset });
132
- const distDir = path.join(workspaceRoot, "dist");
133
- const vercelOutputDir = path.join(workspaceRoot, VERCEL_OUTPUT_DIR);
134
- if (preset === "vercel") {
135
- fs.rmSync(vercelOutputDir, { recursive: true, force: true });
136
- fs.mkdirSync(path.join(vercelOutputDir, "static"), { recursive: true });
137
- fs.mkdirSync(path.join(vercelOutputDir, "functions"), {
138
- recursive: true,
139
- });
140
- } else {
141
- fs.rmSync(distDir, { recursive: true, force: true });
142
- fs.mkdirSync(distDir, { recursive: true });
143
- }
144
-
145
- if (preset === "netlify") {
146
- const functionsDir = netlifyFunctionsDir(workspaceRoot);
147
- fs.rmSync(functionsDir, { recursive: true, force: true });
148
- fs.mkdirSync(functionsDir, { recursive: true });
149
- }
150
-
151
- console.log(
152
- `[workspace-deploy] Building ${apps.length} app(s) for preset=${preset}`,
153
- );
154
-
155
- const execFile = opts.execFile ?? execFileSync;
156
- for (const app of apps) {
157
- buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps);
158
- moveAppBuildIntoWorkspaceOutput(
159
- workspaceRoot,
160
- app,
161
- preset,
162
- distDir,
163
- vercelOutputDir,
164
- workspaceApps,
165
- );
166
- }
167
- writeWorkspaceAppManifests(
168
- workspaceRoot,
169
- distDir,
170
- apps,
171
- workspaceApps,
172
- preset,
173
- );
174
-
175
- if (preset === "netlify") {
176
- writeNetlifyRedirects(distDir, apps);
177
- writeNetlifyHeaders(distDir, apps);
178
- } else if (preset === "vercel") {
179
- writeVercelBuildConfig(vercelOutputDir, apps);
180
- } else {
181
- writeCloudflareRoutingManifest(distDir, apps);
182
- }
183
-
184
- if (buildOnly) {
185
- const outputDir = preset === "vercel" ? vercelOutputDir : distDir;
186
- console.log(
187
- `\n[workspace-deploy] Build complete at ${outputDir}. Skipping publish (--build-only).`,
188
- );
189
- return;
190
- }
191
-
192
- console.log(`\n[workspace-deploy] Build complete. Publish with:\n`);
193
- console.log(` cd ${path.relative(process.cwd(), workspaceRoot) || "."}`);
194
- if (preset === "netlify") {
195
- console.log(
196
- ` netlify deploy --prod --dir=dist --functions=.netlify/functions-internal\n`,
197
- );
198
- } else if (preset === "vercel") {
199
- console.log(` vercel deploy --prebuilt\n`);
200
- } else {
201
- console.log(` wrangler pages deploy dist\n`);
202
- }
203
- console.log(
204
- `All apps live at https://<origin>/<app-name>/*. Log in once on any app\nand the session is shared across the workspace.`,
205
- );
206
- }
207
-
208
- function buildOneApp(
209
- workspaceRoot: string,
210
- app: string,
211
- preset: WorkspaceDeployPreset,
212
- execFile: typeof execFileSync,
213
- workspaceApps: WorkspaceAppManifestEntry[],
214
- ): void {
215
- const appDir = path.join(workspaceRoot, "apps", app);
216
- const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
217
- const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
218
- workspaceApps,
219
- app,
220
- );
221
- const workspaceGatewayUrl =
222
- process.env.VITE_WORKSPACE_GATEWAY_URL || workspaceBaseUrl();
223
- const workspaceOAuthUrl = workspaceOAuthOrigin(workspaceGatewayUrl);
224
- const env: NodeJS.ProcessEnv = {
225
- ...process.env,
226
- NITRO_PRESET: preset,
227
- AGENT_NATIVE_WORKSPACE: "1",
228
- AGENT_NATIVE_WORKSPACE_APP_ID: app,
229
- VITE_AGENT_NATIVE_WORKSPACE: "1",
230
- VITE_AGENT_NATIVE_WORKSPACE_APP_ID: app,
231
- APP_BASE_PATH: `/${app}`,
232
- VITE_APP_BASE_PATH: `/${app}`,
233
- AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: workspaceAppAudience,
234
- AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
235
- workspaceAppRouteAccess.publicPaths,
236
- ),
237
- AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
238
- workspaceAppRouteAccess.protectedPaths,
239
- ),
240
- VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: workspaceAppAudience,
241
- VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
242
- workspaceAppRouteAccess.publicPaths,
243
- ),
244
- VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
245
- workspaceAppRouteAccess.protectedPaths,
246
- ),
247
- VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: JSON.stringify(workspaceApps),
248
- ...(workspaceGatewayUrl
249
- ? {
250
- WORKSPACE_GATEWAY_URL:
251
- process.env.WORKSPACE_GATEWAY_URL || workspaceGatewayUrl,
252
- VITE_WORKSPACE_GATEWAY_URL: workspaceGatewayUrl,
253
- ...(workspaceOAuthUrl
254
- ? { VITE_WORKSPACE_OAUTH_ORIGIN: workspaceOAuthUrl }
255
- : {}),
256
- }
257
- : {}),
258
- [WORKSPACE_APPS_ENV_KEY]: JSON.stringify(workspaceApps),
259
- };
260
-
261
- if (preset === "netlify" && appUsesNetlifyUnpooledDatabaseUrl(appDir)) {
262
- env.DATABASE_URL =
263
- process.env.NETLIFY_DATABASE_URL_UNPOOLED ??
264
- process.env.DATABASE_URL ??
265
- env.DATABASE_URL;
266
- }
267
-
268
- console.log(
269
- `[workspace-deploy] Building ${app} (base=/${app}, preset=${preset})`,
270
- );
271
-
272
- cleanAppBuildOutputs(appDir);
273
-
274
- execFile("pnpm", ["--filter", app, "build"], {
275
- cwd: workspaceRoot,
276
- env,
277
- stdio: "inherit",
278
- });
279
- }
280
-
281
- function moveAppBuildIntoWorkspaceOutput(
282
- workspaceRoot: string,
283
- app: string,
284
- preset: WorkspaceDeployPreset,
285
- distDir: string,
286
- vercelOutputDir: string,
287
- workspaceApps: WorkspaceAppManifestEntry[],
288
- ): void {
289
- const appDir = path.join(workspaceRoot, "apps", app);
290
- if (preset === "vercel") {
291
- copyVercelAppBuildIntoWorkspace(
292
- workspaceRoot,
293
- app,
294
- vercelOutputDir,
295
- workspaceApps,
296
- );
297
- return;
298
- }
299
-
300
- // Resolve the per-app build output: prefer dist/ (standard), fall back to
301
- // .output/ (Nitro's default). The Cloudflare preset emits into dist/
302
- // containing the worker + assets.
303
- const candidates = ["dist", ".output"];
304
- const src = candidates
305
- .map((c) => path.join(appDir, c))
306
- .find((p) => fs.existsSync(p));
307
- if (!src) {
308
- throw new Error(
309
- `Expected ${candidates.join(" or ")} under ${appDir} but none existed. Check the app's build script.`,
310
- );
311
- }
312
- if (preset === "netlify") {
313
- const mountedSrc = path.join(src, app);
314
- const staticSrc = fs.existsSync(mountedSrc) ? mountedSrc : src;
315
- const target = path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app);
316
- fs.mkdirSync(target, { recursive: true });
317
- copyDir(staticSrc, target);
318
- // Nitro/Vite mounted builds can contain a nested copy of public assets at
319
- // dist/<app>/<app>/...; the workspace root already supplies the outer
320
- // mount path, so keeping it would publish duplicate /<app>/<app> URLs.
321
- fs.rmSync(path.join(target, app), { recursive: true, force: true });
322
- copyNetlifyFunctionIntoWorkspace(workspaceRoot, app, workspaceApps, target);
323
- } else {
324
- const target = path.join(distDir, app);
325
- fs.mkdirSync(target, { recursive: true });
326
- copyDir(src, target);
327
- }
328
- }
329
-
330
- function copyVercelAppBuildIntoWorkspace(
331
- workspaceRoot: string,
332
- app: string,
333
- vercelOutputDir: string,
334
- workspaceApps: WorkspaceAppManifestEntry[],
335
- ): void {
336
- const appDir = path.join(workspaceRoot, "apps", app);
337
- const src = path.join(appDir, VERCEL_OUTPUT_DIR);
338
- if (!fs.existsSync(src)) {
339
- throw new Error(
340
- `Expected Vercel output at ${src} after building ${app}. Check the app's build script and NITRO_PRESET.`,
341
- );
342
- }
343
-
344
- const staticSrc = path.join(src, "static");
345
- const staticDest = path.join(vercelOutputDir, "static");
346
- if (fs.existsSync(staticSrc)) {
347
- copyDir(staticSrc, staticDest);
348
- // Nitro's Vercel preset already nests assets under baseURL. The shared
349
- // deploy build also mirrors client assets under baseURL for other Nitro
350
- // presets, so mounted apps can contain a duplicate /<app>/<app> copy.
351
- fs.rmSync(path.join(staticDest, app, app), {
352
- recursive: true,
353
- force: true,
354
- });
355
- }
356
-
357
- const functionSrc = path.join(src, "functions", "__server.func");
358
- if (!fs.existsSync(functionSrc)) {
359
- throw new Error(
360
- `Expected Vercel function at ${functionSrc} after building ${app}. Check the app's build script and NITRO_PRESET.`,
361
- );
362
- }
363
-
364
- const functionDest = path.join(
365
- vercelOutputDir,
366
- "functions",
367
- `${app}-server.func`,
368
- );
369
- fs.rmSync(functionDest, { recursive: true, force: true });
370
- copyDir(functionSrc, functionDest);
371
- patchVercelFunctionEntry(functionDest, app, workspaceApps);
372
- }
373
-
374
- /**
375
- * Write the Cloudflare Pages `_routes.json` and a dispatcher `_worker.js` at
376
- * the workspace dist root so each app is reachable under /<app>/*.
377
- */
378
- function writeCloudflareRoutingManifest(distDir: string, apps: string[]): void {
379
- const dispatchFaviconAsset = apps.includes("dispatch")
380
- ? dispatchRootFaviconAsset(distDir)
381
- : null;
382
- // _routes.json tells Cloudflare which paths are dynamic (Functions) vs
383
- // static. Mark both /<app> and /<app>/* as include so every app's worker
384
- // handles its root and subtree.
385
- const include = apps.flatMap((a) => [`/${a}`, `/${a}/*`]).concat(["/"]);
386
- if (apps.includes("dispatch")) {
387
- include.push("/_agent-native/*");
388
- include.push("/.well-known/*");
389
- include.push(
390
- ...DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(([from]) => `/${from}`),
391
- );
392
- include.push("/apps/*");
393
- if (dispatchFaviconAsset) include.push("/favicon.ico");
394
- }
395
- const routes = {
396
- version: 1,
397
- include,
398
- exclude: [],
399
- };
400
- fs.writeFileSync(
401
- path.join(distDir, "_routes.json"),
402
- JSON.stringify(routes, null, 2) + "\n",
403
- );
404
-
405
- // Dispatcher worker: inspects the path and forwards to the matching
406
- // per-app worker.
407
- const imports = apps
408
- .map((a) => `import ${moduleIdent(a)} from "./${a}/_worker.js";`)
409
- .join("\n");
410
- const dispatch = apps
411
- .map(
412
- (a) =>
413
- ` if (pathname === "/${a}" || pathname.startsWith("/${a}/")) return ${moduleIdent(a)}.fetch(requestForMountedApp(request, "/${a}"), env, ctx);`,
414
- )
415
- .join("\n");
416
- const dispatchRootFrameworkRoutes = apps.includes("dispatch")
417
- ? ` if (pathname === "/_agent-native" || pathname.startsWith("/_agent-native/") || pathname === "/.well-known" || pathname.startsWith("/.well-known/")) return ${moduleIdent("dispatch")}.fetch(request, env, ctx);
418
- `
419
- : "";
420
- const dispatchRootFaviconRoute = dispatchFaviconAsset
421
- ? ` if (pathname === "/favicon.ico") return Response.redirect(new URL("/dispatch/${dispatchFaviconAsset}", request.url).toString(), 302);
422
- `
423
- : "";
424
- const dispatchRootAliasRoutes = apps.includes("dispatch")
425
- ? DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(
426
- ([from, to]) =>
427
- ` if (pathname === "/${from}") return Response.redirect(new URL("/dispatch/${to}" + search, request.url).toString(), 302);`,
428
- ).join("\n") + "\n"
429
- : "";
430
- const dispatchRootDynamicAliasRoutes = apps.includes("dispatch")
431
- ? ` if (pathname.startsWith("/apps/")) return Response.redirect(new URL("/dispatch" + pathname + search, request.url).toString(), 302);
432
- `
433
- : "";
434
- const dispatchMountedRootRedirect = apps.includes("dispatch")
435
- ? ` if (pathname === "/dispatch" || pathname === "/dispatch/") return Response.redirect(new URL("/dispatch/overview" + search, request.url).toString(), 302);
436
- `
437
- : "";
438
-
439
- const worker = `${imports}
440
-
441
- function requestForMountedApp(request, basePath) {
442
- const url = new URL(request.url);
443
- if (url.pathname !== basePath && url.pathname !== \`\${basePath}/\`) {
444
- return request;
445
- }
446
- url.pathname = \`\${basePath}//\`;
447
- return new Request(url, request);
448
- }
449
-
450
- export default {
451
- async fetch(request, env, ctx) {
452
- const { pathname, search } = new URL(request.url);
453
- ${dispatchRootFrameworkRoutes}${dispatchRootFaviconRoute}${dispatchRootAliasRoutes}${dispatchRootDynamicAliasRoutes}${dispatchMountedRootRedirect}${dispatch}
454
- if (pathname === "/") {
455
- return Response.redirect(new URL("${cloudflareRootRedirectPath(apps)}", request.url).toString(), 302);
456
- }
457
- return new Response("Not found", { status: 404 });
458
- },
459
- };
460
- `;
461
- fs.writeFileSync(path.join(distDir, "_worker.js"), worker);
462
- }
463
-
464
- function cloudflareRootRedirectPath(apps: string[]): string {
465
- return apps.includes("dispatch") ? "/dispatch/overview" : `/${apps[0]}/`;
466
- }
467
-
468
- function writeNetlifyRedirects(distDir: string, apps: string[]): void {
469
- const lines: string[] = [
470
- "# Generated by agent-native deploy --preset netlify",
471
- "# Static app assets are stored under a safe namespace; dynamic app routes are handled by function route config.",
472
- ];
473
-
474
- if (apps.includes("dispatch")) {
475
- lines.push("/_agent-native/* /.netlify/functions/dispatch-server 200");
476
- lines.push("/.well-known/* /.netlify/functions/dispatch-server 200");
477
- const faviconAsset = dispatchRootFaviconAsset(distDir);
478
- if (faviconAsset) {
479
- lines.push(`/favicon.ico /dispatch/${faviconAsset} 302`);
480
- }
481
- }
482
-
483
- for (const app of apps) {
484
- lines.push(...netlifyAssetRedirectsFor(app, distDir));
485
- }
486
-
487
- if (apps.includes("dispatch")) {
488
- lines.push("/ /dispatch/overview 302");
489
- lines.push("/dispatch /dispatch/overview 302");
490
- lines.push("/dispatch/ /dispatch/overview 302");
491
- for (const [from, to] of DISPATCH_WORKSPACE_ROOT_REDIRECTS) {
492
- lines.push(`/${from} /dispatch/${to} 302`);
493
- }
494
- lines.push("/apps/* /dispatch/apps/:splat 302");
495
- } else {
496
- lines.push(`/ /${apps[0]}/ 302`);
497
- }
498
-
499
- fs.writeFileSync(path.join(distDir, "_redirects"), lines.join("\n") + "\n");
500
- }
501
-
502
- function writeNetlifyHeaders(distDir: string, apps: string[]): void {
503
- const blocks = apps.flatMap((app) => {
504
- const staticDir = path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app);
505
- return collectImmutableAssetPaths(staticDir).flatMap((assetPath) => [
506
- netlifyHeaderBlock(`/${app}${assetPath}`),
507
- netlifyHeaderBlock(`/${NETLIFY_WORKSPACE_STATIC_DIR}/${app}${assetPath}`),
508
- ]);
509
- });
510
-
511
- if (blocks.length === 0) return;
512
- fs.writeFileSync(path.join(distDir, "_headers"), blocks.join("\n\n") + "\n");
513
- }
514
-
515
- function netlifyHeaderBlock(pathname: string): string {
516
- return [
517
- pathname,
518
- ...Object.entries(IMMUTABLE_ASSET_CACHE_HEADERS).map(
519
- ([name, value]) => ` ${name}: ${value}`,
520
- ),
521
- ].join("\n");
522
- }
523
-
524
- function writeVercelBuildConfig(outputDir: string, apps: string[]): void {
525
- const routes: Array<Record<string, any>> = [
526
- ...vercelImmutableAssetHeaderRoutes(outputDir, apps),
527
- { handle: "filesystem" },
528
- ];
529
-
530
- if (apps.includes("dispatch")) {
531
- routes.push(
532
- { src: "/_agent-native", dest: "/dispatch-server" },
533
- { src: "/_agent-native/(.*)", dest: "/dispatch-server" },
534
- { src: "/\\.well-known", dest: "/dispatch-server" },
535
- { src: "/\\.well-known/(.*)", dest: "/dispatch-server" },
536
- );
537
-
538
- const faviconAsset = dispatchRootFaviconAsset(
539
- path.join(outputDir, "static"),
540
- );
541
- if (faviconAsset) {
542
- routes.push(vercelRedirect("/favicon.ico", `/dispatch/${faviconAsset}`));
543
- }
544
-
545
- routes.push(
546
- vercelRedirect("/", "/dispatch/overview"),
547
- vercelRedirect("/dispatch", "/dispatch/overview"),
548
- vercelRedirect("/dispatch/", "/dispatch/overview"),
549
- );
550
- for (const [from, to] of DISPATCH_WORKSPACE_ROOT_REDIRECTS) {
551
- routes.push(vercelRedirect(`/${from}`, `/dispatch/${to}`));
552
- }
553
- routes.push(vercelRedirect("/apps/(.*)", "/dispatch/apps/$1"));
554
- } else {
555
- routes.push(vercelRedirect("/", `/${apps[0]}/`));
556
- }
557
-
558
- for (const app of apps) {
559
- if (app !== "dispatch") {
560
- routes.push({ src: `/${app}`, dest: `/${app}-server` });
561
- }
562
- routes.push({ src: `/${app}/(.*)`, dest: `/${app}-server` });
563
- }
564
-
565
- const config = {
566
- version: 3,
567
- routes,
568
- };
569
- fs.writeFileSync(
570
- path.join(outputDir, "config.json"),
571
- JSON.stringify(config, null, 2) + "\n",
572
- );
573
- }
574
-
575
- function vercelImmutableAssetHeaderRoutes(
576
- outputDir: string,
577
- apps: string[],
578
- ): Array<Record<string, any>> {
579
- return apps.flatMap((app) => {
580
- const staticDir = path.join(outputDir, "static", app);
581
- return collectImmutableAssetPaths(staticDir).map((assetPath) => ({
582
- src: vercelRouteSrc(`/${app}${assetPath}`),
583
- headers: IMMUTABLE_ASSET_CACHE_HEADERS,
584
- continue: true,
585
- }));
586
- });
587
- }
588
-
589
- function vercelRouteSrc(pathname: string): string {
590
- return pathname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
591
- }
592
-
593
- function vercelRedirect(src: string, location: string): Record<string, any> {
594
- return {
595
- src,
596
- status: 302,
597
- headers: { Location: location },
598
- };
599
- }
600
-
601
- function netlifyAssetRedirectsFor(app: string, distDir: string): string[] {
602
- const from = `/${app}`;
603
- const to = `/${NETLIFY_WORKSPACE_STATIC_DIR}/${app}`;
604
- return [
605
- `${from}/assets/* ${to}/assets/:splat 200`,
606
- ...netlifyPublicRootAssetPaths(
607
- app,
608
- path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app),
609
- ).map((assetPath) => {
610
- const assetName = assetPath.slice(from.length + 1);
611
- return `${assetPath} ${to}/${assetName} 200`;
612
- }),
613
- ];
614
- }
615
-
616
- function dispatchRootFaviconAsset(distDir: string): string | null {
617
- for (const asset of ["favicon.ico", "favicon.svg", "favicon.png"]) {
618
- if (workspaceAppAssetExists(distDir, "dispatch", asset)) return asset;
619
- }
620
- return null;
621
- }
622
-
623
- function workspaceAppAssetExists(
624
- distDir: string,
625
- app: string,
626
- asset: string,
627
- ): boolean {
628
- return [
629
- path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app, asset),
630
- path.join(distDir, app, app, asset),
631
- path.join(distDir, app, asset),
632
- ].some((candidate) => fs.existsSync(candidate));
633
- }
634
-
635
- const RESERVED_WORKSPACE_APP_IDS = new Set([
636
- "_agent-native",
637
- "_workspace_static",
638
- "netlify",
639
- ...DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(([from]) => from),
640
- ]);
641
-
642
- function assertNoReservedWorkspaceAppIds(apps: string[]): void {
643
- const conflicts = apps.filter(
644
- (app) => app !== "dispatch" && RESERVED_WORKSPACE_APP_IDS.has(app),
645
- );
646
- if (conflicts.length === 0) return;
647
- throw new Error(
648
- `Workspace app id ${conflicts.map((id) => `"${id}"`).join(", ")} conflicts with reserved workspace routes. Choose a different app id.`,
649
- );
650
- }
651
-
652
- function copyNetlifyFunctionIntoWorkspace(
653
- workspaceRoot: string,
654
- app: string,
655
- workspaceApps: WorkspaceAppManifestEntry[],
656
- staticDir: string,
657
- ): void {
658
- const appDir = path.join(workspaceRoot, "apps", app);
659
- const src = path.join(appDir, ".netlify", "functions-internal", "server");
660
- if (!fs.existsSync(src)) {
661
- throw new Error(
662
- `Expected Netlify function at ${src} after building ${app}. Check the app's build script and NITRO_PRESET.`,
663
- );
664
- }
665
-
666
- const dest = path.join(netlifyFunctionsDir(workspaceRoot), `${app}-server`);
667
- fs.rmSync(dest, { recursive: true, force: true });
668
- copyDir(src, dest);
669
- patchNetlifyFunctionEntry(dest, app, workspaceApps, staticDir);
670
-
671
- // Durable background agent runs (default-ON; opt out with a falsy
672
- // AGENT_CHAT_DURABLE_BACKGROUND). Additive ONLY: when explicitly opted out
673
- // this emits nothing and the single-function deploy is unchanged.
674
- if (isDurableBackgroundDeployEnabled()) {
675
- emitNetlifyBackgroundFunction(workspaceRoot, app, src, workspaceApps);
676
- }
677
- }
678
-
679
- /**
680
- * Deploy-time gate for emitting the second `-background` Netlify function. Reads
681
- * the same env flag the runtime gate uses (`AGENT_CHAT_DURABLE_BACKGROUND`).
682
- *
683
- * DEFAULT-ON, matching the runtime gate (`isFlagEnabled` in
684
- * durable-background.ts) and the single-template gate
685
- * (`isDurableBackgroundDeployEnabled` in deploy/build.ts): unset/empty/unknown
686
- * means enabled; an app opts OUT only with an explicit falsy value
687
- * (`false`/`0`/`no`/`off`). This emits the per-app 15-min `-background` function
688
- * so the chat `_process-run` dispatch lands on it with the real long budget.
689
- */
690
- function isDurableBackgroundDeployEnabled(): boolean {
691
- const raw = process.env.AGENT_CHAT_DURABLE_BACKGROUND;
692
- if (raw == null) return true;
693
- const v = raw.trim().toLowerCase();
694
- return !(v === "0" || v === "false" || v === "no" || v === "off");
695
- }
696
-
697
- /**
698
- * Emit a SECOND Netlify function for `app` whose name ends in `-background`,
699
- * re-exporting the SAME `main.mjs` handler bundle. Netlify invokes any function
700
- * with `config.background: true` asynchronously (202 immediately, up to 15-min
701
- * budget), which is exactly what the durable-background chat dispatch
702
- * (`fireInternalDispatch` → the function's default url) needs.
703
- *
704
- * DOC-CORRECT DEFAULT-URL APPROACH (mirrors the single-template emit in
705
- * deploy/build.ts): the function declares NO custom `config.path`, so it keeps
706
- * its DEFAULT url `/.netlify/functions/<app>-agent-background`. The `<app>-server`
707
- * function's catch-all already excludes `/.netlify/*`, so that default-url
708
- * namespace is NEVER shadowed by the synchronous function — no overlapping
709
- * `config.path` and no catch-all patch are needed. The foreground dispatches to
710
- * that default url (`resolveAgentChatProcessRunDispatchPath` resolves the per-app
711
- * name from `AGENT_NATIVE_WORKSPACE_APP_ID`); `fireInternalDispatch` strips the
712
- * app base path for `/.netlify/*` targets so the request reaches the host-root
713
- * function url. The entry then REWRITES the incoming pathname to the
714
- * base-path-prefixed `_process-run` route before delegating to the Nitro router.
715
- *
716
- * It shares the same bundle (`includedFiles: ["**"]`) so `A2A_SECRET`, the DB
717
- * URL, and the rest of the env/bundle are present. A prior attempt gave the
718
- * function a custom `config.path` (the framework route) that overlapped the
719
- * synchronous `<app>-server` catch-all; that path was not honored as a route in
720
- * prod (probe → 404). The default url is the doc-correct fix.
721
- *
722
- * Safety net: if the dispatch fast-fails the foreground degrades to an inline
723
- * 40s synchronous run (see production-agent.ts).
724
- */
725
- function emitNetlifyBackgroundFunction(
726
- workspaceRoot: string,
727
- app: string,
728
- srcServerDir: string,
729
- workspaceApps: WorkspaceAppManifestEntry[],
730
- ): void {
731
- // Name MUST end in `-background` (Netlify async convention + the runtime guard
732
- // reads the -background Lambda-name suffix as a fallback). It is reached at its
733
- // DEFAULT url /.netlify/functions/<app>-agent-background.
734
- const backgroundName = `${app}-agent-background`;
735
- const dest = path.join(netlifyFunctionsDir(workspaceRoot), backgroundName);
736
- fs.rmSync(dest, { recursive: true, force: true });
737
- copyDir(srcServerDir, dest);
738
-
739
- const basePath = `/${app}`;
740
- const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
741
- const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
742
- workspaceApps,
743
- app,
744
- );
745
- // The Nitro router for this app expects the base-path-prefixed framework route.
746
- // The function is reached at its default url, so the entry rewrites the
747
- // incoming pathname to `/<app>/_agent-native/agent-chat/_process-run`.
748
- const processRunPath = `${basePath}${AGENT_CHAT_PROCESS_RUN_PATH}`;
749
- const server = `// Mark this isolate as the durable background runtime BEFORE the handler bundle
750
- // is imported, so isInBackgroundFunctionRuntime() reliably returns true in this
751
- // function (the deployed Lambda name is not guaranteed to end in -background). A
752
- // globalThis flag (NOT process.env) avoids the no-env-mutation guard and carries
753
- // no cross-request state.
754
- globalThis.__AGENT_NATIVE_BACKGROUND_RUNTIME__ = true;
755
-
756
- const basePath = ${JSON.stringify(basePath)};
757
- // The base-path-prefixed framework route the Nitro router dispatches to.
758
- const PROCESS_RUN_PATH = ${JSON.stringify(processRunPath)};
759
-
760
- function setBasePathEnv() {
761
- const processRef = globalThis.process ??= { env: {} };
762
- processRef.env ??= {};
763
- Object.assign(processRef.env, {
764
- AGENT_NATIVE_WORKSPACE: "1",
765
- AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
766
- APP_BASE_PATH: basePath,
767
- AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
768
- AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
769
- AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
770
- VITE_AGENT_NATIVE_WORKSPACE: "1",
771
- VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
772
- VITE_APP_BASE_PATH: basePath,
773
- VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
774
- VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
775
- VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
776
- VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
777
- ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
778
- });
779
- }
780
-
781
- setBasePathEnv();
782
-
783
- let cachedHandler;
784
-
785
- // Reached at the DEFAULT url /.netlify/functions/${backgroundName}; REWRITE the
786
- // incoming pathname to the base-path-prefixed _process-run route so the Nitro
787
- // router runs the plugin. Method, ALL headers (the HMAC Authorization: Bearer
788
- // MUST survive) and the body are preserved.
789
- export default async function handler(request) {
790
- setBasePathEnv();
791
- cachedHandler ??= (await import("./main.mjs")).default;
792
- const url = new URL(request.url);
793
- url.pathname = PROCESS_RUN_PATH;
794
- const method = request.method || "POST";
795
- const hasBody = method !== "GET" && method !== "HEAD";
796
- const body = hasBody ? await request.text() : undefined;
797
- const rewritten = new Request(url.toString(), {
798
- method,
799
- headers: request.headers,
800
- body,
801
- });
802
- return cachedHandler(rewritten);
803
- }
804
-
805
- export const config = {
806
- name: ${JSON.stringify(`${app} agent background handler`)},
807
- generator: "agent-native workspace deploy",
808
- // background: true → async invoke (202, 15-min budget). NO custom path: the
809
- // function keeps its default url /.netlify/functions/${backgroundName}, which
810
- // the <app>-server catch-all never shadows (it excludes /.netlify/*).
811
- background: true,
812
- nodeBundler: "none",
813
- includedFiles: ["**"],
814
- preferStatic: false,
815
- };
816
- `;
817
- // Remove the original Nitro entry (server.mjs) so only our background entry
818
- // is the function entrypoint, mirroring patchNetlifyFunctionEntry.
819
- fs.rmSync(path.join(dest, "server.mjs"), { force: true });
820
- fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), server);
821
- console.log(
822
- `[workspace-deploy] Emitted durable-background function "${backgroundName}" ` +
823
- `for app "${app}" with config { background:true } and NO custom path — ` +
824
- `reachable at its default url /.netlify/functions/${backgroundName} ` +
825
- `(rewrites to ${processRunPath}). REQUIRES real-deploy verification of ` +
826
- `Netlify async (202) invocation — see docs/design/durable-agent-runs.md.`,
827
- );
828
- }
829
-
830
- function patchNetlifyFunctionEntry(
831
- functionDir: string,
832
- app: string,
833
- workspaceApps: WorkspaceAppManifestEntry[],
834
- staticDir: string,
835
- ): void {
836
- const serverPath = path.join(functionDir, "server.mjs");
837
- if (!fs.existsSync(serverPath)) return;
838
-
839
- const basePath = `/${app}`;
840
- const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
841
- const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
842
- workspaceApps,
843
- app,
844
- );
845
- const pathConfig =
846
- app === "dispatch"
847
- ? ["/_agent-native/*", "/.well-known/*", `${basePath}/*`]
848
- : [basePath, `${basePath}/*`];
849
- const normalizeBasePathHelper =
850
- app === "dispatch"
851
- ? ""
852
- : `
853
- function normalizeBasePathArgs(args) {
854
- const request = args[0];
855
- if (!request || typeof request.url !== "string" || typeof Request !== "function") {
856
- return args;
857
- }
858
- const url = new URL(request.url);
859
- if (url.pathname === basePath || url.pathname === \`\${basePath}/\`) {
860
- url.pathname = \`\${basePath}//\`;
861
- return [new Request(url, request), ...args.slice(1)];
862
- }
863
- return args;
864
- }
865
- `;
866
- const handlerArgs =
867
- app === "dispatch" ? "...args" : "...normalizeBasePathArgs(args)";
868
- const server = `const basePath = ${JSON.stringify(basePath)};
869
-
870
- function setBasePathEnv() {
871
- const processRef = globalThis.process ??= { env: {} };
872
- processRef.env ??= {};
873
- Object.assign(processRef.env, {
874
- AGENT_NATIVE_WORKSPACE: "1",
875
- AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
876
- APP_BASE_PATH: basePath,
877
- AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
878
- AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
879
- AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
880
- VITE_AGENT_NATIVE_WORKSPACE: "1",
881
- VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
882
- VITE_APP_BASE_PATH: basePath,
883
- VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
884
- VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
885
- VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
886
- VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
887
- ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
888
- });
889
- }
890
-
891
- setBasePathEnv();
892
- ${normalizeBasePathHelper}
893
-
894
- let cachedHandler;
895
-
896
- export default async function handler(...args) {
897
- setBasePathEnv();
898
- cachedHandler ??= (await import("./main.mjs")).default;
899
- return cachedHandler(${handlerArgs});
900
- }
901
-
902
- export const config = {
903
- name: ${JSON.stringify(`${app} server handler`)},
904
- generator: "agent-native workspace deploy",
905
- path: ${JSON.stringify(pathConfig)},
906
- nodeBundler: "none",
907
- includedFiles: ["**"],
908
- excludedPath: ${JSON.stringify(
909
- netlifyFunctionExcludedPaths(app, staticDir),
910
- null,
911
- 2,
912
- )
913
- .split("\n")
914
- .join("\n ")},
915
- preferStatic: false,
916
- };
917
- `;
918
- fs.rmSync(serverPath, { force: true });
919
- fs.writeFileSync(path.join(functionDir, `${app}-server.mjs`), server);
920
- }
921
-
922
- function patchVercelFunctionEntry(
923
- functionDir: string,
924
- app: string,
925
- workspaceApps: WorkspaceAppManifestEntry[],
926
- ): void {
927
- const entryPath = path.join(functionDir, "index.mjs");
928
- if (!fs.existsSync(entryPath)) return;
929
-
930
- const mainPath = path.join(functionDir, "main.mjs");
931
- fs.rmSync(mainPath, { force: true });
932
- fs.renameSync(entryPath, mainPath);
933
-
934
- const basePath = `/${app}`;
935
- const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
936
- const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
937
- workspaceApps,
938
- app,
939
- );
940
- const entry = `const basePath = ${JSON.stringify(basePath)};
941
-
942
- function setBasePathEnv() {
943
- const processRef = globalThis.process ??= { env: {} };
944
- processRef.env ??= {};
945
- Object.assign(processRef.env, {
946
- AGENT_NATIVE_WORKSPACE: "1",
947
- AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
948
- APP_BASE_PATH: basePath,
949
- AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
950
- AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
951
- AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
952
- VITE_AGENT_NATIVE_WORKSPACE: "1",
953
- VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
954
- VITE_APP_BASE_PATH: basePath,
955
- VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
956
- VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
957
- VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
958
- VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
959
- ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
960
- });
961
- }
962
-
963
- function normalizeBasePathArgs(args) {
964
- const request = args[0];
965
- if (!request) return args;
966
-
967
- if (typeof Request === "function" && request instanceof Request) {
968
- const url = new URL(request.url);
969
- if (url.pathname === basePath || url.pathname === \`\${basePath}/\`) {
970
- url.pathname = \`\${basePath}//\`;
971
- return [new Request(url, request), ...args.slice(1)];
972
- }
973
- return args;
974
- }
975
-
976
- if (typeof request.url !== "string") return args;
977
- const url = new URL(request.url, "http://agent-native.local");
978
- if (url.pathname === basePath || url.pathname === \`\${basePath}/\`) {
979
- request.url = \`\${basePath}//\${url.search}\`;
980
- }
981
- return args;
982
- }
983
-
984
- setBasePathEnv();
985
-
986
- let cachedHandler;
987
-
988
- export default async function handler(...args) {
989
- setBasePathEnv();
990
- cachedHandler ??= (await import("./main.mjs")).default;
991
- return cachedHandler(...normalizeBasePathArgs(args));
992
- }
993
- `;
994
- fs.writeFileSync(entryPath, entry);
995
- }
996
-
997
- function netlifyFunctionExcludedPaths(
998
- app: string,
999
- staticDir: string,
1000
- ): string[] {
1001
- return [
1002
- "/.netlify/*",
1003
- `/${app}/assets/*`,
1004
- ...netlifyPublicRootAssetPaths(app, staticDir),
1005
- ];
1006
- }
1007
-
1008
- function netlifyPublicRootAssetPaths(app: string, staticDir: string): string[] {
1009
- if (!fs.existsSync(staticDir)) return [];
1010
- return fs
1011
- .readdirSync(staticDir, { withFileTypes: true })
1012
- .filter((entry) => entry.isFile())
1013
- .map((entry) => entry.name)
1014
- .filter((name) => {
1015
- const ext = path.extname(name).slice(1).toLowerCase();
1016
- return NETLIFY_PUBLIC_ASSET_EXTENSIONS.has(ext);
1017
- })
1018
- .sort()
1019
- .map((name) => `/${app}/${encodeURI(name)}`);
1020
- }
1021
-
1022
- function netlifyFunctionsDir(workspaceRoot: string): string {
1023
- return path.join(workspaceRoot, ".netlify", "functions-internal");
1024
- }
1025
-
1026
- function cleanAppBuildOutputs(appDir: string): void {
1027
- for (const name of ["dist", ".output", "build"]) {
1028
- fs.rmSync(path.join(appDir, name), { recursive: true, force: true });
1029
- }
1030
- fs.rmSync(path.join(appDir, ".netlify", "functions-internal"), {
1031
- recursive: true,
1032
- force: true,
1033
- });
1034
- fs.rmSync(path.join(appDir, ".vercel", "output"), {
1035
- recursive: true,
1036
- force: true,
1037
- });
1038
- }
1039
-
1040
- function appUsesNetlifyUnpooledDatabaseUrl(appDir: string): boolean {
1041
- const netlifyPath = path.join(appDir, "netlify.toml");
1042
- if (!fs.existsSync(netlifyPath)) return false;
1043
- try {
1044
- return fs
1045
- .readFileSync(netlifyPath, "utf-8")
1046
- .includes("NETLIFY_DATABASE_URL_UNPOOLED");
1047
- } catch {
1048
- return false;
1049
- }
1050
- }
1051
-
1052
- function writeWorkspaceAppManifests(
1053
- workspaceRoot: string,
1054
- distDir: string,
1055
- apps: string[],
1056
- workspaceApps: WorkspaceAppManifestEntry[],
1057
- preset: WorkspaceDeployPreset,
1058
- ): void {
1059
- const manifest = JSON.stringify(
1060
- {
1061
- version: 1,
1062
- apps: workspaceApps,
1063
- },
1064
- null,
1065
- 2,
1066
- );
1067
-
1068
- const targets =
1069
- preset === "netlify"
1070
- ? apps.map((app) =>
1071
- path.join(
1072
- netlifyFunctionsDir(workspaceRoot),
1073
- `${app}-server`,
1074
- WORKSPACE_APPS_MANIFEST_DIR,
1075
- WORKSPACE_APPS_MANIFEST_FILE,
1076
- ),
1077
- )
1078
- : preset === "vercel"
1079
- ? apps.map((app) =>
1080
- path.join(
1081
- workspaceRoot,
1082
- VERCEL_OUTPUT_DIR,
1083
- "functions",
1084
- `${app}-server.func`,
1085
- WORKSPACE_APPS_MANIFEST_DIR,
1086
- WORKSPACE_APPS_MANIFEST_FILE,
1087
- ),
1088
- )
1089
- : apps.map((app) =>
1090
- path.join(
1091
- distDir,
1092
- app,
1093
- WORKSPACE_APPS_MANIFEST_DIR,
1094
- WORKSPACE_APPS_MANIFEST_FILE,
1095
- ),
1096
- );
1097
-
1098
- for (const target of targets) {
1099
- fs.mkdirSync(path.dirname(target), { recursive: true });
1100
- fs.writeFileSync(target, `${manifest}\n`);
1101
- }
1102
- }
1103
-
1104
- function readWorkspaceAppManifest(
1105
- workspaceRoot: string,
1106
- apps: string[],
1107
- ): WorkspaceAppManifestEntry[] {
1108
- const explicitApps = readExistingWorkspaceAppManifest(workspaceRoot);
1109
-
1110
- return apps
1111
- .map((app) => {
1112
- const appDir = path.join(workspaceRoot, "apps", app);
1113
- const pkg = readPackageJson(path.join(appDir, "package.json"));
1114
- const appPath = `/${app}`;
1115
- const explicit = explicitApps.get(app);
1116
- const url =
1117
- normalizeWorkspaceAppUrl(explicit?.url) ?? workspaceAppUrl(appPath);
1118
- const audience =
1119
- workspaceAppAudienceFromPackageJson(pkg) ??
1120
- explicit?.audience ??
1121
- DEFAULT_WORKSPACE_APP_AUDIENCE;
1122
- const packageRouteAccess = workspaceAppRouteAccessFromPackageJson(pkg);
1123
- // Prefer the package.json value whenever the field was set — including
1124
- // an explicit empty array, which is how a per-app package.json signals
1125
- // "clear any previously-published manifest override." Falling back on
1126
- // length > 0 would silently keep the explicit override even after the
1127
- // app owner blanked their list.
1128
- const publicPaths =
1129
- packageRouteAccess.publicPaths ?? explicit?.publicPaths ?? [];
1130
- const protectedPaths =
1131
- packageRouteAccess.protectedPaths ?? explicit?.protectedPaths ?? [];
1132
- return {
1133
- id: app,
1134
- name: pkg?.displayName || titleCase(app),
1135
- description: pkg?.description || "",
1136
- path: appPath,
1137
- ...(url ? { url } : {}),
1138
- isDispatch: app === "dispatch",
1139
- audience,
1140
- publicPaths,
1141
- protectedPaths,
1142
- };
1143
- })
1144
- .sort((a, b) => {
1145
- if (a.id === "dispatch") return -1;
1146
- if (b.id === "dispatch") return 1;
1147
- return a.name.localeCompare(b.name);
1148
- });
1149
- }
1150
-
1151
- function readExistingWorkspaceAppManifest(
1152
- workspaceRoot: string,
1153
- ): Map<string, WorkspaceAppManifestOverride> {
1154
- const fromEnv = parseWorkspaceAppsJson(process.env[WORKSPACE_APPS_ENV_KEY]);
1155
- const fromFile =
1156
- readWorkspaceAppsFromFile(
1157
- path.join(
1158
- workspaceRoot,
1159
- WORKSPACE_APPS_MANIFEST_DIR,
1160
- WORKSPACE_APPS_MANIFEST_FILE,
1161
- ),
1162
- ) ??
1163
- readWorkspaceAppsFromFile(
1164
- path.join(workspaceRoot, WORKSPACE_APPS_MANIFEST_FILE),
1165
- );
1166
- const apps = fromEnv ?? fromFile ?? [];
1167
- return new Map(apps.map((app) => [app.id, app]));
1168
- }
1169
-
1170
- function parseWorkspaceAppsJson(
1171
- raw: string | undefined,
1172
- ): WorkspaceAppManifestOverride[] | null {
1173
- if (!raw) return null;
1174
- try {
1175
- return parseWorkspaceAppsManifest(JSON.parse(raw));
1176
- } catch {
1177
- return null;
1178
- }
1179
- }
1180
-
1181
- function readWorkspaceAppsFromFile(
1182
- file: string,
1183
- ): WorkspaceAppManifestOverride[] | null {
1184
- if (!fs.existsSync(file)) return null;
1185
- return parseWorkspaceAppsManifest(readPackageJson(file));
1186
- }
1187
-
1188
- function parseWorkspaceAppsManifest(
1189
- parsed: any,
1190
- ): WorkspaceAppManifestOverride[] | null {
1191
- const rawApps = Array.isArray(parsed?.apps)
1192
- ? parsed.apps
1193
- : Array.isArray(parsed)
1194
- ? parsed
1195
- : null;
1196
- if (!rawApps) return null;
1197
-
1198
- const apps = (rawApps as unknown[])
1199
- .map((entry) => {
1200
- if (!entry || typeof entry !== "object") return null;
1201
- const e = entry as Record<string, unknown>;
1202
- const id = typeof e.id === "string" ? e.id.trim() : "";
1203
- if (!id) return null;
1204
- const url = normalizeWorkspaceAppUrl(e.url);
1205
- const audience =
1206
- e.audience === undefined
1207
- ? undefined
1208
- : normalizeWorkspaceAppAudience(e.audience);
1209
- const publicPaths = normalizeWorkspaceAppPathList(e.publicPaths);
1210
- const protectedPaths = normalizeWorkspaceAppPathList(e.protectedPaths);
1211
- return {
1212
- id,
1213
- ...(url ? { url } : {}),
1214
- ...(audience ? { audience } : {}),
1215
- ...(publicPaths.length > 0 ? { publicPaths } : {}),
1216
- ...(protectedPaths.length > 0 ? { protectedPaths } : {}),
1217
- };
1218
- })
1219
- .filter((app): app is NonNullable<typeof app> => !!app);
1220
-
1221
- return apps.length ? apps : null;
1222
- }
1223
-
1224
- function workspaceBaseUrl(): string | null {
1225
- const gatewayOrigin =
1226
- process.env.WORKSPACE_GATEWAY_URL || process.env.VITE_WORKSPACE_GATEWAY_URL;
1227
- const publicGatewayOrigin = normalizeOrigin(gatewayOrigin);
1228
- const gatewayFallback =
1229
- publicGatewayOrigin && !isLoopbackOrigin(publicGatewayOrigin)
1230
- ? gatewayOrigin
1231
- : null;
1232
- return (
1233
- process.env.APP_URL ||
1234
- process.env.WORKSPACE_OAUTH_ORIGIN ||
1235
- process.env.VITE_WORKSPACE_OAUTH_ORIGIN ||
1236
- process.env.URL ||
1237
- process.env.DEPLOY_URL ||
1238
- process.env.BETTER_AUTH_URL ||
1239
- gatewayFallback ||
1240
- gatewayOrigin ||
1241
- null
1242
- );
1243
- }
1244
-
1245
- function normalizeOrigin(value: string | null | undefined): string | undefined {
1246
- if (!value) return undefined;
1247
- try {
1248
- return new URL(value).origin;
1249
- } catch {
1250
- return undefined;
1251
- }
1252
- }
1253
-
1254
- function isLoopbackOrigin(origin: string | undefined): boolean {
1255
- if (!origin) return false;
1256
- try {
1257
- const host = new URL(origin).hostname.toLowerCase();
1258
- return (
1259
- host === "localhost" ||
1260
- host === "127.0.0.1" ||
1261
- host === "[::1]" ||
1262
- host === "::1"
1263
- );
1264
- } catch {
1265
- return false;
1266
- }
1267
- }
1268
-
1269
- function workspaceOAuthOrigin(
1270
- workspaceGatewayUrl: string | null,
1271
- ): string | undefined {
1272
- // Explicit overrides (env vars set by the operator) win even if they happen
1273
- // to be loopback — that's a deliberate dev-against-prod choice.
1274
- // The gateway-URL fallback, however, is auto-resolved and would silently
1275
- // send users to localhost if the configured gateway is loopback in a
1276
- // production deploy. Strip loopback there so OAuth fails loudly instead.
1277
- const gatewayFallback = normalizeOrigin(workspaceGatewayUrl);
1278
- return (
1279
- normalizeOrigin(process.env.VITE_WORKSPACE_OAUTH_ORIGIN) ||
1280
- normalizeOrigin(process.env.WORKSPACE_OAUTH_ORIGIN) ||
1281
- normalizeOrigin(process.env.APP_URL) ||
1282
- normalizeOrigin(process.env.BETTER_AUTH_URL) ||
1283
- normalizeOrigin(process.env.URL) ||
1284
- normalizeOrigin(process.env.DEPLOY_URL) ||
1285
- (isLoopbackOrigin(gatewayFallback) ? undefined : gatewayFallback)
1286
- );
1287
- }
1288
-
1289
- function workspaceAppUrl(appPath: string): string | undefined {
1290
- const base = workspaceBaseUrl();
1291
- if (!base) return undefined;
1292
- try {
1293
- return new URL(appPath, `${base.replace(/\/$/, "")}/`).toString();
1294
- } catch {
1295
- return undefined;
1296
- }
1297
- }
1298
-
1299
- function normalizeWorkspaceAppUrl(value: unknown): string | undefined {
1300
- if (typeof value !== "string" || !value.trim()) return undefined;
1301
- try {
1302
- return new URL(value.trim()).toString().replace(/\/$/, "");
1303
- } catch {
1304
- return undefined;
1305
- }
1306
- }
1307
-
1308
- function readPackageJson(file: string): Record<string, any> | null {
1309
- try {
1310
- return JSON.parse(fs.readFileSync(file, "utf8"));
1311
- } catch {
1312
- return null;
1313
- }
1314
- }
1315
-
1316
- function titleCase(value: string): string {
1317
- return value
1318
- .split(/[-_\s]+/)
1319
- .filter(Boolean)
1320
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1321
- .join(" ");
1322
- }
1323
-
1324
- function parsePresetArg(args: string[]): WorkspaceDeployPreset | null {
1325
- for (let i = 0; i < args.length; i++) {
1326
- const arg = args[i];
1327
- if (arg === "--preset" && args[i + 1]) {
1328
- return normalizePreset(args[i + 1]);
1329
- }
1330
- if (arg.startsWith("--preset=")) {
1331
- return normalizePreset(arg.slice("--preset=".length));
1332
- }
1333
- }
1334
- return null;
1335
- }
1336
-
1337
- function resolvePreset(
1338
- optionPreset: WorkspaceDeployPreset | undefined,
1339
- args: string[],
1340
- ): WorkspaceDeployPreset {
1341
- return (
1342
- optionPreset ??
1343
- parsePresetArg(args) ??
1344
- normalizePreset(process.env.NITRO_PRESET) ??
1345
- "cloudflare_pages"
1346
- );
1347
- }
1348
-
1349
- function assertWorkspaceDeployProductionEnv(opts: {
1350
- buildOnly: boolean;
1351
- preset: WorkspaceDeployPreset;
1352
- }): void {
1353
- if (!isProductionWorkspaceDeploy(opts)) return;
1354
- if (process.env.A2A_SECRET?.trim()) return;
1355
- const providerHint =
1356
- opts.preset === "netlify"
1357
- ? ' For Netlify, one option is: netlify env:set A2A_SECRET "$(openssl rand -hex 32)".'
1358
- : "";
1359
- throw new Error(
1360
- [
1361
- "A2A_SECRET is required for production workspace deploys.",
1362
- "Workspace Slack, webhook, and cross-app A2A work resumes through signed background processors; without A2A_SECRET those production routes return 503.",
1363
- `Set A2A_SECRET in your deploy provider and redeploy.${providerHint}`,
1364
- "For local artifact checks, run agent-native deploy --build-only outside the deploy provider environment.",
1365
- ].join(" "),
1366
- );
1367
- }
1368
-
1369
- function isProductionWorkspaceDeploy(opts: {
1370
- buildOnly: boolean;
1371
- preset: WorkspaceDeployPreset;
1372
- }): boolean {
1373
- if (!opts.buildOnly) return true;
1374
- if (
1375
- opts.preset === "netlify" &&
1376
- process.env.NETLIFY === "true" &&
1377
- process.env.NETLIFY_LOCAL !== "true"
1378
- ) {
1379
- return true;
1380
- }
1381
- if (opts.preset === "cloudflare_pages" && process.env.CF_PAGES === "1") {
1382
- return true;
1383
- }
1384
- if (opts.preset === "vercel" && process.env.VERCEL === "1") {
1385
- return true;
1386
- }
1387
- return false;
1388
- }
1389
-
1390
- function normalizePreset(
1391
- value: string | undefined,
1392
- ): WorkspaceDeployPreset | null {
1393
- if (!value) return null;
1394
- if (value === "cloudflare_pages" || value === "cloudflare-pages") {
1395
- return "cloudflare_pages";
1396
- }
1397
- if (value === "netlify") return "netlify";
1398
- if (value === "vercel") return "vercel";
1399
- throw new Error(
1400
- `Unsupported workspace deploy preset "${value}". Supported presets: cloudflare_pages, netlify, vercel.`,
1401
- );
1402
- }
1403
-
1404
- function moduleIdent(app: string): string {
1405
- return "app_" + app.replace(/[^a-zA-Z0-9_]/g, "_");
1406
- }
1407
-
1408
- function workspaceAppAudienceForApp(
1409
- workspaceApps: WorkspaceAppManifestEntry[],
1410
- app: string,
1411
- ): WorkspaceAppAudience {
1412
- return (
1413
- workspaceApps.find((entry) => entry.id === app)?.audience ??
1414
- DEFAULT_WORKSPACE_APP_AUDIENCE
1415
- );
1416
- }
1417
-
1418
- function workspaceAppRouteAccessForApp(
1419
- workspaceApps: WorkspaceAppManifestEntry[],
1420
- app: string,
1421
- ): WorkspaceAppRouteAccess {
1422
- const entry = workspaceApps.find((candidate) => candidate.id === app);
1423
- return {
1424
- publicPaths: entry?.publicPaths ?? [],
1425
- protectedPaths: entry?.protectedPaths ?? [],
1426
- };
1427
- }
1428
-
1429
- function compareWorkspaceAppIds(a: string, b: string): number {
1430
- if (a === "dispatch") return -1;
1431
- if (b === "dispatch") return 1;
1432
- return a.localeCompare(b);
1433
- }
1434
-
1435
- function copyDir(src: string, dest: string): void {
1436
- fs.mkdirSync(dest, { recursive: true });
1437
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
1438
- const s = path.join(src, entry.name);
1439
- const d = path.join(dest, entry.name);
1440
- if (entry.isSymbolicLink()) {
1441
- try {
1442
- const target = fs.readlinkSync(s);
1443
- fs.symlinkSync(target, d);
1444
- } catch {
1445
- fs.copyFileSync(s, d);
1446
- }
1447
- } else if (entry.isDirectory()) {
1448
- copyDir(s, d);
1449
- } else {
1450
- fs.copyFileSync(s, d);
1451
- }
1452
- }
1453
- }
1
+ /**
2
+ * `agent-native deploy` — build and deploy every app in a workspace to a
3
+ * single origin. Each app is served from `/<app-name>/*`, so:
4
+ *
5
+ * https://your-agents.com/mail/* → apps/mail
6
+ * https://your-agents.com/calendar/* → apps/calendar
7
+ *
8
+ * Benefits of same-origin deploy:
9
+ * - Shared auth cookie → log in once, every app is signed in
10
+ * - Cross-app A2A is a same-origin fetch (no CORS, no JWT for siblings)
11
+ * - One DNS record, one TLS cert, one CDN cache
12
+ *
13
+ * Per-app independent deploy is still supported — just cd into the app and
14
+ * run `agent-native build` as before. This orchestrator is for teams that
15
+ * want the whole workspace behind one domain.
16
+ */
17
+ import { execFileSync } from "child_process";
18
+ import fs from "fs";
19
+ import path from "path";
20
+
21
+ import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
22
+ import { findWorkspaceRoot } from "../scripts/utils.js";
23
+ import {
24
+ DEFAULT_WORKSPACE_APP_AUDIENCE,
25
+ normalizeWorkspaceAppAudience,
26
+ normalizeWorkspaceAppPathList,
27
+ workspaceAppAudienceFromPackageJson,
28
+ workspaceAppRouteAccessFromPackageJson,
29
+ type WorkspaceAppRouteAccess,
30
+ type WorkspaceAppAudience,
31
+ } from "../shared/workspace-app-audience.js";
32
+ import { DISPATCH_WORKSPACE_ROOT_REDIRECTS } from "../shared/workspace-app-id.js";
33
+ import {
34
+ collectImmutableAssetPaths,
35
+ IMMUTABLE_ASSET_CACHE_HEADERS,
36
+ } from "./immutable-assets.js";
37
+
38
+ export type WorkspaceDeployPreset = "cloudflare_pages" | "netlify" | "vercel";
39
+
40
+ const NETLIFY_WORKSPACE_STATIC_DIR = "_workspace_static";
41
+ const NETLIFY_PUBLIC_ASSET_EXTENSIONS = new Set([
42
+ "avif",
43
+ "css",
44
+ "gif",
45
+ "ico",
46
+ "jpeg",
47
+ "jpg",
48
+ "js",
49
+ "json",
50
+ "map",
51
+ "mp4",
52
+ "pdf",
53
+ "png",
54
+ "svg",
55
+ "txt",
56
+ "wasm",
57
+ "webm",
58
+ "webmanifest",
59
+ "webp",
60
+ "xml",
61
+ ]);
62
+ const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
63
+ const WORKSPACE_APPS_MANIFEST_DIR = ".agent-native";
64
+ const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
65
+ const VERCEL_OUTPUT_DIR = ".vercel/output";
66
+
67
+ interface WorkspaceAppManifestEntry {
68
+ id: string;
69
+ name: string;
70
+ description: string;
71
+ path: string;
72
+ url?: string;
73
+ isDispatch: boolean;
74
+ audience: WorkspaceAppAudience;
75
+ publicPaths: string[];
76
+ protectedPaths: string[];
77
+ }
78
+
79
+ interface WorkspaceAppManifestOverride {
80
+ id: string;
81
+ url?: string;
82
+ audience?: WorkspaceAppAudience;
83
+ publicPaths?: string[];
84
+ protectedPaths?: string[];
85
+ }
86
+
87
+ export interface WorkspaceDeployOptions {
88
+ args?: string[];
89
+ /** Override the workspace root (defaults to walking up from cwd). */
90
+ workspaceRoot?: string;
91
+ /** Only build — don't invoke the deploy platform CLI. */
92
+ buildOnly?: boolean;
93
+ /** Target preset. Defaults to `cloudflare_pages`. */
94
+ preset?: WorkspaceDeployPreset;
95
+ /** @internal Override process execution in tests. */
96
+ execFile?: typeof execFileSync;
97
+ }
98
+
99
+ export async function runWorkspaceDeploy(
100
+ opts: WorkspaceDeployOptions = {},
101
+ ): Promise<void> {
102
+ const workspaceRoot =
103
+ opts.workspaceRoot ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
104
+ const appsDir = path.join(workspaceRoot, "apps");
105
+ if (!fs.existsSync(appsDir)) {
106
+ throw new Error(
107
+ `No apps/ directory found at ${workspaceRoot}. Run this inside an agent-native workspace.`,
108
+ );
109
+ }
110
+
111
+ const rawArgs = opts.args ?? [];
112
+ const args = new Set(rawArgs);
113
+ const buildOnly = opts.buildOnly ?? args.has("--build-only");
114
+
115
+ const apps = fs
116
+ .readdirSync(appsDir, { withFileTypes: true })
117
+ .filter((e) => e.isDirectory())
118
+ .map((e) => e.name)
119
+ .filter((n) => fs.existsSync(path.join(appsDir, n, "package.json")))
120
+ .sort(compareWorkspaceAppIds);
121
+
122
+ if (apps.length === 0) {
123
+ throw new Error(
124
+ `Workspace has no apps. Run \`agent-native add-app\` to add one.`,
125
+ );
126
+ }
127
+ assertNoReservedWorkspaceAppIds(apps);
128
+ const workspaceApps = readWorkspaceAppManifest(workspaceRoot, apps);
129
+
130
+ const preset = resolvePreset(opts.preset, rawArgs);
131
+ assertWorkspaceDeployProductionEnv({ buildOnly, preset });
132
+ const distDir = path.join(workspaceRoot, "dist");
133
+ const vercelOutputDir = path.join(workspaceRoot, VERCEL_OUTPUT_DIR);
134
+ if (preset === "vercel") {
135
+ fs.rmSync(vercelOutputDir, { recursive: true, force: true });
136
+ fs.mkdirSync(path.join(vercelOutputDir, "static"), { recursive: true });
137
+ fs.mkdirSync(path.join(vercelOutputDir, "functions"), {
138
+ recursive: true,
139
+ });
140
+ } else {
141
+ fs.rmSync(distDir, { recursive: true, force: true });
142
+ fs.mkdirSync(distDir, { recursive: true });
143
+ }
144
+
145
+ if (preset === "netlify") {
146
+ const functionsDir = netlifyFunctionsDir(workspaceRoot);
147
+ fs.rmSync(functionsDir, { recursive: true, force: true });
148
+ fs.mkdirSync(functionsDir, { recursive: true });
149
+ }
150
+
151
+ console.log(
152
+ `[workspace-deploy] Building ${apps.length} app(s) for preset=${preset}`,
153
+ );
154
+
155
+ const execFile = opts.execFile ?? execFileSync;
156
+ for (const app of apps) {
157
+ buildOneApp(workspaceRoot, app, preset, execFile, workspaceApps);
158
+ moveAppBuildIntoWorkspaceOutput(
159
+ workspaceRoot,
160
+ app,
161
+ preset,
162
+ distDir,
163
+ vercelOutputDir,
164
+ workspaceApps,
165
+ );
166
+ }
167
+ writeWorkspaceAppManifests(
168
+ workspaceRoot,
169
+ distDir,
170
+ apps,
171
+ workspaceApps,
172
+ preset,
173
+ );
174
+
175
+ if (preset === "netlify") {
176
+ writeNetlifyRedirects(distDir, apps);
177
+ writeNetlifyHeaders(distDir, apps);
178
+ } else if (preset === "vercel") {
179
+ writeVercelBuildConfig(vercelOutputDir, apps);
180
+ } else {
181
+ writeCloudflareRoutingManifest(distDir, apps);
182
+ }
183
+
184
+ if (buildOnly) {
185
+ const outputDir = preset === "vercel" ? vercelOutputDir : distDir;
186
+ console.log(
187
+ `\n[workspace-deploy] Build complete at ${outputDir}. Skipping publish (--build-only).`,
188
+ );
189
+ return;
190
+ }
191
+
192
+ console.log(`\n[workspace-deploy] Build complete. Publish with:\n`);
193
+ console.log(` cd ${path.relative(process.cwd(), workspaceRoot) || "."}`);
194
+ if (preset === "netlify") {
195
+ console.log(
196
+ ` netlify deploy --prod --dir=dist --functions=.netlify/functions-internal\n`,
197
+ );
198
+ } else if (preset === "vercel") {
199
+ console.log(` vercel deploy --prebuilt\n`);
200
+ } else {
201
+ console.log(` wrangler pages deploy dist\n`);
202
+ }
203
+ console.log(
204
+ `All apps live at https://<origin>/<app-name>/*. Log in once on any app\nand the session is shared across the workspace.`,
205
+ );
206
+ }
207
+
208
+ function buildOneApp(
209
+ workspaceRoot: string,
210
+ app: string,
211
+ preset: WorkspaceDeployPreset,
212
+ execFile: typeof execFileSync,
213
+ workspaceApps: WorkspaceAppManifestEntry[],
214
+ ): void {
215
+ const appDir = path.join(workspaceRoot, "apps", app);
216
+ const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
217
+ const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
218
+ workspaceApps,
219
+ app,
220
+ );
221
+ const workspaceGatewayUrl =
222
+ process.env.VITE_WORKSPACE_GATEWAY_URL || workspaceBaseUrl();
223
+ const workspaceOAuthUrl = workspaceOAuthOrigin(workspaceGatewayUrl);
224
+ const env: NodeJS.ProcessEnv = {
225
+ ...process.env,
226
+ NITRO_PRESET: preset,
227
+ AGENT_NATIVE_WORKSPACE: "1",
228
+ AGENT_NATIVE_WORKSPACE_APP_ID: app,
229
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
230
+ VITE_AGENT_NATIVE_WORKSPACE_APP_ID: app,
231
+ APP_BASE_PATH: `/${app}`,
232
+ VITE_APP_BASE_PATH: `/${app}`,
233
+ AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: workspaceAppAudience,
234
+ AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
235
+ workspaceAppRouteAccess.publicPaths,
236
+ ),
237
+ AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
238
+ workspaceAppRouteAccess.protectedPaths,
239
+ ),
240
+ VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: workspaceAppAudience,
241
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: JSON.stringify(
242
+ workspaceAppRouteAccess.publicPaths,
243
+ ),
244
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: JSON.stringify(
245
+ workspaceAppRouteAccess.protectedPaths,
246
+ ),
247
+ VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: JSON.stringify(workspaceApps),
248
+ ...(workspaceGatewayUrl
249
+ ? {
250
+ WORKSPACE_GATEWAY_URL:
251
+ process.env.WORKSPACE_GATEWAY_URL || workspaceGatewayUrl,
252
+ VITE_WORKSPACE_GATEWAY_URL: workspaceGatewayUrl,
253
+ ...(workspaceOAuthUrl
254
+ ? { VITE_WORKSPACE_OAUTH_ORIGIN: workspaceOAuthUrl }
255
+ : {}),
256
+ }
257
+ : {}),
258
+ [WORKSPACE_APPS_ENV_KEY]: JSON.stringify(workspaceApps),
259
+ };
260
+
261
+ if (preset === "netlify" && appUsesNetlifyUnpooledDatabaseUrl(appDir)) {
262
+ env.DATABASE_URL =
263
+ process.env.NETLIFY_DATABASE_URL_UNPOOLED ??
264
+ process.env.DATABASE_URL ??
265
+ env.DATABASE_URL;
266
+ }
267
+
268
+ console.log(
269
+ `[workspace-deploy] Building ${app} (base=/${app}, preset=${preset})`,
270
+ );
271
+
272
+ cleanAppBuildOutputs(appDir);
273
+
274
+ execFile("pnpm", ["--filter", app, "build"], {
275
+ cwd: workspaceRoot,
276
+ env,
277
+ stdio: "inherit",
278
+ });
279
+ }
280
+
281
+ function moveAppBuildIntoWorkspaceOutput(
282
+ workspaceRoot: string,
283
+ app: string,
284
+ preset: WorkspaceDeployPreset,
285
+ distDir: string,
286
+ vercelOutputDir: string,
287
+ workspaceApps: WorkspaceAppManifestEntry[],
288
+ ): void {
289
+ const appDir = path.join(workspaceRoot, "apps", app);
290
+ if (preset === "vercel") {
291
+ copyVercelAppBuildIntoWorkspace(
292
+ workspaceRoot,
293
+ app,
294
+ vercelOutputDir,
295
+ workspaceApps,
296
+ );
297
+ return;
298
+ }
299
+
300
+ // Resolve the per-app build output: prefer dist/ (standard), fall back to
301
+ // .output/ (Nitro's default). The Cloudflare preset emits into dist/
302
+ // containing the worker + assets.
303
+ const candidates = ["dist", ".output"];
304
+ const src = candidates
305
+ .map((c) => path.join(appDir, c))
306
+ .find((p) => fs.existsSync(p));
307
+ if (!src) {
308
+ throw new Error(
309
+ `Expected ${candidates.join(" or ")} under ${appDir} but none existed. Check the app's build script.`,
310
+ );
311
+ }
312
+ if (preset === "netlify") {
313
+ const mountedSrc = path.join(src, app);
314
+ const staticSrc = fs.existsSync(mountedSrc) ? mountedSrc : src;
315
+ const target = path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app);
316
+ fs.mkdirSync(target, { recursive: true });
317
+ copyDir(staticSrc, target);
318
+ // Nitro/Vite mounted builds can contain a nested copy of public assets at
319
+ // dist/<app>/<app>/...; the workspace root already supplies the outer
320
+ // mount path, so keeping it would publish duplicate /<app>/<app> URLs.
321
+ fs.rmSync(path.join(target, app), { recursive: true, force: true });
322
+ copyNetlifyFunctionIntoWorkspace(workspaceRoot, app, workspaceApps, target);
323
+ } else {
324
+ const target = path.join(distDir, app);
325
+ fs.mkdirSync(target, { recursive: true });
326
+ copyDir(src, target);
327
+ }
328
+ }
329
+
330
+ function copyVercelAppBuildIntoWorkspace(
331
+ workspaceRoot: string,
332
+ app: string,
333
+ vercelOutputDir: string,
334
+ workspaceApps: WorkspaceAppManifestEntry[],
335
+ ): void {
336
+ const appDir = path.join(workspaceRoot, "apps", app);
337
+ const src = path.join(appDir, VERCEL_OUTPUT_DIR);
338
+ if (!fs.existsSync(src)) {
339
+ throw new Error(
340
+ `Expected Vercel output at ${src} after building ${app}. Check the app's build script and NITRO_PRESET.`,
341
+ );
342
+ }
343
+
344
+ const staticSrc = path.join(src, "static");
345
+ const staticDest = path.join(vercelOutputDir, "static");
346
+ if (fs.existsSync(staticSrc)) {
347
+ copyDir(staticSrc, staticDest);
348
+ // Nitro's Vercel preset already nests assets under baseURL. The shared
349
+ // deploy build also mirrors client assets under baseURL for other Nitro
350
+ // presets, so mounted apps can contain a duplicate /<app>/<app> copy.
351
+ fs.rmSync(path.join(staticDest, app, app), {
352
+ recursive: true,
353
+ force: true,
354
+ });
355
+ }
356
+
357
+ const functionSrc = path.join(src, "functions", "__server.func");
358
+ if (!fs.existsSync(functionSrc)) {
359
+ throw new Error(
360
+ `Expected Vercel function at ${functionSrc} after building ${app}. Check the app's build script and NITRO_PRESET.`,
361
+ );
362
+ }
363
+
364
+ const functionDest = path.join(
365
+ vercelOutputDir,
366
+ "functions",
367
+ `${app}-server.func`,
368
+ );
369
+ fs.rmSync(functionDest, { recursive: true, force: true });
370
+ copyDir(functionSrc, functionDest);
371
+ patchVercelFunctionEntry(functionDest, app, workspaceApps);
372
+ }
373
+
374
+ /**
375
+ * Write the Cloudflare Pages `_routes.json` and a dispatcher `_worker.js` at
376
+ * the workspace dist root so each app is reachable under /<app>/*.
377
+ */
378
+ function writeCloudflareRoutingManifest(distDir: string, apps: string[]): void {
379
+ const dispatchFaviconAsset = apps.includes("dispatch")
380
+ ? dispatchRootFaviconAsset(distDir)
381
+ : null;
382
+ // _routes.json tells Cloudflare which paths are dynamic (Functions) vs
383
+ // static. Mark both /<app> and /<app>/* as include so every app's worker
384
+ // handles its root and subtree.
385
+ const include = apps.flatMap((a) => [`/${a}`, `/${a}/*`]).concat(["/"]);
386
+ if (apps.includes("dispatch")) {
387
+ include.push("/_agent-native/*");
388
+ include.push("/.well-known/*");
389
+ include.push(
390
+ ...DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(([from]) => `/${from}`),
391
+ );
392
+ include.push("/apps/*");
393
+ if (dispatchFaviconAsset) include.push("/favicon.ico");
394
+ }
395
+ const routes = {
396
+ version: 1,
397
+ include,
398
+ exclude: [],
399
+ };
400
+ fs.writeFileSync(
401
+ path.join(distDir, "_routes.json"),
402
+ JSON.stringify(routes, null, 2) + "\n",
403
+ );
404
+
405
+ // Dispatcher worker: inspects the path and forwards to the matching
406
+ // per-app worker.
407
+ const imports = apps
408
+ .map((a) => `import ${moduleIdent(a)} from "./${a}/_worker.js";`)
409
+ .join("\n");
410
+ const dispatch = apps
411
+ .map(
412
+ (a) =>
413
+ ` if (pathname === "/${a}" || pathname.startsWith("/${a}/")) return ${moduleIdent(a)}.fetch(requestForMountedApp(request, "/${a}"), env, ctx);`,
414
+ )
415
+ .join("\n");
416
+ const dispatchRootFrameworkRoutes = apps.includes("dispatch")
417
+ ? ` if (pathname === "/_agent-native" || pathname.startsWith("/_agent-native/") || pathname === "/.well-known" || pathname.startsWith("/.well-known/")) return ${moduleIdent("dispatch")}.fetch(request, env, ctx);
418
+ `
419
+ : "";
420
+ const dispatchRootFaviconRoute = dispatchFaviconAsset
421
+ ? ` if (pathname === "/favicon.ico") return Response.redirect(new URL("/dispatch/${dispatchFaviconAsset}", request.url).toString(), 302);
422
+ `
423
+ : "";
424
+ const dispatchRootAliasRoutes = apps.includes("dispatch")
425
+ ? DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(
426
+ ([from, to]) =>
427
+ ` if (pathname === "/${from}") return Response.redirect(new URL("/dispatch/${to}" + search, request.url).toString(), 302);`,
428
+ ).join("\n") + "\n"
429
+ : "";
430
+ const dispatchRootDynamicAliasRoutes = apps.includes("dispatch")
431
+ ? ` if (pathname.startsWith("/apps/")) return Response.redirect(new URL("/dispatch" + pathname + search, request.url).toString(), 302);
432
+ `
433
+ : "";
434
+ const dispatchMountedRootRedirect = apps.includes("dispatch")
435
+ ? ` if (pathname === "/dispatch" || pathname === "/dispatch/") return Response.redirect(new URL("/dispatch/overview" + search, request.url).toString(), 302);
436
+ `
437
+ : "";
438
+
439
+ const worker = `${imports}
440
+
441
+ function requestForMountedApp(request, basePath) {
442
+ const url = new URL(request.url);
443
+ if (url.pathname !== basePath && url.pathname !== \`\${basePath}/\`) {
444
+ return request;
445
+ }
446
+ url.pathname = \`\${basePath}//\`;
447
+ return new Request(url, request);
448
+ }
449
+
450
+ export default {
451
+ async fetch(request, env, ctx) {
452
+ const { pathname, search } = new URL(request.url);
453
+ ${dispatchRootFrameworkRoutes}${dispatchRootFaviconRoute}${dispatchRootAliasRoutes}${dispatchRootDynamicAliasRoutes}${dispatchMountedRootRedirect}${dispatch}
454
+ if (pathname === "/") {
455
+ return Response.redirect(new URL("${cloudflareRootRedirectPath(apps)}", request.url).toString(), 302);
456
+ }
457
+ return new Response("Not found", { status: 404 });
458
+ },
459
+ };
460
+ `;
461
+ fs.writeFileSync(path.join(distDir, "_worker.js"), worker);
462
+ }
463
+
464
+ function cloudflareRootRedirectPath(apps: string[]): string {
465
+ return apps.includes("dispatch") ? "/dispatch/overview" : `/${apps[0]}/`;
466
+ }
467
+
468
+ function writeNetlifyRedirects(distDir: string, apps: string[]): void {
469
+ const lines: string[] = [
470
+ "# Generated by agent-native deploy --preset netlify",
471
+ "# Static app assets are stored under a safe namespace; dynamic app routes are handled by function route config.",
472
+ ];
473
+
474
+ if (apps.includes("dispatch")) {
475
+ lines.push("/_agent-native/* /.netlify/functions/dispatch-server 200");
476
+ lines.push("/.well-known/* /.netlify/functions/dispatch-server 200");
477
+ const faviconAsset = dispatchRootFaviconAsset(distDir);
478
+ if (faviconAsset) {
479
+ lines.push(`/favicon.ico /dispatch/${faviconAsset} 302`);
480
+ }
481
+ }
482
+
483
+ for (const app of apps) {
484
+ lines.push(...netlifyAssetRedirectsFor(app, distDir));
485
+ }
486
+
487
+ if (apps.includes("dispatch")) {
488
+ lines.push("/ /dispatch/overview 302");
489
+ lines.push("/dispatch /dispatch/overview 302");
490
+ lines.push("/dispatch/ /dispatch/overview 302");
491
+ for (const [from, to] of DISPATCH_WORKSPACE_ROOT_REDIRECTS) {
492
+ lines.push(`/${from} /dispatch/${to} 302`);
493
+ }
494
+ lines.push("/apps/* /dispatch/apps/:splat 302");
495
+ } else {
496
+ lines.push(`/ /${apps[0]}/ 302`);
497
+ }
498
+
499
+ fs.writeFileSync(path.join(distDir, "_redirects"), lines.join("\n") + "\n");
500
+ }
501
+
502
+ function writeNetlifyHeaders(distDir: string, apps: string[]): void {
503
+ const blocks = apps.flatMap((app) => {
504
+ const staticDir = path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app);
505
+ return collectImmutableAssetPaths(staticDir).flatMap((assetPath) => [
506
+ netlifyHeaderBlock(`/${app}${assetPath}`),
507
+ netlifyHeaderBlock(`/${NETLIFY_WORKSPACE_STATIC_DIR}/${app}${assetPath}`),
508
+ ]);
509
+ });
510
+
511
+ if (blocks.length === 0) return;
512
+ fs.writeFileSync(path.join(distDir, "_headers"), blocks.join("\n\n") + "\n");
513
+ }
514
+
515
+ function netlifyHeaderBlock(pathname: string): string {
516
+ return [
517
+ pathname,
518
+ ...Object.entries(IMMUTABLE_ASSET_CACHE_HEADERS).map(
519
+ ([name, value]) => ` ${name}: ${value}`,
520
+ ),
521
+ ].join("\n");
522
+ }
523
+
524
+ function writeVercelBuildConfig(outputDir: string, apps: string[]): void {
525
+ const routes: Array<Record<string, any>> = [
526
+ ...vercelImmutableAssetHeaderRoutes(outputDir, apps),
527
+ { handle: "filesystem" },
528
+ ];
529
+
530
+ if (apps.includes("dispatch")) {
531
+ routes.push(
532
+ { src: "/_agent-native", dest: "/dispatch-server" },
533
+ { src: "/_agent-native/(.*)", dest: "/dispatch-server" },
534
+ { src: "/\\.well-known", dest: "/dispatch-server" },
535
+ { src: "/\\.well-known/(.*)", dest: "/dispatch-server" },
536
+ );
537
+
538
+ const faviconAsset = dispatchRootFaviconAsset(
539
+ path.join(outputDir, "static"),
540
+ );
541
+ if (faviconAsset) {
542
+ routes.push(vercelRedirect("/favicon.ico", `/dispatch/${faviconAsset}`));
543
+ }
544
+
545
+ routes.push(
546
+ vercelRedirect("/", "/dispatch/overview"),
547
+ vercelRedirect("/dispatch", "/dispatch/overview"),
548
+ vercelRedirect("/dispatch/", "/dispatch/overview"),
549
+ );
550
+ for (const [from, to] of DISPATCH_WORKSPACE_ROOT_REDIRECTS) {
551
+ routes.push(vercelRedirect(`/${from}`, `/dispatch/${to}`));
552
+ }
553
+ routes.push(vercelRedirect("/apps/(.*)", "/dispatch/apps/$1"));
554
+ } else {
555
+ routes.push(vercelRedirect("/", `/${apps[0]}/`));
556
+ }
557
+
558
+ for (const app of apps) {
559
+ if (app !== "dispatch") {
560
+ routes.push({ src: `/${app}`, dest: `/${app}-server` });
561
+ }
562
+ routes.push({ src: `/${app}/(.*)`, dest: `/${app}-server` });
563
+ }
564
+
565
+ const config = {
566
+ version: 3,
567
+ routes,
568
+ };
569
+ fs.writeFileSync(
570
+ path.join(outputDir, "config.json"),
571
+ JSON.stringify(config, null, 2) + "\n",
572
+ );
573
+ }
574
+
575
+ function vercelImmutableAssetHeaderRoutes(
576
+ outputDir: string,
577
+ apps: string[],
578
+ ): Array<Record<string, any>> {
579
+ return apps.flatMap((app) => {
580
+ const staticDir = path.join(outputDir, "static", app);
581
+ return collectImmutableAssetPaths(staticDir).map((assetPath) => ({
582
+ src: vercelRouteSrc(`/${app}${assetPath}`),
583
+ headers: IMMUTABLE_ASSET_CACHE_HEADERS,
584
+ continue: true,
585
+ }));
586
+ });
587
+ }
588
+
589
+ function vercelRouteSrc(pathname: string): string {
590
+ return pathname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
591
+ }
592
+
593
+ function vercelRedirect(src: string, location: string): Record<string, any> {
594
+ return {
595
+ src,
596
+ status: 302,
597
+ headers: { Location: location },
598
+ };
599
+ }
600
+
601
+ function netlifyAssetRedirectsFor(app: string, distDir: string): string[] {
602
+ const from = `/${app}`;
603
+ const to = `/${NETLIFY_WORKSPACE_STATIC_DIR}/${app}`;
604
+ return [
605
+ `${from}/assets/* ${to}/assets/:splat 200`,
606
+ ...netlifyPublicRootAssetPaths(
607
+ app,
608
+ path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app),
609
+ ).map((assetPath) => {
610
+ const assetName = assetPath.slice(from.length + 1);
611
+ return `${assetPath} ${to}/${assetName} 200`;
612
+ }),
613
+ ];
614
+ }
615
+
616
+ function dispatchRootFaviconAsset(distDir: string): string | null {
617
+ for (const asset of ["favicon.ico", "favicon.svg", "favicon.png"]) {
618
+ if (workspaceAppAssetExists(distDir, "dispatch", asset)) return asset;
619
+ }
620
+ return null;
621
+ }
622
+
623
+ function workspaceAppAssetExists(
624
+ distDir: string,
625
+ app: string,
626
+ asset: string,
627
+ ): boolean {
628
+ return [
629
+ path.join(distDir, NETLIFY_WORKSPACE_STATIC_DIR, app, asset),
630
+ path.join(distDir, app, app, asset),
631
+ path.join(distDir, app, asset),
632
+ ].some((candidate) => fs.existsSync(candidate));
633
+ }
634
+
635
+ const RESERVED_WORKSPACE_APP_IDS = new Set([
636
+ "_agent-native",
637
+ "_workspace_static",
638
+ "netlify",
639
+ ...DISPATCH_WORKSPACE_ROOT_REDIRECTS.map(([from]) => from),
640
+ ]);
641
+
642
+ function assertNoReservedWorkspaceAppIds(apps: string[]): void {
643
+ const conflicts = apps.filter(
644
+ (app) => app !== "dispatch" && RESERVED_WORKSPACE_APP_IDS.has(app),
645
+ );
646
+ if (conflicts.length === 0) return;
647
+ throw new Error(
648
+ `Workspace app id ${conflicts.map((id) => `"${id}"`).join(", ")} conflicts with reserved workspace routes. Choose a different app id.`,
649
+ );
650
+ }
651
+
652
+ function copyNetlifyFunctionIntoWorkspace(
653
+ workspaceRoot: string,
654
+ app: string,
655
+ workspaceApps: WorkspaceAppManifestEntry[],
656
+ staticDir: string,
657
+ ): void {
658
+ const appDir = path.join(workspaceRoot, "apps", app);
659
+ const src = path.join(appDir, ".netlify", "functions-internal", "server");
660
+ if (!fs.existsSync(src)) {
661
+ throw new Error(
662
+ `Expected Netlify function at ${src} after building ${app}. Check the app's build script and NITRO_PRESET.`,
663
+ );
664
+ }
665
+
666
+ const dest = path.join(netlifyFunctionsDir(workspaceRoot), `${app}-server`);
667
+ fs.rmSync(dest, { recursive: true, force: true });
668
+ copyDir(src, dest);
669
+ patchNetlifyFunctionEntry(dest, app, workspaceApps, staticDir);
670
+
671
+ // Durable background agent runs (default-ON; opt out with a falsy
672
+ // AGENT_CHAT_DURABLE_BACKGROUND). Additive ONLY: when explicitly opted out
673
+ // this emits nothing and the single-function deploy is unchanged.
674
+ if (isDurableBackgroundDeployEnabled()) {
675
+ emitNetlifyBackgroundFunction(workspaceRoot, app, src, workspaceApps);
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Deploy-time gate for emitting the second `-background` Netlify function. Reads
681
+ * the same env flag the runtime gate uses (`AGENT_CHAT_DURABLE_BACKGROUND`).
682
+ *
683
+ * DEFAULT-ON, matching the runtime gate (`isFlagEnabled` in
684
+ * durable-background.ts) and the single-template gate
685
+ * (`isDurableBackgroundDeployEnabled` in deploy/build.ts): unset/empty/unknown
686
+ * means enabled; an app opts OUT only with an explicit falsy value
687
+ * (`false`/`0`/`no`/`off`). This emits the per-app 15-min `-background` function
688
+ * so the chat `_process-run` dispatch lands on it with the real long budget.
689
+ */
690
+ function isDurableBackgroundDeployEnabled(): boolean {
691
+ const raw = process.env.AGENT_CHAT_DURABLE_BACKGROUND;
692
+ if (raw == null) return true;
693
+ const v = raw.trim().toLowerCase();
694
+ return !(v === "0" || v === "false" || v === "no" || v === "off");
695
+ }
696
+
697
+ /**
698
+ * Emit a SECOND Netlify function for `app` whose name ends in `-background`,
699
+ * re-exporting the SAME `main.mjs` handler bundle. Netlify invokes any function
700
+ * with `config.background: true` asynchronously (202 immediately, up to 15-min
701
+ * budget), which is exactly what the durable-background chat dispatch
702
+ * (`fireInternalDispatch` → the function's default url) needs.
703
+ *
704
+ * DOC-CORRECT DEFAULT-URL APPROACH (mirrors the single-template emit in
705
+ * deploy/build.ts): the function declares NO custom `config.path`, so it keeps
706
+ * its DEFAULT url `/.netlify/functions/<app>-agent-background`. The `<app>-server`
707
+ * function's catch-all already excludes `/.netlify/*`, so that default-url
708
+ * namespace is NEVER shadowed by the synchronous function — no overlapping
709
+ * `config.path` and no catch-all patch are needed. The foreground dispatches to
710
+ * that default url (`resolveAgentChatProcessRunDispatchPath` resolves the per-app
711
+ * name from `AGENT_NATIVE_WORKSPACE_APP_ID`); `fireInternalDispatch` strips the
712
+ * app base path for `/.netlify/*` targets so the request reaches the host-root
713
+ * function url. The entry then REWRITES the incoming pathname to the
714
+ * base-path-prefixed `_process-run` route before delegating to the Nitro router.
715
+ *
716
+ * It shares the same bundle (`includedFiles: ["**"]`) so `A2A_SECRET`, the DB
717
+ * URL, and the rest of the env/bundle are present. A prior attempt gave the
718
+ * function a custom `config.path` (the framework route) that overlapped the
719
+ * synchronous `<app>-server` catch-all; that path was not honored as a route in
720
+ * prod (probe → 404). The default url is the doc-correct fix.
721
+ *
722
+ * Safety net: if the dispatch fast-fails the foreground degrades to an inline
723
+ * 40s synchronous run (see production-agent.ts).
724
+ */
725
+ function emitNetlifyBackgroundFunction(
726
+ workspaceRoot: string,
727
+ app: string,
728
+ srcServerDir: string,
729
+ workspaceApps: WorkspaceAppManifestEntry[],
730
+ ): void {
731
+ // Name MUST end in `-background` (Netlify async convention + the runtime guard
732
+ // reads the -background Lambda-name suffix as a fallback). It is reached at its
733
+ // DEFAULT url /.netlify/functions/<app>-agent-background.
734
+ const backgroundName = `${app}-agent-background`;
735
+ const dest = path.join(netlifyFunctionsDir(workspaceRoot), backgroundName);
736
+ fs.rmSync(dest, { recursive: true, force: true });
737
+ copyDir(srcServerDir, dest);
738
+
739
+ const basePath = `/${app}`;
740
+ const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
741
+ const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
742
+ workspaceApps,
743
+ app,
744
+ );
745
+ // The Nitro router for this app expects the base-path-prefixed framework route.
746
+ // The function is reached at its default url, so the entry rewrites the
747
+ // incoming pathname to `/<app>/_agent-native/agent-chat/_process-run`.
748
+ const processRunPath = `${basePath}${AGENT_CHAT_PROCESS_RUN_PATH}`;
749
+ const server = `// Mark this isolate as the durable background runtime BEFORE the handler bundle
750
+ // is imported, so isInBackgroundFunctionRuntime() reliably returns true in this
751
+ // function (the deployed Lambda name is not guaranteed to end in -background). A
752
+ // globalThis flag (NOT process.env) avoids the no-env-mutation guard and carries
753
+ // no cross-request state.
754
+ globalThis.__AGENT_NATIVE_BACKGROUND_RUNTIME__ = true;
755
+
756
+ const basePath = ${JSON.stringify(basePath)};
757
+ // The base-path-prefixed framework route the Nitro router dispatches to.
758
+ const PROCESS_RUN_PATH = ${JSON.stringify(processRunPath)};
759
+
760
+ function setBasePathEnv() {
761
+ const processRef = globalThis.process ??= { env: {} };
762
+ processRef.env ??= {};
763
+ Object.assign(processRef.env, {
764
+ AGENT_NATIVE_WORKSPACE: "1",
765
+ AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
766
+ APP_BASE_PATH: basePath,
767
+ AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
768
+ AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
769
+ AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
770
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
771
+ VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
772
+ VITE_APP_BASE_PATH: basePath,
773
+ VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
774
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
775
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
776
+ VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
777
+ ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
778
+ });
779
+ }
780
+
781
+ setBasePathEnv();
782
+
783
+ let cachedHandler;
784
+
785
+ // Reached at the DEFAULT url /.netlify/functions/${backgroundName}; REWRITE the
786
+ // incoming pathname to the base-path-prefixed _process-run route so the Nitro
787
+ // router runs the plugin. Method, ALL headers (the HMAC Authorization: Bearer
788
+ // MUST survive) and the body are preserved.
789
+ export default async function handler(request) {
790
+ setBasePathEnv();
791
+ cachedHandler ??= (await import("./main.mjs")).default;
792
+ const url = new URL(request.url);
793
+ url.pathname = PROCESS_RUN_PATH;
794
+ const method = request.method || "POST";
795
+ const hasBody = method !== "GET" && method !== "HEAD";
796
+ const body = hasBody ? await request.text() : undefined;
797
+ const rewritten = new Request(url.toString(), {
798
+ method,
799
+ headers: request.headers,
800
+ body,
801
+ });
802
+ return cachedHandler(rewritten);
803
+ }
804
+
805
+ export const config = {
806
+ name: ${JSON.stringify(`${app} agent background handler`)},
807
+ generator: "agent-native workspace deploy",
808
+ // background: true → async invoke (202, 15-min budget). NO custom path: the
809
+ // function keeps its default url /.netlify/functions/${backgroundName}, which
810
+ // the <app>-server catch-all never shadows (it excludes /.netlify/*).
811
+ background: true,
812
+ nodeBundler: "none",
813
+ includedFiles: ["**"],
814
+ preferStatic: false,
815
+ };
816
+ `;
817
+ // Remove the original Nitro entry (server.mjs) so only our background entry
818
+ // is the function entrypoint, mirroring patchNetlifyFunctionEntry.
819
+ fs.rmSync(path.join(dest, "server.mjs"), { force: true });
820
+ fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), server);
821
+ console.log(
822
+ `[workspace-deploy] Emitted durable-background function "${backgroundName}" ` +
823
+ `for app "${app}" with config { background:true } and NO custom path — ` +
824
+ `reachable at its default url /.netlify/functions/${backgroundName} ` +
825
+ `(rewrites to ${processRunPath}). REQUIRES real-deploy verification of ` +
826
+ `Netlify async (202) invocation — see docs/design/durable-agent-runs.md.`,
827
+ );
828
+ }
829
+
830
+ function patchNetlifyFunctionEntry(
831
+ functionDir: string,
832
+ app: string,
833
+ workspaceApps: WorkspaceAppManifestEntry[],
834
+ staticDir: string,
835
+ ): void {
836
+ const serverPath = path.join(functionDir, "server.mjs");
837
+ if (!fs.existsSync(serverPath)) return;
838
+
839
+ const basePath = `/${app}`;
840
+ const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
841
+ const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
842
+ workspaceApps,
843
+ app,
844
+ );
845
+ const pathConfig =
846
+ app === "dispatch"
847
+ ? ["/_agent-native/*", "/.well-known/*", `${basePath}/*`]
848
+ : [basePath, `${basePath}/*`];
849
+ const normalizeBasePathHelper =
850
+ app === "dispatch"
851
+ ? ""
852
+ : `
853
+ function normalizeBasePathArgs(args) {
854
+ const request = args[0];
855
+ if (!request || typeof request.url !== "string" || typeof Request !== "function") {
856
+ return args;
857
+ }
858
+ const url = new URL(request.url);
859
+ if (url.pathname === basePath || url.pathname === \`\${basePath}/\`) {
860
+ url.pathname = \`\${basePath}//\`;
861
+ return [new Request(url, request), ...args.slice(1)];
862
+ }
863
+ return args;
864
+ }
865
+ `;
866
+ const handlerArgs =
867
+ app === "dispatch" ? "...args" : "...normalizeBasePathArgs(args)";
868
+ const server = `const basePath = ${JSON.stringify(basePath)};
869
+
870
+ function setBasePathEnv() {
871
+ const processRef = globalThis.process ??= { env: {} };
872
+ processRef.env ??= {};
873
+ Object.assign(processRef.env, {
874
+ AGENT_NATIVE_WORKSPACE: "1",
875
+ AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
876
+ APP_BASE_PATH: basePath,
877
+ AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
878
+ AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
879
+ AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
880
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
881
+ VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
882
+ VITE_APP_BASE_PATH: basePath,
883
+ VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
884
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
885
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
886
+ VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
887
+ ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
888
+ });
889
+ }
890
+
891
+ setBasePathEnv();
892
+ ${normalizeBasePathHelper}
893
+
894
+ let cachedHandler;
895
+
896
+ export default async function handler(...args) {
897
+ setBasePathEnv();
898
+ cachedHandler ??= (await import("./main.mjs")).default;
899
+ return cachedHandler(${handlerArgs});
900
+ }
901
+
902
+ export const config = {
903
+ name: ${JSON.stringify(`${app} server handler`)},
904
+ generator: "agent-native workspace deploy",
905
+ path: ${JSON.stringify(pathConfig)},
906
+ nodeBundler: "none",
907
+ includedFiles: ["**"],
908
+ excludedPath: ${JSON.stringify(
909
+ netlifyFunctionExcludedPaths(app, staticDir),
910
+ null,
911
+ 2,
912
+ )
913
+ .split("\n")
914
+ .join("\n ")},
915
+ preferStatic: false,
916
+ };
917
+ `;
918
+ fs.rmSync(serverPath, { force: true });
919
+ fs.writeFileSync(path.join(functionDir, `${app}-server.mjs`), server);
920
+ }
921
+
922
+ function patchVercelFunctionEntry(
923
+ functionDir: string,
924
+ app: string,
925
+ workspaceApps: WorkspaceAppManifestEntry[],
926
+ ): void {
927
+ const entryPath = path.join(functionDir, "index.mjs");
928
+ if (!fs.existsSync(entryPath)) return;
929
+
930
+ const mainPath = path.join(functionDir, "main.mjs");
931
+ fs.rmSync(mainPath, { force: true });
932
+ fs.renameSync(entryPath, mainPath);
933
+
934
+ const basePath = `/${app}`;
935
+ const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app);
936
+ const workspaceAppRouteAccess = workspaceAppRouteAccessForApp(
937
+ workspaceApps,
938
+ app,
939
+ );
940
+ const entry = `const basePath = ${JSON.stringify(basePath)};
941
+
942
+ function setBasePathEnv() {
943
+ const processRef = globalThis.process ??= { env: {} };
944
+ processRef.env ??= {};
945
+ Object.assign(processRef.env, {
946
+ AGENT_NATIVE_WORKSPACE: "1",
947
+ AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
948
+ APP_BASE_PATH: basePath,
949
+ AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
950
+ AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
951
+ AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
952
+ VITE_AGENT_NATIVE_WORKSPACE: "1",
953
+ VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)},
954
+ VITE_APP_BASE_PATH: basePath,
955
+ VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)},
956
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))},
957
+ VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))},
958
+ VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))},
959
+ ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))},
960
+ });
961
+ }
962
+
963
+ function normalizeBasePathArgs(args) {
964
+ const request = args[0];
965
+ if (!request) return args;
966
+
967
+ if (typeof Request === "function" && request instanceof Request) {
968
+ const url = new URL(request.url);
969
+ if (url.pathname === basePath || url.pathname === \`\${basePath}/\`) {
970
+ url.pathname = \`\${basePath}//\`;
971
+ return [new Request(url, request), ...args.slice(1)];
972
+ }
973
+ return args;
974
+ }
975
+
976
+ if (typeof request.url !== "string") return args;
977
+ const url = new URL(request.url, "http://agent-native.local");
978
+ if (url.pathname === basePath || url.pathname === \`\${basePath}/\`) {
979
+ request.url = \`\${basePath}//\${url.search}\`;
980
+ }
981
+ return args;
982
+ }
983
+
984
+ setBasePathEnv();
985
+
986
+ let cachedHandler;
987
+
988
+ export default async function handler(...args) {
989
+ setBasePathEnv();
990
+ cachedHandler ??= (await import("./main.mjs")).default;
991
+ return cachedHandler(...normalizeBasePathArgs(args));
992
+ }
993
+ `;
994
+ fs.writeFileSync(entryPath, entry);
995
+ }
996
+
997
+ function netlifyFunctionExcludedPaths(
998
+ app: string,
999
+ staticDir: string,
1000
+ ): string[] {
1001
+ return [
1002
+ "/.netlify/*",
1003
+ `/${app}/assets/*`,
1004
+ ...netlifyPublicRootAssetPaths(app, staticDir),
1005
+ ];
1006
+ }
1007
+
1008
+ function netlifyPublicRootAssetPaths(app: string, staticDir: string): string[] {
1009
+ if (!fs.existsSync(staticDir)) return [];
1010
+ return fs
1011
+ .readdirSync(staticDir, { withFileTypes: true })
1012
+ .filter((entry) => entry.isFile())
1013
+ .map((entry) => entry.name)
1014
+ .filter((name) => {
1015
+ const ext = path.extname(name).slice(1).toLowerCase();
1016
+ return NETLIFY_PUBLIC_ASSET_EXTENSIONS.has(ext);
1017
+ })
1018
+ .sort()
1019
+ .map((name) => `/${app}/${encodeURI(name)}`);
1020
+ }
1021
+
1022
+ function netlifyFunctionsDir(workspaceRoot: string): string {
1023
+ return path.join(workspaceRoot, ".netlify", "functions-internal");
1024
+ }
1025
+
1026
+ function cleanAppBuildOutputs(appDir: string): void {
1027
+ // .deploy-tmp: a crashed prior per-app build leaves partial artifacts that
1028
+ // silently poison the next build for that app (observed on Windows: stale
1029
+ // state made a cloudflare_pages build die after the vite phases with no
1030
+ // error output). The deploy post-build also sweeps it, but clean here too
1031
+ // so the unified build never depends on the child's hygiene.
1032
+ for (const name of ["dist", ".output", "build", ".deploy-tmp"]) {
1033
+ fs.rmSync(path.join(appDir, name), { recursive: true, force: true });
1034
+ }
1035
+ fs.rmSync(path.join(appDir, ".netlify", "functions-internal"), {
1036
+ recursive: true,
1037
+ force: true,
1038
+ });
1039
+ fs.rmSync(path.join(appDir, ".vercel", "output"), {
1040
+ recursive: true,
1041
+ force: true,
1042
+ });
1043
+ }
1044
+
1045
+ function appUsesNetlifyUnpooledDatabaseUrl(appDir: string): boolean {
1046
+ const netlifyPath = path.join(appDir, "netlify.toml");
1047
+ if (!fs.existsSync(netlifyPath)) return false;
1048
+ try {
1049
+ return fs
1050
+ .readFileSync(netlifyPath, "utf-8")
1051
+ .includes("NETLIFY_DATABASE_URL_UNPOOLED");
1052
+ } catch {
1053
+ return false;
1054
+ }
1055
+ }
1056
+
1057
+ function writeWorkspaceAppManifests(
1058
+ workspaceRoot: string,
1059
+ distDir: string,
1060
+ apps: string[],
1061
+ workspaceApps: WorkspaceAppManifestEntry[],
1062
+ preset: WorkspaceDeployPreset,
1063
+ ): void {
1064
+ const manifest = JSON.stringify(
1065
+ {
1066
+ version: 1,
1067
+ apps: workspaceApps,
1068
+ },
1069
+ null,
1070
+ 2,
1071
+ );
1072
+
1073
+ const targets =
1074
+ preset === "netlify"
1075
+ ? apps.map((app) =>
1076
+ path.join(
1077
+ netlifyFunctionsDir(workspaceRoot),
1078
+ `${app}-server`,
1079
+ WORKSPACE_APPS_MANIFEST_DIR,
1080
+ WORKSPACE_APPS_MANIFEST_FILE,
1081
+ ),
1082
+ )
1083
+ : preset === "vercel"
1084
+ ? apps.map((app) =>
1085
+ path.join(
1086
+ workspaceRoot,
1087
+ VERCEL_OUTPUT_DIR,
1088
+ "functions",
1089
+ `${app}-server.func`,
1090
+ WORKSPACE_APPS_MANIFEST_DIR,
1091
+ WORKSPACE_APPS_MANIFEST_FILE,
1092
+ ),
1093
+ )
1094
+ : apps.map((app) =>
1095
+ path.join(
1096
+ distDir,
1097
+ app,
1098
+ WORKSPACE_APPS_MANIFEST_DIR,
1099
+ WORKSPACE_APPS_MANIFEST_FILE,
1100
+ ),
1101
+ );
1102
+
1103
+ for (const target of targets) {
1104
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1105
+ fs.writeFileSync(target, `${manifest}\n`);
1106
+ }
1107
+ }
1108
+
1109
+ function readWorkspaceAppManifest(
1110
+ workspaceRoot: string,
1111
+ apps: string[],
1112
+ ): WorkspaceAppManifestEntry[] {
1113
+ const explicitApps = readExistingWorkspaceAppManifest(workspaceRoot);
1114
+
1115
+ return apps
1116
+ .map((app) => {
1117
+ const appDir = path.join(workspaceRoot, "apps", app);
1118
+ const pkg = readPackageJson(path.join(appDir, "package.json"));
1119
+ const appPath = `/${app}`;
1120
+ const explicit = explicitApps.get(app);
1121
+ const url =
1122
+ normalizeWorkspaceAppUrl(explicit?.url) ?? workspaceAppUrl(appPath);
1123
+ const audience =
1124
+ workspaceAppAudienceFromPackageJson(pkg) ??
1125
+ explicit?.audience ??
1126
+ DEFAULT_WORKSPACE_APP_AUDIENCE;
1127
+ const packageRouteAccess = workspaceAppRouteAccessFromPackageJson(pkg);
1128
+ // Prefer the package.json value whenever the field was set — including
1129
+ // an explicit empty array, which is how a per-app package.json signals
1130
+ // "clear any previously-published manifest override." Falling back on
1131
+ // length > 0 would silently keep the explicit override even after the
1132
+ // app owner blanked their list.
1133
+ const publicPaths =
1134
+ packageRouteAccess.publicPaths ?? explicit?.publicPaths ?? [];
1135
+ const protectedPaths =
1136
+ packageRouteAccess.protectedPaths ?? explicit?.protectedPaths ?? [];
1137
+ return {
1138
+ id: app,
1139
+ name: pkg?.displayName || titleCase(app),
1140
+ description: pkg?.description || "",
1141
+ path: appPath,
1142
+ ...(url ? { url } : {}),
1143
+ isDispatch: app === "dispatch",
1144
+ audience,
1145
+ publicPaths,
1146
+ protectedPaths,
1147
+ };
1148
+ })
1149
+ .sort((a, b) => {
1150
+ if (a.id === "dispatch") return -1;
1151
+ if (b.id === "dispatch") return 1;
1152
+ return a.name.localeCompare(b.name);
1153
+ });
1154
+ }
1155
+
1156
+ function readExistingWorkspaceAppManifest(
1157
+ workspaceRoot: string,
1158
+ ): Map<string, WorkspaceAppManifestOverride> {
1159
+ const fromEnv = parseWorkspaceAppsJson(process.env[WORKSPACE_APPS_ENV_KEY]);
1160
+ const fromFile =
1161
+ readWorkspaceAppsFromFile(
1162
+ path.join(
1163
+ workspaceRoot,
1164
+ WORKSPACE_APPS_MANIFEST_DIR,
1165
+ WORKSPACE_APPS_MANIFEST_FILE,
1166
+ ),
1167
+ ) ??
1168
+ readWorkspaceAppsFromFile(
1169
+ path.join(workspaceRoot, WORKSPACE_APPS_MANIFEST_FILE),
1170
+ );
1171
+ const apps = fromEnv ?? fromFile ?? [];
1172
+ return new Map(apps.map((app) => [app.id, app]));
1173
+ }
1174
+
1175
+ function parseWorkspaceAppsJson(
1176
+ raw: string | undefined,
1177
+ ): WorkspaceAppManifestOverride[] | null {
1178
+ if (!raw) return null;
1179
+ try {
1180
+ return parseWorkspaceAppsManifest(JSON.parse(raw));
1181
+ } catch {
1182
+ return null;
1183
+ }
1184
+ }
1185
+
1186
+ function readWorkspaceAppsFromFile(
1187
+ file: string,
1188
+ ): WorkspaceAppManifestOverride[] | null {
1189
+ if (!fs.existsSync(file)) return null;
1190
+ return parseWorkspaceAppsManifest(readPackageJson(file));
1191
+ }
1192
+
1193
+ function parseWorkspaceAppsManifest(
1194
+ parsed: any,
1195
+ ): WorkspaceAppManifestOverride[] | null {
1196
+ const rawApps = Array.isArray(parsed?.apps)
1197
+ ? parsed.apps
1198
+ : Array.isArray(parsed)
1199
+ ? parsed
1200
+ : null;
1201
+ if (!rawApps) return null;
1202
+
1203
+ const apps = (rawApps as unknown[])
1204
+ .map((entry) => {
1205
+ if (!entry || typeof entry !== "object") return null;
1206
+ const e = entry as Record<string, unknown>;
1207
+ const id = typeof e.id === "string" ? e.id.trim() : "";
1208
+ if (!id) return null;
1209
+ const url = normalizeWorkspaceAppUrl(e.url);
1210
+ const audience =
1211
+ e.audience === undefined
1212
+ ? undefined
1213
+ : normalizeWorkspaceAppAudience(e.audience);
1214
+ const publicPaths = normalizeWorkspaceAppPathList(e.publicPaths);
1215
+ const protectedPaths = normalizeWorkspaceAppPathList(e.protectedPaths);
1216
+ return {
1217
+ id,
1218
+ ...(url ? { url } : {}),
1219
+ ...(audience ? { audience } : {}),
1220
+ ...(publicPaths.length > 0 ? { publicPaths } : {}),
1221
+ ...(protectedPaths.length > 0 ? { protectedPaths } : {}),
1222
+ };
1223
+ })
1224
+ .filter((app): app is NonNullable<typeof app> => !!app);
1225
+
1226
+ return apps.length ? apps : null;
1227
+ }
1228
+
1229
+ function workspaceBaseUrl(): string | null {
1230
+ const gatewayOrigin =
1231
+ process.env.WORKSPACE_GATEWAY_URL || process.env.VITE_WORKSPACE_GATEWAY_URL;
1232
+ const publicGatewayOrigin = normalizeOrigin(gatewayOrigin);
1233
+ const gatewayFallback =
1234
+ publicGatewayOrigin && !isLoopbackOrigin(publicGatewayOrigin)
1235
+ ? gatewayOrigin
1236
+ : null;
1237
+ return (
1238
+ process.env.APP_URL ||
1239
+ process.env.WORKSPACE_OAUTH_ORIGIN ||
1240
+ process.env.VITE_WORKSPACE_OAUTH_ORIGIN ||
1241
+ process.env.URL ||
1242
+ process.env.DEPLOY_URL ||
1243
+ process.env.BETTER_AUTH_URL ||
1244
+ gatewayFallback ||
1245
+ gatewayOrigin ||
1246
+ null
1247
+ );
1248
+ }
1249
+
1250
+ function normalizeOrigin(value: string | null | undefined): string | undefined {
1251
+ if (!value) return undefined;
1252
+ try {
1253
+ return new URL(value).origin;
1254
+ } catch {
1255
+ return undefined;
1256
+ }
1257
+ }
1258
+
1259
+ function isLoopbackOrigin(origin: string | undefined): boolean {
1260
+ if (!origin) return false;
1261
+ try {
1262
+ const host = new URL(origin).hostname.toLowerCase();
1263
+ return (
1264
+ host === "localhost" ||
1265
+ host === "127.0.0.1" ||
1266
+ host === "[::1]" ||
1267
+ host === "::1"
1268
+ );
1269
+ } catch {
1270
+ return false;
1271
+ }
1272
+ }
1273
+
1274
+ function workspaceOAuthOrigin(
1275
+ workspaceGatewayUrl: string | null,
1276
+ ): string | undefined {
1277
+ // Explicit overrides (env vars set by the operator) win even if they happen
1278
+ // to be loopback — that's a deliberate dev-against-prod choice.
1279
+ // The gateway-URL fallback, however, is auto-resolved and would silently
1280
+ // send users to localhost if the configured gateway is loopback in a
1281
+ // production deploy. Strip loopback there so OAuth fails loudly instead.
1282
+ const gatewayFallback = normalizeOrigin(workspaceGatewayUrl);
1283
+ return (
1284
+ normalizeOrigin(process.env.VITE_WORKSPACE_OAUTH_ORIGIN) ||
1285
+ normalizeOrigin(process.env.WORKSPACE_OAUTH_ORIGIN) ||
1286
+ normalizeOrigin(process.env.APP_URL) ||
1287
+ normalizeOrigin(process.env.BETTER_AUTH_URL) ||
1288
+ normalizeOrigin(process.env.URL) ||
1289
+ normalizeOrigin(process.env.DEPLOY_URL) ||
1290
+ (isLoopbackOrigin(gatewayFallback) ? undefined : gatewayFallback)
1291
+ );
1292
+ }
1293
+
1294
+ function workspaceAppUrl(appPath: string): string | undefined {
1295
+ const base = workspaceBaseUrl();
1296
+ if (!base) return undefined;
1297
+ try {
1298
+ return new URL(appPath, `${base.replace(/\/$/, "")}/`).toString();
1299
+ } catch {
1300
+ return undefined;
1301
+ }
1302
+ }
1303
+
1304
+ function normalizeWorkspaceAppUrl(value: unknown): string | undefined {
1305
+ if (typeof value !== "string" || !value.trim()) return undefined;
1306
+ try {
1307
+ return new URL(value.trim()).toString().replace(/\/$/, "");
1308
+ } catch {
1309
+ return undefined;
1310
+ }
1311
+ }
1312
+
1313
+ function readPackageJson(file: string): Record<string, any> | null {
1314
+ try {
1315
+ return JSON.parse(fs.readFileSync(file, "utf8"));
1316
+ } catch {
1317
+ return null;
1318
+ }
1319
+ }
1320
+
1321
+ function titleCase(value: string): string {
1322
+ return value
1323
+ .split(/[-_\s]+/)
1324
+ .filter(Boolean)
1325
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1326
+ .join(" ");
1327
+ }
1328
+
1329
+ function parsePresetArg(args: string[]): WorkspaceDeployPreset | null {
1330
+ for (let i = 0; i < args.length; i++) {
1331
+ const arg = args[i];
1332
+ if (arg === "--preset" && args[i + 1]) {
1333
+ return normalizePreset(args[i + 1]);
1334
+ }
1335
+ if (arg.startsWith("--preset=")) {
1336
+ return normalizePreset(arg.slice("--preset=".length));
1337
+ }
1338
+ }
1339
+ return null;
1340
+ }
1341
+
1342
+ function resolvePreset(
1343
+ optionPreset: WorkspaceDeployPreset | undefined,
1344
+ args: string[],
1345
+ ): WorkspaceDeployPreset {
1346
+ return (
1347
+ optionPreset ??
1348
+ parsePresetArg(args) ??
1349
+ normalizePreset(process.env.NITRO_PRESET) ??
1350
+ "cloudflare_pages"
1351
+ );
1352
+ }
1353
+
1354
+ function assertWorkspaceDeployProductionEnv(opts: {
1355
+ buildOnly: boolean;
1356
+ preset: WorkspaceDeployPreset;
1357
+ }): void {
1358
+ if (!isProductionWorkspaceDeploy(opts)) return;
1359
+ if (process.env.A2A_SECRET?.trim()) return;
1360
+ const providerHint =
1361
+ opts.preset === "netlify"
1362
+ ? ' For Netlify, one option is: netlify env:set A2A_SECRET "$(openssl rand -hex 32)".'
1363
+ : "";
1364
+ throw new Error(
1365
+ [
1366
+ "A2A_SECRET is required for production workspace deploys.",
1367
+ "Workspace Slack, webhook, and cross-app A2A work resumes through signed background processors; without A2A_SECRET those production routes return 503.",
1368
+ `Set A2A_SECRET in your deploy provider and redeploy.${providerHint}`,
1369
+ "For local artifact checks, run agent-native deploy --build-only outside the deploy provider environment.",
1370
+ ].join(" "),
1371
+ );
1372
+ }
1373
+
1374
+ function isProductionWorkspaceDeploy(opts: {
1375
+ buildOnly: boolean;
1376
+ preset: WorkspaceDeployPreset;
1377
+ }): boolean {
1378
+ if (!opts.buildOnly) return true;
1379
+ if (
1380
+ opts.preset === "netlify" &&
1381
+ process.env.NETLIFY === "true" &&
1382
+ process.env.NETLIFY_LOCAL !== "true"
1383
+ ) {
1384
+ return true;
1385
+ }
1386
+ if (opts.preset === "cloudflare_pages" && process.env.CF_PAGES === "1") {
1387
+ return true;
1388
+ }
1389
+ if (opts.preset === "vercel" && process.env.VERCEL === "1") {
1390
+ return true;
1391
+ }
1392
+ return false;
1393
+ }
1394
+
1395
+ function normalizePreset(
1396
+ value: string | undefined,
1397
+ ): WorkspaceDeployPreset | null {
1398
+ if (!value) return null;
1399
+ if (value === "cloudflare_pages" || value === "cloudflare-pages") {
1400
+ return "cloudflare_pages";
1401
+ }
1402
+ if (value === "netlify") return "netlify";
1403
+ if (value === "vercel") return "vercel";
1404
+ throw new Error(
1405
+ `Unsupported workspace deploy preset "${value}". Supported presets: cloudflare_pages, netlify, vercel.`,
1406
+ );
1407
+ }
1408
+
1409
+ function moduleIdent(app: string): string {
1410
+ return "app_" + app.replace(/[^a-zA-Z0-9_]/g, "_");
1411
+ }
1412
+
1413
+ function workspaceAppAudienceForApp(
1414
+ workspaceApps: WorkspaceAppManifestEntry[],
1415
+ app: string,
1416
+ ): WorkspaceAppAudience {
1417
+ return (
1418
+ workspaceApps.find((entry) => entry.id === app)?.audience ??
1419
+ DEFAULT_WORKSPACE_APP_AUDIENCE
1420
+ );
1421
+ }
1422
+
1423
+ function workspaceAppRouteAccessForApp(
1424
+ workspaceApps: WorkspaceAppManifestEntry[],
1425
+ app: string,
1426
+ ): WorkspaceAppRouteAccess {
1427
+ const entry = workspaceApps.find((candidate) => candidate.id === app);
1428
+ return {
1429
+ publicPaths: entry?.publicPaths ?? [],
1430
+ protectedPaths: entry?.protectedPaths ?? [],
1431
+ };
1432
+ }
1433
+
1434
+ function compareWorkspaceAppIds(a: string, b: string): number {
1435
+ if (a === "dispatch") return -1;
1436
+ if (b === "dispatch") return 1;
1437
+ return a.localeCompare(b);
1438
+ }
1439
+
1440
+ function copyDir(src: string, dest: string): void {
1441
+ fs.mkdirSync(dest, { recursive: true });
1442
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
1443
+ const s = path.join(src, entry.name);
1444
+ const d = path.join(dest, entry.name);
1445
+ if (entry.isSymbolicLink()) {
1446
+ try {
1447
+ const target = fs.readlinkSync(s);
1448
+ fs.symlinkSync(target, d);
1449
+ } catch {
1450
+ fs.copyFileSync(s, d);
1451
+ }
1452
+ } else if (entry.isDirectory()) {
1453
+ copyDir(s, d);
1454
+ } else {
1455
+ fs.copyFileSync(s, d);
1456
+ }
1457
+ }
1458
+ }