@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.
- package/dist/agent-durable.js +47 -5
- package/package.json +2 -2
- package/src/agent-durable.ts +84 -6
package/dist/agent-durable.js
CHANGED
|
@@ -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
|
|
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(`
|
|
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.
|
|
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.
|
|
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",
|
package/src/agent-durable.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|