@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.
- package/dist/agent-durable.d.ts +1 -0
- package/dist/agent-durable.js +88 -2
- package/package.json +2 -2
- package/src/agent-durable.ts +120 -2
package/dist/agent-durable.d.ts
CHANGED
package/dist/agent-durable.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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",
|
package/src/agent-durable.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|