@junejs/server 1.0.0-dev.11 → 1.0.0-dev.12

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.
@@ -66,6 +66,7 @@ declare class AgentDurableObject {
66
66
  private readonly sink;
67
67
  private readonly model;
68
68
  private readonly tools;
69
+ private readonly channels;
69
70
  private readonly channelInstructions?;
70
71
  private readonly resources;
71
72
  private readonly services;
@@ -1,6 +1,6 @@
1
1
  import { isolateLocal } from "./isolate-local.js";
2
2
  import { assertCoreRuntimeVersion } from "./core-version.js";
3
- import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
3
+ import { DeliverUnsupportedError, channelDispatch, resolveChannel } from "@junejs/core/agent-config";
4
4
  import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
5
5
  import { ensureScope, runInScope } from "@junejs/db";
6
6
  //#region src/agent-durable.ts
@@ -81,6 +81,7 @@ var AgentDurableObject = class {
81
81
  sink;
82
82
  model;
83
83
  tools;
84
+ channels;
84
85
  channelInstructions;
85
86
  resources;
86
87
  services;
@@ -112,7 +113,8 @@ var AgentDurableObject = class {
112
113
  }
113
114
  defaultLog();
114
115
  });
115
- const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
116
+ const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
117
+ const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
116
118
  const tools = [...def.tools];
117
119
  const seen = new Set(tools.map((t) => t.spec.name));
