@junejs/server 0.1.2-dev.4 → 0.1.2-dev.9

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.
@@ -1,5 +1,5 @@
1
1
  import { Channel, ChannelFactory } from "@junejs/core/agent-config";
2
- import { InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnEvent } from "@junejs/core/agent-runtime";
2
+ import { InboundEvent, Model, Msg, ProactiveTrigger, SessionStore, Tool, TurnError, TurnEvent, TurnFailurePhase } from "@junejs/core/agent-runtime";
3
3
  import { Resources } from "@junejs/core/resources";
4
4
  //#region src/agent-durable.d.ts
5
5
  type SqlStorageValue = ArrayBuffer | string | number | null;
@@ -36,6 +36,8 @@ declare class DoSessionStore implements SessionStore {
36
36
  delStep(id: string): void;
37
37
  getStatus(): string;
38
38
  setStatus(s: string): void;
39
+ getSessionKey(): string | undefined;
40
+ setSessionKey(key: string): void;
39
41
  tx<T>(fn: () => T): T;
40
42
  unwrap<H = unknown>(): H;
41
43
  }
@@ -49,23 +51,48 @@ type DoAgentDef = {
49
51
  env?: unknown;
50
52
  resources?: Resources;
51
53
  services?: unknown;
54
+ onTurnError?: (failure: {
55
+ turnId: string;
56
+ error: TurnError;
57
+ phase?: TurnFailurePhase;
58
+ step?: string;
59
+ }) => void | Promise<void>;
52
60
  };
53
61
  declare class AgentDurableObject {
54
62
  private session;
63
+ private sessionKey;
64
+ private readonly name;
65
+ private readonly store;
66
+ private readonly sink;
67
+ private readonly model;
68
+ private readonly tools;
69
+ private readonly channelInstructions?;
55
70
  private readonly resources;
56
71
  private readonly services;
57
72
  constructor(state: DurableObjectState, def: DoAgentDef);
73
+ private resolveSession;
58
74
  turn(input: {
59
75
  turnId?: string;
60
76
  userText: string;
61
77
  event?: InboundEvent;
62
78
  trigger?: ProactiveTrigger;
79
+ session?: string;
63
80
  }): Promise<string>;
81
+ start(input: {
82
+ turnId?: string;
83
+ userText: string;
84
+ event?: InboundEvent;
85
+ trigger?: ProactiveTrigger;
86
+ session?: string;
87
+ }): Promise<{
88
+ turnId: string;
89
+ }>;
64
90
  transcript(): import("@junejs/core/agent-runtime").Turn[];
65
91
  fetch(req: Request): Promise<Response>;
66
92
  }
67
93
  declare function sseTurnFinalText(res: Response): Promise<string>;
68
94
  declare function sseTurnEvents(res: Response): AsyncIterable<TurnEvent>;
95
+ declare const SESSION_HEADER = "x-june-session";
69
96
  declare function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response>;
70
97
  declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
71
98
  agentName: string;
@@ -79,4 +106,4 @@ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespac
79
106
  waitUntil?: (p: Promise<unknown>) => void;
80
107
  }): (req: Request) => Promise<Response | null>;
81
108
  //#endregion
82
- export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
109
+ export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SESSION_HEADER, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -1,3 +1,4 @@
1
+ import { assertCoreRuntimeVersion } from "./core-version.js";
1
2
  import { channelDispatch, resolveChannel } from "@junejs/core/agent-config";
2
3
  import { AgentSession, ResumeAuthorizationError, withSystem } from "@junejs/core/agent-runtime";
3
4
  import { ensureScope, runInScope } from "@junejs/db";
