@junejs/server 1.0.0-dev.13 → 1.0.0-dev.17
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/adapter.d.ts +7 -1
- package/dist/adapter.js +62 -5
- package/dist/agent-compile.d.ts +35 -0
- package/dist/agent-compile.js +139 -0
- package/dist/agent-discover.d.ts +3 -2
- package/dist/agent-discover.js +18 -44
- package/dist/agent-durable.d.ts +12 -2
- package/dist/agent-durable.js +71 -16
- package/dist/agent-native.js +35 -3
- package/dist/build.js +73 -8
- package/dist/client-bundle.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/tsconfig-jsx.d.ts +2 -1
- package/dist/tsconfig-jsx.js +1 -1
- package/package.json +13 -3
- package/src/adapter.ts +84 -3
- package/src/agent-compile.ts +245 -0
- package/src/agent-discover.ts +39 -54
- package/src/agent-durable.ts +112 -21
- package/src/agent-native.ts +38 -2
- package/src/build.ts +124 -9
- package/src/index.ts +8 -0
- package/src/tsconfig-jsx.ts +2 -2
package/src/agent-durable.ts
CHANGED
|
@@ -137,6 +137,28 @@ export class DoSessionStore implements SessionStore {
|
|
|
137
137
|
tx<T>(fn: () => T): T {
|
|
138
138
|
return this.storage.transactionSync(fn);
|
|
139
139
|
}
|
|
140
|
+
// Session reset (#129): ARCHIVE messages/steps under the current generation (the audit
|
|
141
|
+
// trail — never deleted), clear the live tables, status → "new". The session key in
|
|
142
|
+
// agent_meta is deliberately untouched: the DO's identity survives its history. Archive
|
|
143
|
+
// tables + the generation counter are created lazily, so existing objects stay
|
|
144
|
+
// untouched until their first reset. seq keeps counting across generations (DELETE
|
|
145
|
+
// doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
|
|
146
|
+
reset(): number {
|
|
147
|
+
return this.storage.transactionSync(() => {
|
|
148
|
+
const sql = this.sql;
|
|
149
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
|
|
150
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (generation INTEGER, id TEXT, output TEXT)`);
|
|
151
|
+
const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'generation'").toArray();
|
|
152
|
+
const generation = rows.length ? Number(rows[0]!.v) : 0;
|
|
153
|
+
sql.exec("INSERT INTO agent_messages_archive (generation, seq, body) SELECT ?, seq, body FROM agent_messages", generation);
|
|
154
|
+
sql.exec("DELETE FROM agent_messages");
|
|
155
|
+
sql.exec("INSERT INTO agent_steps_archive (generation, id, output) SELECT ?, id, output FROM agent_steps", generation);
|
|
156
|
+
sql.exec("DELETE FROM agent_steps");
|
|
157
|
+
this.setStatus("new");
|
|
158
|
+
sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
|
|
159
|
+
return generation;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
140
162
|
unwrap<H = unknown>(): H {
|
|
141
163
|
return this.sql as unknown as H;
|
|
142
164
|
}
|
|
@@ -186,7 +208,11 @@ export type DoAgentDef = {
|
|
|
186
208
|
// channel factories you mount on the worker, plus this DO's env.
|
|
187
209
|
channels?: (Channel | ChannelFactory)[];
|
|
188
210
|
env?: unknown;
|
|
189
|
-
|
|
211
|
+
// A resolved bag, OR an env-driven — possibly async — provider
|
|
212
|
+
// (bindWorkerResources' shape): opening a D1 handle is async and a DO
|
|
213
|
+
// constructor cannot await, so a provider is resolved lazily at the first
|
|
214
|
+
// turn (once per DO instance) with this def's `env`.
|
|
215
|
+
resources?: Resources | ((env: unknown) => Promise<Resources> | Resources);
|
|
190
216
|
services?: unknown;
|
|
191
217
|
// Called on every turn.failed for this session — the app's seam for routing turn
|
|
192
218
|
// failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
|
|
@@ -231,15 +257,25 @@ export class AgentDurableObject {
|
|
|
231
257
|
private readonly tools: Tool[];
|
|
232
258
|
private readonly channels: Channel[];
|
|
233
259
|
private readonly channelInstructions?: Record<string, string>;
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
260
|
+
// A bag is used as-is; a provider (bindWorkerResources' shape) is resolved
|
|
261
|
+
// lazily at the first turn with the def's env — opening a D1 handle is async
|
|
262
|
+
// and this constructor cannot await. Resolved once, shared across turns (env
|
|
263
|
+
// is stable per isolate, like bindWorkerResources memoizes per worker isolate).
|
|
264
|
+
private readonly resourcesInput: DoAgentDef["resources"];
|
|
265
|
+
private resourcesOpened: Promise<Resources> | undefined;
|
|
266
|
+
private readonly doEnv: unknown;
|
|
237
267
|
private readonly services: unknown;
|
|
268
|
+
private resolveResources(): Promise<Resources> {
|
|
269
|
+
return (this.resourcesOpened ??= Promise.resolve(
|
|
270
|
+
typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : (this.resourcesInput ?? {}),
|
|
271
|
+
));
|
|
272
|
+
}
|
|
238
273
|
constructor(state: DurableObjectState, def: DoAgentDef) {
|
|
239
274
|
assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
|
|
240
275
|
const store = new DoSessionStore(state.storage);
|
|
241
276
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
242
|
-
this.
|
|
277
|
+
this.resourcesInput = def.resources;
|
|
278
|
+
this.doEnv = def.env;
|
|
243
279
|
this.services = def.services;
|
|
244
280
|
const name = def.name ?? "agent";
|
|
245
281
|
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
@@ -346,8 +382,9 @@ export class AgentDurableObject {
|
|
|
346
382
|
// pipeline does; without it runInScope is a pass-through and ambient reads throw.
|
|
347
383
|
async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
|
|
348
384
|
await ensureScope();
|
|
385
|
+
const resources = await this.resolveResources();
|
|
349
386
|
const session = this.resolveSession(input.session);
|
|
350
|
-
return runInScope({ resources
|
|
387
|
+
return runInScope({ resources, services: this.services }, () => session.turn(input));
|
|
351
388
|
}
|
|
352
389
|
// Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
|
|
353
390
|
// it then runs under the DO's own lifetime (a DO stays alive while it has pending
|
|
@@ -355,8 +392,14 @@ export class AgentDurableObject {
|
|
|
355
392
|
// the default turn-failure log / onTurnError (#76).
|
|
356
393
|
async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
|
|
357
394
|
await ensureScope();
|
|
395
|
+
const resources = await this.resolveResources();
|
|
358
396
|
const session = this.resolveSession(input.session);
|
|
359
|
-
return runInScope({ resources
|
|
397
|
+
return runInScope({ resources, services: this.services }, () => session.start(input));
|
|
398
|
+
}
|
|
399
|
+
// Session reset (#129) for custom shells — the direct sibling of the /reset route. No
|
|
400
|
+
// scope needed: reset touches only the store (no tools run).
|
|
401
|
+
reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
|
|
402
|
+
return this.resolveSession(opts?.session).reset();
|
|
360
403
|
}
|
|
361
404
|
// Read-only: folds the durable log. When an identity exists (live, or persisted from a
|
|
362
405
|
// prior life) the session resolves and caches like any other path. Only a read on a
|
|
@@ -378,6 +421,11 @@ export class AgentDurableObject {
|
|
|
378
421
|
if (req.method === "POST" && url.pathname.endsWith("/turn")) {
|
|
379
422
|
const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
|
|
380
423
|
await ensureScope();
|
|
424
|
+
// CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
|
|
425
|
+
// queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
|
|
426
|
+
// string and composes with both. A suspended session still 409s below — replace
|
|
427
|
+
// never cancels a parked approval.
|
|
428
|
+
const replace = url.searchParams.get("replace") === "1";
|
|
381
429
|
// DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
|
|
382
430
|
// dropped: this DO renders the turn's event stream through the source channel's own
|
|
383
431
|
// deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
|
|
@@ -400,12 +448,14 @@ export class AgentDurableObject {
|
|
|
400
448
|
}
|
|
401
449
|
// start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
|
|
402
450
|
// db/services (ALS propagates to the .then continuation registered here); subscribing
|
|
403
|
-
// happens synchronously right after, before any event can emit.
|
|
451
|
+
// happens synchronously right after, before any event can emit. Resources resolve
|
|
452
|
+
// BEFORE this block — the section from start() to the subscription stays synchronous.
|
|
453
|
+
const resources = await this.resolveResources();
|
|
404
454
|
let session: AgentSession;
|
|
405
455
|
let started: { turnId: string };
|
|
406
456
|
try {
|
|
407
457
|
session = this.resolveSession(key);
|
|
408
|
-
started = runInScope({ resources
|
|
458
|
+
started = runInScope({ resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
|
|
409
459
|
} catch (err) {
|
|
410
460
|
// e.g. the session is suspended awaiting input, or the key mis-matches this
|
|
411
461
|
// object's identity — a client-resolvable conflict, not a crash
|
|
@@ -438,7 +488,7 @@ export class AgentDurableObject {
|
|
|
438
488
|
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
439
489
|
const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
|
|
440
490
|
Promise.resolve()
|
|
441
|
-
.then(() => runInScope({ resources
|
|
491
|
+
.then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
|
|
442
492
|
.catch(logRenderFailure)
|
|
443
493
|
.finally(() => { void it.return?.(); });
|
|
444
494
|
} catch (err) {
|
|
@@ -483,10 +533,13 @@ export class AgentDurableObject {
|
|
|
483
533
|
);
|
|
484
534
|
}
|
|
485
535
|
}
|
|
536
|
+
// Same discipline as /turn: resolve resources before the synchronous
|
|
537
|
+
// resume-then-subscribe section.
|
|
538
|
+
const resources = await this.resolveResources();
|
|
486
539
|
let session: AgentSession;
|
|
487
540
|
try {
|
|
488
541
|
session = this.resolveSession(key);
|
|
489
|
-
runInScope({ resources
|
|
542
|
+
runInScope({ resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
|
|
490
543
|
} catch (err) {
|
|
491
544
|
const message = err instanceof Error ? err.message : String(err);
|
|
492
545
|
// 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
|
|
@@ -506,7 +559,7 @@ export class AgentDurableObject {
|
|
|
506
559
|
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
507
560
|
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
508
561
|
Promise.resolve()
|
|
509
|
-
.then(() => runInScope({ resources
|
|
562
|
+
.then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
|
|
510
563
|
.catch(logRenderFailure)
|
|
511
564
|
.finally(() => { void it.return?.(); });
|
|
512
565
|
} catch (err) {
|
|
@@ -516,6 +569,25 @@ export class AgentDurableObject {
|
|
|
516
569
|
}
|
|
517
570
|
return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
|
|
518
571
|
}
|
|
572
|
+
// SESSION RESET (#129): terminally retire this session's history — supersede unfinished
|
|
573
|
+
// turns, archive messages/steps under the current generation (the audit trail), and
|
|
574
|
+
// answer with the audit handle. The response waits for in-flight turns to unwind at
|
|
575
|
+
// their next checkpoint boundary (the reset op runs on the turn chain), which is fine:
|
|
576
|
+
// a DO stays alive while it has pending work.
|
|
577
|
+
if (req.method === "POST" && url.pathname.endsWith("/reset")) {
|
|
578
|
+
let session: AgentSession;
|
|
579
|
+
try {
|
|
580
|
+
session = this.resolveSession(key);
|
|
581
|
+
} catch (err) {
|
|
582
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
return Response.json(await session.reset());
|
|
586
|
+
} catch (err) {
|
|
587
|
+
// e.g. a store without reset() — a caller-resolvable refusal, not a crash
|
|
588
|
+
return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
519
591
|
if (url.pathname.endsWith("/transcript")) {
|
|
520
592
|
// A keyed read still conflict-checks (and may commit the key — it is authentic,
|
|
521
593
|
// idFromName routed it here); a key-less read stays non-committal via transcript().
|
|
@@ -528,7 +600,7 @@ export class AgentDurableObject {
|
|
|
528
600
|
}
|
|
529
601
|
return Response.json({ transcript: this.transcript() });
|
|
530
602
|
}
|
|
531
|
-
return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
|
|
603
|
+
return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
|
|
532
604
|
}
|
|
533
605
|
}
|
|
534
606
|
|
|
@@ -556,9 +628,9 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
|
|
|
556
628
|
}, 20_000);
|
|
557
629
|
unsub = session.observe((e) => {
|
|
558
630
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
559
|
-
// terminal for the STREAM: completed, failed, OR suspended (input.requested
|
|
560
|
-
// parked; the stream ends and a later /resume opens a fresh continuation stream).
|
|
561
|
-
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
|
|
631
|
+
// terminal for the STREAM: completed, failed, cancelled, OR suspended (input.requested
|
|
632
|
+
// → the turn parked; the stream ends and a later /resume opens a fresh continuation stream).
|
|
633
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") { stop(); controller.close(); }
|
|
562
634
|
}, { turnId });
|
|
563
635
|
},
|
|
564
636
|
cancel() { stop(); },
|
|
@@ -578,7 +650,7 @@ function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable
|
|
|
578
650
|
let notify: (() => void) | undefined;
|
|
579
651
|
const unsub = session.observe((e) => {
|
|
580
652
|
queue.push(e);
|
|
581
|
-
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
|
|
653
|
+
if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
|
|
582
654
|
notify?.();
|
|
583
655
|
}, { turnId });
|
|
584
656
|
return {
|
|
@@ -627,6 +699,7 @@ export async function sseTurnFinalText(res: Response): Promise<string> {
|
|
|
627
699
|
const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
|
|
628
700
|
if (e.type === "turn.completed") return e.text;
|
|
629
701
|
if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
|
|
702
|
+
if (e.type === "turn.cancelled") throw new Error(`turn ${e.turnId} was cancelled (${e.reason})`);
|
|
630
703
|
}
|
|
631
704
|
if (done) break;
|
|
632
705
|
}
|
|
@@ -827,7 +900,7 @@ export function durableChannelSurface(
|
|
|
827
900
|
namespace,
|
|
828
901
|
opts.agentName,
|
|
829
902
|
o?.session ?? "default",
|
|
830
|
-
new Request(
|
|
903
|
+
new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
831
904
|
);
|
|
832
905
|
// /turn streams SSE; the simple path collapses it to the final text.
|
|
833
906
|
return sseTurnFinalText(res);
|
|
@@ -843,7 +916,7 @@ export function durableChannelSurface(
|
|
|
843
916
|
namespace,
|
|
844
917
|
opts.agentName,
|
|
845
918
|
o?.session ?? "default",
|
|
846
|
-
new Request(
|
|
919
|
+
new Request(`https://do/turn?detach=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
847
920
|
);
|
|
848
921
|
if (res.status !== 202) {
|
|
849
922
|
const detail = (await res.text()).slice(0, 200);
|
|
@@ -864,7 +937,7 @@ export function durableChannelSurface(
|
|
|
864
937
|
namespace,
|
|
865
938
|
opts.agentName,
|
|
866
939
|
o?.session ?? "default",
|
|
867
|
-
new Request(
|
|
940
|
+
new Request(`https://do/turn?deliver=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
868
941
|
);
|
|
869
942
|
if (res.status === 501) {
|
|
870
943
|
throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
|
|
@@ -883,7 +956,7 @@ export function durableChannelSurface(
|
|
|
883
956
|
namespace,
|
|
884
957
|
opts.agentName,
|
|
885
958
|
o?.session ?? "default",
|
|
886
|
-
new Request(
|
|
959
|
+
new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
|
|
887
960
|
);
|
|
888
961
|
yield* sseTurnEvents(res);
|
|
889
962
|
},
|
|
@@ -924,6 +997,24 @@ export function durableChannelSurface(
|
|
|
924
997
|
}
|
|
925
998
|
return (await res.json()) as { turnId: string };
|
|
926
999
|
},
|
|
1000
|
+
// SESSION RESET (#129): retire the session's history on its DO. The response carries
|
|
1001
|
+
// the audit handle ({ previousSession, generation }); a 409 (store without reset(),
|
|
1002
|
+
// key mismatch) surfaces as an ordinary error — there is no fallback to invent.
|
|
1003
|
+
resetSession: async (o) => {
|
|
1004
|
+
const namespace = getNamespace();
|
|
1005
|
+
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
|
1006
|
+
const res = await durableFetch(
|
|
1007
|
+
namespace,
|
|
1008
|
+
opts.agentName,
|
|
1009
|
+
o?.session ?? "default",
|
|
1010
|
+
new Request("https://do/reset", { method: "POST" }),
|
|
1011
|
+
);
|
|
1012
|
+
if (!res.ok) {
|
|
1013
|
+
const detail = (await res.text()).slice(0, 200);
|
|
1014
|
+
throw new Error(`resetSession: reset was not accepted (status ${res.status}): ${detail}`);
|
|
1015
|
+
}
|
|
1016
|
+
return (await res.json()) as { previousSession: string; generation: number };
|
|
1017
|
+
},
|
|
927
1018
|
};
|
|
928
1019
|
return channelDispatch(resolved, ctx);
|
|
929
1020
|
}
|
package/src/agent-native.ts
CHANGED
|
@@ -79,6 +79,26 @@ class SqliteSessionStore implements SessionStore {
|
|
|
79
79
|
throw e;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
// Session reset (#129): ARCHIVE this session's messages/steps under the current
|
|
83
|
+
// generation (audit trail — never deleted), clear the live rows, status → "new".
|
|
84
|
+
// Archive tables + the generation counter are created lazily here, so existing
|
|
85
|
+
// databases stay untouched until the first reset.
|
|
86
|
+
reset(): number {
|
|
87
|
+
return this.tx(() => {
|
|
88
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
|
|
89
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
|
|
90
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
|
|
91
|
+
const r = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid) as { generation: number } | undefined;
|
|
92
|
+
const generation = r?.generation ?? 0;
|
|
93
|
+
this.db.query("INSERT INTO agent_messages_archive (session_id, generation, seq, body) SELECT session_id, ?, seq, body FROM agent_messages WHERE session_id = ?").run(generation, this.sid);
|
|
94
|
+
this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
|
|
95
|
+
this.db.query("INSERT INTO agent_steps_archive (session_id, generation, id, output) SELECT session_id, ?, id, output FROM agent_steps WHERE session_id = ?").run(generation, this.sid);
|
|
96
|
+
this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
|
|
97
|
+
this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
|
|
98
|
+
this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
|
|
99
|
+
return generation;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
82
102
|
unwrap<H = unknown>(): H { return this.db as unknown as H; }
|
|
83
103
|
}
|
|
84
104
|
|
|
@@ -139,6 +159,10 @@ class MemorySessionStore implements SessionStore {
|
|
|
139
159
|
private msgs: Msg[] = [];
|
|
140
160
|
private steps = new Map<string, unknown>();
|
|
141
161
|
private status = "new";
|
|
162
|
+
private generation = 0;
|
|
163
|
+
// Retired generations (#129) — kept for the audit contract even in memory, so the
|
|
164
|
+
// backend behaves like the durable tiers within one process lifetime.
|
|
165
|
+
private readonly archives: { generation: number; msgs: Msg[]; steps: Map<string, unknown> }[] = [];
|
|
142
166
|
appendMessage(m: Msg) { this.msgs.push(m); }
|
|
143
167
|
messages(): Msg[] { return this.msgs.slice(); }
|
|
144
168
|
hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
|
|
@@ -148,6 +172,14 @@ class MemorySessionStore implements SessionStore {
|
|
|
148
172
|
getStatus(): string { return this.status; }
|
|
149
173
|
setStatus(s: string) { this.status = s; }
|
|
150
174
|
tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
|
|
175
|
+
reset(): number {
|
|
176
|
+
const generation = this.generation++;
|
|
177
|
+
this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
|
|
178
|
+
this.msgs = [];
|
|
179
|
+
this.steps = new Map();
|
|
180
|
+
this.status = "new";
|
|
181
|
+
return generation;
|
|
182
|
+
}
|
|
151
183
|
unwrap<H = unknown>(): H { return undefined as unknown as H; }
|
|
152
184
|
}
|
|
153
185
|
|
|
@@ -216,12 +248,16 @@ export function mountAgent(
|
|
|
216
248
|
agent,
|
|
217
249
|
services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
|
|
218
250
|
run: (message, o) =>
|
|
219
|
-
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
|
|
251
|
+
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
|
|
220
252
|
// FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
|
|
221
253
|
// ceiling (Node keeps floating promises alive), but the seam is the same so a channel
|
|
222
254
|
// written against ctx.runDetached behaves identically on both targets.
|
|
223
255
|
runDetached: async (message, o) =>
|
|
224
|
-
runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
|
|
256
|
+
runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
|
|
257
|
+
// SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
|
|
258
|
+
// stores implement archival, so a channel written against ctx.resetSession behaves
|
|
259
|
+
// identically on both targets.
|
|
260
|
+
resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset(),
|
|
225
261
|
};
|
|
226
262
|
const channels = channelFetch(agent, ctx);
|
|
227
263
|
const surface = async (req: Request): Promise<Response | null> => {
|
package/src/build.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { localeHref } from "@junejs/core/i18n";
|
|
|
32
32
|
import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
|
|
33
33
|
import { workers, vercel, deno, staticSite, type JuneAdapter, type ResourcePlan } from "./adapter";
|
|
34
34
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
35
|
+
import { generateAgentModule } from "./agent-compile";
|
|
35
36
|
import { generateContentModule } from "./content";
|
|
36
37
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
37
38
|
import { findMiddlewareFile } from "./router";
|
|
@@ -441,6 +442,20 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
441
442
|
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
442
443
|
}
|
|
443
444
|
|
|
445
|
+
// The durable agent's DO address (createWorker routes the chat endpoint by
|
|
446
|
+
// it). Set whenever an agent/ directory exists — the surface is inert without
|
|
447
|
+
// an env.AGENT binding, so in-process consumers (prerender, the parity test)
|
|
448
|
+
// are unaffected.
|
|
449
|
+
let agentName: string | undefined;
|
|
450
|
+
const agentDir = join(appDir, frozen.agent.runtime.dir);
|
|
451
|
+
if (frozen.agent.runtime.enabled && existsSync(agentDir)) {
|
|
452
|
+
const cfgFile = join(agentDir, "agent.ts");
|
|
453
|
+
const cfg = existsSync(cfgFile)
|
|
454
|
+
? ((await import(pathToFileURL(cfgFile).href)).default as { name?: string } | undefined)
|
|
455
|
+
: undefined;
|
|
456
|
+
agentName = cfg?.name ?? basename(agentDir);
|
|
457
|
+
}
|
|
458
|
+
|
|
444
459
|
return {
|
|
445
460
|
routes,
|
|
446
461
|
dynamicRoutes,
|
|
@@ -450,6 +465,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
450
465
|
loadings,
|
|
451
466
|
document: frozen.document,
|
|
452
467
|
agent: frozen.agent,
|
|
468
|
+
agentName,
|
|
453
469
|
i18n: frozen.i18n,
|
|
454
470
|
earlyHints: frozen.earlyHints,
|
|
455
471
|
extra,
|
|
@@ -521,6 +537,52 @@ export async function juneBuild(
|
|
|
521
537
|
config: fullConfig,
|
|
522
538
|
});
|
|
523
539
|
|
|
540
|
+
// ---- durable agent (the F7 mount): compile app/agent → _agent.gen.ts ------
|
|
541
|
+
// The generated entry mounts the compiled module statically (fs discovery
|
|
542
|
+
// can't run on workerd) and exports the per-session Durable Object class.
|
|
543
|
+
// Only a target with Durable Objects can host the session actor — elsewhere
|
|
544
|
+
// the agent surface is skipped with a notice, and an app without an agent/
|
|
545
|
+
// directory emits a byte-identical entry (the parity guarantee).
|
|
546
|
+
const agentDir = join(appDir, frozen.agent.runtime.dir);
|
|
547
|
+
let agentModule: { file: string } | null = null;
|
|
548
|
+
if (frozen.agent.runtime.enabled) {
|
|
549
|
+
const generated = generateAgentModule(agentDir);
|
|
550
|
+
if (generated && adapter.capabilities.durableObjects) {
|
|
551
|
+
agentModule = generated;
|
|
552
|
+
} else if (generated) {
|
|
553
|
+
console.warn(
|
|
554
|
+
`[june build] app/${frozen.agent.runtime.dir}/ found, but the ${adapter.name}() target has no Durable Objects — the durable agent is not mounted.`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (agentModule) {
|
|
559
|
+
// The generated DO's model is Claude, and workerd has no runtime module
|
|
560
|
+
// resolution — the SDK must be a real dependency of the app so the entry's
|
|
561
|
+
// STATIC import bundles it (@junejs/core treats it as an optional peer; the
|
|
562
|
+
// adapter's own lazy import can never be bundled). Fail the build with the
|
|
563
|
+
// fix, never ship a worker whose first model turn dies on a bare import.
|
|
564
|
+
// An explicit node_modules walk, not a resolver API: the check must behave
|
|
565
|
+
// identically on every host (Bun.resolveSync missed a symlinked scoped
|
|
566
|
+
// package on Linux CI that it found on macOS), and Rolldown does its own
|
|
567
|
+
// resolution at bundle time anyway — this is a preflight, not the resolver.
|
|
568
|
+
const sdkResolvable = (fromDir: string): boolean => {
|
|
569
|
+
// Absolute first: with a relative root (juneBuild(".")), dirname(".") is
|
|
570
|
+
// still "." and the walk would stop before reaching a hoisted parent.
|
|
571
|
+
for (let d = resolve(fromDir); ; ) {
|
|
572
|
+
if (existsSync(join(d, "node_modules", "@anthropic-ai", "sdk", "package.json"))) return true;
|
|
573
|
+
const parent = dirname(d);
|
|
574
|
+
if (parent === d) return false;
|
|
575
|
+
d = parent;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
if (!sdkResolvable(appRoot)) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
`app/${frozen.agent.runtime.dir}/ mounts a durable agent whose model is Claude — ` +
|
|
581
|
+
`add the SDK to the app so it bundles for workerd: bun add @anthropic-ai/sdk`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
524
586
|
// Compile the global stylesheet ONCE and content-hash it: the built worker and
|
|
525
587
|
// prerendered HTML link `/global.<hash>.css`, served immutable (cache forever,
|
|
526
588
|
// a content change ships a new URL → no revalidation, no stale window). Dev
|
|
@@ -691,11 +753,15 @@ export async function juneBuild(
|
|
|
691
753
|
} else if (hasResources) {
|
|
692
754
|
imports.push(`import { bindWorkerResources } from "@junejs/server/resources";`);
|
|
693
755
|
}
|
|
694
|
-
|
|
695
|
-
|
|
756
|
+
// One expression, TWO instances: the pipeline (createWorker) and the durable
|
|
757
|
+
// agent's DO each open their own provider — the DO is a separate isolate whose
|
|
758
|
+
// env must not be captured by the worker-side memoization.
|
|
759
|
+
const resourcesExpr = tursoDb
|
|
760
|
+
? `memoizeResources({ db: turso() })`
|
|
696
761
|
: hasResources
|
|
697
|
-
?
|
|
698
|
-
:
|
|
762
|
+
? `bindWorkerResources(${JSON.stringify(resourceFlags)})`
|
|
763
|
+
: null;
|
|
764
|
+
const resourcesField = resourcesExpr ? `\n resources: ${resourcesExpr},` : "";
|
|
699
765
|
|
|
700
766
|
// Opt-in Tier-3 data layer: import its installDataLayer from the declared module
|
|
701
767
|
// and call it at worker boot — the prod twin of the dev host's dataLayer.install()
|
|
@@ -723,6 +789,46 @@ export async function juneBuild(
|
|
|
723
789
|
}
|
|
724
790
|
const servicesField = servicesModule ? `\n services: __appServices,` : "";
|
|
725
791
|
|
|
792
|
+
// Durable agent: import the compiled agent module + the DO shell pieces, set
|
|
793
|
+
// agentName on the manifest (what activates createWorker's chat routing), and
|
|
794
|
+
// export the DO class the adapter's wrangler binding names. cloudflare:workers
|
|
795
|
+
// is a workerd runtime module — external to the bundle (see the predicate below).
|
|
796
|
+
if (agentModule) {
|
|
797
|
+
imports.push(`import __agentModule from ${JSON.stringify(importPath(genDir, agentModule.file))};`);
|
|
798
|
+
imports.push(`import { assembleDurable } from "@junejs/core/agent-config";`);
|
|
799
|
+
imports.push(`import { anthropic } from "@junejs/core/agent-models";`);
|
|
800
|
+
imports.push(`import { AgentDurableObject } from "@junejs/server/agent-durable";`);
|
|
801
|
+
imports.push(`import { DurableObject } from "cloudflare:workers";`);
|
|
802
|
+
// STATIC so Rolldown bundles the SDK (the adapter's own import is a
|
|
803
|
+
// non-literal lazy specifier — deliberately unbundleable); injected via
|
|
804
|
+
// anthropic({ client }), which skips that lazy path entirely.
|
|
805
|
+
imports.push(`import Anthropic from "@anthropic-ai/sdk";`);
|
|
806
|
+
}
|
|
807
|
+
const agentNameField = agentModule ? `\n agentName: __agentModule.config.name,` : "";
|
|
808
|
+
const doClass = agentModule
|
|
809
|
+
? `
|
|
810
|
+
// The durable agent: one Durable Object per session, the compiled agent/
|
|
811
|
+
// directory as its definition — adapted tools (+ read_skill when skills exist),
|
|
812
|
+
// the assembled system prompt, channel factories resolved with the DO's own
|
|
813
|
+
// env, and the app's declared resources opened from THIS isolate's env (lazily,
|
|
814
|
+
// at the first turn — the same ambient db a tool sees in native dev). Model:
|
|
815
|
+
// Anthropic over a statically bundled client (workerd cannot resolve a bare
|
|
816
|
+
// import at runtime), keyed by the ANTHROPIC_API_KEY secret — a missing secret
|
|
817
|
+
// surfaces as the SDK's own clear construction error on the first agent request.
|
|
818
|
+
const __agentDef = assembleDurable(__agentModule);
|
|
819
|
+
export class JuneAgentDO extends DurableObject {
|
|
820
|
+
#agent = new AgentDurableObject(this.ctx, {
|
|
821
|
+
...__agentDef,
|
|
822
|
+
model: anthropic({ model: __agentModule.config.model, client: new Anthropic({ apiKey: (this.env as { ANTHROPIC_API_KEY?: string }).ANTHROPIC_API_KEY }) }),
|
|
823
|
+
env: this.env,${resourcesExpr ? `\n resources: ${resourcesExpr},` : ""}${servicesModule ? "\n services: __appServices(this.env)," : ""}
|
|
824
|
+
});
|
|
825
|
+
fetch(req: Request): Promise<Response> {
|
|
826
|
+
return this.#agent.fetch(req);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
`
|
|
830
|
+
: "";
|
|
831
|
+
|
|
726
832
|
const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
|
|
727
833
|
${adapterEntry.imports.join("\n")}
|
|
728
834
|
${imports.join("\n")}
|
|
@@ -741,12 +847,12 @@ ${chains.join("\n")}
|
|
|
741
847
|
${loadings.join("\n")}
|
|
742
848
|
},
|
|
743
849
|
document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
|
|
744
|
-
agent: ${JSON.stringify(frozen.agent)},${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
|
|
850
|
+
agent: ${JSON.stringify(frozen.agent)},${agentNameField}${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
|
|
745
851
|
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}${servicesField}
|
|
746
852
|
});
|
|
747
853
|
|
|
748
854
|
${adapterEntry.wrap("pipeline")}
|
|
749
|
-
`;
|
|
855
|
+
${doClass}`;
|
|
750
856
|
const entryFile = join(genDir, "worker-entry.tsx");
|
|
751
857
|
await writeFile(entryFile, entry);
|
|
752
858
|
|
|
@@ -773,6 +879,8 @@ ${adapterEntry.wrap("pipeline")}
|
|
|
773
879
|
external: (id: string) => {
|
|
774
880
|
// Bun built-ins exist only at Bun runtime, never in the workerd graph (see isBunSpecifier).
|
|
775
881
|
if (isBunSpecifier(id)) return true;
|
|
882
|
+
// workerd runtime modules (the durable-agent entry imports cloudflare:workers).
|
|
883
|
+
if (id.startsWith("cloudflare:")) return true;
|
|
776
884
|
// Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
|
|
777
885
|
if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
|
|
778
886
|
// Merge: adapter.buildExternal (target-specific, e.g. workers-og for the
|
|
@@ -960,16 +1068,23 @@ ${adapterEntry.wrap("pipeline")}
|
|
|
960
1068
|
const defaultName = workerName(pkgName ?? basename(appRoot));
|
|
961
1069
|
// A declared D1-backed db (sqlite/d1) → a D1 binding named DB (the name
|
|
962
1070
|
// bindWorkerResources reads). turso() connects from env, not a binding — no plan.
|
|
963
|
-
const plan: ResourcePlan =
|
|
964
|
-
resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
|
|
1071
|
+
const plan: ResourcePlan = {
|
|
1072
|
+
...(resourcesCfg?.db && resourcesCfg.db.kind !== "turso"
|
|
965
1073
|
? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
|
|
966
|
-
: {}
|
|
1074
|
+
: {}),
|
|
1075
|
+
// The durable agent's DO binding — the name createWorker reads (env.AGENT)
|
|
1076
|
+
// and the class the generated entry exports.
|
|
1077
|
+
...(agentModule ? { agent: { binding: "AGENT", className: "JuneAgentDO" } } : {}),
|
|
1078
|
+
};
|
|
967
1079
|
await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName, publicFiles });
|
|
968
1080
|
if (plan.db) {
|
|
969
1081
|
console.log(
|
|
970
1082
|
` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`,
|
|
971
1083
|
);
|
|
972
1084
|
}
|
|
1085
|
+
if (plan.agent) {
|
|
1086
|
+
console.log(` ↳ durable agent mounted (${plan.agent.className} → env.${plan.agent.binding}; chat at ${frozen.agent.runtime.chat.path})`);
|
|
1087
|
+
}
|
|
973
1088
|
|
|
974
1089
|
return {
|
|
975
1090
|
outFile,
|
package/src/index.ts
CHANGED
|
@@ -91,6 +91,14 @@ export {
|
|
|
91
91
|
freezeConfig,
|
|
92
92
|
type BuildResult,
|
|
93
93
|
} from "./build";
|
|
94
|
+
export {
|
|
95
|
+
generateAgentModule,
|
|
96
|
+
findAgentDir,
|
|
97
|
+
scanAgentDir,
|
|
98
|
+
emitAgentModule,
|
|
99
|
+
AGENT_MODULE_FILE,
|
|
100
|
+
type AgentDirScan,
|
|
101
|
+
} from "./agent-compile";
|
|
94
102
|
export { juneDeploy, type DeployResult } from "./deploy";
|
|
95
103
|
export { migrateD1, wranglerD1, resolveD1Database, type D1Exec } from "./d1-migrate";
|
|
96
104
|
export { startDevServer, type DevServer, type DevServerOptions } from "./dev";
|
package/src/tsconfig-jsx.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { join } from "node:path";
|
|
|
9
9
|
// tsconfig.json is JSONC by convention (comments + trailing commas are idiomatic), and a strict
|
|
10
10
|
// JSON.parse failure here silently degrades to "not declared" → the conflict warning reappears.
|
|
11
11
|
// Strip comments (`//` only at line-start/after-whitespace so "https://…" in strings survives)
|
|
12
|
-
// and trailing commas before parsing.
|
|
13
|
-
const stripJsonc = (s: string): string =>
|
|
12
|
+
// and trailing commas before parsing. Shared: agent-compile's tsconfig sniff parses the same way.
|
|
13
|
+
export const stripJsonc = (s: string): string =>
|
|
14
14
|
s
|
|
15
15
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
16
16
|
.replace(/(^|\s)\/\/.*$/gm, "$1")
|