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

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.
@@ -197,7 +197,8 @@ var AgentDurableObject = class {
197
197
  if (wantsDeliver) {
198
198
  const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
199
199
  try {
200
- const events = observeTurnEvents(session, started.turnId);
200
+ const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
201
+ const events = { [Symbol.asyncIterator]: () => it };
201
202
  const target = {
202
203
  channelId: event.channelId,
203
204
  threadId: event.threadId,
@@ -207,7 +208,9 @@ var AgentDurableObject = class {
207
208
  Promise.resolve().then(() => runInScope({
208
209
  resources: this.resources,
209
210
  services: this.services
210
- }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure);
211
+ }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
212
+ it.return?.();
213
+ });
211
214
  } catch (err) {
212
215
  logRenderFailure(err);
213
216
  }
@@ -217,8 +220,15 @@ var AgentDurableObject = class {
217
220
  return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
218
221
  }
219
222
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
220
- const { turnId, inputId, input, by } = await req.json();
223
+ const { turnId, inputId, input, by, source, target } = await req.json();
221
224
  await ensureScope();
225
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
226
+ let resumeChannel;
227
+ if (wantsDeliver) {
228
+ if (!source || !target?.channelId || !target.messageTs) return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
229
+ resumeChannel = this.channels.find((c) => c.name === source);
230
+ if (!resumeChannel?.deliverResume) return Response.json({ error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` }, { status: 501 });
231
+ }
222
232
  let session;
223
233
  try {
224
234
  session = this.resolveSession(key);
@@ -230,6 +240,22 @@ var AgentDurableObject = class {
230
240
  const message = err instanceof Error ? err.message : String(err);
231
241
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
232
242
  }
243
+ if (wantsDeliver) {
244
+ const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
245
+ try {
246
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
247
+ const events = { [Symbol.asyncIterator]: () => it };
248
+ Promise.resolve().then(() => runInScope({
249
+ resources: this.resources,
250
+ services: this.services
251
+ }, () => resumeChannel.deliverResume(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
252
+ it.return?.();
253
+ });
254
+ } catch (err) {
255
+ logRenderFailure(err);
256
+ }
257
+ return Response.json({ turnId }, { status: 202 });
258
+ }
233
259
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
234
260
  }
235
261
  if (url.pathname.endsWith("/transcript")) {
@@ -472,6 +498,20 @@ function durableChannelSurface(getNamespace, opts) {
472
498
  method: "POST",
473
499
  body: serializeResume(o)
474
500
  })));
501
+ },
502
+ resumeDelivered: async (o) => {
503
+ const namespace = getNamespace();
504
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
505
+ const res = await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume?deliver=1", {
506
+ method: "POST",
507
+ body: serializeResume(o)
508
+ }));
509
+ if (res.status === 501) throw new DeliverUnsupportedError(`resumeDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this continuation"}`);
510
+ if (res.status !== 202) {
511
+ const detail = (await res.text()).slice(0, 200);
512
+ throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
513
+ }
514
+ return await res.json();
475
515
  }
476
516
  });
477
517
  }
@@ -481,10 +521,12 @@ function serializeResume(o) {
481
521
  turnId: o.turnId,
482
522
  inputId: o.inputId,
483
523
  input: o.input,
484
- by: o.by
524
+ by: o.by,
525
+ source: o.source,
526
+ target: o.target
485
527
  });
486
528
  } catch (err) {
487
- throw new Error(`resumeStream: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
529
+ throw new Error(`resume: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
488
530
  }
489
531
  }
490
532
  function serializeTurn(userText, o) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.12",
3
+ "version": "1.0.0-dev.13",
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.27",
135
+ "@junejs/core": ">=0.2.0-dev.30",
136
136
  "react": "^19.2.0",
137
137
  "react-dom": "^19.2.0",
138
138
  "react-server-dom-webpack": "19.2.7",
@@ -40,6 +40,7 @@ import {
40
40
  type Channel,
41
41
  type ChannelContext,
42
42
  type ChannelFactory,
43
+ type ResumeDeliveryTarget,
43
44
  } from "@junejs/core/agent-config";
44
45
  import { ensureScope, runInScope } from "@junejs/db";
45
46
  import { isolateLocal } from "./isolate-local";