@@ -40,6 +41,13 @@ var DoSessionStore = class {
40
41
  setStatus(s) {
41
42
  this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
42
43
  }
44
+ getSessionKey() {
45
+ const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
46
+ return rows.length ? rows[0].v : void 0;
47
+ }
48
+ setSessionKey(key) {
49
+ this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
50
+ }
43
51
  tx(fn) {
44
52
  return this.storage.transactionSync(fn);
45
53
  }
@@ -66,13 +74,43 @@ const crossDoUnsupported = { session() {
66
74
  } };
67
75
  var AgentDurableObject = class {
68
76
  session;
77
+ sessionKey;
78
+ name;
79
+ store;
80
+ sink;
81
+ model;
82
+ tools;
83
+ channelInstructions;
69
84
  resources;
70
85
  services;
71
86
  constructor(state, def) {
87
+ assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`);
72
88
  const store = new DoSessionStore(state.storage);
73
89
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
74
90
  this.resources = def.resources ?? {};
75
91
  this.services = def.services;
92
+ const name = def.name ?? "agent";
93
+ const sink = new InProcEventSink();
94
+ sink.subscribe((e) => {
95
+ if (e.type !== "turn.failed") return;
96
+ const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed${e.step ? ` at ${e.step}` : ""}: ${e.error.stack ?? e.error.message}${e.error.causeChain?.length ? `\ncaused by: ${e.error.causeChain.join(" ← ")}` : ""}`);
97
+ if (def.onTurnError) try {
98
+ const out = def.onTurnError({
99
+ turnId: e.turnId,
100
+ error: e.error,
101
+ phase: e.phase,
102
+ step: e.step
103
+ });
104
+ if (out && typeof out.then === "function") out.then(void 0, (hookErr) => {
105
+ console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
106
+ defaultLog();
107
+ });
108
+ return;
109
+ } catch (hookErr) {
110
+ console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
111
+ }
112
+ defaultLog();
113
+ });
76
114
  const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
77
115
  const tools = [...def.tools];
78
116
  const seen = new Set(tools.map((t) => t.spec.name));
@@ -81,29 +119,62 @@ var AgentDurableObject = class {
81
119
  seen.add(t.spec.name);
82
120
  tools.push(t);
83
121
  }
