@flue/sdk 0.3.10 → 0.4.0
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.
- package/README.md +15 -24
- package/dist/abort-Bg3qsAkU.mjs +43 -0
- package/dist/app.d.mts +106 -0
- package/dist/app.mjs +4 -0
- package/dist/client.d.mts +9 -3
- package/dist/client.mjs +10 -24
- package/dist/cloudflare/index.d.mts +10 -6
- package/dist/cloudflare/index.mjs +388 -26
- package/dist/cloudflare-model-BeiZ1pLz.d.mts +6 -0
- package/dist/config.d.mts +133 -0
- package/dist/config.mjs +195 -0
- package/dist/flue-app-CG8i4wNG.d.mts +184 -0
- package/dist/flue-app-DeTOZjPs.mjs +730 -0
- package/dist/index.d.mts +41 -19
- package/dist/index.mjs +451 -539
- package/dist/internal.d.mts +8 -274
- package/dist/internal.mjs +16 -430
- package/dist/{mcp-B13ZPduG.mjs → mcp-2SW_tpox.mjs} +19 -33
- package/dist/{mcp-CKMPhMDe.d.mts → mcp-C3UBXVkR.d.mts} +1 -1
- package/dist/node/index.d.mts +8 -12
- package/dist/node/index.mjs +94 -64
- package/dist/providers-DeFRIwp0.mjs +158 -0
- package/dist/result-K1IRhWKM.mjs +685 -0
- package/dist/sandbox.d.mts +25 -4
- package/dist/sandbox.mjs +44 -62
- package/dist/{session-CNOAfV45.mjs → session-CO_uGVOk.mjs} +490 -264
- package/dist/types-BAmV4f3Q.d.mts +727 -0
- package/package.json +13 -1
- package/dist/agent-BTB0809P.mjs +0 -453
- package/dist/command-helpers-5DpOaRIB.d.mts +0 -21
- package/dist/command-helpers-hTZKWK13.mjs +0 -37
- package/dist/types-CKcp6T-y.d.mts +0 -509
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as createTools, d as parseFrontmatterFile, s as BUILTIN_TOOL_NAMES, t as ResultUnavailableError } from "./result-K1IRhWKM.mjs";
|
|
2
2
|
import * as esbuild from "esbuild";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import * as ts from "typescript";
|
|
5
6
|
import { packageUpSync } from "package-up";
|
|
6
7
|
import { spawn } from "node:child_process";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
@@ -38,7 +39,7 @@ const MIN_COMPATIBILITY_DATE = "2026-04-01";
|
|
|
38
39
|
/** compatibility_flag Flue requires for pi-ai's process.env-based API key lookup. */
|
|
39
40
|
const REQUIRED_COMPAT_FLAG = "nodejs_compat";
|
|
40
41
|
/**
|
|
41
|
-
* Read and normalize the user's wrangler config from `
|
|
42
|
+
* Read and normalize the user's wrangler config from `root`.
|
|
42
43
|
*
|
|
43
44
|
* Looks for `wrangler.jsonc`, `wrangler.json`, then `wrangler.toml` (jsonc is
|
|
44
45
|
* Cloudflare's recommended format for new projects, but all three work).
|
|
@@ -60,7 +61,7 @@ const REQUIRED_COMPAT_FLAG = "nodejs_compat";
|
|
|
60
61
|
* `dist/wrangler.jsonc` and the benefit is correctness without us reimplementing
|
|
61
62
|
* wrangler's path-resolution logic.
|
|
62
63
|
*/
|
|
63
|
-
async function readUserWranglerConfig(
|
|
64
|
+
async function readUserWranglerConfig(root) {
|
|
64
65
|
const candidates = [
|
|
65
66
|
"wrangler.jsonc",
|
|
66
67
|
"wrangler.json",
|
|
@@ -68,7 +69,7 @@ async function readUserWranglerConfig(outputDir) {
|
|
|
68
69
|
];
|
|
69
70
|
let foundPath = null;
|
|
70
71
|
for (const name of candidates) {
|
|
71
|
-
const candidate = path.join(
|
|
72
|
+
const candidate = path.join(root, name);
|
|
72
73
|
if (fs.existsSync(candidate)) {
|
|
73
74
|
foundPath = candidate;
|
|
74
75
|
break;
|
|
@@ -316,43 +317,48 @@ function detectSandboxBindings(userConfig) {
|
|
|
316
317
|
* silently and let esbuild's own error path take over. This avoids false
|
|
317
318
|
* positives in unusual project layouts.
|
|
318
319
|
*/
|
|
319
|
-
function assertSandboxPackageInstalled(sandboxClassNames,
|
|
320
|
+
function assertSandboxPackageInstalled(sandboxClassNames, root) {
|
|
320
321
|
if (sandboxClassNames.length === 0) return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
current = path.dirname(current);
|
|
322
|
+
let current = root;
|
|
323
|
+
while (current !== path.dirname(current)) {
|
|
324
|
+
const pkgPath = path.join(current, "package.json");
|
|
325
|
+
if (fs.existsSync(pkgPath)) try {
|
|
326
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
327
|
+
if ("@cloudflare/sandbox" in {
|
|
328
|
+
...pkg.dependencies ?? {},
|
|
329
|
+
...pkg.devDependencies ?? {},
|
|
330
|
+
...pkg.peerDependencies ?? {},
|
|
331
|
+
...pkg.optionalDependencies ?? {}
|
|
332
|
+
}) return;
|
|
333
|
+
} catch {
|
|
334
|
+
return;
|
|
337
335
|
}
|
|
336
|
+
current = path.dirname(current);
|
|
338
337
|
}
|
|
339
338
|
throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name ends with "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
|
|
340
339
|
}
|
|
341
340
|
/**
|
|
342
|
-
* Write the wrangler deploy-redirect file at
|
|
343
|
-
* so that `wrangler deploy` run from
|
|
344
|
-
* generated
|
|
341
|
+
* Write the wrangler deploy-redirect file at
|
|
342
|
+
* `<root>/.wrangler/deploy/config.json` so that `wrangler deploy` run from
|
|
343
|
+
* the project root automatically picks up the generated wrangler config at
|
|
344
|
+
* `<output>/wrangler.jsonc`.
|
|
345
345
|
*
|
|
346
346
|
* This is wrangler's own native redirection mechanism (the same one Astro's
|
|
347
347
|
* Cloudflare adapter uses). We only write the file if one doesn't already
|
|
348
348
|
* exist — if the user has set one up, respect their intent.
|
|
349
|
+
*
|
|
350
|
+
* `output` may be anywhere (typically `<root>/dist`, but the user
|
|
351
|
+
* can redirect it via `--output`). We compute a relative path so the
|
|
352
|
+
* redirect file is portable across machines / repos.
|
|
349
353
|
*/
|
|
350
|
-
function writeDeployRedirectIfMissing(
|
|
351
|
-
const redirectDir = path.join(
|
|
354
|
+
function writeDeployRedirectIfMissing(root, output) {
|
|
355
|
+
const redirectDir = path.join(root, ".wrangler", "deploy");
|
|
352
356
|
const redirectPath = path.join(redirectDir, "config.json");
|
|
353
357
|
if (fs.existsSync(redirectPath)) return;
|
|
354
358
|
fs.mkdirSync(redirectDir, { recursive: true });
|
|
355
|
-
|
|
359
|
+
const targetPath = path.join(output, "wrangler.jsonc");
|
|
360
|
+
const relConfigPath = path.relative(redirectDir, targetPath).split(path.sep).join("/");
|
|
361
|
+
fs.writeFileSync(redirectPath, JSON.stringify({ configPath: relConfigPath }, null, 2) + "\n", "utf-8");
|
|
356
362
|
}
|
|
357
363
|
|
|
358
364
|
//#endregion
|
|
@@ -370,27 +376,30 @@ var CloudflarePlugin = class {
|
|
|
370
376
|
* single build.
|
|
371
377
|
*/
|
|
372
378
|
userConfigCache;
|
|
373
|
-
|
|
374
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Read the user's wrangler config from `root`. The user's config always
|
|
381
|
+
* lives at the project root, regardless of where the build artifacts get
|
|
382
|
+
* written via `output`. We only re-locate the *generated*
|
|
383
|
+
* `wrangler.jsonc` (the merged one) — never the source one.
|
|
384
|
+
*/
|
|
385
|
+
async getUserConfig(root) {
|
|
386
|
+
if (!this.userConfigCache) this.userConfigCache = await readUserWranglerConfig(root);
|
|
375
387
|
return this.userConfigCache;
|
|
376
388
|
}
|
|
377
389
|
async generateEntryPoint(ctx) {
|
|
378
|
-
const { agents, roles } = ctx;
|
|
390
|
+
const { agents, roles, appEntry } = ctx;
|
|
379
391
|
const rolesJson = JSON.stringify(roles);
|
|
392
|
+
validateCloudflareAgentNames(ctx);
|
|
380
393
|
const webhookAgents = agents.filter((a) => a.triggers.webhook);
|
|
381
|
-
const agentImports = agents.map((a) => {
|
|
382
|
-
return `import ${agentVarName$1(a.name)} from '${a.filePath.replace(/\\/g, "/")}';`;
|
|
394
|
+
const agentImports = agents.map((a, index) => {
|
|
395
|
+
return `import ${agentVarName$1(a.name, index)} from '${a.filePath.replace(/\\/g, "/")}';`;
|
|
383
396
|
}).join("\n");
|
|
384
|
-
const manifest = JSON.stringify({ agents: agents.map((a) => ({
|
|
385
|
-
name: a.name,
|
|
386
|
-
triggers: a.triggers
|
|
387
|
-
})) }, null, 2);
|
|
388
397
|
const agentClasses = webhookAgents.map((a) => {
|
|
389
398
|
const className = agentClassName(a.name);
|
|
390
|
-
const handlerVar = agentVarName$1(a.name);
|
|
399
|
+
const handlerVar = agentVarName$1(a.name, agents.indexOf(a));
|
|
391
400
|
return `export class ${className} extends Agent {
|
|
392
401
|
async onRequest(request) {
|
|
393
|
-
return
|
|
402
|
+
return dispatchAgent(request, this, ${JSON.stringify(a.name)}, ${handlerVar});
|
|
394
403
|
}
|
|
395
404
|
|
|
396
405
|
async onFiberRecovered(ctx) {
|
|
@@ -403,10 +412,11 @@ var CloudflarePlugin = class {
|
|
|
403
412
|
}
|
|
404
413
|
}`;
|
|
405
414
|
}).join("\n\n");
|
|
406
|
-
const { config: userConfig } = await this.getUserConfig(ctx.
|
|
415
|
+
const { config: userConfig } = await this.getUserConfig(ctx.root);
|
|
407
416
|
const sandboxReExports = detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
|
|
408
417
|
return `
|
|
409
418
|
// Auto-generated by @flue/sdk build (cloudflare)
|
|
419
|
+
import { env } from 'cloudflare:workers';
|
|
410
420
|
import { Agent, routeAgentRequest } from 'agents';
|
|
411
421
|
import { Bash, InMemoryFs } from 'just-bash';
|
|
412
422
|
import {
|
|
@@ -414,30 +424,43 @@ import {
|
|
|
414
424
|
InMemorySessionStore,
|
|
415
425
|
bashFactoryToSessionEnv,
|
|
416
426
|
resolveModel,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
AgentNotFoundError,
|
|
421
|
-
MethodNotAllowedError,
|
|
422
|
-
RouteNotFoundError,
|
|
423
|
-
InvalidRequestError,
|
|
427
|
+
handleAgentRequest,
|
|
428
|
+
configureFlueRuntime,
|
|
429
|
+
createDefaultFlueApp,
|
|
424
430
|
} from '@flue/sdk/internal';
|
|
425
|
-
import {
|
|
431
|
+
import {
|
|
432
|
+
runWithCloudflareContext,
|
|
433
|
+
cfSandboxToSessionEnv,
|
|
434
|
+
getCloudflareAIBindingApiProvider,
|
|
435
|
+
} from '@flue/sdk/cloudflare';
|
|
436
|
+
import { registerApiProvider, registerProvider } from '@flue/sdk/app';
|
|
426
437
|
|
|
427
438
|
${agentImports}
|
|
428
439
|
|
|
440
|
+
${appEntry ? `import userApp from '${appEntry.replace(/\\/g, "/")}';` : ""}
|
|
441
|
+
|
|
442
|
+
// ─── Internal provider registrations ────────────────────────────────────────
|
|
443
|
+
// Imports evaluate before this file's top-level body, so these built-ins
|
|
444
|
+
// reserve the \`cloudflare\` prefix after any user app.ts registrations run.
|
|
445
|
+
|
|
446
|
+
// Wire-protocol handler for the cloudflare-ai-binding api.
|
|
447
|
+
registerApiProvider(getCloudflareAIBindingApiProvider());
|
|
448
|
+
|
|
449
|
+
// Capture the binding reference at module init; invoke it only per request.
|
|
450
|
+
registerProvider('cloudflare', {
|
|
451
|
+
api: 'cloudflare-ai-binding',
|
|
452
|
+
binding: env.AI,
|
|
453
|
+
});
|
|
454
|
+
|
|
429
455
|
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
430
456
|
|
|
431
457
|
const roles = ${rolesJson};
|
|
432
458
|
const skills = {};
|
|
433
459
|
const systemPrompt = '';
|
|
434
|
-
const manifest = ${manifest};
|
|
435
460
|
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
// dispatcher (which would otherwise return text/plain "Invalid request").
|
|
440
|
-
const webhookAgentNames = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
|
|
461
|
+
// Webhook-accessible agent names. Consumed by the seeded flue() runtime
|
|
462
|
+
// for the "is this name registered?" check inside the agent route.
|
|
463
|
+
const webhookAgentNames = ${JSON.stringify(webhookAgents.map((a) => a.name))};
|
|
441
464
|
|
|
442
465
|
// ─── Sandbox Environments ───────────────────────────────────────────────────
|
|
443
466
|
|
|
@@ -531,7 +554,7 @@ function createDOStore(sql) {
|
|
|
531
554
|
};
|
|
532
555
|
}
|
|
533
556
|
|
|
534
|
-
function createContextForRequest(id, payload, doInstance) {
|
|
557
|
+
function createContextForRequest(id, payload, doInstance, req) {
|
|
535
558
|
// Use DO SQLite storage by default, fall back to in-memory
|
|
536
559
|
const defaultStore = doInstance?.ctx?.storage?.sql
|
|
537
560
|
? createDOStore(doInstance.ctx.storage.sql)
|
|
@@ -541,6 +564,7 @@ function createContextForRequest(id, payload, doInstance) {
|
|
|
541
564
|
id,
|
|
542
565
|
payload,
|
|
543
566
|
env: doInstance?.env ?? {},
|
|
567
|
+
req,
|
|
544
568
|
agentConfig: {
|
|
545
569
|
systemPrompt, skills, roles, model: undefined, resolveModel,
|
|
546
570
|
},
|
|
@@ -568,154 +592,54 @@ function assertAgentsDurabilityApi(doInstance, method) {
|
|
|
568
592
|
}
|
|
569
593
|
}
|
|
570
594
|
|
|
571
|
-
function runHandlerWithKeepAlive(doInstance, ctx, handler) {
|
|
572
|
-
return runWithInstanceContext(doInstance, () => {
|
|
573
|
-
assertAgentsDurabilityApi(doInstance, 'keepAliveWhile');
|
|
574
|
-
return doInstance.keepAliveWhile(() => handler(ctx));
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
function startWebhookFiber(doInstance, requestId, agentName, id, payload, handler) {
|
|
579
|
-
const run = async (fiber) => {
|
|
580
|
-
fiber?.stash?.({
|
|
581
|
-
version: 1,
|
|
582
|
-
kind: 'webhook',
|
|
583
|
-
agentName,
|
|
584
|
-
id,
|
|
585
|
-
requestId,
|
|
586
|
-
phase: 'running',
|
|
587
|
-
startedAt: Date.now(),
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
const ctx = createContextForRequest(id, payload, doInstance);
|
|
591
|
-
return runWithInstanceContext(doInstance, async () => {
|
|
592
|
-
try {
|
|
593
|
-
return await handler(ctx);
|
|
594
|
-
} finally {
|
|
595
|
-
ctx.setEventCallback(undefined);
|
|
596
|
-
}
|
|
597
|
-
});
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
assertAgentsDurabilityApi(doInstance, 'runFiber');
|
|
601
|
-
return doInstance.runFiber('flue:webhook:' + requestId, run);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
595
|
async function handleFlueFiberRecovered(ctx, _doInstance, agentName) {
|
|
605
596
|
if (!ctx.name || !ctx.name.startsWith('flue:')) return;
|
|
606
597
|
console.warn('[flue] Cloudflare fiber interrupted:', agentName, ctx.name, ctx.snapshot ?? null);
|
|
607
598
|
}
|
|
608
599
|
|
|
609
|
-
// ───
|
|
610
|
-
|
|
611
|
-
async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
612
|
-
// Agent id is the DO "room name" set by routeAgentRequest
|
|
613
|
-
const id = doInstance.name;
|
|
614
|
-
|
|
615
|
-
try {
|
|
616
|
-
// Parse the request body. Throws on invalid Content-Type or malformed
|
|
617
|
-
// JSON; returns {} for genuinely empty bodies (so no-payload agents
|
|
618
|
-
// still work).
|
|
619
|
-
const payload = await parseJsonBody(request);
|
|
600
|
+
// ─── Per-DO Dispatch ───────────────────────────────────────────────────────
|
|
620
601
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
console.error('[flue] Webhook handler error:', agentName, err);
|
|
635
|
-
},
|
|
636
|
-
);
|
|
637
|
-
return new Response(JSON.stringify({ status: 'accepted', requestId }), {
|
|
638
|
-
status: 202,
|
|
639
|
-
headers: { 'content-type': 'application/json' },
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// SSE streaming mode. Two error regimes meet here:
|
|
644
|
-
// - Pre-stream errors (body parsing, etc.) have already thrown above
|
|
645
|
-
// and are caught by the outer try/catch — rendered as plain HTTP
|
|
646
|
-
// responses by toHttpResponse, since headers haven't been sent yet.
|
|
647
|
-
// - Errors during agent execution surface as in-stream \`error\`
|
|
648
|
-
// events with the canonical envelope (via toSseData), since by
|
|
649
|
-
// then the 200 + text/event-stream headers are already on the wire.
|
|
650
|
-
if (isSSE) {
|
|
651
|
-
const { readable, writable } = new TransformStream();
|
|
652
|
-
const writer = writable.getWriter();
|
|
653
|
-
const encoder = new TextEncoder();
|
|
654
|
-
let eventId = 0;
|
|
655
|
-
let isIdle = false;
|
|
602
|
+
/**
|
|
603
|
+
* Per-DO entry point invoked from each generated agent class's onRequest().
|
|
604
|
+
* Wraps the shared handleAgentRequest with CF-specific bits:
|
|
605
|
+
*
|
|
606
|
+
* - keepAliveWhile around the foreground handler so the DO doesn't
|
|
607
|
+
* hibernate mid-stream.
|
|
608
|
+
* - runFiber for fire-and-forget webhook execution so it survives across
|
|
609
|
+
* hibernation.
|
|
610
|
+
* - runWithCloudflareContext for AsyncLocalStorage-based env propagation
|
|
611
|
+
* (the workers-ai provider reads env.AI through it).
|
|
612
|
+
*/
|
|
613
|
+
async function dispatchAgent(request, doInstance, agentName, handler) {
|
|
614
|
+
const id = doInstance.name; // DO room name set by routeAgentRequest
|
|
656
615
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
616
|
+
return handleAgentRequest({
|
|
617
|
+
request,
|
|
618
|
+
agentName,
|
|
619
|
+
id,
|
|
620
|
+
handler,
|
|
621
|
+
createContext: (id_, payload, req) => createContextForRequest(id_, payload, doInstance, req),
|
|
622
|
+
startWebhook: (requestId, run) => {
|
|
623
|
+
const wrapped = (fiber) => {
|
|
624
|
+
fiber?.stash?.({
|
|
625
|
+
version: 1,
|
|
626
|
+
kind: 'webhook',
|
|
627
|
+
agentName,
|
|
628
|
+
id,
|
|
629
|
+
requestId,
|
|
630
|
+
phase: 'running',
|
|
631
|
+
startedAt: Date.now(),
|
|
632
|
+
});
|
|
633
|
+
return runWithInstanceContext(doInstance, run);
|
|
664
634
|
};
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
try {
|
|
674
|
-
const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
|
|
675
|
-
if (!isIdle) {
|
|
676
|
-
await writeSSE({ type: 'idle' }, 'idle');
|
|
677
|
-
}
|
|
678
|
-
await writeSSE(
|
|
679
|
-
{ type: 'result', data: result !== undefined ? result : null },
|
|
680
|
-
'result',
|
|
681
|
-
);
|
|
682
|
-
} catch (err) {
|
|
683
|
-
await writeSSE(toSseData(err), 'error');
|
|
684
|
-
if (!isIdle) {
|
|
685
|
-
await writeSSE({ type: 'idle' }, 'idle');
|
|
686
|
-
}
|
|
687
|
-
} finally {
|
|
688
|
-
ctx.setEventCallback(undefined);
|
|
689
|
-
await writer.close();
|
|
690
|
-
}
|
|
691
|
-
})();
|
|
692
|
-
|
|
693
|
-
return new Response(readable, {
|
|
694
|
-
headers: {
|
|
695
|
-
'content-type': 'text/event-stream',
|
|
696
|
-
'cache-control': 'no-cache',
|
|
697
|
-
'connection': 'keep-alive',
|
|
698
|
-
},
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Sync mode (default)
|
|
703
|
-
const ctx = createContextForRequest(id, payload, doInstance);
|
|
704
|
-
try {
|
|
705
|
-
const result = await runHandlerWithKeepAlive(doInstance, ctx, handler);
|
|
706
|
-
return new Response(
|
|
707
|
-
JSON.stringify({ result: result !== undefined ? result : null }),
|
|
708
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
709
|
-
);
|
|
710
|
-
} finally {
|
|
711
|
-
ctx.setEventCallback(undefined);
|
|
712
|
-
}
|
|
713
|
-
} catch (err) {
|
|
714
|
-
// toHttpResponse logs unknowns via flueLog.error — no extra console.error
|
|
715
|
-
// needed here. The agentName tag is captured in the wrapped error's
|
|
716
|
-
// server-side log line via flueLog's prefix.
|
|
717
|
-
return toHttpResponse(err);
|
|
718
|
-
}
|
|
635
|
+
assertAgentsDurabilityApi(doInstance, 'runFiber');
|
|
636
|
+
return doInstance.runFiber('flue:webhook:' + requestId, wrapped);
|
|
637
|
+
},
|
|
638
|
+
runHandler: (ctx, h) => runWithInstanceContext(doInstance, () => {
|
|
639
|
+
assertAgentsDurabilityApi(doInstance, 'keepAliveWhile');
|
|
640
|
+
return doInstance.keepAliveWhile(() => h(ctx));
|
|
641
|
+
}),
|
|
642
|
+
});
|
|
719
643
|
}
|
|
720
644
|
|
|
721
645
|
// ─── Per-Agent Durable Object Classes ──────────────────────────────────────
|
|
@@ -730,71 +654,43 @@ ${agentClasses}
|
|
|
730
654
|
// by the user's wrangler.jsonc.
|
|
731
655
|
${sandboxReExports}
|
|
732
656
|
|
|
733
|
-
// ───
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
657
|
+
// ─── Runtime seed ───────────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
// Seed the public flue() sub-app's runtime. On Cloudflare the agent route
|
|
660
|
+
// forwards to routeAgentRequest() (provided by the Agents SDK), which
|
|
661
|
+
// dispatches into the per-agent DO's onRequest → dispatchAgent above.
|
|
662
|
+
// Validation (method, name, id) happens inside flue() before forwarding,
|
|
663
|
+
// which suppresses partyserver's noisy default text/plain "Invalid
|
|
664
|
+
// request" response for unknown / malformed routes.
|
|
665
|
+
configureFlueRuntime({
|
|
666
|
+
target: 'cloudflare',
|
|
667
|
+
webhookAgents: webhookAgentNames,
|
|
668
|
+
// Cloudflare deploys never run in local mode — the trigger-less agents
|
|
669
|
+
// simply have no DO class to land in.
|
|
670
|
+
allowNonWebhook: false,
|
|
671
|
+
routeAgentRequest: (request, env) => routeAgentRequest(request, env),
|
|
672
|
+
});
|
|
747
673
|
|
|
748
|
-
|
|
749
|
-
if (url.pathname === '/agents' && method === 'GET') {
|
|
750
|
-
return new Response(JSON.stringify(manifest), {
|
|
751
|
-
headers: { 'content-type': 'application/json' },
|
|
752
|
-
});
|
|
753
|
-
}
|
|
674
|
+
// ─── App composition ────────────────────────────────────────────────────────
|
|
754
675
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const name = decodeURIComponent(agentRouteMatch[1]);
|
|
770
|
-
const id = decodeURIComponent(agentRouteMatch[2]);
|
|
771
|
-
if (name.trim() === '' || id.trim() === '') {
|
|
772
|
-
throw new InvalidRequestError({
|
|
773
|
-
reason: 'Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments.',
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
if (!webhookAgentNames.has(name)) {
|
|
777
|
-
throw new AgentNotFoundError({
|
|
778
|
-
name,
|
|
779
|
-
available: Array.from(webhookAgentNames),
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
// All gating passed. Delegate to the Agents SDK / partyserver, which
|
|
783
|
-
// dispatches into the per-agent DO's onRequest → handleAgentRequest.
|
|
784
|
-
// routeAgentRequest may still return null for shape mismatches we
|
|
785
|
-
// didn't anticipate; treat that as a route_not_found with a hint.
|
|
786
|
-
const response = await routeAgentRequest(request, env);
|
|
787
|
-
if (response) return response;
|
|
788
|
-
throw new RouteNotFoundError({ method, path: url.pathname });
|
|
789
|
-
}
|
|
676
|
+
${appEntry ? `// User-supplied app.ts. Their default export owns the entire request
|
|
677
|
+
// pipeline — the worker just verifies a fetch method exists and pipes
|
|
678
|
+
// through. The default flue() handler is available for them to mount
|
|
679
|
+
// however they want; this file does not impose a composition.
|
|
680
|
+
const app = userApp;
|
|
681
|
+
if (!app || typeof app.fetch !== 'function') {
|
|
682
|
+
throw new Error(
|
|
683
|
+
'[flue] app.ts default export must be a Hono app or an object with a fetch(request, env, ctx) method.'
|
|
684
|
+
);
|
|
685
|
+
}` : `// No app.ts: build the default app via the SDK so the generated entry
|
|
686
|
+
// stays \`hono\`-free (users only need hono in their node_modules when
|
|
687
|
+
// they author their own app.ts). The default mounts \`flue()\` at root
|
|
688
|
+
// and renders canonical Flue envelopes for unmatched paths.
|
|
689
|
+
const app = createDefaultFlueApp();`}
|
|
790
690
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
// toHttpResponse logs unknowns via flueLog.error — no extra
|
|
795
|
-
// console.error needed at this layer.
|
|
796
|
-
return toHttpResponse(err);
|
|
797
|
-
}
|
|
691
|
+
export default {
|
|
692
|
+
fetch(request, env, ctx) {
|
|
693
|
+
return app.fetch(request, env, ctx);
|
|
798
694
|
},
|
|
799
695
|
};
|
|
800
696
|
`;
|
|
@@ -806,31 +702,40 @@ export default {
|
|
|
806
702
|
name: agentClassName(a.name)
|
|
807
703
|
}));
|
|
808
704
|
const flueSqliteClasses = flueBindings.map((b) => b.class_name);
|
|
809
|
-
const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.
|
|
705
|
+
const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.root);
|
|
810
706
|
if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
|
|
811
707
|
validateUserWranglerConfig(userConfig);
|
|
812
708
|
const flueMigrations = computeFlueMigrations(flueSqliteClasses, userConfig.migrations);
|
|
813
709
|
const additions = {
|
|
814
|
-
defaultName: path.basename(ctx.
|
|
710
|
+
defaultName: path.basename(ctx.root) || "flue-agents",
|
|
815
711
|
main: "_entry.ts",
|
|
816
712
|
doBindings: flueBindings,
|
|
817
713
|
migrations: flueMigrations
|
|
818
714
|
};
|
|
819
715
|
const sandboxClassNames = detectSandboxBindings(userConfig);
|
|
820
716
|
if (sandboxClassNames.length > 0) {
|
|
821
|
-
assertSandboxPackageInstalled(sandboxClassNames,
|
|
717
|
+
assertSandboxPackageInstalled(sandboxClassNames, ctx.root);
|
|
822
718
|
for (const className of sandboxClassNames) console.log(`[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`);
|
|
823
719
|
}
|
|
824
720
|
const merged = mergeFlueAdditions(userConfig, additions);
|
|
825
721
|
stripNoisyWranglerDefaults(merged);
|
|
826
722
|
if (typeof merged.$schema !== "string") merged.$schema = "https://workers.cloudflare.com/schema/wrangler.json";
|
|
827
723
|
outputs["wrangler.jsonc"] = JSON.stringify(merged, null, 2);
|
|
828
|
-
writeDeployRedirectIfMissing(ctx.
|
|
724
|
+
writeDeployRedirectIfMissing(ctx.root, ctx.output);
|
|
829
725
|
return outputs;
|
|
830
726
|
}
|
|
831
727
|
};
|
|
832
|
-
function agentVarName$1(name) {
|
|
833
|
-
return
|
|
728
|
+
function agentVarName$1(name, index) {
|
|
729
|
+
return `handler_${name.replace(/[^a-zA-Z0-9]/g, "_").replace(/^_+|_+$/g, "") || "agent"}_${index}`;
|
|
730
|
+
}
|
|
731
|
+
const CLOUDFLARE_AGENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
732
|
+
function validateCloudflareAgentNames(ctx) {
|
|
733
|
+
const invalidAgents = ctx.agents.filter((agent) => !CLOUDFLARE_AGENT_NAME_PATTERN.test(agent.name));
|
|
734
|
+
if (invalidAgents.length === 0) return;
|
|
735
|
+
const invalidList = invalidAgents.map((agent) => {
|
|
736
|
+
return `${path.relative(ctx.root, agent.filePath)} (${agent.name})`;
|
|
737
|
+
}).join(", ");
|
|
738
|
+
throw new Error(`[flue] Cloudflare target requires agent filenames to use lower-kebab-case so Durable Object bindings route correctly. Invalid agent file(s): ${invalidList}. Rename them to match ${CLOUDFLARE_AGENT_NAME_PATTERN}.`);
|
|
834
739
|
}
|
|
835
740
|
/**
|
|
836
741
|
* Convert agent name to a PascalCase DO class name.
|
|
@@ -849,31 +754,30 @@ var NodePlugin = class {
|
|
|
849
754
|
name = "node";
|
|
850
755
|
bundle = "esbuild";
|
|
851
756
|
generateEntryPoint(ctx) {
|
|
852
|
-
const { agents, roles } = ctx;
|
|
757
|
+
const { agents, roles, appEntry } = ctx;
|
|
853
758
|
const rolesJson = JSON.stringify(roles);
|
|
854
759
|
const webhookAgents = agents.filter((a) => a.triggers.webhook);
|
|
760
|
+
const agentImports = agents.map((a, index) => {
|
|
761
|
+
return `import ${agentVarName(a.name, index)} from '${a.filePath.replace(/\\/g, "/")}';`;
|
|
762
|
+
}).join("\n");
|
|
763
|
+
const handlerMapEntries = agents.map((a, index) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name, index)},`).join("\n");
|
|
764
|
+
const webhookNames = JSON.stringify(webhookAgents.map((a) => a.name));
|
|
855
765
|
return `
|
|
856
766
|
// Auto-generated by @flue/sdk build (node)
|
|
857
|
-
import { Hono } from 'hono';
|
|
858
|
-
import { streamSSE } from 'hono/streaming';
|
|
859
767
|
import { serve } from '@hono/node-server';
|
|
860
|
-
import { Bash, InMemoryFs
|
|
768
|
+
import { Bash, InMemoryFs } from 'just-bash';
|
|
861
769
|
import {
|
|
862
770
|
createFlueContext,
|
|
863
771
|
InMemorySessionStore,
|
|
864
772
|
bashFactoryToSessionEnv,
|
|
865
773
|
resolveModel,
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
toHttpResponse,
|
|
869
|
-
toSseData,
|
|
870
|
-
RouteNotFoundError,
|
|
774
|
+
configureFlueRuntime,
|
|
775
|
+
createDefaultFlueApp,
|
|
871
776
|
} from '@flue/sdk/internal';
|
|
872
|
-
import {
|
|
777
|
+
import { createLocalSessionEnv } from '@flue/sdk/node';
|
|
873
778
|
|
|
874
|
-
${
|
|
875
|
-
|
|
876
|
-
}).join("\n")}
|
|
779
|
+
${agentImports}
|
|
780
|
+
${appEntry ? `import userApp from '${appEntry.replace(/\\/g, "/")}';` : ""}
|
|
877
781
|
|
|
878
782
|
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
879
783
|
|
|
@@ -882,13 +786,11 @@ const roles = ${rolesJson};
|
|
|
882
786
|
const systemPrompt = '';
|
|
883
787
|
|
|
884
788
|
const handlers = {
|
|
885
|
-
${
|
|
789
|
+
${handlerMapEntries}
|
|
886
790
|
};
|
|
887
791
|
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
// below readable.
|
|
891
|
-
const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
|
|
792
|
+
// Webhook-accessible agent names.
|
|
793
|
+
const webhookAgentNames = ${webhookNames};
|
|
892
794
|
|
|
893
795
|
// When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
|
|
894
796
|
// In local mode the HTTP route accepts any registered agent (including
|
|
@@ -897,11 +799,6 @@ const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name
|
|
|
897
799
|
// agents that the user only intended to invoke from their CI pipeline.
|
|
898
800
|
const isLocalMode = process.env.FLUE_MODE === 'local';
|
|
899
801
|
|
|
900
|
-
const manifest = ${JSON.stringify({ agents: agents.map((a) => ({
|
|
901
|
-
name: a.name,
|
|
902
|
-
triggers: a.triggers
|
|
903
|
-
})) }, null, 2)};
|
|
904
|
-
|
|
905
802
|
// ─── Sandbox Environments ───────────────────────────────────────────────────
|
|
906
803
|
|
|
907
804
|
/**
|
|
@@ -918,28 +815,25 @@ async function createDefaultEnv() {
|
|
|
918
815
|
}
|
|
919
816
|
|
|
920
817
|
/**
|
|
921
|
-
* Create a local
|
|
922
|
-
*
|
|
818
|
+
* Create a local SessionEnv backed directly by the host filesystem and
|
|
819
|
+
* child_process. No virtual filesystem, no sandbox layer — file methods
|
|
820
|
+
* call node:fs/promises and shell commands run on the host. Use this
|
|
821
|
+
* when flue itself is running inside an external sandbox / container /
|
|
822
|
+
* CI runner that already provides the isolation boundary.
|
|
923
823
|
*/
|
|
924
824
|
async function createLocalEnv() {
|
|
925
|
-
|
|
926
|
-
const fs = new MountableFs({ base: new InMemoryFs() });
|
|
927
|
-
fs.mount('/workspace', rwfs);
|
|
928
|
-
return bashFactoryToSessionEnv(() => new Bash({
|
|
929
|
-
fs,
|
|
930
|
-
cwd: '/workspace',
|
|
931
|
-
network: { dangerouslyAllowFullInternetAccess: true },
|
|
932
|
-
}));
|
|
825
|
+
return createLocalSessionEnv();
|
|
933
826
|
}
|
|
934
827
|
|
|
935
828
|
// Default persistence store for Node — in-memory, process lifetime.
|
|
936
829
|
const defaultStore = new InMemorySessionStore();
|
|
937
830
|
|
|
938
|
-
function createContextForRequest(id, payload) {
|
|
831
|
+
function createContextForRequest(id, payload, req) {
|
|
939
832
|
return createFlueContext({
|
|
940
833
|
id,
|
|
941
834
|
payload,
|
|
942
835
|
env: process.env,
|
|
836
|
+
req,
|
|
943
837
|
agentConfig: {
|
|
944
838
|
systemPrompt, skills, roles, model: undefined, resolveModel,
|
|
945
839
|
},
|
|
@@ -949,130 +843,49 @@ function createContextForRequest(id, payload) {
|
|
|
949
843
|
});
|
|
950
844
|
}
|
|
951
845
|
|
|
952
|
-
// ───
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
app.
|
|
958
|
-
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
// Validate method, name shape, registration, webhook-accessibility.
|
|
967
|
-
// Throws FlueHttpError on any failure; caught by app.onError below.
|
|
968
|
-
validateAgentRequest({
|
|
969
|
-
method: c.req.method,
|
|
970
|
-
name,
|
|
971
|
-
id,
|
|
972
|
-
registeredAgents: Object.keys(handlers),
|
|
973
|
-
webhookAgents: Array.from(webhookAgentSet),
|
|
974
|
-
allowNonWebhook: isLocalMode,
|
|
975
|
-
});
|
|
976
|
-
|
|
977
|
-
const handler = handlers[name];
|
|
978
|
-
|
|
979
|
-
// Parse the request body. Throws on invalid Content-Type or malformed JSON;
|
|
980
|
-
// returns {} for genuinely empty bodies (so no-payload agents still work).
|
|
981
|
-
const payload = await parseJsonBody(c.req.raw);
|
|
982
|
-
|
|
983
|
-
const accept = c.req.header('accept') || '';
|
|
984
|
-
const isWebhook = c.req.header('x-webhook') === 'true';
|
|
985
|
-
const isSSE = accept.includes('text/event-stream') && !isWebhook;
|
|
986
|
-
|
|
987
|
-
// Fire-and-forget (webhook mode)
|
|
988
|
-
if (isWebhook) {
|
|
989
|
-
const requestId = randomUUID();
|
|
990
|
-
const ctx = createContextForRequest(id, payload);
|
|
991
|
-
handler(ctx).then(
|
|
992
|
-
(result) => {
|
|
993
|
-
ctx.setEventCallback(undefined);
|
|
994
|
-
console.log('[flue] Webhook handler complete:', name, result !== undefined ? JSON.stringify(result) : '(no return)');
|
|
995
|
-
},
|
|
996
|
-
(err) => {
|
|
997
|
-
ctx.setEventCallback(undefined);
|
|
998
|
-
console.error('[flue] Webhook handler error:', name, err);
|
|
999
|
-
},
|
|
1000
|
-
);
|
|
1001
|
-
return c.json({ status: 'accepted', requestId }, 202);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// SSE streaming mode. Two error regimes meet here:
|
|
1005
|
-
// - Pre-stream errors (validation, body parsing, agent lookup) have
|
|
1006
|
-
// already thrown above and are rendered as plain HTTP responses by
|
|
1007
|
-
// app.onError — headers haven't been sent yet, so this works.
|
|
1008
|
-
// - Errors during agent execution surface as in-stream \`error\` events
|
|
1009
|
-
// with the canonical envelope (via toSseData), since by then the
|
|
1010
|
-
// 200 + text/event-stream headers are already on the wire.
|
|
1011
|
-
if (isSSE) {
|
|
1012
|
-
return streamSSE(c, async (stream) => {
|
|
1013
|
-
let eventId = 0;
|
|
1014
|
-
let isIdle = false;
|
|
1015
|
-
const ctx = createContextForRequest(id, payload);
|
|
1016
|
-
ctx.setEventCallback((event) => {
|
|
1017
|
-
if (event.type === 'idle') isIdle = true;
|
|
1018
|
-
stream.writeSSE({ data: JSON.stringify(event), event: event.type, id: String(eventId++) }).catch(() => {});
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
try {
|
|
1022
|
-
const result = await handler(ctx);
|
|
1023
|
-
if (!isIdle) {
|
|
1024
|
-
const idle = { type: 'idle' };
|
|
1025
|
-
await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
|
|
1026
|
-
}
|
|
1027
|
-
await stream.writeSSE({
|
|
1028
|
-
data: JSON.stringify({ type: 'result', data: result !== undefined ? result : null }),
|
|
1029
|
-
event: 'result',
|
|
1030
|
-
id: String(eventId++),
|
|
1031
|
-
});
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
await stream.writeSSE({
|
|
1034
|
-
data: toSseData(err),
|
|
1035
|
-
event: 'error',
|
|
1036
|
-
id: String(eventId++),
|
|
1037
|
-
});
|
|
1038
|
-
if (!isIdle) {
|
|
1039
|
-
const idle = { type: 'idle' };
|
|
1040
|
-
await stream.writeSSE({ data: JSON.stringify(idle), event: 'idle', id: String(eventId++) });
|
|
1041
|
-
}
|
|
1042
|
-
} finally {
|
|
1043
|
-
ctx.setEventCallback(undefined);
|
|
1044
|
-
}
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// Sync mode (default). Errors propagate to app.onError.
|
|
1049
|
-
const ctx = createContextForRequest(id, payload);
|
|
1050
|
-
try {
|
|
1051
|
-
const result = await handler(ctx);
|
|
1052
|
-
return c.json({ result: result !== undefined ? result : null });
|
|
1053
|
-
} finally {
|
|
1054
|
-
ctx.setEventCallback(undefined);
|
|
1055
|
-
}
|
|
846
|
+
// ─── Runtime seed ───────────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
// Seed the public flue() sub-app with everything it needs to dispatch agent
|
|
849
|
+
// requests in-process. Must run before \`flue()\` handles any request — by
|
|
850
|
+
// virtue of being a top-level statement, it executes before \`serve(...)\`
|
|
851
|
+
// below binds the listener. User app.ts files that call \`flue()\` at top
|
|
852
|
+
// level are also fine because Hono routes are lazy: they read this config
|
|
853
|
+
// only when a request arrives.
|
|
854
|
+
configureFlueRuntime({
|
|
855
|
+
target: 'node',
|
|
856
|
+
webhookAgents: webhookAgentNames,
|
|
857
|
+
allowNonWebhook: isLocalMode,
|
|
858
|
+
handlers,
|
|
859
|
+
createContext: createContextForRequest,
|
|
1056
860
|
});
|
|
1057
861
|
|
|
1058
|
-
//
|
|
1059
|
-
app.notFound((c) => {
|
|
1060
|
-
// Throw rather than return so the onError handler is the single source of
|
|
1061
|
-
// truth for error-envelope shaping.
|
|
1062
|
-
throw new RouteNotFoundError({ method: c.req.method, path: new URL(c.req.url).pathname });
|
|
1063
|
-
});
|
|
862
|
+
// ─── App composition ────────────────────────────────────────────────────────
|
|
1064
863
|
|
|
1065
|
-
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
app
|
|
864
|
+
${appEntry ? `// User-supplied app.ts: their default export owns the entire request
|
|
865
|
+
// pipeline. We just verify it exposes a fetch method and pass the
|
|
866
|
+
// listener through. flue() is available for them to mount, but the
|
|
867
|
+
// composition is theirs to author.
|
|
868
|
+
const app = userApp;
|
|
869
|
+
if (!app || typeof app.fetch !== 'function') {
|
|
870
|
+
throw new Error(
|
|
871
|
+
'[flue] app.ts default export must be a Hono app or an object with a fetch(request) method.'
|
|
872
|
+
);
|
|
873
|
+
}` : `// No app.ts: build the default app via the SDK so the generated entry
|
|
874
|
+
// stays \`hono\`-free (users only need hono in their node_modules when
|
|
875
|
+
// they author their own app.ts). The default mounts \`flue()\` at root
|
|
876
|
+
// and renders canonical Flue envelopes for unmatched paths.
|
|
877
|
+
const app = createDefaultFlueApp();`}
|
|
1070
878
|
|
|
1071
879
|
// ─── Start ──────────────────────────────────────────────────────────────────
|
|
1072
880
|
|
|
1073
881
|
const port = parseInt(process.env.PORT || '3000', 10);
|
|
1074
882
|
|
|
1075
|
-
const server = serve({
|
|
883
|
+
const server = serve({
|
|
884
|
+
fetch: app.fetch,
|
|
885
|
+
port,
|
|
886
|
+
// SSE requests can outlive Node's default 300s request timeout.
|
|
887
|
+
serverOptions: { requestTimeout: 0 },
|
|
888
|
+
});
|
|
1076
889
|
console.log('[flue] Server listening on http://localhost:' + port);
|
|
1077
890
|
if (isLocalMode) {
|
|
1078
891
|
console.log('[flue] Mode: local (all agents invokable, including trigger-less)');
|
|
@@ -1093,68 +906,163 @@ process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
|
1093
906
|
};
|
|
1094
907
|
}
|
|
1095
908
|
};
|
|
1096
|
-
function agentVarName(name) {
|
|
1097
|
-
return
|
|
909
|
+
function agentVarName(name, index) {
|
|
910
|
+
return `handler_${name.replace(/[^a-zA-Z0-9]/g, "_").replace(/^_+|_+$/g, "") || "agent"}_${index}`;
|
|
1098
911
|
}
|
|
1099
912
|
|
|
1100
913
|
//#endregion
|
|
1101
914
|
//#region src/build.ts
|
|
915
|
+
/** Extract static agent metadata at build time without evaluating the agent module. */
|
|
916
|
+
function parseAgentFile(filePath) {
|
|
917
|
+
return { triggers: parseTriggers(filePath) };
|
|
918
|
+
}
|
|
919
|
+
function parseTriggers(filePath) {
|
|
920
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
921
|
+
const ast = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
|
|
922
|
+
let result;
|
|
923
|
+
for (const statement of ast.statements) {
|
|
924
|
+
if (isTriggersReExport(statement)) throwUnsupportedTriggers(filePath, "re-exported triggers are not supported");
|
|
925
|
+
if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) continue;
|
|
926
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
927
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "triggers") continue;
|
|
928
|
+
if (result) throwUnsupportedTriggers(filePath, "multiple triggers exports were found");
|
|
929
|
+
if (!declaration.initializer) throwUnsupportedTriggers(filePath, "missing initializer");
|
|
930
|
+
result = parseTriggersInitializer(filePath, declaration.initializer);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return result ?? {};
|
|
934
|
+
}
|
|
935
|
+
function scriptKindForFile(filePath) {
|
|
936
|
+
if (/\.m?js$/.test(filePath)) return ts.ScriptKind.JS;
|
|
937
|
+
return ts.ScriptKind.TS;
|
|
938
|
+
}
|
|
939
|
+
function hasExportModifier(statement) {
|
|
940
|
+
return statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
941
|
+
}
|
|
942
|
+
function isTriggersReExport(statement) {
|
|
943
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause) return false;
|
|
944
|
+
if (!ts.isNamedExports(statement.exportClause)) return false;
|
|
945
|
+
return statement.exportClause.elements.some((element) => element.name.text === "triggers");
|
|
946
|
+
}
|
|
947
|
+
function parseTriggersInitializer(filePath, initializer) {
|
|
948
|
+
const expr = unwrapExpression(initializer);
|
|
949
|
+
if (!ts.isObjectLiteralExpression(expr)) throwUnsupportedTriggers(filePath, "expected a static object literal");
|
|
950
|
+
const result = {};
|
|
951
|
+
for (const property of expr.properties) {
|
|
952
|
+
if (ts.isSpreadAssignment(property)) throwUnsupportedTriggers(filePath, "spread properties are not supported");
|
|
953
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
954
|
+
const name = property.name.text;
|
|
955
|
+
if (name === "webhook") throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (!ts.isPropertyAssignment(property)) {
|
|
959
|
+
const name = propertyNameText(filePath, property.name);
|
|
960
|
+
if (name === "webhook") throwUnsupportedTriggers(filePath, `"${name}" must use an explicit static value`);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (propertyNameText(filePath, property.name) === "webhook") {
|
|
964
|
+
const value = unwrapExpression(property.initializer);
|
|
965
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword) result.webhook = true;
|
|
966
|
+
else if (value.kind === ts.SyntaxKind.FalseKeyword) delete result.webhook;
|
|
967
|
+
else throwUnsupportedTriggers(filePath, "\"webhook\" must be true or false");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
function unwrapExpression(expr) {
|
|
973
|
+
while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr) || ts.isTypeAssertionExpression(expr) || ts.isParenthesizedExpression(expr)) expr = expr.expression;
|
|
974
|
+
return expr;
|
|
975
|
+
}
|
|
976
|
+
function propertyNameText(filePath, name) {
|
|
977
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
|
978
|
+
if (ts.isComputedPropertyName(name)) {
|
|
979
|
+
const expression = unwrapExpression(name.expression);
|
|
980
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
|
|
981
|
+
throwUnsupportedTriggers(filePath, "computed property names must be static");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function throwUnsupportedTriggers(filePath, reason) {
|
|
985
|
+
throw new Error(`[flue] Unsupported triggers export in ${filePath}: ${reason}. Use a static object literal, for example: export const triggers = { webhook: true }.`);
|
|
986
|
+
}
|
|
987
|
+
const VALID_THINKING_LEVELS = {
|
|
988
|
+
off: true,
|
|
989
|
+
minimal: true,
|
|
990
|
+
low: true,
|
|
991
|
+
medium: true,
|
|
992
|
+
high: true,
|
|
993
|
+
xhigh: true
|
|
994
|
+
};
|
|
995
|
+
function parseThinkingLevel(value, source) {
|
|
996
|
+
if (value === void 0) return void 0;
|
|
997
|
+
const normalized = value.trim();
|
|
998
|
+
if (!normalized) return void 0;
|
|
999
|
+
if (!(normalized in VALID_THINKING_LEVELS)) throw new Error(`[flue] Invalid thinkingLevel ${JSON.stringify(value)} in ${source}. Expected one of: ${Object.keys(VALID_THINKING_LEVELS).join(", ")}.`);
|
|
1000
|
+
return normalized;
|
|
1001
|
+
}
|
|
1102
1002
|
/**
|
|
1103
|
-
* Build a
|
|
1003
|
+
* Build a project into a deployable artifact.
|
|
1004
|
+
*
|
|
1005
|
+
* `options.root` is the project root — typically the user's cwd. Source files
|
|
1006
|
+
* (agents, roles) are discovered from one of two locations inside the root,
|
|
1007
|
+
* with the same precedence rule the CLI uses:
|
|
1008
|
+
*
|
|
1009
|
+
* - If `<root>/.flue/` exists, it is the source root. Look for
|
|
1010
|
+
* `.flue/agents/` and `.flue/roles/`. The bare `<root>/agents/` and
|
|
1011
|
+
* `<root>/roles/` are ignored entirely (no mixing).
|
|
1012
|
+
* - Otherwise, look at `<root>/agents/` and `<root>/roles/`.
|
|
1104
1013
|
*
|
|
1105
|
-
* `options.
|
|
1106
|
-
* directly containing agents/ and roles/. No .flue/ waterfall is performed here;
|
|
1107
|
-
* callers that want waterfall behavior (e.g. the CLI when --workspace is omitted)
|
|
1108
|
-
* should use `resolveWorkspaceFromCwd` first.
|
|
1014
|
+
* Build output lands in `options.output` (defaults to `<root>/dist`).
|
|
1109
1015
|
*
|
|
1110
1016
|
* AGENTS.md and .agents/skills/ are NOT bundled — discovered at runtime from session cwd.
|
|
1111
1017
|
*/
|
|
1112
1018
|
async function build(options) {
|
|
1113
|
-
const
|
|
1114
|
-
const
|
|
1019
|
+
const root = path.resolve(options.root);
|
|
1020
|
+
const output = path.resolve(options.output ?? path.join(root, "dist"));
|
|
1115
1021
|
const plugin = resolvePlugin(options);
|
|
1116
|
-
|
|
1117
|
-
console.log(`[flue]
|
|
1022
|
+
const sourceRoot = resolveSourceRoot(root);
|
|
1023
|
+
console.log(`[flue] Building: ${root}`);
|
|
1024
|
+
if (sourceRoot !== root) console.log(`[flue] Source root: ${sourceRoot}`);
|
|
1025
|
+
console.log(`[flue] Output: ${output}`);
|
|
1118
1026
|
console.log(`[flue] Target: ${plugin.name}`);
|
|
1119
|
-
const roles = discoverRoles(
|
|
1120
|
-
const agents = discoverAgents(
|
|
1121
|
-
|
|
1027
|
+
const roles = discoverRoles(sourceRoot);
|
|
1028
|
+
const agents = discoverAgents(sourceRoot);
|
|
1029
|
+
const appEntry = discoverAppEntry(sourceRoot);
|
|
1030
|
+
if (agents.length === 0) throw new Error(`[flue] No agent files found.\n\nExpected at: ${path.join(sourceRoot, "agents")}/\nAdd at least one agent file (e.g. hello.ts).`);
|
|
1031
|
+
if (appEntry) console.log(`[flue] Custom app entry: ${path.relative(root, appEntry) || appEntry}`);
|
|
1122
1032
|
const webhookAgents = agents.filter((a) => a.triggers.webhook);
|
|
1123
|
-
const
|
|
1124
|
-
const triggerlessAgents = agents.filter((a) => !a.triggers.webhook && !a.triggers.cron);
|
|
1033
|
+
const triggerlessAgents = agents.filter((a) => !a.triggers.webhook);
|
|
1125
1034
|
console.log(`[flue] Found ${Object.keys(roles).length} role(s): ${Object.keys(roles).join(", ") || "(none)"}`);
|
|
1126
1035
|
console.log(`[flue] Found ${agents.length} agent(s): ${agents.map((a) => a.name).join(", ")}`);
|
|
1127
1036
|
if (webhookAgents.length > 0) console.log(`[flue] Webhook agents: ${webhookAgents.map((a) => a.name).join(", ")}`);
|
|
1128
|
-
if (cronAgents.length > 0) console.log(`[flue] Cron agents (manifest only): ${cronAgents.map((a) => `${a.name} (${a.triggers.cron})`).join(", ")}`);
|
|
1129
1037
|
if (triggerlessAgents.length > 0) console.log(`[flue] CLI-only agents (no HTTP route in deployed build): ${triggerlessAgents.map((a) => a.name).join(", ")}`);
|
|
1130
1038
|
console.log(`[flue] AGENTS.md and .agents/skills/ will be discovered at runtime from session cwd`);
|
|
1131
|
-
|
|
1132
|
-
fs.mkdirSync(distDir, { recursive: true });
|
|
1039
|
+
fs.mkdirSync(output, { recursive: true });
|
|
1133
1040
|
const manifest = { agents: agents.map((a) => ({
|
|
1134
1041
|
name: a.name,
|
|
1135
1042
|
triggers: a.triggers
|
|
1136
1043
|
})) };
|
|
1137
|
-
const manifestPath = path.join(
|
|
1044
|
+
const manifestPath = path.join(output, "manifest.json");
|
|
1138
1045
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
1139
1046
|
console.log(`[flue] Generated: ${manifestPath}`);
|
|
1140
1047
|
const ctx = {
|
|
1141
1048
|
agents,
|
|
1142
1049
|
roles,
|
|
1143
|
-
|
|
1144
|
-
|
|
1050
|
+
root,
|
|
1051
|
+
output,
|
|
1052
|
+
appEntry,
|
|
1145
1053
|
options
|
|
1146
1054
|
};
|
|
1147
1055
|
const serverCode = await plugin.generateEntryPoint(ctx);
|
|
1148
1056
|
const bundleStrategy = plugin.bundle ?? "esbuild";
|
|
1149
1057
|
let anyChanged = false;
|
|
1150
1058
|
if (bundleStrategy === "esbuild") {
|
|
1151
|
-
const entryPath = path.join(
|
|
1152
|
-
const outPath = path.join(
|
|
1059
|
+
const entryPath = path.join(output, "_entry_server.ts");
|
|
1060
|
+
const outPath = path.join(output, "server.mjs");
|
|
1153
1061
|
fs.writeFileSync(entryPath, serverCode, "utf-8");
|
|
1154
1062
|
try {
|
|
1155
|
-
const nodePathsSet = collectNodePaths(
|
|
1063
|
+
const nodePathsSet = collectNodePaths(root);
|
|
1156
1064
|
const { external: pluginExternal = [], ...pluginEsbuildOpts } = plugin.esbuildOptions ? plugin.esbuildOptions(ctx) : {};
|
|
1157
|
-
const userExternals = getUserExternals(
|
|
1065
|
+
const userExternals = getUserExternals(root);
|
|
1158
1066
|
await esbuild.build({
|
|
1159
1067
|
entryPoints: [entryPath],
|
|
1160
1068
|
bundle: true,
|
|
@@ -1180,7 +1088,7 @@ async function build(options) {
|
|
|
1180
1088
|
}
|
|
1181
1089
|
} else if (bundleStrategy === "none") {
|
|
1182
1090
|
if (!plugin.entryFilename) throw new Error(`[flue] Plugin "${plugin.name}" set bundle: 'none' but did not provide entryFilename.`);
|
|
1183
|
-
const outPath = path.join(
|
|
1091
|
+
const outPath = path.join(output, plugin.entryFilename);
|
|
1184
1092
|
if (!fs.existsSync(outPath) || fs.readFileSync(outPath, "utf-8") !== serverCode) {
|
|
1185
1093
|
fs.writeFileSync(outPath, serverCode, "utf-8");
|
|
1186
1094
|
console.log(`[flue] Wrote entry: ${outPath} (no bundle — downstream tool handles it)`);
|
|
@@ -1190,7 +1098,7 @@ async function build(options) {
|
|
|
1190
1098
|
if (plugin.additionalOutputs) {
|
|
1191
1099
|
const outputs = await plugin.additionalOutputs(ctx);
|
|
1192
1100
|
for (const [filename, content] of Object.entries(outputs)) {
|
|
1193
|
-
const filePath = path.join(
|
|
1101
|
+
const filePath = path.join(output, filename);
|
|
1194
1102
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1195
1103
|
if (!fs.existsSync(filePath) || fs.readFileSync(filePath, "utf-8") !== content) {
|
|
1196
1104
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
@@ -1199,7 +1107,7 @@ async function build(options) {
|
|
|
1199
1107
|
}
|
|
1200
1108
|
}
|
|
1201
1109
|
}
|
|
1202
|
-
console.log(`[flue] Build complete. Output: ${
|
|
1110
|
+
console.log(`[flue] Build complete. Output: ${output}`);
|
|
1203
1111
|
return { changed: anyChanged };
|
|
1204
1112
|
}
|
|
1205
1113
|
function resolvePlugin(options) {
|
|
@@ -1212,26 +1120,25 @@ function resolvePlugin(options) {
|
|
|
1212
1120
|
}
|
|
1213
1121
|
}
|
|
1214
1122
|
/**
|
|
1215
|
-
* Resolve
|
|
1216
|
-
*
|
|
1217
|
-
* not provided — callers that pass an explicit workspace path should skip this
|
|
1218
|
-
* and pass the path straight to `build()`.
|
|
1123
|
+
* Resolve the source root for a project, using the `.flue/`-as-src
|
|
1124
|
+
* convention (analogous to Next.js's `src/` folder).
|
|
1219
1125
|
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1126
|
+
* If `<root>/.flue/` exists, it is the source root. Otherwise the source root
|
|
1127
|
+
* is the project root itself. The two layouts never mix — if `.flue/` exists,
|
|
1128
|
+
* the bare layout is ignored entirely (even if a `<root>/agents/` directory
|
|
1129
|
+
* also happens to be present).
|
|
1223
1130
|
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1131
|
+
* The project root (cwd) stays the same in both cases — `.flue/` only shifts
|
|
1132
|
+
* where source files are discovered from. The build output directory is
|
|
1133
|
+
* independent (defaults to `<root>/dist`, override with `output`).
|
|
1226
1134
|
*/
|
|
1227
|
-
function
|
|
1228
|
-
const dotFlue = path.join(
|
|
1135
|
+
function resolveSourceRoot(root) {
|
|
1136
|
+
const dotFlue = path.join(root, ".flue");
|
|
1229
1137
|
if (fs.existsSync(dotFlue)) return dotFlue;
|
|
1230
|
-
|
|
1231
|
-
return null;
|
|
1138
|
+
return root;
|
|
1232
1139
|
}
|
|
1233
|
-
function discoverRoles(
|
|
1234
|
-
const rolesDir = path.join(
|
|
1140
|
+
function discoverRoles(sourceRoot) {
|
|
1141
|
+
const rolesDir = path.join(sourceRoot, "roles");
|
|
1235
1142
|
if (!fs.existsSync(rolesDir)) return {};
|
|
1236
1143
|
const roles = {};
|
|
1237
1144
|
for (const entry of fs.readdirSync(rolesDir)) {
|
|
@@ -1240,21 +1147,23 @@ function discoverRoles(workspaceRoot) {
|
|
|
1240
1147
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
1241
1148
|
const name = entry.replace(/\.(md|markdown)$/i, "");
|
|
1242
1149
|
const parsed = parseFrontmatterFile(content, name);
|
|
1150
|
+
const thinkingLevel = parseThinkingLevel(parsed.frontmatter.thinkingLevel, `role "${name}" frontmatter`);
|
|
1243
1151
|
roles[name] = {
|
|
1244
1152
|
name,
|
|
1245
1153
|
description: parsed.description,
|
|
1246
1154
|
instructions: parsed.body,
|
|
1247
|
-
model: parsed.frontmatter.model
|
|
1155
|
+
model: parsed.frontmatter.model,
|
|
1156
|
+
thinkingLevel
|
|
1248
1157
|
};
|
|
1249
1158
|
}
|
|
1250
1159
|
return roles;
|
|
1251
1160
|
}
|
|
1252
|
-
function discoverAgents(
|
|
1253
|
-
const agentsDir = path.join(
|
|
1161
|
+
function discoverAgents(sourceRoot) {
|
|
1162
|
+
const agentsDir = path.join(sourceRoot, "agents");
|
|
1254
1163
|
if (!fs.existsSync(agentsDir)) return [];
|
|
1255
1164
|
return fs.readdirSync(agentsDir).filter((f) => /\.(ts|js|mts|mjs)$/.test(f)).map((f) => {
|
|
1256
1165
|
const filePath = path.join(agentsDir, f);
|
|
1257
|
-
const triggers =
|
|
1166
|
+
const { triggers } = parseAgentFile(filePath);
|
|
1258
1167
|
return {
|
|
1259
1168
|
name: f.replace(/\.(ts|js|mts|mjs)$/, ""),
|
|
1260
1169
|
filePath,
|
|
@@ -1262,21 +1171,29 @@ function discoverAgents(workspaceRoot) {
|
|
|
1262
1171
|
};
|
|
1263
1172
|
});
|
|
1264
1173
|
}
|
|
1265
|
-
/**
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1174
|
+
/**
|
|
1175
|
+
* Discover an optional `app.{ts,mts,js,mjs}` entry alongside `agents/`
|
|
1176
|
+
* and `roles/`. Returns the absolute path to the first match found, or
|
|
1177
|
+
* undefined when no app entry is present.
|
|
1178
|
+
*
|
|
1179
|
+
* Extension priority matches {@link discoverAgents}: `.ts` > `.mts`
|
|
1180
|
+
* > `.js` > `.mjs`. Source-files-only — we don't probe inside the
|
|
1181
|
+
* `agents/` or `roles/` subdirs.
|
|
1182
|
+
*/
|
|
1183
|
+
function discoverAppEntry(sourceRoot) {
|
|
1184
|
+
for (const ext of [
|
|
1185
|
+
"ts",
|
|
1186
|
+
"mts",
|
|
1187
|
+
"js",
|
|
1188
|
+
"mjs"
|
|
1189
|
+
]) {
|
|
1190
|
+
const candidate = path.join(sourceRoot, `app.${ext}`);
|
|
1191
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1192
|
+
}
|
|
1276
1193
|
}
|
|
1277
1194
|
/** Externalize user's direct deps (bare name + subpath wildcard). */
|
|
1278
|
-
function getUserExternals(
|
|
1279
|
-
const pkgPath = packageUpSync({ cwd:
|
|
1195
|
+
function getUserExternals(root) {
|
|
1196
|
+
const pkgPath = packageUpSync({ cwd: root });
|
|
1280
1197
|
if (!pkgPath) return [];
|
|
1281
1198
|
try {
|
|
1282
1199
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
@@ -1289,9 +1206,9 @@ function getUserExternals(workspaceDir) {
|
|
|
1289
1206
|
return [];
|
|
1290
1207
|
}
|
|
1291
1208
|
}
|
|
1292
|
-
function collectNodePaths(
|
|
1209
|
+
function collectNodePaths(root) {
|
|
1293
1210
|
const nodePathsSet = /* @__PURE__ */ new Set();
|
|
1294
|
-
for (const startDir of [
|
|
1211
|
+
for (const startDir of [root, getSDKDir()]) {
|
|
1295
1212
|
let dir = startDir;
|
|
1296
1213
|
while (dir !== path.dirname(dir)) {
|
|
1297
1214
|
const nm = path.join(dir, "node_modules");
|
|
@@ -1314,7 +1231,7 @@ function getSDKDir() {
|
|
|
1314
1231
|
/**
|
|
1315
1232
|
* Flue dev server.
|
|
1316
1233
|
*
|
|
1317
|
-
* Watches the
|
|
1234
|
+
* Watches the project root, rebuilds on file changes, and reloads the
|
|
1318
1235
|
* underlying server. Distinct from `flue run`: dev is the long-running,
|
|
1319
1236
|
* edit-and-iterate command, while `flue run` is the one-shot
|
|
1320
1237
|
* production-style invoker (build → run → exit).
|
|
@@ -1325,7 +1242,7 @@ function getSDKDir() {
|
|
|
1325
1242
|
* what they each provide downstream is fundamentally different:
|
|
1326
1243
|
*
|
|
1327
1244
|
* - **Node** has no host bundler. Our esbuild pass produces the final
|
|
1328
|
-
* `dist/server.mjs`. On any change in the
|
|
1245
|
+
* `dist/server.mjs`. On any change in the root we rebuild and respawn
|
|
1329
1246
|
* the child Node process. Sub-second restart is fine.
|
|
1330
1247
|
*
|
|
1331
1248
|
* - **Cloudflare** uses Wrangler's bundler (the same one `wrangler dev` and
|
|
@@ -1354,18 +1271,18 @@ const DEFAULT_DEV_PORT = 3583;
|
|
|
1354
1271
|
* — the user is editing code, after all, and we want to recover when they fix it.
|
|
1355
1272
|
*/
|
|
1356
1273
|
async function dev(options) {
|
|
1357
|
-
const
|
|
1358
|
-
const
|
|
1274
|
+
const root = path.resolve(options.root);
|
|
1275
|
+
const output = path.resolve(options.output ?? path.join(root, "dist"));
|
|
1359
1276
|
const port = options.port ?? DEFAULT_DEV_PORT;
|
|
1360
|
-
const envFiles = resolveEnvFiles(options.envFiles,
|
|
1277
|
+
const envFiles = resolveEnvFiles(options.envFiles, root);
|
|
1361
1278
|
for (const f of envFiles) console.error(`[flue] Loading env from: ${f}`);
|
|
1362
1279
|
const buildOptions = {
|
|
1363
|
-
|
|
1364
|
-
|
|
1280
|
+
root,
|
|
1281
|
+
output,
|
|
1365
1282
|
target: options.target
|
|
1366
1283
|
};
|
|
1367
1284
|
console.error(`[flue] Starting dev server (target: ${options.target})`);
|
|
1368
|
-
console.error(`[flue] Watching: ${
|
|
1285
|
+
console.error(`[flue] Watching: ${root}`);
|
|
1369
1286
|
console.error(`[flue] Building...`);
|
|
1370
1287
|
const initialStart = Date.now();
|
|
1371
1288
|
try {
|
|
@@ -1375,18 +1292,19 @@ async function dev(options) {
|
|
|
1375
1292
|
}
|
|
1376
1293
|
console.error(`[flue] Built in ${Date.now() - initialStart}ms`);
|
|
1377
1294
|
const reloader = options.target === "node" ? new NodeReloader({
|
|
1378
|
-
|
|
1295
|
+
root,
|
|
1296
|
+
output,
|
|
1379
1297
|
port,
|
|
1380
1298
|
envFiles
|
|
1381
1299
|
}) : await createCloudflareReloader({
|
|
1382
|
-
|
|
1300
|
+
output,
|
|
1383
1301
|
port,
|
|
1384
1302
|
envFiles
|
|
1385
1303
|
});
|
|
1386
1304
|
await reloader.start();
|
|
1387
1305
|
if (reloader.url) {
|
|
1388
1306
|
console.error(`[flue] Server: ${reloader.url}`);
|
|
1389
|
-
const exampleAgent = pickExampleAgentName(
|
|
1307
|
+
const exampleAgent = pickExampleAgentName(output, root);
|
|
1390
1308
|
if (exampleAgent) {
|
|
1391
1309
|
console.error(`[flue] Try: curl -X POST ${reloader.url}/agents/${exampleAgent}/test-1 \\`);
|
|
1392
1310
|
console.error(` -H 'Content-Type: application/json' -d '{}'`);
|
|
@@ -1396,8 +1314,8 @@ async function dev(options) {
|
|
|
1396
1314
|
const rebuilder = createRebuilder(buildOptions, reloader);
|
|
1397
1315
|
const envFileSet = new Set(envFiles);
|
|
1398
1316
|
const watcher = createWatcher({
|
|
1399
|
-
|
|
1400
|
-
|
|
1317
|
+
root,
|
|
1318
|
+
output,
|
|
1401
1319
|
target: options.target,
|
|
1402
1320
|
envFiles,
|
|
1403
1321
|
onChange: (relPath) => {
|
|
@@ -1471,39 +1389,46 @@ function createRebuilder(buildOptions, reloader) {
|
|
|
1471
1389
|
} };
|
|
1472
1390
|
}
|
|
1473
1391
|
/**
|
|
1474
|
-
* Watch the
|
|
1392
|
+
* Watch the root for changes. Uses `fs.watch` recursive (Node 20+).
|
|
1475
1393
|
*
|
|
1476
1394
|
* Watched roots:
|
|
1477
|
-
* - `<
|
|
1478
|
-
*
|
|
1395
|
+
* - `<root>` — agents/, roles/, AGENTS.md, .agents/skills/, plus
|
|
1396
|
+
* `.flue/agents/` and `.flue/roles/` if the root uses the .flue/
|
|
1397
|
+
* source layout.
|
|
1398
|
+
* - For Cloudflare: also `<root>/wrangler.jsonc` (and `.json`),
|
|
1479
1399
|
* since changes there require a worker restart.
|
|
1480
1400
|
*
|
|
1481
1401
|
* Ignored:
|
|
1482
|
-
* -
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
* -
|
|
1402
|
+
* - The build output directory (`output`, defaults to `<root>/dist`).
|
|
1403
|
+
* Critical to break the build → file-change → rebuild loop.
|
|
1404
|
+
* - `node_modules/`, `.git/`, `.turbo/`
|
|
1405
|
+
* - Dotfiles and dotdirs at the project root, with one exception: the
|
|
1406
|
+
* `.flue/` source directory and everything inside it is allowed through
|
|
1407
|
+
* (since that's a valid source location under the .flue-as-src layout).
|
|
1408
|
+
* - Editor backup/swap suffixes
|
|
1486
1409
|
*/
|
|
1487
1410
|
function createWatcher(options) {
|
|
1488
|
-
const {
|
|
1411
|
+
const { root, output, target, envFiles, onChange } = options;
|
|
1489
1412
|
const watchers = [];
|
|
1413
|
+
const outputRelToRoot = path.relative(root, output).split(path.sep).join("/");
|
|
1490
1414
|
const isIgnoredPath = (relPath) => {
|
|
1491
|
-
const
|
|
1415
|
+
const normalized = relPath.replace(/\\/g, "/");
|
|
1416
|
+
if (normalized === ".flue" || normalized.startsWith(".flue/")) return false;
|
|
1417
|
+
if (outputRelToRoot && !outputRelToRoot.startsWith("..") && (normalized === outputRelToRoot || normalized.startsWith(outputRelToRoot + "/"))) return true;
|
|
1418
|
+
const parts = normalized.split("/");
|
|
1492
1419
|
for (const part of parts) {
|
|
1493
1420
|
if (part === "node_modules") return true;
|
|
1494
|
-
if (part === "dist") return true;
|
|
1495
1421
|
if (part === ".git") return true;
|
|
1496
1422
|
if (part === ".turbo") return true;
|
|
1497
1423
|
}
|
|
1498
1424
|
const base = parts[parts.length - 1] ?? "";
|
|
1499
1425
|
if (!base) return true;
|
|
1500
|
-
if (base.startsWith(".")
|
|
1426
|
+
if (base.startsWith(".")) return true;
|
|
1501
1427
|
if (base.endsWith("~") || base.endsWith(".swp") || base.endsWith(".swx")) return true;
|
|
1502
|
-
if (base === ".DS_Store") return true;
|
|
1503
1428
|
return false;
|
|
1504
1429
|
};
|
|
1505
1430
|
try {
|
|
1506
|
-
const w = fs.watch(
|
|
1431
|
+
const w = fs.watch(root, { recursive: true }, (_event, filename) => {
|
|
1507
1432
|
if (!filename) return;
|
|
1508
1433
|
const rel = filename.toString();
|
|
1509
1434
|
if (isIgnoredPath(rel)) return;
|
|
@@ -1511,14 +1436,14 @@ function createWatcher(options) {
|
|
|
1511
1436
|
});
|
|
1512
1437
|
watchers.push(w);
|
|
1513
1438
|
} catch (err) {
|
|
1514
|
-
console.error(`[flue] Failed to watch ${
|
|
1439
|
+
console.error(`[flue] Failed to watch ${root}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1515
1440
|
}
|
|
1516
1441
|
if (target === "cloudflare") for (const cfgName of [
|
|
1517
1442
|
"wrangler.jsonc",
|
|
1518
1443
|
"wrangler.json",
|
|
1519
1444
|
"wrangler.toml"
|
|
1520
1445
|
]) {
|
|
1521
|
-
const cfgPath = path.join(
|
|
1446
|
+
const cfgPath = path.join(root, cfgName);
|
|
1522
1447
|
if (!fs.existsSync(cfgPath)) continue;
|
|
1523
1448
|
try {
|
|
1524
1449
|
const w = fs.watch(cfgPath, () => onChange(cfgName));
|
|
@@ -1538,15 +1463,15 @@ function createWatcher(options) {
|
|
|
1538
1463
|
var NodeReloader = class {
|
|
1539
1464
|
child = null;
|
|
1540
1465
|
serverPath;
|
|
1541
|
-
|
|
1466
|
+
root;
|
|
1542
1467
|
port;
|
|
1543
1468
|
envFiles;
|
|
1544
1469
|
url;
|
|
1545
1470
|
constructor(opts) {
|
|
1546
|
-
this.
|
|
1471
|
+
this.root = opts.root;
|
|
1547
1472
|
this.port = opts.port;
|
|
1548
1473
|
this.envFiles = opts.envFiles;
|
|
1549
|
-
this.serverPath = path.join(
|
|
1474
|
+
this.serverPath = path.join(opts.output, "server.mjs");
|
|
1550
1475
|
this.url = `http://localhost:${this.port}`;
|
|
1551
1476
|
}
|
|
1552
1477
|
async start() {
|
|
@@ -1577,7 +1502,7 @@ var NodeReloader = class {
|
|
|
1577
1502
|
"pipe",
|
|
1578
1503
|
"pipe"
|
|
1579
1504
|
],
|
|
1580
|
-
cwd: this.
|
|
1505
|
+
cwd: this.root,
|
|
1581
1506
|
env: {
|
|
1582
1507
|
...fromFiles,
|
|
1583
1508
|
...process.env,
|
|
@@ -1602,10 +1527,6 @@ var NodeReloader = class {
|
|
|
1602
1527
|
if (code !== 0 && code !== null) console.error(`[flue] Node server exited unexpectedly (code=${code}, signal=${signal ?? "none"})`);
|
|
1603
1528
|
}
|
|
1604
1529
|
});
|
|
1605
|
-
if (!await waitForHealth(this.url, 15e3)) {
|
|
1606
|
-
await this.killChild();
|
|
1607
|
-
throw new Error("Node server did not become ready within 15s");
|
|
1608
|
-
}
|
|
1609
1530
|
}
|
|
1610
1531
|
async killChild() {
|
|
1611
1532
|
const child = this.child;
|
|
@@ -1659,7 +1580,6 @@ Underlying error: ${err instanceof Error ? err.message : String(err)}`);
|
|
|
1659
1580
|
var CloudflareReloader = class {
|
|
1660
1581
|
worker = null;
|
|
1661
1582
|
wrangler;
|
|
1662
|
-
outputDir;
|
|
1663
1583
|
port;
|
|
1664
1584
|
configPath;
|
|
1665
1585
|
envFiles;
|
|
@@ -1689,10 +1609,9 @@ var CloudflareReloader = class {
|
|
|
1689
1609
|
url;
|
|
1690
1610
|
constructor(wrangler, opts) {
|
|
1691
1611
|
this.wrangler = wrangler;
|
|
1692
|
-
this.outputDir = opts.outputDir;
|
|
1693
1612
|
this.port = opts.port;
|
|
1694
1613
|
this.envFiles = opts.envFiles;
|
|
1695
|
-
this.configPath = path.join(
|
|
1614
|
+
this.configPath = path.join(opts.output, "wrangler.jsonc");
|
|
1696
1615
|
this.containerBuildId = randomUUID().slice(0, 8);
|
|
1697
1616
|
}
|
|
1698
1617
|
async start() {
|
|
@@ -1711,19 +1630,26 @@ var CloudflareReloader = class {
|
|
|
1711
1630
|
* so we have to re-parse them. (Plain body edits redo a tiny amount
|
|
1712
1631
|
* of work but the rebuild is cheap and idempotent.)
|
|
1713
1632
|
* - Changes to `roles/*.md` — roles are baked into the entry as JSON.
|
|
1633
|
+
* - Adds/removes/edits of `app.{ts,mts,js,mjs}` — discovery flips the
|
|
1634
|
+
* entry between the user-app form and the default-app fallback,
|
|
1635
|
+
* and the import path is baked into `_entry.ts`. Body edits are
|
|
1636
|
+
* handled by wrangler's source watcher, but emitting a rebuild on
|
|
1637
|
+
* the path itself is cheap and means add/remove is correctly
|
|
1638
|
+
* observed even when the user toggles the file in/out.
|
|
1714
1639
|
* - Changes to the user's `wrangler.jsonc` — affects the merged config.
|
|
1715
1640
|
*
|
|
1716
1641
|
* Notes we explicitly DO ignore for rebuild purposes (wrangler handles
|
|
1717
|
-
* them): edits to imported source files outside of `agents/`/`roles
|
|
1718
|
-
* AGENTS.md, and `.agents/skills/` (those are runtime-
|
|
1719
|
-
* baked into the entry).
|
|
1642
|
+
* them): edits to imported source files outside of `agents/`/`roles/`/
|
|
1643
|
+
* `app.*`, AGENTS.md, and `.agents/skills/` (those are runtime-
|
|
1644
|
+
* discovered, not baked into the entry).
|
|
1720
1645
|
*/
|
|
1721
1646
|
shouldRebuildOn(relPath) {
|
|
1722
1647
|
if (this.envFiles.includes(relPath)) return true;
|
|
1723
1648
|
const normalized = relPath.replace(/\\/g, "/");
|
|
1724
1649
|
if (normalized === "wrangler.jsonc" || normalized === "wrangler.json" || normalized === "wrangler.toml") return true;
|
|
1725
|
-
if (normalized.startsWith("agents/")) return true;
|
|
1726
|
-
if (normalized.startsWith("roles/")) return true;
|
|
1650
|
+
if (normalized.startsWith("agents/") || normalized.startsWith(".flue/agents/")) return true;
|
|
1651
|
+
if (normalized.startsWith("roles/") || normalized.startsWith(".flue/roles/")) return true;
|
|
1652
|
+
if (/^(?:\.flue\/)?app\.(?:ts|mts|js|mjs)$/.test(normalized)) return true;
|
|
1727
1653
|
return false;
|
|
1728
1654
|
}
|
|
1729
1655
|
async reload(buildChanged) {
|
|
@@ -1820,32 +1746,18 @@ function parseEnvFiles(absolutePaths) {
|
|
|
1820
1746
|
}
|
|
1821
1747
|
return merged;
|
|
1822
1748
|
}
|
|
1823
|
-
async function waitForHealth(baseUrl, timeoutMs) {
|
|
1824
|
-
const start = Date.now();
|
|
1825
|
-
while (Date.now() - start < timeoutMs) {
|
|
1826
|
-
try {
|
|
1827
|
-
const controller = new AbortController();
|
|
1828
|
-
const timeout = setTimeout(() => controller.abort(), 1e3);
|
|
1829
|
-
const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
|
|
1830
|
-
clearTimeout(timeout);
|
|
1831
|
-
if (res.ok) return true;
|
|
1832
|
-
} catch {}
|
|
1833
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
1834
|
-
}
|
|
1835
|
-
return false;
|
|
1836
|
-
}
|
|
1837
1749
|
/**
|
|
1838
1750
|
* Pick a webhook agent name to print in the friendly curl example. Falls back
|
|
1839
1751
|
* to any agent if none have webhook triggers (the example would 404 on the
|
|
1840
1752
|
* dev server in that case, but it's still a hint at the URL shape). Reads the
|
|
1841
|
-
* manifest written by the build
|
|
1842
|
-
* manifest is somehow missing.
|
|
1753
|
+
* manifest written by the build at `<output>/manifest.json`, with a
|
|
1754
|
+
* source-tree scan fallback in case the manifest is somehow missing.
|
|
1843
1755
|
*
|
|
1844
1756
|
* Best-effort — silently returns null if anything goes wrong.
|
|
1845
1757
|
*/
|
|
1846
|
-
function pickExampleAgentName(
|
|
1758
|
+
function pickExampleAgentName(output, root) {
|
|
1847
1759
|
try {
|
|
1848
|
-
const manifestPath = path.join(
|
|
1760
|
+
const manifestPath = path.join(output, "manifest.json");
|
|
1849
1761
|
if (fs.existsSync(manifestPath)) {
|
|
1850
1762
|
const agents = JSON.parse(fs.readFileSync(manifestPath, "utf-8")).agents ?? [];
|
|
1851
1763
|
const webhook = agents.find((a) => a.triggers?.webhook);
|
|
@@ -1854,7 +1766,7 @@ function pickExampleAgentName(outputDir, workspaceDir) {
|
|
|
1854
1766
|
}
|
|
1855
1767
|
} catch {}
|
|
1856
1768
|
try {
|
|
1857
|
-
const agentsDir = path.join(
|
|
1769
|
+
const agentsDir = path.join(resolveSourceRoot(root), "agents");
|
|
1858
1770
|
if (!fs.existsSync(agentsDir)) return null;
|
|
1859
1771
|
for (const e of fs.readdirSync(agentsDir)) {
|
|
1860
1772
|
const m = e.match(/^([a-zA-Z0-9_-]+)\.(ts|js|mts|mjs)$/);
|
|
@@ -1867,4 +1779,4 @@ function pickExampleAgentName(outputDir, workspaceDir) {
|
|
|
1867
1779
|
}
|
|
1868
1780
|
|
|
1869
1781
|
//#endregion
|
|
1870
|
-
export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, build, createTools, dev, parseEnvFiles, resolveEnvFiles,
|
|
1782
|
+
export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, ResultUnavailableError, build, createTools, dev, parseEnvFiles, resolveEnvFiles, resolveSourceRoot };
|