118
120
  for (const t of channelTools) {
@@ -125,6 +127,7 @@ var AgentDurableObject = class {
125
127
  this.sink = sink;
126
128
  this.model = model;
127
129
  this.tools = tools;
130
+ this.channels = channels;
128
131
  this.channelInstructions = def.channelInstructions;
129
132
  }
130
133
  resolveSession(key) {
@@ -168,6 +171,13 @@ var AgentDurableObject = class {
168
171
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
169
172
  const { userText, turnId, event, trigger } = await req.json();
170
173
  await ensureScope();
174
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
175
+ let deliverChannel;
176
+ if (wantsDeliver) {
177
+ if (!event?.channelId) return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
178
+ deliverChannel = this.channels.find((c) => c.name === event.source);
179
+ if (!deliverChannel?.deliver) return Response.json({ error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` }, { status: 501 });
180
+ }
171
181
  let session;
172
182
  let started;
173
183
  try {
@@ -184,6 +194,25 @@ var AgentDurableObject = class {
184
194
  } catch (err) {
185
195
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
186
196
  }
197
+ if (wantsDeliver) {
198
+ const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
199
+ try {
200
+ const events = observeTurnEvents(session, started.turnId);
201
+ const target = {
202
+ channelId: event.channelId,
203
+ threadId: event.threadId,
204
+ recipientUserId: event.user?.id,
205
+ recipientTeamId: event.teamId
206
+ };
207
+ Promise.resolve().then(() => runInScope({
208
+ resources: this.resources,
209
+ services: this.services
210
+ }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure);
211
+ } catch (err) {
212
+ logRenderFailure(err);
213
+ }
214
+ return Response.json({ turnId: started.turnId }, { status: 202 });
215
+ }
187
216
  if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
188
217
  return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
189
218
  }
@@ -248,6 +277,49 @@ function sseTurnStream(session, turnId) {
248
277
  }
249
278
  });
250
279
  }
280
+ function observeTurnEvents(session, turnId) {
281
+ const queue = [];
282
+ let terminal = false;
283
+ let notify;
284
+ const unsub = session.observe((e) => {
285
+ queue.push(e);
286
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
287
+ notify?.();
288
+ }, { turnId });
289
+ return { [Symbol.asyncIterator]() {
290
+ return {
291
+ async next() {
292
+ for (;;) {
293
+ const e = queue.shift();
294
+ if (e) return {
295
+ value: e,
296
+ done: false
297
+ };
298
+ if (terminal) {
299
+ unsub();
300
+ return {
301
+ value: void 0,
302
+ done: true
303
+ };
304
+ }
305
+ await new Promise((resolve) => {
306
+ notify = resolve;
307
+ });
308
+ notify = void 0;
309
+ }
310
+ },
311
+ async return() {
312
+ unsub();
313
+ terminal = true;
314
+ queue.length = 0;
315
+ return {
316
+ value: void 0,
317
+ done: true
318
+ };
319
+ }
320
+ };
321
+ } };
322
+ }
251
323
  async function sseTurnFinalText(res) {
252
324
  const ct = res.headers.get("content-type") ?? "";
253
325
  if (!res.body || !ct.includes("text/event-stream")) {
@@ -371,6 +443,20 @@ function durableChannelSurface(getNamespace, opts) {
371
443
  }
372
444
  return await res.json();
373
445
  },
446
+ runDelivered: async (message, o) => {
447
+ const namespace = getNamespace();
448
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
449
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?deliver=1", {
450
+ method: "POST",
451
+ body: serializeTurn(message, o)
452
+ }));
453
+ if (res.status === 501) throw new DeliverUnsupportedError(`runDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this turn"}`);
454
+ if (res.status !== 202) {
455
+ const detail = (await res.text()).slice(0, 200);
456
+ throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
457
+ }
458
+ return await res.json();
459
+ },
374
460
  runStream: async function* (message, o) {
375
461
  const namespace = getNamespace();
376
462
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.11",
3
+ "version": "1.0.0-dev.12",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -132,7 +132,7 @@
132
132
  "rolldown": "^1.1.0"
133
133
  },
134
134
  "peerDependencies": {
135
- "@junejs/core": ">=0.2.0-dev.26",
135
+ "@junejs/core": ">=0.2.0-dev.27",
136
136
  "react": "^19.2.0",
137
137
  "react-dom": "^19.2.0",
138
138
  "react-server-dom-webpack": "19.2.7",
@@ -34,6 +34,7 @@ import {
34
34
  import type { Resources } from "@junejs/core/resources";
35
35
  import {
36
36
  channelDispatch,
37
+ DeliverUnsupportedError,
37
38
  resolveChannel,
38
39
  type AgentDefinition,
39
40
  type Channel,
@@ -227,6 +228,7 @@ export class AgentDurableObject {
227
228
  private readonly sink: InProcEventSink;
228
229
  private readonly model: Model;
229
230
  private readonly tools: Tool[];
231
+ private readonly channels: Channel[];
230
232
  private readonly channelInstructions?: Record<string, string>;
231
233
  // Built from the DO's env in the constructor (this isolate), shared across turns —
232
234
  // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
@@ -277,8 +279,11 @@ export class AgentDurableObject {
277
279
  });
278
280
  // Merge the mounted channels' capability tools (built here from this DO's env, since a
279
281
  // tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
280
- // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
281
- const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
282
+ // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe. The
283
+ // resolved channels are also RETAINED (this.channels): a delivered turn (/turn?deliver=1)
284
+ // renders its reply through the source channel's deliver() from inside this DO.
285
+ const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
286
+ const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
282
287
  const tools: Tool[] = [...def.tools];
283
288
  const seen = new Set(tools.map((t) => t.spec.name));
284
289
  for (const t of channelTools) {
@@ -291,6 +296,7 @@ export class AgentDurableObject {
291
296
  this.sink = sink;
292
297
  this.model = model;
293
298
  this.tools = tools;
299
+ this.channels = channels;
294
300
  this.channelInstructions = def.channelInstructions;
295
301
  }
296
302
  // Resolve THE session for this DO: explicit key (from a routed request) → persisted
@@ -371,6 +377,26 @@ export class AgentDurableObject {
371
377
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
372
378
  const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
373
379
  await ensureScope();
380
+ // DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
381
+ // dropped: this DO renders the turn's event stream through the source channel's own
382
+ // deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
383
+ // BEFORE the turn starts — that ordering is DeliverUnsupportedError's contract at the
384
+ // surface: on a pre-start rejection the caller may fall back to consumer-side rendering
385
+ // without double-running the turn.
386
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
387
+ let deliverChannel: Channel | undefined;
388
+ if (wantsDeliver) {
389
+ if (!event?.channelId) {
390
+ return Response.json({ error: "deliver=1 needs an inbound event (event.channelId names the reply target)" }, { status: 400 });
391
+ }
392
+ deliverChannel = this.channels.find((c) => c.name === event.source);
393
+ if (!deliverChannel?.deliver) {
394
+ return Response.json(
395
+ { error: `agent "${this.name}": no deliver()-capable channel named "${event.source}" is wired into this DO (DoAgentDef.channels) — the turn was NOT started` },
396
+ { status: 501 },
397
+ );
398
+ }
399
+ }
374
400
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
375
401
  // db/services (ALS propagates to the .then continuation registered here); subscribing
376
402
  // happens synchronously right after, before any event can emit.
@@ -384,6 +410,35 @@ export class AgentDurableObject {
384
410
  // object's identity — a client-resolvable conflict, not a crash
385
411
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
386
412
  }
413
+ if (wantsDeliver) {
414
+ // Subscribe synchronously (same guarantee sseTurnStream relies on: start() scheduled
415
+ // the turn, no event can have emitted yet), then render under THIS DO's lifetime —
416
+ // the retained promise is pending work that keeps the DO alive, so a long turn's
417
+ // rendering survives however briefly the caller held its connection. The event is
418
+ // present (checked above); its author/thread are the reply target, exactly the
419
+ // surface the channel's inbound renderStream derives.
420
+ //
421
+ // Once start() has queued the turn, NOTHING here may escape: a throw would 500 the
422
+ // caller for a turn that runs anyway — the exact ambiguity delivered turns exist to
423
+ // remove. deliver() is app/third-party code that can throw synchronously before
424
+ // returning its promise, so it is deferred into the chain (the eager subscription
425
+ // above stays synchronous — that timing guarantee must not move); the try/catch
426
+ // backstops the subscription itself. Rendering failures have no live consumer —
427
+ // this log is their only surfacing path (the TURN's own failures already surface
428
+ // via the #76 sink subscription).
429
+ const logRenderFailure = (err: unknown) =>
430
+ console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
431
+ try {
432
+ const events = observeTurnEvents(session, started.turnId);
433
+ const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
434
+ Promise.resolve()
435
+ .then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
436
+ .catch(logRenderFailure);
437
+ } catch (err) {
438
+ logRenderFailure(err);
439
+ }
440
+ return Response.json({ turnId: started.turnId }, { status: 202 });
441
+ }
387
442
  // DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
388
443
  // this DO's lifetime (alive while work is pending), so its duration is no longer
389
444
  // bounded by however long the CALLER can hold a connection (the edge waitUntil
@@ -458,6 +513,45 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
458
513
  });
459
514
  }
460
515
 
516
+ // The IN-PROCESS sibling of sseTurnStream: one turn's TurnEvents as an AsyncIterable, for a
517
+ // consumer living in the SAME isolate as the session (a delivered render). Subscribes eagerly
518
+ // at call time — not at first iteration — so the sseTurnStream timing guarantee carries over
519
+ // (call this synchronously after start(), before any event can emit); events landing before
520
+ // the consumer catches up are buffered. Ends after the turn's terminal event (completed,
521
+ // failed, or input.requested — a park ends this stream; a later /resume is a new one), and an
522
+ // early consumer exit (for-await break/return) unsubscribes rather than buffering forever.
523
+ function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable<TurnEvent> {
524
+ const queue: TurnEvent[] = [];
525
+ let terminal = false;
526
+ let notify: (() => void) | undefined;
527
+ const unsub = session.observe((e) => {
528
+ queue.push(e);
529
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
530
+ notify?.();
531
+ }, { turnId });
532
+ return {
533
+ [Symbol.asyncIterator]() {
534
+ return {
535
+ async next(): Promise<IteratorResult<TurnEvent>> {
536
+ for (;;) {
537
+ const e = queue.shift();
538
+ if (e) return { value: e, done: false };
539
+ if (terminal) { unsub(); return { value: undefined, done: true }; }
540
+ await new Promise<void>((resolve) => { notify = resolve; });
541
+ notify = undefined;
542
+ }
543
+ },
544
+ async return(): Promise<IteratorResult<TurnEvent>> {
545
+ unsub();
546
+ terminal = true;
547
+ queue.length = 0;
548
+ return { value: undefined, done: true };
549
+ },
550
+ };
551
+ },
552
+ };
553
+ }
554
+
461
555
  // Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
462
556
  // The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
463
557
  // body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
@@ -705,6 +799,30 @@ export function durableChannelSurface(
705
799
  }
706
800
  return (await res.json()) as { turnId: string };
707
801
  },
802
+ // DELIVERED: runDetached's reply-bearing sibling — the DO runs the turn AND renders its
803
+ // reply through the source channel's deliver() under its OWN lifetime, so a reply-bearing
804
+ // turn escapes the edge waitUntil ceiling exactly as a shadow turn does. A 501 means the
805
+ // DO refused BEFORE starting the turn (the channel isn't wired there / has no deliver) —
806
+ // surfaced as DeliverUnsupportedError, the one rejection a channel may answer with a
807
+ // consumer-side rendering fallback without double-running the turn.
808
+ runDelivered: async (message, o) => {
809
+ const namespace = getNamespace();
810
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
811
+ const res = await durableFetch(
812
+ namespace,
813
+ opts.agentName,
814
+ o?.session ?? "default",
815
+ new Request("https://do/turn?deliver=1", { method: "POST", body: serializeTurn(message, o) }),
816
+ );
817
+ if (res.status === 501) {
818
+ throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
819
+ }
820
+ if (res.status !== 202) {
821
+ const detail = (await res.text()).slice(0, 200);
822
+ throw new Error(`runDelivered: delivered turn was not accepted (status ${res.status}): ${detail}`);
823
+ }
824
+ return (await res.json()) as { turnId: string };
825
+ },
708
826
  // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
709
827
  runStream: async function* (message, o) {
710
828
  const namespace = getNamespace();