@@ -429,11 +430,17 @@ export class AgentDurableObject {
429
430
  const logRenderFailure = (err: unknown) =>
430
431
  console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
431
432
  try {
432
- const events = observeTurnEvents(session, started.turnId);
433
+ // Own the iterator here: observeTurnEvents subscribes EAGERLY, and a deliver()
434
+ // that throws before ever iterating would otherwise leave that subscription
435
+ // attached, buffering this turn's events forever. return() is idempotent, so
436
+ // closing in finally is safe whether deliver consumed the stream or died early.
437
+ const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
438
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
433
439
  const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
434
440
  Promise.resolve()
435
441
  .then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
436
- .catch(logRenderFailure);
442
+ .catch(logRenderFailure)
443
+ .finally(() => { void it.return?.(); });
437
444
  } catch (err) {
438
445
  logRenderFailure(err);
439
446
  }
@@ -451,8 +458,31 @@ export class AgentDurableObject {
451
458
  // VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
452
459
  // signature-checked Slack interaction payload), never expose this endpoint raw to clients.
453
460
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
454
- const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
461
+ const { turnId, inputId, input, by, source, target } = (await req.json()) as {
462
+ turnId: string; inputId: string; input: unknown; by?: string;
463
+ source?: string; target?: ResumeDeliveryTarget;
464
+ };
455
465
  await ensureScope();
466
+ // DELIVERED resume: like /turn?deliver=1, the caller 202s away and THIS DO renders the
467
+ // continuation through the source channel's deliverResume() — a long continuation no
468
+ // longer dies with the webhook isolate. Capability is checked BEFORE resume() applies
469
+ // the answer — the DeliverUnsupportedError contract at the surface: on a pre-effect
470
+ // refusal the caller may fall back to consumer-side rendering without double-answering
471
+ // (a second resume of an already-applied input would 409).
472
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
473
+ let resumeChannel: Channel | undefined;
474
+ if (wantsDeliver) {
475
+ if (!source || !target?.channelId || !target.messageTs) {
476
+ return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
477
+ }
478
+ resumeChannel = this.channels.find((c) => c.name === source);
479
+ if (!resumeChannel?.deliverResume) {
480
+ return Response.json(
481
+ { error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` },
482
+ { status: 501 },
483
+ );
484
+ }
485
+ }
456
486
  let session: AgentSession;
457
487
  try {
458
488
  session = this.resolveSession(key);
@@ -462,6 +492,28 @@ export class AgentDurableObject {
462
492
  // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
463
493
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
464
494
  }
495
+ if (wantsDeliver) {
496
+ // Same post-acceptance discipline as delivered turns: subscribe synchronously, render
497
+ // under this DO's lifetime, and let NOTHING escape past the accepted resume — the log
498
+ // is the render failure's only surfacing path (the TURN's own failures surface via
499
+ // the #76 sink subscription).
500
+ const logRenderFailure = (err: unknown) =>
501
+ console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
502
+ try {
503
+ // Own the iterator (same rationale as the delivered-turn path): the eager
504
+ // subscription must be released even when deliverResume throws before
505
+ // iterating — e.g. its very first message update fails.
506
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
507
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
508
+ Promise.resolve()
509
+ .then(() => runInScope({ resources: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
510
+ .catch(logRenderFailure)
511
+ .finally(() => { void it.return?.(); });
512
+ } catch (err) {
513
+ logRenderFailure(err);
514
+ }
515
+ return Response.json({ turnId }, { status: 202 });
516
+ }
465
517
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
466
518
  }
467
519
  if (url.pathname.endsWith("/transcript")) {
@@ -847,6 +899,31 @@ export function durableChannelSurface(
847
899
  );
848
900
  yield* sseTurnEvents(res);
849
901
  },
902
+ // DELIVERED resume (runDelivered's sibling for the HITL leg): the DO applies the answer
903
+ // AND renders the continuation through the source channel's deliverResume() under its
904
+ // OWN lifetime. A 501 means the DO refused BEFORE applying the answer (channel not
905
+ // wired there / no deliverResume) — surfaced as DeliverUnsupportedError, the one
906
+ // rejection a channel may answer with a consumer-side fallback without double-answering.
907
+ // Engine rejections (403 unauthorized clicker / 409 stale-or-double click) throw as
908
+ // ordinary errors, mirroring what resumeStream's first pull would do.
909
+ resumeDelivered: async (o) => {
910
+ const namespace = getNamespace();
911
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
912
+ const res = await durableFetch(
913
+ namespace,
914
+ opts.agentName,
915
+ o.session ?? "default",
916
+ new Request("https://do/resume?deliver=1", { method: "POST", body: serializeResume(o) }),
917
+ );
918
+ if (res.status === 501) {
919
+ throw new DeliverUnsupportedError(`resumeDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this continuation"}`);
920
+ }
921
+ if (res.status !== 202) {
922
+ const detail = (await res.text()).slice(0, 200);
923
+ throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
924
+ }
925
+ return (await res.json()) as { turnId: string };
926
+ },
850
927
  };
851
928
  return channelDispatch(resolved, ctx);
852
929
  }
@@ -861,11 +938,12 @@ export function durableChannelSurface(
861
938
  // (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
862
939
  // Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
863
940
  // turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
864
- function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
941
+ function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string; source?: string; target?: ResumeDeliveryTarget }): string {
865
942
  try {
866
- return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
943
+ return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, source: o.source, target: o.target });
867
944
  } catch (err) {
868
- throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
945
+ // API-neutral prefix: this serializer backs both resumeStream and resumeDelivered.
946
+ throw new Error(`resume: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
869
947
  }
870
948
  }
871
949