@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.
@@ -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
- return toHttpResponse(err);
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 requestId = generateRequestId();
461
- const ctx = createContext(id, payload, request);
462
- const run = async () => {
463
- try {
464
- return await handler(ctx);
465
- } finally {
466
- ctx.setEventCallback(void 0);
467
- }
468
- };
469
- startWebhook(requestId, run).then((result) => {
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
- requestId
506
+ runId
477
507
  }), {
478
508
  status: 202,
479
- headers: { "content-type": "application/json" }
509
+ headers: {
510
+ "content-type": "application/json",
511
+ "X-Flue-Run-Id": runId
512
+ }
480
513
  });
481
514
  }
482
515
  /**
483
- * Heartbeat interval for long-idle SSE streams. The actual cadence matters
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 = 25e3;
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, event) => {
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: ${event}`);
502
- lines.push(`id: ${eventId++}`);
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
- const result = await runHandler(ctx, handler);
526
- if (!isIdle) await writeSSE({ type: "idle" }, "idle");
527
- await writeSSE({
528
- type: "result",
529
- data: result !== void 0 ? result : null
530
- }, "result");
531
- } catch (err) {
532
- await writeSSE(toSseData(err), "error");
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 ctx = createContext(id, payload, request);
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({ result: result !== void 0 ? result : null }), { headers: { "content-type": "application/json" } });
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 = (_requestId, run) => run();
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
- * Generate a UUID for webhook request correlation. `crypto.randomUUID()` is
574
- * available on both modern Node (≥18) and workerd, so no per-target shim is
575
- * needed.
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 generateRequestId() {
578
- return crypto.randomUUID();
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
- * Public Hono sub-app exposing Flue's built-in agent route.
585
- *
586
- * Two consumers:
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 };