@junejs/server 1.0.0-dev.10 → 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 +102 -3
- package/dist/isolate-local.d.ts +6 -0
- package/dist/isolate-local.js +12 -0
- package/package.json +3 -3
- package/src/agent-durable.ts +170 -4
- package/src/isolate-local.ts +33 -0
package/dist/agent-durable.d.ts
CHANGED
package/dist/agent-durable.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { isolateLocal } from "./isolate-local.js";
|
|
1
2
|
import { assertCoreRuntimeVersion } from "./core-version.js";
|
|
2
|
-
import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
|
|
3
|
+
import { DeliverUnsupportedError, channelDispatch, resolveChannel } from "@junejs/core/agent-config";
|
|
3
4
|
import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
|
|
4
5
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
5
6
|
//#region src/agent-durable.ts
|
|
@@ -80,6 +81,7 @@ var AgentDurableObject = class {
|
|
|
80
81
|
sink;
|
|
81
82
|
model;
|
|
82
83
|
tools;
|
|
84
|
+
channels;
|
|
83
85
|
channelInstructions;
|
|
84
86
|
resources;
|
|
85
87
|
services;
|
|
@@ -111,7 +113,8 @@ var AgentDurableObject = class {
|
|
|
111
113
|
}
|
|
112
114
|
defaultLog();
|
|
113
115
|
});
|
|
114
|
-
const
|
|
116
|
+
const channels = (def.channels ?? []).map((c) => resolveChannel(c, def.env));
|
|
117
|
+
const channelTools = channels.flatMap((c) => c.tools?.() ?? []);
|
|
115
118
|
const tools = [...def.tools];
|
|
116
119
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
117
120
|
for (const t of channelTools) {
|
|
@@ -124,6 +127,7 @@ var AgentDurableObject = class {
|
|
|
124
127
|
this.sink = sink;
|
|
125
128
|
this.model = model;
|
|
126
129
|
this.tools = tools;
|
|
130
|
+
this.channels = channels;
|
|
127
131
|
this.channelInstructions = def.channelInstructions;
|
|
128
132
|
}
|
|
129
133
|
resolveSession(key) {
|
|
@@ -167,6 +171,13 @@ var AgentDurableObject = class {
|
|
|
167
171
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
168
172
|
const { userText, turnId, event, trigger } = await req.json();
|
|
169
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
|
+
}
|
|
170
181
|
let session;
|
|
171
182
|
let started;
|
|
172
183
|
try {
|
|
@@ -183,6 +194,25 @@ var AgentDurableObject = class {
|
|
|
183
194
|
} catch (err) {
|
|
184
195
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
185
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
|
+
}
|
|
186
216
|
if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
|
|
187
217
|
return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
|
|
188
218
|
}
|
|
@@ -247,6 +277,49 @@ function sseTurnStream(session, turnId) {
|
|
|
247
277
|
}
|
|
248
278
|
});
|
|
249
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
|
+
}
|
|
250
323
|
async function sseTurnFinalText(res) {
|
|
251
324
|
const ct = res.headers.get("content-type") ?? "";
|
|
252
325
|
if (!res.body || !ct.includes("text/event-stream")) {
|
|
@@ -325,6 +398,18 @@ function durableAgentSurface(getNamespace, opts) {
|
|
|
325
398
|
return Response.json({ text: await sseTurnFinalText(res) });
|
|
326
399
|
};
|
|
327
400
|
}
|
|
401
|
+
function resolveServices(factory, env, agentName) {
|
|
402
|
+
if (!factory) return void 0;
|
|
403
|
+
if (typeof env !== "object" || env === null) return factory(env);
|
|
404
|
+
const cache = isolateLocal("june.durableChannelSurface.services", () => /* @__PURE__ */ new WeakMap());
|
|
405
|
+
let perAgent = cache.get(env);
|
|
406
|
+
if (!perAgent) {
|
|
407
|
+
perAgent = /* @__PURE__ */ new Map();
|
|
408
|
+
cache.set(env, perAgent);
|
|
409
|
+
}
|
|
410
|
+
if (!perAgent.has(agentName)) perAgent.set(agentName, factory(env));
|
|
411
|
+
return perAgent.get(agentName);
|
|
412
|
+
}
|
|
328
413
|
function durableChannelSurface(getNamespace, opts) {
|
|
329
414
|
return channelDispatch(opts.channels.map((c) => resolveChannel(c, opts.env)), {
|
|
330
415
|
agent: {
|
|
@@ -335,7 +420,7 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
335
420
|
channels: [],
|
|
336
421
|
connections: []
|
|
337
422
|
},
|
|
338
|
-
services: opts.services
|
|
423
|
+
services: resolveServices(opts.services, opts.env, opts.agentName),
|
|
339
424
|
waitUntil: opts.waitUntil,
|
|
340
425
|
run: async (message, o) => {
|
|
341
426
|
const namespace = getNamespace();
|
|
@@ -358,6 +443,20 @@ function durableChannelSurface(getNamespace, opts) {
|
|
|
358
443
|
}
|
|
359
444
|
return await res.json();
|
|
360
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
|
+
},
|
|
361
460
|
runStream: async function* (message, o) {
|
|
362
461
|
const namespace = getNamespace();
|
|
363
462
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
//#region src/isolate-local.d.ts
|
|
2
|
+
type IsolateLocal = <T>(key: string | symbol, make: () => T) => T;
|
|
3
|
+
declare function makeIsolateLocalFallback(): IsolateLocal;
|
|
4
|
+
declare const isolateLocal: IsolateLocal;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { IsolateLocal, isolateLocal, makeIsolateLocalFallback };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as juneDb from "@junejs/db";
|
|
2
|
+
//#region src/isolate-local.ts
|
|
3
|
+
function makeIsolateLocalFallback() {
|
|
4
|
+
const values = /* @__PURE__ */ new Map();
|
|
5
|
+
return (key, make) => {
|
|
6
|
+
if (!values.has(key)) values.set(key, make());
|
|
7
|
+
return values.get(key);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
const isolateLocal = juneDb.isolateLocal ?? makeIsolateLocalFallback();
|
|
11
|
+
//#endregion
|
|
12
|
+
export { isolateLocal, makeIsolateLocalFallback };
|
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",
|
|
@@ -125,14 +125,14 @@
|
|
|
125
125
|
"build": "tsdown"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@junejs/db": ">=0.0
|
|
128
|
+
"@junejs/db": ">=0.1.0-dev.2",
|
|
129
129
|
"@momiji-rs/sparkdown": "^0.0.4",
|
|
130
130
|
"lightningcss": "^1.27.0",
|
|
131
131
|
"oxc-parser": "^0.137.0",
|
|
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,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
type ChannelFactory,
|
|
42
43
|
} from "@junejs/core/agent-config";
|
|
43
44
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
45
|
+
import { isolateLocal } from "./isolate-local";
|
|
44
46
|
import { assertCoreRuntimeVersion } from "./core-version";
|
|
45
47
|
|
|
46
48
|
// ── minimal structural Cloudflare surface (no @cloudflare/workers-types dep) ──
|
|
@@ -226,6 +228,7 @@ export class AgentDurableObject {
|
|
|
226
228
|
private readonly sink: InProcEventSink;
|
|
227
229
|
private readonly model: Model;
|
|
228
230
|
private readonly tools: Tool[];
|
|
231
|
+
private readonly channels: Channel[];
|
|
229
232
|
private readonly channelInstructions?: Record<string, string>;
|
|
230
233
|
// Built from the DO's env in the constructor (this isolate), shared across turns —
|
|
231
234
|
// env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
|
|
@@ -276,8 +279,11 @@ export class AgentDurableObject {
|
|
|
276
279
|
});
|
|
277
280
|
// Merge the mounted channels' capability tools (built here from this DO's env, since a
|
|
278
281
|
// tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
|
|
279
|
-
// keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
|
|
280
|
-
|
|
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?.() ?? []);
|
|
281
287
|
const tools: Tool[] = [...def.tools];
|
|
282
288
|
const seen = new Set(tools.map((t) => t.spec.name));
|
|
283
289
|
for (const t of channelTools) {
|
|
@@ -290,6 +296,7 @@ export class AgentDurableObject {
|
|
|
290
296
|
this.sink = sink;
|
|
291
297
|
this.model = model;
|
|
292
298
|
this.tools = tools;
|
|
299
|
+
this.channels = channels;
|
|
293
300
|
this.channelInstructions = def.channelInstructions;
|
|
294
301
|
}
|
|
295
302
|
// Resolve THE session for this DO: explicit key (from a routed request) → persisted
|
|
@@ -370,6 +377,26 @@ export class AgentDurableObject {
|
|
|
370
377
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
371
378
|
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
372
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
|
+
}
|
|
373
400
|
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
374
401
|
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
375
402
|
// happens synchronously right after, before any event can emit.
|
|
@@ -383,6 +410,35 @@ export class AgentDurableObject {
|
|
|
383
410
|
// object's identity — a client-resolvable conflict, not a crash
|
|
384
411
|
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
385
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
|
+
}
|
|
386
442
|
// DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
|
|
387
443
|
// this DO's lifetime (alive while work is pending), so its duration is no longer
|
|
388
444
|
// bounded by however long the CALLER can hold a connection (the edge waitUntil
|
|
@@ -457,6 +513,45 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
|
|
|
457
513
|
});
|
|
458
514
|
}
|
|
459
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
|
+
|
|
460
555
|
// Consume an SSE turn stream to its terminal state: the final text, or throw on failure.
|
|
461
556
|
// The non-streaming path (channels, a JSON chat response) uses this. Guards a non-SSE /
|
|
462
557
|
// body-less upstream response (a misroute or an error) into a clear error, not a TypeError.
|
|
@@ -605,6 +700,43 @@ export function durableAgentSurface(
|
|
|
605
700
|
// fails in the background — surfaced via the channel's onError IF one is configured,
|
|
606
701
|
// otherwise swallowed (runBackground never rejects). The app is expected to bind
|
|
607
702
|
// env.AGENT when it mounts channels. Returns null for unclaimed requests.
|
|
703
|
+
// The services bag, memoized per (env object, agentName).
|
|
704
|
+
//
|
|
705
|
+
// Why that key. `env` identity is what makes the memo survive: a worker builds
|
|
706
|
+
// the surface inside `fetch`, so the only thing stable across requests is the
|
|
707
|
+
// env object the host hands in — and NOT the provider function, which the
|
|
708
|
+
// documented shape (`services: (e) => makeServices(e)`) recreates on every
|
|
709
|
+
// fetch. But env alone is too coarse: two surfaces mounted in one worker over
|
|
710
|
+
// the same env would then share whichever bag was built first, making the
|
|
711
|
+
// result depend on mount order. `agentName` is the surface's own stable
|
|
712
|
+
// identity, so it partitions them. (One agent must therefore have ONE services
|
|
713
|
+
// provider — mounting the same agentName twice with different providers is
|
|
714
|
+
// incoherent and keeps the first bag.)
|
|
715
|
+
//
|
|
716
|
+
// Held in an isolateLocal so duplicate module instances (workspace symlinks)
|
|
717
|
+
// share one cache; the outer WeakMap is keyed by env so a host handing out
|
|
718
|
+
// fresh env objects gets fresh bags instead of a leak. A non-object env (tests
|
|
719
|
+
// passing a primitive) skips memoization rather than throwing.
|
|
720
|
+
function resolveServices(
|
|
721
|
+
factory: ((env: unknown) => unknown) | undefined,
|
|
722
|
+
env: unknown,
|
|
723
|
+
agentName: string,
|
|
724
|
+
): unknown {
|
|
725
|
+
if (!factory) return undefined;
|
|
726
|
+
if (typeof env !== "object" || env === null) return factory(env);
|
|
727
|
+
const cache = isolateLocal(
|
|
728
|
+
"june.durableChannelSurface.services",
|
|
729
|
+
() => new WeakMap<object, Map<string, unknown>>(),
|
|
730
|
+
);
|
|
731
|
+
let perAgent = cache.get(env);
|
|
732
|
+
if (!perAgent) {
|
|
733
|
+
perAgent = new Map<string, unknown>();
|
|
734
|
+
cache.set(env, perAgent);
|
|
735
|
+
}
|
|
736
|
+
if (!perAgent.has(agentName)) perAgent.set(agentName, factory(env));
|
|
737
|
+
return perAgent.get(agentName);
|
|
738
|
+
}
|
|
739
|
+
|
|
608
740
|
export function durableChannelSurface(
|
|
609
741
|
getNamespace: () => DurableObjectNamespace | undefined,
|
|
610
742
|
opts: {
|
|
@@ -614,7 +746,17 @@ export function durableChannelSurface(
|
|
|
614
746
|
// The app's services factory — the SAME one AgentDurableObject uses — resolved HERE
|
|
615
747
|
// (worker isolate) and exposed to channel hooks as ctx.services. Channel hooks run at
|
|
616
748
|
// the edge, outside the DO, so they can't read the DO's ambient currentServices(); this
|
|
617
|
-
// gives them the same DI bag.
|
|
749
|
+
// gives them the same DI bag.
|
|
750
|
+
//
|
|
751
|
+
// Called once per (env object, agentName) — NOT once per surface. A worker
|
|
752
|
+
// typically constructs the surface inside `fetch` (env only exists inside an
|
|
753
|
+
// invocation), so resolving per construction rebuilt every client on every
|
|
754
|
+
// request and any cache the bag held could never hit. Memoizing on env
|
|
755
|
+
// identity fixes that; see resolveServices above for why the key is env ×
|
|
756
|
+
// agentName and not the provider function. Practical consequences: the
|
|
757
|
+
// provider must be a function of `env` alone, it runs again whenever the
|
|
758
|
+
// host hands in a different env object (a new isolate, or a test), and one
|
|
759
|
+
// agentName must have one provider.
|
|
618
760
|
services?: (env: unknown) => unknown;
|
|
619
761
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
620
762
|
},
|
|
@@ -624,7 +766,7 @@ export function durableChannelSurface(
|
|
|
624
766
|
// A minimal but complete AgentDefinition — channels only read ctx.agent.name; the
|
|
625
767
|
// full def isn't present in the worker (tools/model live in the DO).
|
|
626
768
|
agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
|
|
627
|
-
services: opts.services
|
|
769
|
+
services: resolveServices(opts.services, opts.env, opts.agentName),
|
|
628
770
|
waitUntil: opts.waitUntil,
|
|
629
771
|
run: async (message, o) => {
|
|
630
772
|
const namespace = getNamespace();
|
|
@@ -657,6 +799,30 @@ export function durableChannelSurface(
|
|
|
657
799
|
}
|
|
658
800
|
return (await res.json()) as { turnId: string };
|
|
659
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
|
+
},
|
|
660
826
|
// The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
|
|
661
827
|
runStream: async function* (message, o) {
|
|
662
828
|
const namespace = getNamespace();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// isolate-local.ts — read @junejs/db's `isolateLocal` WITHOUT a named import.
|
|
2
|
+
//
|
|
3
|
+
// Same hazard core-version.ts exists for: a NAMED import of an export the
|
|
4
|
+
// resolved copy doesn't have dies at module LINK time with an unhelpful
|
|
5
|
+
// SyntaxError, before any of our own diagnostics can run. `isolateLocal` is new
|
|
6
|
+
// in @junejs/db 0.1.0-dev.2, and a version range cannot save us here — the
|
|
7
|
+
// floor has to stay satisfiable by the workspace's own db (which is the very
|
|
8
|
+
// version that lacks it), and `bun pm pack` rewrites a `workspace:*` dep to
|
|
9
|
+
// whatever version it resolves, which can be OLDER still.
|
|
10
|
+
//
|
|
11
|
+
// So: namespace import + optional read, with a local fallback. Unlike the core
|
|
12
|
+
// runtime contract, this one does NOT warrant throwing — isolate-scoped
|
|
13
|
+
// memoization is a lifetime optimization, not a correctness contract. The
|
|
14
|
+
// fallback memoizes in this module instance, so callers keep their cache; the
|
|
15
|
+
// only thing lost against an older db is sharing one value across DUPLICATE
|
|
16
|
+
// copies of the module (workspace symlinks), which costs a missed cache hit,
|
|
17
|
+
// never a wrong answer.
|
|
18
|
+
import * as juneDb from "@junejs/db";
|
|
19
|
+
|
|
20
|
+
export type IsolateLocal = <T>(key: string | symbol, make: () => T) => T;
|
|
21
|
+
|
|
22
|
+
// Exported for tests: the ambient read below can only ever see the workspace's
|
|
23
|
+
// own (matching) db, so the degraded path needs a direct handle to be provable.
|
|
24
|
+
export function makeIsolateLocalFallback(): IsolateLocal {
|
|
25
|
+
const values = new Map<string | symbol, unknown>();
|
|
26
|
+
return <T>(key: string | symbol, make: () => T): T => {
|
|
27
|
+
if (!values.has(key)) values.set(key, make());
|
|
28
|
+
return values.get(key) as T;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const isolateLocal: IsolateLocal =
|
|
33
|
+
(juneDb as { isolateLocal?: IsolateLocal }).isolateLocal ?? makeIsolateLocalFallback();
|