@flue/sdk 0.4.1 → 0.5.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 +44 -42
- package/dist/app.d.mts +2 -2
- package/dist/app.mjs +2 -2
- package/dist/client.d.mts +7 -3
- package/dist/client.mjs +86 -14
- package/dist/cloudflare/index.d.mts +1 -1
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/{flue-app-CG8i4wNG.d.mts → flue-app-O4_iqLkn.d.mts} +69 -4
- package/dist/{flue-app-DeTOZjPs.mjs → flue-app-SjL4I83Y.mjs} +520 -112
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +56 -6
- package/dist/internal.d.mts +40 -3
- package/dist/internal.mjs +284 -5
- package/dist/{mcp-C3UBXVkR.d.mts → mcp-BfcWmA-A.d.mts} +1 -1
- package/dist/{mcp-DM6yv_Qc.mjs → mcp-DwLSoSxp.mjs} +52 -38
- package/dist/node/index.d.mts +1 -1
- package/dist/{providers-DeFRIwp0.mjs → providers-BjEEoKLy.mjs} +11 -1
- package/dist/sandbox.d.mts +1 -1
- package/dist/sandbox.mjs +2 -2
- package/dist/{session-CFOByKnM.mjs → session-CRFfAJDq.mjs} +201 -79
- package/dist/{types-BAmV4f3Q.d.mts → types-Cdcq_ET2.d.mts} +87 -32
- package/package.json +2 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { u as generateRunId } from "./providers-BjEEoKLy.mjs";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
|
|
3
4
|
//#region src/errors.ts
|
|
@@ -221,6 +222,28 @@ var RouteNotFoundError = class extends FlueHttpError {
|
|
|
221
222
|
});
|
|
222
223
|
}
|
|
223
224
|
};
|
|
225
|
+
var RunNotFoundError = class extends FlueHttpError {
|
|
226
|
+
constructor({ runId }) {
|
|
227
|
+
super({
|
|
228
|
+
type: "run_not_found",
|
|
229
|
+
message: `Run "${runId}" was not found.`,
|
|
230
|
+
details: "Verify the run id is correct and still within retention.",
|
|
231
|
+
dev: "",
|
|
232
|
+
status: 404
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
var RunStoreUnavailableError = class extends FlueHttpError {
|
|
237
|
+
constructor() {
|
|
238
|
+
super({
|
|
239
|
+
type: "run_store_unavailable",
|
|
240
|
+
message: "Run history is not available in this runtime.",
|
|
241
|
+
details: "This endpoint requires the generated runtime to be configured with a run store.",
|
|
242
|
+
dev: "",
|
|
243
|
+
status: 501
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
};
|
|
224
247
|
var InvalidRequestError = class extends FlueHttpError {
|
|
225
248
|
constructor({ reason }) {
|
|
226
249
|
super({
|
|
@@ -334,25 +357,6 @@ function toHttpResponse(err) {
|
|
|
334
357
|
});
|
|
335
358
|
}
|
|
336
359
|
/**
|
|
337
|
-
* Render any thrown value into a JSON string suitable for the `data:` line of
|
|
338
|
-
* an SSE `error` event. Same envelope as `toHttpResponse`. Unknown / non-Flue
|
|
339
|
-
* errors are logged and replaced with a generic envelope.
|
|
340
|
-
*/
|
|
341
|
-
function toSseData(err) {
|
|
342
|
-
if (isFlueError(err)) {
|
|
343
|
-
if (!(err instanceof FlueHttpError)) flueLog.error(err);
|
|
344
|
-
return JSON.stringify({
|
|
345
|
-
type: "error",
|
|
346
|
-
...envelope(err)
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
flueLog.error(err);
|
|
350
|
-
return JSON.stringify({
|
|
351
|
-
type: "error",
|
|
352
|
-
...GENERIC_INTERNAL
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
360
|
* Parse a request body as JSON. Returns `{}` for genuinely empty bodies
|
|
357
361
|
* (Content-Length: 0 or missing) so that webhook agents which don't accept
|
|
358
362
|
* a payload can be invoked without one.
|
|
@@ -385,6 +389,16 @@ function validateAgentRequest(opts) {
|
|
|
385
389
|
method: opts.method,
|
|
386
390
|
allowed: ["POST"]
|
|
387
391
|
});
|
|
392
|
+
validateAgentIdentity(opts);
|
|
393
|
+
}
|
|
394
|
+
function validateAgentRunRequest(opts) {
|
|
395
|
+
if (opts.method !== "GET") throw new MethodNotAllowedError({
|
|
396
|
+
method: opts.method,
|
|
397
|
+
allowed: ["GET"]
|
|
398
|
+
});
|
|
399
|
+
validateAgentIdentity(opts);
|
|
400
|
+
}
|
|
401
|
+
function validateAgentIdentity(opts) {
|
|
388
402
|
if (opts.name.trim() === "" || opts.id.trim() === "") throw new InvalidRequestError({ reason: "Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments." });
|
|
389
403
|
if (!opts.registeredAgents.includes(opts.name)) throw new AgentNotFoundError({
|
|
390
404
|
name: opts.name,
|
|
@@ -395,7 +409,6 @@ function validateAgentRequest(opts) {
|
|
|
395
409
|
|
|
396
410
|
//#endregion
|
|
397
411
|
//#region src/runtime/handle-agent.ts
|
|
398
|
-
/** Shared per-agent HTTP dispatcher for the Node and Cloudflare targets. */
|
|
399
412
|
/**
|
|
400
413
|
* Dispatch a single `/agents/:name/:id` request. The mode is chosen by
|
|
401
414
|
* inspecting headers:
|
|
@@ -418,9 +431,10 @@ function validateAgentRequest(opts) {
|
|
|
418
431
|
* already been validated as a POST against a registered agent.
|
|
419
432
|
*/
|
|
420
433
|
async function handleAgentRequest(opts) {
|
|
421
|
-
const { request, agentName, id, handler, createContext } = opts;
|
|
434
|
+
const { request, agentName, id, handler, createContext, runStore, runSubscribers } = opts;
|
|
422
435
|
const startWebhook = opts.startWebhook ?? defaultStartWebhook;
|
|
423
436
|
const runHandler = opts.runHandler ?? defaultRunHandler;
|
|
437
|
+
const runId = generateRunId();
|
|
424
438
|
try {
|
|
425
439
|
const payload = await parseJsonBody(request);
|
|
426
440
|
const accept = request.headers.get("accept") || "";
|
|
@@ -429,77 +443,92 @@ async function handleAgentRequest(opts) {
|
|
|
429
443
|
if (isWebhook) return runWebhookMode({
|
|
430
444
|
agentName,
|
|
431
445
|
id,
|
|
446
|
+
runId,
|
|
432
447
|
handler,
|
|
433
448
|
payload,
|
|
434
449
|
request,
|
|
435
450
|
createContext,
|
|
436
|
-
startWebhook
|
|
451
|
+
startWebhook,
|
|
452
|
+
runStore,
|
|
453
|
+
runSubscribers
|
|
437
454
|
});
|
|
438
455
|
if (isSSE) return runSseMode({
|
|
456
|
+
agentName,
|
|
439
457
|
id,
|
|
458
|
+
runId,
|
|
440
459
|
handler,
|
|
441
460
|
payload,
|
|
442
461
|
request,
|
|
443
462
|
createContext,
|
|
444
|
-
runHandler
|
|
463
|
+
runHandler,
|
|
464
|
+
runStore,
|
|
465
|
+
runSubscribers
|
|
445
466
|
});
|
|
446
467
|
return runSyncMode({
|
|
468
|
+
agentName,
|
|
447
469
|
id,
|
|
470
|
+
runId,
|
|
448
471
|
handler,
|
|
449
472
|
payload,
|
|
450
473
|
request,
|
|
451
474
|
createContext,
|
|
452
|
-
runHandler
|
|
475
|
+
runHandler,
|
|
476
|
+
runStore,
|
|
477
|
+
runSubscribers
|
|
453
478
|
});
|
|
454
479
|
} catch (err) {
|
|
455
|
-
|
|
480
|
+
const response = toHttpResponse(err);
|
|
481
|
+
response.headers.set("X-Flue-Run-Id", runId);
|
|
482
|
+
return response;
|
|
456
483
|
}
|
|
457
484
|
}
|
|
458
|
-
function runWebhookMode(opts) {
|
|
459
|
-
const { agentName, id, handler, payload, request, createContext, startWebhook } = opts;
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
485
|
+
async function runWebhookMode(opts) {
|
|
486
|
+
const { agentName, id, runId, handler, payload, request, createContext, startWebhook, runStore, runSubscribers } = opts;
|
|
487
|
+
const lifecycle = await createRunLifecycle({
|
|
488
|
+
agentName,
|
|
489
|
+
id,
|
|
490
|
+
runId,
|
|
491
|
+
payload,
|
|
492
|
+
request,
|
|
493
|
+
createContext,
|
|
494
|
+
runStore,
|
|
495
|
+
runSubscribers
|
|
496
|
+
});
|
|
497
|
+
const { ctx } = lifecycle;
|
|
498
|
+
const run = async () => withRunLifecycle(lifecycle, () => handler(ctx));
|
|
499
|
+
startWebhook(runId, run).then((result) => {
|
|
470
500
|
console.log("[flue] Webhook handler complete:", agentName, result !== void 0 ? JSON.stringify(result) : "(no return)");
|
|
471
501
|
}, (err) => {
|
|
472
502
|
console.error("[flue] Webhook handler error:", agentName, err);
|
|
473
503
|
});
|
|
474
504
|
return new Response(JSON.stringify({
|
|
475
505
|
status: "accepted",
|
|
476
|
-
|
|
506
|
+
runId
|
|
477
507
|
}), {
|
|
478
508
|
status: 202,
|
|
479
|
-
headers: {
|
|
509
|
+
headers: {
|
|
510
|
+
"content-type": "application/json",
|
|
511
|
+
"X-Flue-Run-Id": runId
|
|
512
|
+
}
|
|
480
513
|
});
|
|
481
514
|
}
|
|
482
515
|
/**
|
|
483
|
-
*
|
|
484
|
-
* less than the existence of *some* periodic payload — the heartbeat exists
|
|
485
|
-
* to defeat intermediary timeouts (Node's default 300s requestTimeout, CDN
|
|
486
|
-
* proxies, browser EventSource reconnect heuristics). 25s is the conventional
|
|
487
|
-
* choice and matches what Hono's `streamSSE` defaults to.
|
|
516
|
+
* Shared heartbeat interval for SSE streams.
|
|
488
517
|
*/
|
|
489
|
-
const SSE_HEARTBEAT_MS =
|
|
518
|
+
const SSE_HEARTBEAT_MS = 15e3;
|
|
490
519
|
function runSseMode(opts) {
|
|
491
|
-
const { id, handler, payload, request, createContext, runHandler } = opts;
|
|
520
|
+
const { agentName, id, runId, handler, payload, request, createContext, runHandler, runStore, runSubscribers } = opts;
|
|
492
521
|
const { readable, writable } = new TransformStream();
|
|
493
522
|
const writer = writable.getWriter();
|
|
494
523
|
const encoder = new TextEncoder();
|
|
495
|
-
let eventId = 0;
|
|
496
524
|
let isIdle = false;
|
|
497
525
|
let closed = false;
|
|
498
|
-
const writeSSE = async (data,
|
|
526
|
+
const writeSSE = async (data, eventType) => {
|
|
499
527
|
if (closed) return;
|
|
528
|
+
const eventIndex = getEventIndex(data) ?? 0;
|
|
500
529
|
const lines = [];
|
|
501
|
-
lines.push(`event: ${
|
|
502
|
-
lines.push(`id: ${
|
|
530
|
+
lines.push(`event: ${eventType}`);
|
|
531
|
+
lines.push(`id: ${eventIndex}`);
|
|
503
532
|
lines.push(`data: ${typeof data === "string" ? data : JSON.stringify(data)}`);
|
|
504
533
|
lines.push("", "");
|
|
505
534
|
try {
|
|
@@ -515,23 +544,31 @@ function runSseMode(opts) {
|
|
|
515
544
|
const heartbeat = setInterval(() => {
|
|
516
545
|
writeHeartbeat().catch(() => {});
|
|
517
546
|
}, SSE_HEARTBEAT_MS);
|
|
518
|
-
const ctx = createContext(id, payload, request);
|
|
519
|
-
ctx.setEventCallback((event) => {
|
|
520
|
-
if (event.type === "idle") isIdle = true;
|
|
521
|
-
writeSSE(event, event.type).catch(() => {});
|
|
522
|
-
});
|
|
523
547
|
(async () => {
|
|
548
|
+
const lifecycle = await createRunLifecycle({
|
|
549
|
+
agentName,
|
|
550
|
+
id,
|
|
551
|
+
runId,
|
|
552
|
+
payload,
|
|
553
|
+
request,
|
|
554
|
+
createContext,
|
|
555
|
+
runStore,
|
|
556
|
+
runSubscribers
|
|
557
|
+
});
|
|
558
|
+
const { ctx } = lifecycle;
|
|
559
|
+
ctx.setEventCallback((event) => {
|
|
560
|
+
if (event.type === "idle") isIdle = true;
|
|
561
|
+
writeSSE(event, event.type).catch(() => {});
|
|
562
|
+
});
|
|
524
563
|
try {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (!isIdle) await writeSSE({ type: "idle" }, "idle");
|
|
534
|
-
} finally {
|
|
564
|
+
await withRunLifecycle(lifecycle, async () => {
|
|
565
|
+
try {
|
|
566
|
+
return await runHandler(ctx, handler);
|
|
567
|
+
} finally {
|
|
568
|
+
if (!isIdle) ctx.emitEvent({ type: "idle" });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
} catch {} finally {
|
|
535
572
|
clearInterval(heartbeat);
|
|
536
573
|
ctx.setEventCallback(void 0);
|
|
537
574
|
closed = true;
|
|
@@ -543,77 +580,413 @@ function runSseMode(opts) {
|
|
|
543
580
|
return new Response(readable, { headers: {
|
|
544
581
|
"content-type": "text/event-stream",
|
|
545
582
|
"cache-control": "no-cache",
|
|
546
|
-
connection: "keep-alive"
|
|
583
|
+
connection: "keep-alive",
|
|
584
|
+
"X-Flue-Run-Id": runId
|
|
547
585
|
} });
|
|
548
586
|
}
|
|
549
587
|
async function runSyncMode(opts) {
|
|
550
|
-
const { id, handler, payload, request, createContext, runHandler } = opts;
|
|
551
|
-
const
|
|
588
|
+
const { agentName, id, runId, handler, payload, request, createContext, runHandler, runStore, runSubscribers } = opts;
|
|
589
|
+
const lifecycle = await createRunLifecycle({
|
|
590
|
+
agentName,
|
|
591
|
+
id,
|
|
592
|
+
runId,
|
|
593
|
+
payload,
|
|
594
|
+
request,
|
|
595
|
+
createContext,
|
|
596
|
+
runStore,
|
|
597
|
+
runSubscribers
|
|
598
|
+
});
|
|
599
|
+
const { ctx } = lifecycle;
|
|
552
600
|
try {
|
|
553
|
-
const result = await runHandler(ctx, handler);
|
|
554
|
-
return new Response(JSON.stringify({
|
|
601
|
+
const result = await withRunLifecycle(lifecycle, () => runHandler(ctx, handler));
|
|
602
|
+
return new Response(JSON.stringify({
|
|
603
|
+
result: result === void 0 ? null : result,
|
|
604
|
+
_meta: { runId }
|
|
605
|
+
}), { headers: {
|
|
606
|
+
"content-type": "application/json",
|
|
607
|
+
"X-Flue-Run-Id": runId
|
|
608
|
+
} });
|
|
555
609
|
} finally {
|
|
556
610
|
ctx.setEventCallback(void 0);
|
|
557
611
|
}
|
|
558
612
|
}
|
|
613
|
+
async function createRunLifecycle(options) {
|
|
614
|
+
const startedAtMs = Date.now();
|
|
615
|
+
const startedAt = new Date(startedAtMs).toISOString();
|
|
616
|
+
const ctx = options.createContext(options.id, options.runId, options.payload, options.request);
|
|
617
|
+
await safeRunStore("createRun", () => options.runStore?.createRun({
|
|
618
|
+
runId: options.runId,
|
|
619
|
+
instanceId: options.id,
|
|
620
|
+
agentName: options.agentName,
|
|
621
|
+
startedAt,
|
|
622
|
+
payload: options.payload
|
|
623
|
+
}));
|
|
624
|
+
return {
|
|
625
|
+
...options,
|
|
626
|
+
ctx,
|
|
627
|
+
startedAt,
|
|
628
|
+
startedAtMs
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Wrap all invocation modes with the same run-start/run-end envelope.
|
|
633
|
+
*/
|
|
634
|
+
async function withRunLifecycle(lifecycle, body) {
|
|
635
|
+
const unsubscribeFanout = subscribeRunFanout(lifecycle);
|
|
636
|
+
emitRunStart(lifecycle);
|
|
637
|
+
try {
|
|
638
|
+
const result = await body();
|
|
639
|
+
await emitRunEnd(lifecycle, {
|
|
640
|
+
result,
|
|
641
|
+
isError: false
|
|
642
|
+
});
|
|
643
|
+
return result;
|
|
644
|
+
} catch (error) {
|
|
645
|
+
await emitRunEnd(lifecycle, {
|
|
646
|
+
isError: true,
|
|
647
|
+
error
|
|
648
|
+
});
|
|
649
|
+
throw error;
|
|
650
|
+
} finally {
|
|
651
|
+
unsubscribeFanout();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function emitRunStart(lifecycle) {
|
|
655
|
+
lifecycle.ctx.emitEvent({
|
|
656
|
+
type: "run_start",
|
|
657
|
+
runId: lifecycle.runId,
|
|
658
|
+
instanceId: lifecycle.id,
|
|
659
|
+
agentName: lifecycle.agentName,
|
|
660
|
+
startedAt: lifecycle.startedAt,
|
|
661
|
+
payload: lifecycle.payload
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Emit `run_end` and finalize the run.
|
|
666
|
+
*
|
|
667
|
+
* Terminal ordering matters for `/runs/:runId/stream`: append `run_end`
|
|
668
|
+
* before marking the run terminal, then publish and close subscribers.
|
|
669
|
+
*/
|
|
670
|
+
async function emitRunEnd(lifecycle, input) {
|
|
671
|
+
const endedAtMs = Date.now();
|
|
672
|
+
const endedAt = new Date(endedAtMs).toISOString();
|
|
673
|
+
const durationMs = endedAtMs - lifecycle.startedAtMs;
|
|
674
|
+
const result = input.isError ? void 0 : input.result;
|
|
675
|
+
const error = input.isError ? serializeError(input.error) : void 0;
|
|
676
|
+
const normalizedResult = result === void 0 ? null : result;
|
|
677
|
+
const { runStore, runSubscribers, runId } = lifecycle;
|
|
678
|
+
const decorated = lifecycle.ctx.emitEvent({
|
|
679
|
+
type: "run_end",
|
|
680
|
+
runId,
|
|
681
|
+
result: normalizedResult,
|
|
682
|
+
isError: input.isError,
|
|
683
|
+
error,
|
|
684
|
+
durationMs
|
|
685
|
+
});
|
|
686
|
+
await safeRunStore("appendEvent(run_end)", () => runStore?.appendEvent(runId, decorated));
|
|
687
|
+
runSubscribers?.publish(runId, decorated);
|
|
688
|
+
await safeRunStore("endRun", () => runStore?.endRun({
|
|
689
|
+
runId,
|
|
690
|
+
endedAt,
|
|
691
|
+
isError: input.isError,
|
|
692
|
+
durationMs,
|
|
693
|
+
result,
|
|
694
|
+
error
|
|
695
|
+
}));
|
|
696
|
+
runSubscribers?.complete(runId);
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Persist non-terminal events before publishing them to live subscribers.
|
|
700
|
+
* `run_end` is handled separately by {@link emitRunEnd}.
|
|
701
|
+
*/
|
|
702
|
+
function subscribeRunFanout(lifecycle) {
|
|
703
|
+
const { ctx, runStore, runSubscribers, runId } = lifecycle;
|
|
704
|
+
if (!runStore && !runSubscribers) return () => {};
|
|
705
|
+
let chain = Promise.resolve();
|
|
706
|
+
return ctx.subscribeEvent((event) => {
|
|
707
|
+
if (event.type === "run_end") return;
|
|
708
|
+
chain = chain.then(() => fanOutEvent(runStore, runSubscribers, runId, event));
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
async function fanOutEvent(runStore, runSubscribers, runId, event) {
|
|
712
|
+
if (runStore) try {
|
|
713
|
+
await runStore.appendEvent(runId, event);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
console.error("[flue:run-store] appendEvent failed:", error);
|
|
716
|
+
}
|
|
717
|
+
runSubscribers?.publish(runId, event);
|
|
718
|
+
}
|
|
719
|
+
async function safeRunStore(label, fn) {
|
|
720
|
+
try {
|
|
721
|
+
await fn();
|
|
722
|
+
} catch (error) {
|
|
723
|
+
console.error(`[flue:run-store] ${label} failed:`, error);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function serializeError(error) {
|
|
727
|
+
if (error instanceof Error) return {
|
|
728
|
+
name: error.name,
|
|
729
|
+
message: error.message
|
|
730
|
+
};
|
|
731
|
+
return error;
|
|
732
|
+
}
|
|
733
|
+
function getEventIndex(data) {
|
|
734
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
735
|
+
const value = data.eventIndex;
|
|
736
|
+
return typeof value === "number" ? value : void 0;
|
|
737
|
+
}
|
|
559
738
|
/**
|
|
560
739
|
* Default webhook runner: invoke `run()` directly so the handler executes
|
|
561
740
|
* in the current process. Used by the Node target. The Cloudflare target
|
|
562
741
|
* overrides this with a `runFiber` wrapper for crash-recoverable execution
|
|
563
742
|
* across DO hibernation.
|
|
564
743
|
*/
|
|
565
|
-
const defaultStartWebhook = (
|
|
744
|
+
const defaultStartWebhook = (_runId, run) => run();
|
|
566
745
|
/**
|
|
567
746
|
* Default foreground handler runner: invoke directly. Used by the Node
|
|
568
747
|
* target. The Cloudflare target overrides this with a `keepAliveWhile`
|
|
569
748
|
* wrapper.
|
|
570
749
|
*/
|
|
571
750
|
const defaultRunHandler = (ctx, handler) => handler(ctx);
|
|
751
|
+
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/runtime/handle-run-routes.ts
|
|
754
|
+
/** Run-history HTTP endpoints shared by the Node and Cloudflare targets. */
|
|
755
|
+
const EVENTS_DEFAULT_LIMIT = 100;
|
|
756
|
+
const EVENTS_MAX_LIMIT = 1e3;
|
|
757
|
+
/** Buffer cap for events published while a live stream is replaying history. */
|
|
758
|
+
const REPLAY_BUFFER_CAP = 1e3;
|
|
759
|
+
async function handleRunRouteRequest(opts) {
|
|
760
|
+
const store = opts.runStore;
|
|
761
|
+
if (!store) throw new RunStoreUnavailableError();
|
|
762
|
+
switch (opts.action) {
|
|
763
|
+
case "get": return getRun(store, requireRunId(opts.runId), opts.agentName, opts.id);
|
|
764
|
+
case "events": return getRunEvents(opts.request, store, requireRunId(opts.runId), opts.agentName, opts.id);
|
|
765
|
+
case "stream": return streamRunEvents(opts.request, store, opts.runSubscribers, requireRunId(opts.runId), opts.agentName, opts.id);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async function getRun(store, runId, agentName, instanceId) {
|
|
769
|
+
return json(await getRunForInstance(store, runId, agentName, instanceId));
|
|
770
|
+
}
|
|
771
|
+
async function getRunEvents(request, store, runId, agentName, instanceId) {
|
|
772
|
+
await getRunForInstance(store, runId, agentName, instanceId);
|
|
773
|
+
const url = new URL(request.url);
|
|
774
|
+
const after = parseEventIndex(url.searchParams.get("after"));
|
|
775
|
+
const types = parseTypes(url.searchParams.get("types"));
|
|
776
|
+
const limit = parseLimit(url.searchParams.get("limit"), EVENTS_DEFAULT_LIMIT, EVENTS_MAX_LIMIT);
|
|
777
|
+
let events = await store.getEvents(runId, after === void 0 ? void 0 : after + 1);
|
|
778
|
+
if (types) events = events.filter((event) => types.has(event.type));
|
|
779
|
+
return json({ events: events.slice(0, limit) });
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Replay durable history, then tail live events for active runs.
|
|
783
|
+
* Subscribe-before-replay avoids dropping events produced during the
|
|
784
|
+
* store read; eventIndex dedup handles overlap.
|
|
785
|
+
*/
|
|
786
|
+
async function streamRunEvents(request, store, subscribers, runId, agentName, instanceId) {
|
|
787
|
+
const run = await getRunForInstance(store, runId, agentName, instanceId);
|
|
788
|
+
const lastEventId = parseLastEventId(request.headers.get("last-event-id"));
|
|
789
|
+
const fromIndex = lastEventId === void 0 ? void 0 : lastEventId + 1;
|
|
790
|
+
if (isTerminal(run)) return sseResponse(encodeSseEvents(await store.getEvents(runId, fromIndex)));
|
|
791
|
+
if (!subscribers) throw new Error("[flue] Active run streaming requires a run subscriber registry, but none was configured for this target. Wire one through HandleRunRouteOptions.runSubscribers.");
|
|
792
|
+
return streamReplayThenTail({
|
|
793
|
+
store,
|
|
794
|
+
subscribers,
|
|
795
|
+
runId,
|
|
796
|
+
fromIndex
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function streamReplayThenTail(opts) {
|
|
800
|
+
const { store, subscribers, runId, fromIndex } = opts;
|
|
801
|
+
const encoder = new TextEncoder();
|
|
802
|
+
let buffer = [];
|
|
803
|
+
let bufferOverflowed = false;
|
|
804
|
+
let replayDone = false;
|
|
805
|
+
let lastSentIndex = fromIndex === void 0 ? void 0 : fromIndex - 1;
|
|
806
|
+
let closed = false;
|
|
807
|
+
let onLiveEvent;
|
|
808
|
+
let onClose;
|
|
809
|
+
const subscriberListener = (event) => {
|
|
810
|
+
if (closed) return;
|
|
811
|
+
if (!replayDone) {
|
|
812
|
+
if (buffer.length >= REPLAY_BUFFER_CAP) {
|
|
813
|
+
bufferOverflowed = true;
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
buffer.push(event);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
onLiveEvent?.(event);
|
|
820
|
+
};
|
|
821
|
+
const unsubscribe = subscribers.subscribe(runId, subscriberListener);
|
|
822
|
+
return sseResponse(new ReadableStream({
|
|
823
|
+
start(controller) {
|
|
824
|
+
const heartbeat = setInterval(() => {
|
|
825
|
+
if (closed) return;
|
|
826
|
+
try {
|
|
827
|
+
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
|
828
|
+
} catch {}
|
|
829
|
+
}, SSE_HEARTBEAT_MS);
|
|
830
|
+
const close = () => {
|
|
831
|
+
if (closed) return;
|
|
832
|
+
closed = true;
|
|
833
|
+
clearInterval(heartbeat);
|
|
834
|
+
unsubscribe();
|
|
835
|
+
try {
|
|
836
|
+
controller.close();
|
|
837
|
+
} catch {}
|
|
838
|
+
};
|
|
839
|
+
onClose = close;
|
|
840
|
+
const write = (event) => {
|
|
841
|
+
if (closed) return;
|
|
842
|
+
try {
|
|
843
|
+
controller.enqueue(encoder.encode(encodeSseEvent(event)));
|
|
844
|
+
} catch {
|
|
845
|
+
close();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (typeof event.eventIndex === "number") lastSentIndex = event.eventIndex;
|
|
849
|
+
if (event.type === "run_end") close();
|
|
850
|
+
};
|
|
851
|
+
onLiveEvent = write;
|
|
852
|
+
(async () => {
|
|
853
|
+
try {
|
|
854
|
+
await runReplayPhase({
|
|
855
|
+
store,
|
|
856
|
+
runId,
|
|
857
|
+
fromIndex,
|
|
858
|
+
write,
|
|
859
|
+
getBuffer: () => buffer,
|
|
860
|
+
drainBuffer: () => {
|
|
861
|
+
const drained = buffer;
|
|
862
|
+
buffer = [];
|
|
863
|
+
return drained;
|
|
864
|
+
},
|
|
865
|
+
getBufferOverflowed: () => bufferOverflowed,
|
|
866
|
+
resetBufferOverflowed: () => {
|
|
867
|
+
bufferOverflowed = false;
|
|
868
|
+
},
|
|
869
|
+
getLastSentIndex: () => lastSentIndex,
|
|
870
|
+
markReplayDone: () => {
|
|
871
|
+
replayDone = true;
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (closed) return;
|
|
876
|
+
try {
|
|
877
|
+
controller.enqueue(encoder.encode(encodeSseError(error, lastSentIndex)));
|
|
878
|
+
} catch {}
|
|
879
|
+
close();
|
|
880
|
+
}
|
|
881
|
+
})();
|
|
882
|
+
},
|
|
883
|
+
cancel() {
|
|
884
|
+
closed = true;
|
|
885
|
+
onClose?.();
|
|
886
|
+
}
|
|
887
|
+
}));
|
|
888
|
+
}
|
|
889
|
+
async function runReplayPhase(opts) {
|
|
890
|
+
const { store, runId, fromIndex, write, drainBuffer, getBufferOverflowed, resetBufferOverflowed, getLastSentIndex, markReplayDone } = opts;
|
|
891
|
+
const replay = await store.getEvents(runId, fromIndex);
|
|
892
|
+
for (const event of replay) write(event);
|
|
893
|
+
while (getBufferOverflowed()) {
|
|
894
|
+
resetBufferOverflowed();
|
|
895
|
+
const lastSent = getLastSentIndex();
|
|
896
|
+
const refetchFrom = lastSent === void 0 ? void 0 : lastSent + 1;
|
|
897
|
+
const refetched = await store.getEvents(runId, refetchFrom);
|
|
898
|
+
for (const event of refetched) write(event);
|
|
899
|
+
}
|
|
900
|
+
const buffered = drainBuffer();
|
|
901
|
+
for (const event of buffered) {
|
|
902
|
+
const lastSent = getLastSentIndex();
|
|
903
|
+
if (typeof event.eventIndex === "number" && lastSent !== void 0 && event.eventIndex <= lastSent) continue;
|
|
904
|
+
write(event);
|
|
905
|
+
}
|
|
906
|
+
markReplayDone();
|
|
907
|
+
}
|
|
908
|
+
async function getRunForInstance(store, runId, agentName, instanceId) {
|
|
909
|
+
const run = await store.getRun(runId);
|
|
910
|
+
if (!run) throw new RunNotFoundError({ runId });
|
|
911
|
+
if (run.agentName !== agentName || run.instanceId !== instanceId) throw new RunNotFoundError({ runId });
|
|
912
|
+
return run;
|
|
913
|
+
}
|
|
914
|
+
function isTerminal(run) {
|
|
915
|
+
return run.status === "completed" || run.status === "errored";
|
|
916
|
+
}
|
|
917
|
+
function encodeSseEvents(events) {
|
|
918
|
+
return events.map(encodeSseEvent).join("");
|
|
919
|
+
}
|
|
920
|
+
function encodeSseEvent(event) {
|
|
921
|
+
const id = typeof event.eventIndex === "number" ? event.eventIndex : 0;
|
|
922
|
+
return [
|
|
923
|
+
`event: ${event.type}`,
|
|
924
|
+
`id: ${id}`,
|
|
925
|
+
`data: ${JSON.stringify(event)}`,
|
|
926
|
+
"",
|
|
927
|
+
""
|
|
928
|
+
].join("\n");
|
|
929
|
+
}
|
|
930
|
+
function encodeSseError(error, lastSentIndex) {
|
|
931
|
+
const data = { message: error instanceof Error ? error.message : String(error) };
|
|
932
|
+
return [
|
|
933
|
+
`event: error`,
|
|
934
|
+
`id: ${lastSentIndex ?? 0}`,
|
|
935
|
+
`data: ${JSON.stringify(data)}`,
|
|
936
|
+
"",
|
|
937
|
+
""
|
|
938
|
+
].join("\n");
|
|
939
|
+
}
|
|
940
|
+
function sseResponse(body) {
|
|
941
|
+
return new Response(body, { headers: {
|
|
942
|
+
"content-type": "text/event-stream",
|
|
943
|
+
"cache-control": "no-cache",
|
|
944
|
+
connection: "keep-alive"
|
|
945
|
+
} });
|
|
946
|
+
}
|
|
947
|
+
function requireRunId(runId) {
|
|
948
|
+
if (!runId) throw new InvalidRequestError({ reason: "Run id is required for this endpoint." });
|
|
949
|
+
return runId;
|
|
950
|
+
}
|
|
951
|
+
function parseTypes(value) {
|
|
952
|
+
if (!value) return void 0;
|
|
953
|
+
const types = value.split(",").map((type) => type.trim()).filter(Boolean);
|
|
954
|
+
return types.length > 0 ? new Set(types) : void 0;
|
|
955
|
+
}
|
|
956
|
+
function parseLimit(value, defaultLimit, maxLimit) {
|
|
957
|
+
if (!value) return defaultLimit;
|
|
958
|
+
const parsed = Number.parseInt(value, 10);
|
|
959
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return defaultLimit;
|
|
960
|
+
return Math.min(parsed, maxLimit);
|
|
961
|
+
}
|
|
962
|
+
function parseEventIndex(value) {
|
|
963
|
+
if (!value) return void 0;
|
|
964
|
+
const parsed = Number.parseInt(value, 10);
|
|
965
|
+
if (!Number.isFinite(parsed) || parsed < 0) return void 0;
|
|
966
|
+
return parsed;
|
|
967
|
+
}
|
|
572
968
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
969
|
+
* `Last-Event-ID` is the standard SSE reconnect header. Browsers send the
|
|
970
|
+
* last `id:` field they saw; the server uses it to resume from that point.
|
|
971
|
+
* Malformed values are ignored — equivalent to no header.
|
|
576
972
|
*/
|
|
577
|
-
function
|
|
578
|
-
|
|
973
|
+
function parseLastEventId(value) {
|
|
974
|
+
if (!value) return void 0;
|
|
975
|
+
const parsed = Number.parseInt(value, 10);
|
|
976
|
+
if (!Number.isFinite(parsed) || parsed < 0) return void 0;
|
|
977
|
+
return parsed;
|
|
978
|
+
}
|
|
979
|
+
function json(data) {
|
|
980
|
+
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });
|
|
579
981
|
}
|
|
580
982
|
|
|
581
983
|
//#endregion
|
|
582
984
|
//#region src/runtime/flue-app.ts
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
* 1. **User `app.ts` files.** Users mount this sub-app inside their own
|
|
589
|
-
* Hono app via `app.route('/', flue())`. The user owns the outer
|
|
590
|
-
* Hono and controls everything around Flue's routes (logging,
|
|
591
|
-
* auth, custom routes, framework-level error handlers).
|
|
592
|
-
*
|
|
593
|
-
* 2. **The default fallback when no `app.ts` exists.** {@link
|
|
594
|
-
* createDefaultFlueApp} wraps `flue()` in a thin outer Hono so the
|
|
595
|
-
* no-customization case ships the same routes as it always has.
|
|
596
|
-
*
|
|
597
|
-
* Only the agent route at `/agents/:name/:id` is exposed. `/health` and
|
|
598
|
-
* `/agents` are NOT mounted — projects that want them add them in their
|
|
599
|
-
* own `app.ts`. The magic surface stays minimal; users opt in to
|
|
600
|
-
* whatever shape of liveness / introspection endpoint they actually
|
|
601
|
-
* want.
|
|
602
|
-
*
|
|
603
|
-
* Targets diverge inside the agent route:
|
|
604
|
-
*
|
|
605
|
-
* - **Node**: dispatches in-process via `handleAgentRequest` against
|
|
606
|
-
* the seeded handler map.
|
|
607
|
-
* - **Cloudflare**: forwards to `routeAgentRequest()` (provided by
|
|
608
|
-
* the seeded runtime), which reaches the per-agent Durable Object
|
|
609
|
-
* class. The DO's `onRequest` then calls `handleAgentRequest`
|
|
610
|
-
* itself with the CF-specific keepalive / fiber wrappers.
|
|
611
|
-
*
|
|
612
|
-
* The split is invisible to the user. They `import { flue } from
|
|
613
|
-
* '@flue/sdk/app'` and mount it the same way regardless of target. See
|
|
614
|
-
* {@link configureFlueRuntime} for the seeding contract that lets user
|
|
615
|
-
* `app.ts` files call `flue()` at top level.
|
|
616
|
-
*/
|
|
985
|
+
const RUN_ROUTES = [
|
|
986
|
+
["/agents/:name/:id/runs/:runId", "get"],
|
|
987
|
+
["/agents/:name/:id/runs/:runId/events", "events"],
|
|
988
|
+
["/agents/:name/:id/runs/:runId/stream", "stream"]
|
|
989
|
+
];
|
|
617
990
|
/** Module-scoped runtime config seeded by the generated server entry. */
|
|
618
991
|
let runtimeConfig;
|
|
619
992
|
/**
|
|
@@ -652,6 +1025,7 @@ function configureFlueRuntime(cfg) {
|
|
|
652
1025
|
function flue() {
|
|
653
1026
|
const app = new Hono();
|
|
654
1027
|
app.all("/agents/:name/:id", agentRouteHandler);
|
|
1028
|
+
for (const [routePath, action] of RUN_ROUTES) app.all(routePath, runRouteHandler(action));
|
|
655
1029
|
app.onError((err) => toHttpResponse(err));
|
|
656
1030
|
return app;
|
|
657
1031
|
}
|
|
@@ -701,7 +1075,9 @@ const agentRouteHandler = async (c) => {
|
|
|
701
1075
|
handler,
|
|
702
1076
|
createContext: rt.createContext,
|
|
703
1077
|
startWebhook: rt.startWebhook,
|
|
704
|
-
runHandler: rt.runHandler
|
|
1078
|
+
runHandler: rt.runHandler,
|
|
1079
|
+
runStore: rt.runStore,
|
|
1080
|
+
runSubscribers: rt.runSubscribers
|
|
705
1081
|
});
|
|
706
1082
|
}
|
|
707
1083
|
const response = await rt.routeAgentRequest(c.req.raw, c.env);
|
|
@@ -711,6 +1087,38 @@ const agentRouteHandler = async (c) => {
|
|
|
711
1087
|
path: new URL(c.req.url).pathname
|
|
712
1088
|
});
|
|
713
1089
|
};
|
|
1090
|
+
function runRouteHandler(action) {
|
|
1091
|
+
return async (c) => {
|
|
1092
|
+
const rt = runtimeConfig;
|
|
1093
|
+
if (!rt) throw new Error("[flue] flue() route invoked before runtime was configured. This usually means flue() was used outside a Flue-built server entry.");
|
|
1094
|
+
const name = c.req.param("name") ?? "";
|
|
1095
|
+
const id = c.req.param("id") ?? "";
|
|
1096
|
+
const runId = c.req.param("runId") || void 0;
|
|
1097
|
+
validateAgentRunRequest({
|
|
1098
|
+
method: c.req.method,
|
|
1099
|
+
name,
|
|
1100
|
+
id,
|
|
1101
|
+
registeredAgents: registeredAgentsFor(rt),
|
|
1102
|
+
webhookAgents: rt.webhookAgents,
|
|
1103
|
+
allowNonWebhook: rt.allowNonWebhook
|
|
1104
|
+
});
|
|
1105
|
+
if (rt.target === "node") return handleRunRouteRequest({
|
|
1106
|
+
request: c.req.raw,
|
|
1107
|
+
runStore: rt.runStore,
|
|
1108
|
+
runSubscribers: rt.runSubscribers,
|
|
1109
|
+
agentName: name,
|
|
1110
|
+
id,
|
|
1111
|
+
runId,
|
|
1112
|
+
action
|
|
1113
|
+
});
|
|
1114
|
+
const response = await rt.routeAgentRequest(c.req.raw, c.env);
|
|
1115
|
+
if (response) return response;
|
|
1116
|
+
throw new RouteNotFoundError({
|
|
1117
|
+
method: c.req.method,
|
|
1118
|
+
path: new URL(c.req.url).pathname
|
|
1119
|
+
});
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
714
1122
|
/**
|
|
715
1123
|
* Compute the set of agent names considered "registered" for purposes
|
|
716
1124
|
* of the agent route's name-validity check.
|
|
@@ -727,4 +1135,4 @@ function registeredAgentsFor(rt) {
|
|
|
727
1135
|
}
|
|
728
1136
|
|
|
729
1137
|
//#endregion
|
|
730
|
-
export { handleAgentRequest as i, createDefaultFlueApp as n, flue as r, configureFlueRuntime as t };
|
|
1138
|
+
export { handleAgentRequest as a, handleRunRouteRequest as i, createDefaultFlueApp as n, flue as r, configureFlueRuntime as t };
|