@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.
@@ -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
- resources?: Resources;
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
- // Built from the DO's env in the constructor (this isolate), shared across turns —
235
- // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
236
- private readonly resources: Resources;
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.resources = def.resources ?? {};
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: this.resources, services: this.services }, () => session.turn(input));
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: this.resources, services: this.services }, () => session.start(input));
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: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
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: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
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: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
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: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
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 → the turn
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("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
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("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
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("https://do/turn?deliver=1", { method: "POST", body: serializeTurn(message, o) }),
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("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
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
  }
@@ -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
- const resourcesField = tursoDb
695
- ? `\n resources: memoizeResources({ db: turso() }),`
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
- ? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
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";
@@ -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")