84
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
122
+ this.name = name;
123
+ this.store = store;
124
+ this.sink = sink;
125
+ this.model = model;
126
+ this.tools = tools;
127
+ this.channelInstructions = def.channelInstructions;
128
+ }
129
+ resolveSession(key) {
130
+ if (key !== void 0) assertSessionKey(key);
131
+ if (this.session) {
132
+ if (key !== void 0 && this.sessionKey !== key) throw new Error(this.sessionKey === "self" ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)` : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
133
+ return this.session;
134
+ }
135
+ const stored = this.store.getSessionKey();
136
+ if (key !== void 0 && stored !== void 0 && key !== stored) throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
137
+ const resolved = key ?? stored ?? "self";
138
+ if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
139
+ this.sessionKey = resolved;
140
+ this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
141
+ return this.session;
85
142
  }
86
143
  async turn(input) {
87
144
  await ensureScope();
145
+ const session = this.resolveSession(input.session);
88
146
  return runInScope({
89
147
  resources: this.resources,
90
148
  services: this.services
91
- }, () => this.session.turn(input));
149
+ }, () => session.turn(input));
150
+ }
151
+ async start(input) {
152
+ await ensureScope();
153
+ const session = this.resolveSession(input.session);
154
+ return runInScope({
155
+ resources: this.resources,
156
+ services: this.services
157
+ }, () => session.start(input));
92
158
  }
93
159
  transcript() {
94
- return this.session.transcript();
160
+ if (this.session) return this.session.transcript();
161
+ if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
162
+ return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
95
163
  }
96
164
  async fetch(req) {
97
165
  const url = new URL(req.url);
166
+ const key = req.headers.get("x-june-session") ?? void 0;
98
167
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
99
168
  const { userText, turnId, event, trigger } = await req.json();
100
169
  await ensureScope();
170
+ let session;
101
171
  let started;
102
172
  try {
173
+ session = this.resolveSession(key);
103
174
  started = runInScope({
104
175
  resources: this.resources,
105
176
  services: this.services
106
- }, () => this.session.start({
177
+ }, () => session.start({
107
178
  userText,
108
179
  turnId,
109
180
  event,
@@ -112,23 +183,33 @@ var AgentDurableObject = class {
112
183
  } catch (err) {
113
184
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
114
185
  }
115
- return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
186
+ if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
187
+ return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
116
188
  }
117
189
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
118
190
  const { turnId, inputId, input, by } = await req.json();
119
191
  await ensureScope();
192
+ let session;
120
193
  try {
194
+ session = this.resolveSession(key);
121
195
  runInScope({
122
196
  resources: this.resources,
123
197
  services: this.services
124
- }, () => this.session.resume(turnId, inputId, input, { by }));
198
+ }, () => session.resume(turnId, inputId, input, { by }));
125
199
  } catch (err) {
126
200
  const message = err instanceof Error ? err.message : String(err);
127
201
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
128
202
  }
129
- return new Response(sseTurnStream(this.session, turnId), { headers: SSE_HEADERS });
203
+ return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
204
+ }
205
+ if (url.pathname.endsWith("/transcript")) {
206
+ if (key !== void 0) try {
207
+ return Response.json({ transcript: this.resolveSession(key).transcript() });
208
+ } catch (err) {
209
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
210
+ }
211
+ return Response.json({ transcript: this.transcript() });
130
212
  }
131
- if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
132
213
  return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
133
214
  }
134
215
  };
@@ -185,7 +266,7 @@ async function sseTurnFinalText(res) {
185
266
  if (!line) continue;
186
267
  const e = JSON.parse(line.slice(5).trim());
187
268
  if (e.type === "turn.completed") return e.text;
188
- if (e.type === "turn.failed") throw new Error(e.error.message);
269
+ if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error });
189
270
  }
190
271
  if (done) break;
191
272
  }
@@ -212,8 +293,16 @@ async function* sseTurnEvents(res) {
212
293
  if (done) break;
213
294
  }
214
295
  }
296
+ const SESSION_HEADER = "x-june-session";
297
+ const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
298
+ function assertSessionKey(session) {
299
+ if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
300
+ }
215
301
  function durableFetch(namespace, agent, session, req) {
216
- return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
302
+ assertSessionKey(session);
303
+ const headers = new Headers(req.headers);
304
+ headers.set(SESSION_HEADER, session);
305
+ return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
217
306
  }
218
307
  function durableAgentSurface(getNamespace, opts) {
219
308
  const chatPath = opts.chatPath ?? "/message";
@@ -223,6 +312,11 @@ function durableAgentSurface(getNamespace, opts) {
223
312
  const namespace = getNamespace();
224
313
  if (!namespace) return null;
225
314
  const { message, session } = await req.json();
315
+ if (session !== void 0) try {
316
+ assertSessionKey(session);
317
+ } catch (err) {
318
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
319
+ }
226
320
  const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
227
321
  method: "POST",
228
322
  body: JSON.stringify({ userText: message })
@@ -251,6 +345,19 @@ function durableChannelSurface(getNamespace, opts) {
251
345
  body: serializeTurn(message, o)
252
346
  })));
253
347
  },
348
+ runDetached: async (message, o) => {
349
+ const namespace = getNamespace();
350
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
351
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
352
+ method: "POST",
353
+ body: serializeTurn(message, o)
354
+ }));
355
+ if (res.status !== 202) {
356
+ const detail = (await res.text()).slice(0, 200);
357
+ throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
358
+ }
359
+ return await res.json();
360
+ },
254
361
  runStream: async function* (message, o) {
255
362
  const namespace = getNamespace();
256
363
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
@@ -302,4 +409,4 @@ function serializeTurn(userText, o) {
302
409
  }
303
410
  }
304
411
  //#endregion
305
- export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
412
+ export { AgentDurableObject, DoSessionStore, SESSION_HEADER, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -1,3 +1,4 @@
1
+ import { assertCoreRuntimeVersion } from "./core-version.js";
1
2
  import { openLocalSqliteSync } from "./sqlite-driver.js";
2
3
  import { channelFetch } from "@junejs/core/agent-config";
3
4
  import { AgentSession, withSystem } from "@junejs/core/agent-runtime";
@@ -73,6 +74,7 @@ var NativeRuntime = class {
73
74
  agents;
74
75
  db;
75
76
  constructor(agents, db) {
77
+ assertCoreRuntimeVersion("NativeRuntime");
76
78
  this.agents = agents;
77
79
  this.db = db;
78
80
  initSchema(db);
@@ -131,6 +133,7 @@ var MemoryRuntime = class {
131
133
  stores = /* @__PURE__ */ new Map();
132
134
  agents;
133
135
  constructor(agents) {
136
+ assertCoreRuntimeVersion("MemoryRuntime");
134
137
  this.agents = agents;
135
138
  }
136
139
  session(agent, id) {
@@ -164,6 +167,12 @@ function mountAgent(agent, runtime, opts = {}) {
164
167
  userText: message,
165
168
  event: o?.event,
166
169
  trigger: o?.trigger
170
+ }),
171
+ runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
172
+ turnId: o?.turnId,
173
+ userText: message,
174
+ event: o?.event,
175
+ trigger: o?.trigger
167
176
  })
168
177
  };
169
178
  const channels = channelFetch(agent, ctx);
@@ -0,0 +1,5 @@
1
+ //#region src/core-version.d.ts
2
+ declare function assertCoreRuntimeVersion(surface: string): void;
3
+ declare function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected?: number): void;
4
+ //#endregion
5
+ export { assertCoreRuntimeVersion, checkCoreRuntimeVersion };
@@ -0,0 +1,12 @@
1
+ import * as coreRuntime from "@junejs/core/agent-runtime";
2
+ //#region src/core-version.ts
3
+ const EXPECTED_CORE_RUNTIME_API = 1;
4
+ function assertCoreRuntimeVersion(surface) {
5
+ checkCoreRuntimeVersion(surface, coreRuntime.RUNTIME_API_VERSION);
6
+ }
7
+ function checkCoreRuntimeVersion(surface, actual, expected = EXPECTED_CORE_RUNTIME_API) {
8
+ if (actual === expected) return;
9
+ throw new Error(`${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, but the @junejs/core it resolved provides ${actual === void 0 ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — two copies of @junejs/core are likely installed (one nested under @junejs/server). Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`);
10
+ }
11
+ //#endregion
12
+ export { assertCoreRuntimeVersion, checkCoreRuntimeVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.2-dev.4",
3
+ "version": "0.1.2-dev.9",
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,7 +125,6 @@
125
125
  "build": "tsdown"
126
126
  },
127
127
  "dependencies": {
128
- "@junejs/core": ">=0.1.1-dev.8",
129
128
  "@junejs/db": ">=0.0.34-dev.0",
130
129
  "@momiji-rs/sparkdown": "^0.0.4",
131
130
  "lightningcss": "^1.27.0",
@@ -133,6 +132,7 @@
133
132
  "rolldown": "^1.1.0"
134
133
  },
135
134
  "peerDependencies": {
135
+ "@junejs/core": ">=0.1.1-dev.16",
136
136
  "react": "^19.2.0",
137
137
  "react-dom": "^19.2.0",
138
138
  "react-server-dom-webpack": "19.2.7",
@@ -28,6 +28,8 @@ import {
28
28
  type SessionStore,
29
29
  type ProactiveTrigger,
30
30
  type Tool,
31
+ type TurnError,
32
+ type TurnFailurePhase,
31
33
  } from "@junejs/core/agent-runtime";
32
34
  import type { Resources } from "@junejs/core/resources";
33
35
  import {
@@ -39,6 +41,7 @@ import {
39
41
  type ChannelFactory,
40
42
  } from "@junejs/core/agent-config";
41
43
  import { ensureScope, runInScope } from "@junejs/db";
44
+ import { assertCoreRuntimeVersion } from "./core-version";
42
45
 
43
46
  // ── minimal structural Cloudflare surface (no @cloudflare/workers-types dep) ──
44
47
  // SqlStorage row values are exactly what workerd's SQLite returns. Mirroring
@@ -117,6 +120,17 @@ export class DoSessionStore implements SessionStore {
117
120
  setStatus(s: string) {
118
121
  this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
119
122
  }
123
+ // The externally-assigned session key (#75). A DO cannot read its own idFromName
124
+ // name, so the key arrives on the first routed request and is persisted here —
125
+ // surviving hibernation/eviction, where the constructor re-runs with no request
126
+ // in hand. Not part of core's SessionStore contract: only the DO seam needs it.
127
+ getSessionKey(): string | undefined {
128
+ const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
129
+ return rows.length ? rows[0]!.v : undefined;
130
+ }
131
+ setSessionKey(key: string) {
132
+ this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
133
+ }
120
134
  tx<T>(fn: () => T): T {
121
135
  return this.storage.transactionSync(fn);
122
136
  }
@@ -171,6 +185,17 @@ export type DoAgentDef = {
171
185
  env?: unknown;
172
186
  resources?: Resources;
173
187
  services?: unknown;
188
+ // Called on every turn.failed for this session — the app's seam for routing turn
189
+ // failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
190
+ // default console.error; if the hook throws (or an async hook rejects), the default
191
+ // log fires anyway (both errors), so a failure is never silent. `error` carries the
192
+ // full serialized failure (stack, cause chain — see TurnError, #96), and phase/step
193
+ // name the engine step that was in flight — for a DETACHED turn this hook is the
194
+ // only failure-surfacing path, so nothing may be flattened before it fires. It
195
+ // happens to run inside the failed turn's request scope today (turn.failed is
196
+ // emitted mid-turn and ALS propagates), but that is NOT contract — don't rely on
197
+ // ambient db/services; close over what you need.
198
+ onTurnError?: (failure: { turnId: string; error: TurnError; phase?: TurnFailurePhase; step?: string }) => void | Promise<void>;
174
199
  };
175
200
 
176
201
  // The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
@@ -188,16 +213,67 @@ export type DoAgentDef = {
188
213
  // fetch(req) { return this.agent.fetch(req); }
189
214
  // }
190
215
  export class AgentDurableObject {
191
- private session: AgentSession;
216
+ // The AgentSession is constructed LAZILY (#75): the DO is keyed externally by
217
+ // idFromName(`${agent}:${session}`) but cannot read its own name, so the true
218
+ // session key only exists on a routed request (the SESSION_HEADER durableFetch
219
+ // sets). First keyed request wins and persists the key (agent_meta) so it
220
+ // survives hibernation; key-less paths fall back to the persisted key, then
221
+ // "self" (the pre-#75 behavior, kept for backward compatibility).
222
+ private session: AgentSession | undefined;
223
+ private sessionKey: string | undefined;
224
+ private readonly name: string;
225
+ private readonly store: DoSessionStore;
226
+ private readonly sink: InProcEventSink;
227
+ private readonly model: Model;
228
+ private readonly tools: Tool[];
229
+ private readonly channelInstructions?: Record<string, string>;
192
230
  // Built from the DO's env in the constructor (this isolate), shared across turns —
193
231
  // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
194
232
  private readonly resources: Resources;
195
233
  private readonly services: unknown;
196
234
  constructor(state: DurableObjectState, def: DoAgentDef) {
235
+ assertCoreRuntimeVersion(`AgentDurableObject(${def.name ?? "agent"})`); // #94: fail power-on, not mid-turn
197
236
  const store = new DoSessionStore(state.storage);
198
237
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
199
238
  this.resources = def.resources ?? {};
200
239
  this.services = def.services;
240
+ const name = def.name ?? "agent";
241
+ // Failure observability (#76): a turn that dies after the fast-ACK has no other
242
+ // observable surface on the edge — the webhook already 200'd and runBackground
243
+ // swallows the rejection unless the channel wired onError. Subscribing HERE (the
244
+ // session sink) covers every turn path — turn(), /turn, /resume — with one seam.
245
+ // Default: console.error, which wrangler tail surfaces. An app onTurnError takes
246
+ // over reporting; if IT throws, fall back to the default so nothing goes silent.
247
+ // (InProcEventSink already guards emit against a throwing subscriber.)
248
+ const sink = new InProcEventSink();
249
+ sink.subscribe((e) => {
250
+ if (e.type !== "turn.failed") return;
251
+ // The stack already opens with the message; log it INSTEAD of the message line
252
+ // when present so wrangler tail shows one coherent trace, not the message twice.
253
+ const defaultLog = () =>
254
+ console.error(`[june] agent "${name}" turn ${e.turnId} failed${e.step ? ` at ${e.step}` : ""}: ${e.error.stack ?? e.error.message}${e.error.causeChain?.length ? `\ncaused by: ${e.error.causeChain.join(" ← ")}` : ""}`);
255
+ if (def.onTurnError) {
256
+ try {
257
+ const out = def.onTurnError({ turnId: e.turnId, error: e.error, phase: e.phase, step: e.step });
258
+ // An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
259
+ // Promise — a rejection there must hit the same fallback as a sync throw, or
260
+ // the failure goes silent again (plus an unhandled rejection). No waitUntil
261
+ // keeper is needed here: unlike a Worker, a DO stays active while it has
262
+ // pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
263
+ // this .then subscription is what retains the promise.
264
+ if (out && typeof (out as Promise<void>).then === "function") {
265
+ (out as Promise<void>).then(undefined, (hookErr) => {
266
+ console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
267
+ defaultLog();
268
+ });
269
+ }
270
+ return;
271
+ } catch (hookErr) {
272
+ console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
273
+ }
274
+ }
275
+ defaultLog();
276
+ });
201
277
  // Merge the mounted channels' capability tools (built here from this DO's env, since a
202
278
  // tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
203
279
  // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
@@ -209,7 +285,49 @@ export class AgentDurableObject {
209
285
  seen.add(t.spec.name);
210
286
  tools.push(t);
211
287
  }
212
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
288
+ this.name = name;
289
+ this.store = store;
290
+ this.sink = sink;
291
+ this.model = model;
292
+ this.tools = tools;
293
+ this.channelInstructions = def.channelInstructions;
294
+ }
295
+ // Resolve THE session for this DO: explicit key (from a routed request) → persisted
296
+ // key (a prior life learned it) → "self". A key that contradicts the persisted or
297
+ // live identity is a mis-route (or a key-less path was used first) — fail loudly:
298
+ // silently proceeding is exactly the per-conversation data corruption #75 hit.
299
+ private resolveSession(key?: string): AgentSession {
300
+ // Every entry point shares the session-key contract (turn({ session }) and hand-rolled
301
+ // SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
302
+ // would bind an identity no routed request could ever address — an orphaned session.
303
+ if (key !== undefined) assertSessionKey(key);
304
+ // Live fast path — no storage read once the session exists: this method is the only
305
+ // writer of the persisted key, so within one life it cannot diverge from the live one.
306
+ if (this.session) {
307
+ if (key !== undefined && this.sessionKey !== key) {
308
+ throw new Error(
309
+ this.sessionKey === "self"
310
+ ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)`
311
+ : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`,
312
+ );
313
+ }
314
+ return this.session;
315
+ }
316
+ const stored = this.store.getSessionKey();
317
+ if (key !== undefined && stored !== undefined && key !== stored) {
318
+ throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
319
+ }
320
+ const resolved = key ?? stored ?? "self";
321
+ // "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
322
+ // is a placeholder ("no identity externally assigned yet"), not an identity. Within
323
+ // one life a keyed request after a "self" binding is refused above — two concurrently
324
+ // active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
325
+ // the legacy transcript: that asymmetry IS the migration path from pre-keyed
326
+ // deployments, where a persisted "self" would 409 every keyed request forever.
327
+ if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
328
+ this.sessionKey = resolved;
329
+ this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
330
+ return this.session;
213
331
  }
214
332
  // Run the whole turn inside a request scope seeded from this DO's env, so ambient
215
333
  // `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
@@ -218,32 +336,59 @@ export class AgentDurableObject {
218
336
  // Juno's batch-loader registry) can't leak across turns on a long-lived DO.
219
337
  // ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
220
338
  // pipeline does; without it runInScope is a pass-through and ambient reads throw.
221
- async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger }): Promise<string> {
339
+ async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
222
340
  await ensureScope();
223
- return runInScope({ resources: this.resources, services: this.services }, () => this.session.turn(input));
341
+ const session = this.resolveSession(input.session);
342
+ return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
224
343
  }
344
+ // Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
345
+ // it then runs under the DO's own lifetime (a DO stays alive while it has pending
346
+ // work), not the caller's. No live consumer sees the result — failures surface via
347
+ // the default turn-failure log / onTurnError (#76).
348
+ async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
349
+ await ensureScope();
350
+ const session = this.resolveSession(input.session);
351
+ return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
352
+ }
353
+ // Read-only: folds the durable log. When an identity exists (live, or persisted from a
354
+ // prior life) the session resolves and caches like any other path. Only a read on a
355
+ // NEVER-keyed object stays non-committal — caching there would bake "self" in as the
356
+ // identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
225
357
  transcript() {
226
- return this.session.transcript();
358
+ if (this.session) return this.session.transcript();
359
+ if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
360
+ return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
227
361
  }
228
362
  // Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
229
363
  // turn in-scope, then stream from the session sink); GET …/transcript reads the log.
230
364
  // The app can call turn()/transcript() directly instead (turn() awaits the final text).
231
365
  async fetch(req: Request): Promise<Response> {
232
366
  const url = new URL(req.url);
367
+ // The session key durableFetch stamped on the routed request (#75); absent on a
368
+ // hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
369
+ const key = req.headers.get(SESSION_HEADER) ?? undefined;
233
370
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
234
371
  const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
235
372
  await ensureScope();
236
373
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
237
374
  // db/services (ALS propagates to the .then continuation registered here); subscribing
238
375
  // happens synchronously right after, before any event can emit.
376
+ let session: AgentSession;
239
377
  let started: { turnId: string };
240
378
  try {
241
- started = runInScope({ resources: this.resources, services: this.services }, () => this.session.start({ userText, turnId, event, trigger }));
379
+ session = this.resolveSession(key);
380
+ started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
242
381
  } catch (err) {
243
- // e.g. the session is suspended awaiting input — a client-resolvable conflict, not a crash
382
+ // e.g. the session is suspended awaiting input, or the key mis-matches this
383
+ // object's identity — a client-resolvable conflict, not a crash
244
384
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
245
385
  }
246
- return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
386
+ // DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
387
+ // this DO's lifetime (alive while work is pending), so its duration is no longer
388
+ // bounded by however long the CALLER can hold a connection (the edge waitUntil
389
+ // ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
390
+ if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
391
+ return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
247
392
  }
248
393
  // Provide the input a suspended turn is waiting on and stream its continuation as SSE.
249
394
  // NOTE: this default surface passes `by` straight from the body — the engine treats it as a
@@ -252,16 +397,29 @@ export class AgentDurableObject {
252
397
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
253
398
  const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
254
399
  await ensureScope();
400
+ let session: AgentSession;
255
401
  try {
256
- runInScope({ resources: this.resources, services: this.services }, () => this.session.resume(turnId, inputId, input, { by }));
402
+ session = this.resolveSession(key);
403
+ runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
257
404
  } catch (err) {
258
405
  const message = err instanceof Error ? err.message : String(err);
259
- // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id
406
+ // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
260
407
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
261
408
  }
262
- return new Response(sseTurnStream(this.session, turnId), { headers: SSE_HEADERS });
409
+ return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
410
+ }
411
+ if (url.pathname.endsWith("/transcript")) {
412
+ // A keyed read still conflict-checks (and may commit the key — it is authentic,
413
+ // idFromName routed it here); a key-less read stays non-committal via transcript().
414
+ if (key !== undefined) {
415
+ try {
416
+ return Response.json({ transcript: this.resolveSession(key).transcript() });
417
+ } catch (err) {
418
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
419
+ }
420
+ }
421
+ return Response.json({ transcript: this.transcript() });
263
422
  }
264
- if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
265
423
  return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
266
424
  }
267
425
  }
@@ -321,7 +479,7 @@ export async function sseTurnFinalText(res: Response): Promise<string> {
321
479
  if (!line) continue;
322
480
  const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
323
481
  if (e.type === "turn.completed") return e.text;
324
- if (e.type === "turn.failed") throw new Error(e.error.message);
482
+ if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
325
483
  }
326
484
  if (done) break;
327
485
  }
@@ -352,10 +510,31 @@ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
352
510
  }
353
511
  }
354
512
 
513
+ // The header durableFetch stamps the session key on (#75). A DO cannot read its own
514
+ // idFromName name, so this is how the externally-assigned session identity crosses
515
+ // into the object — exported so a custom shell that bypasses durableFetch can set it.
516
+ export const SESSION_HEADER = "x-june-session";
517
+
518
+ // A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
519
+ // reject values that can't: CR/LF would be header injection, and Headers.set throws a
520
+ // bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
521
+ // 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
522
+ const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
523
+ function assertSessionKey(session: string) {
524
+ if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
525
+ throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
526
+ }
527
+ }
528
+
355
529
  // Worker-side routing: address a session's DO by (agent, session) and forward the
356
- // request to it. `env.AGENT` is the DO namespace binding.
530
+ // request to it. `env.AGENT` is the DO namespace binding. The session key rides a
531
+ // header (not the body): one seam covers /turn, /resume, and /transcript without
532
+ // touching any body contract, and an older DO simply ignores it.
357
533
  export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
358
- return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
534
+ assertSessionKey(session);
535
+ const headers = new Headers(req.headers);
536
+ headers.set(SESSION_HEADER, session);
537
+ return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
359
538
  }
360
539
 
361
540
  // The EDGE agent surface for June's router: route the chat endpoint to the
@@ -377,6 +556,15 @@ export function durableAgentSurface(
377
556
  const namespace = getNamespace();
378
557
  if (!namespace) return null; // no DO binding → not mounted here
379
558
  const { message, session } = (await req.json()) as { message: string; session?: string };
559
+ // `session` is CLIENT input here — an un-headerable value must be a clear 400, not
560
+ // the opaque 500 assertSessionKey would become this deep in the worker.
561
+ if (session !== undefined) {
562
+ try {
563
+ assertSessionKey(session);
564
+ } catch (err) {
565
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
566
+ }
567
+ }
380
568
  // Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
381
569
  // that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
382
570
  // through; otherwise collapse to the final { text } (the prior JSON contract).
@@ -450,6 +638,25 @@ export function durableChannelSurface(
450
638
  // /turn streams SSE; the simple path collapses it to the final text.
451
639
  return sseTurnFinalText(res);
452
640
  },
641
+ // FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
642
+ // and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
643
+ // assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
644
+ // 24–38s turns in production: nothing holds the edge open while the turn runs.
645
+ runDetached: async (message, o) => {
646
+ const namespace = getNamespace();
647
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
648
+ const res = await durableFetch(
649
+ namespace,
650
+ opts.agentName,
651
+ o?.session ?? "default",
652
+ new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
653
+ );
654
+ if (res.status !== 202) {
655
+ const detail = (await res.text()).slice(0, 200);
656
+ throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
657
+ }
658
+ return (await res.json()) as { turnId: string };
659
+ },
453
660
  // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
454
661
  runStream: async function* (message, o) {
455
662
  const namespace = getNamespace();
@@ -20,6 +20,7 @@ import {
20
20
  } from "@junejs/core/agent-runtime";
21
21
  import { channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
22
22
  import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
23
+ import { assertCoreRuntimeVersion } from "./core-version";
23
24
 
24
25
  function initSchema(db: SyncSqlite) {
25
26
  db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
@@ -99,6 +100,7 @@ export class NativeRuntime implements Runtime {
99
100
  private readonly db: SyncSqlite;
100
101
 
101
102
  constructor(agents: Record<string, AgentDef>, db: SyncSqlite) {
103
+ assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
102
104
  this.agents = agents;
103
105
  this.db = db;
104
106
  initSchema(db);
@@ -154,6 +156,7 @@ export class MemoryRuntime implements Runtime {
154
156
  private stores = new Map<string, MemorySessionStore>();
155
157
  private readonly agents: Record<string, AgentDef>;
156
158
  constructor(agents: Record<string, AgentDef>) {
159
+ assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
157
160
  this.agents = agents;
158
161
  }
159
162
  session(agent: string, id: string): AgentSession {
@@ -214,6 +217,11 @@ export function mountAgent(
214
217
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
215
218
  run: (message, o) =>
216
219
  runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
220
+ // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
221
+ // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
222
+ // written against ctx.runDetached behaves identically on both targets.
223
+ runDetached: async (message, o) =>
224
+ runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
217
225
  };
218
226
  const channels = channelFetch(agent, ctx);
219
227
  const surface = async (req: Request): Promise<Response | null> => {
@@ -0,0 +1,34 @@
1
+ // core-version.ts — the #94 belt-and-braces: fail at POWER-ON when the @junejs/core
2
+ // copy this server resolved is not the one it was built for.
3
+ //
4
+ // The primary fix is @junejs/core being a peerDependency (the app's single resolution
5
+ // always wins), but a nested second copy can still appear (npm legacy peer handling,
6
+ // a stale lockfile, a transitive pin). When it does, the failure without this check
7
+ // is far from the cause and mid-turn ("sink.emit is not a function", first seen in
8
+ // crisp-agent production); with it, construction throws with both versions named.
9
+ //
10
+ // Namespace import + optional read on purpose: a PRE-#94 core has no
11
+ // RUNTIME_API_VERSION export, and a named import of a missing export would die at
12
+ // module link time with an unhelpful SyntaxError — exactly the confusion this
13
+ // module exists to remove.
14
+ import * as coreRuntime from "@junejs/core/agent-runtime";
15
+
16
+ // The contract number this server tree is built against. Bump in lockstep with
17
+ // core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
18
+ const EXPECTED_CORE_RUNTIME_API = 1;
19
+
20
+ export function assertCoreRuntimeVersion(surface: string): void {
21
+ checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
22
+ }
23
+
24
+ // The pure check, split out so the mismatch paths are unit-testable (the ambient
25
+ // read above can only ever see the workspace's own — matching — core).
26
+ export function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected: number = EXPECTED_CORE_RUNTIME_API): void {
27
+ if (actual === expected) return;
28
+ throw new Error(
29
+ `${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, ` +
30
+ `but the @junejs/core it resolved provides ${actual === undefined ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — ` +
31
+ `two copies of @junejs/core are likely installed (one nested under @junejs/server). ` +
32
+ `Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`,
33
+ );
34
+ }