@junejs/server 0.1.3 → 0.2.0

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.
Files changed (128) hide show
  1. package/assets/icon-latin.LICENSE.txt +92 -0
  2. package/assets/icon-latin.ttf +0 -0
  3. package/dist/adapter-DcZ-W1-7.d.ts +62 -0
  4. package/dist/adapter.d.ts +2 -56
  5. package/dist/adapter.js +62 -5
  6. package/dist/agent-compile.d.ts +38 -0
  7. package/dist/agent-compile.js +145 -0
  8. package/dist/agent-discover.d.ts +4 -4
  9. package/dist/agent-discover.js +30 -44
  10. package/dist/agent-durable.d.ts +94 -19
  11. package/dist/agent-durable.js +647 -32
  12. package/dist/agent-native.d.ts +44 -13
  13. package/dist/agent-native.js +264 -27
  14. package/dist/app.d.ts +5 -5
  15. package/dist/app.js +42 -10
  16. package/dist/blob.d.ts +4 -5
  17. package/dist/build.d.ts +8 -30
  18. package/dist/build.js +135 -296
  19. package/dist/client-bundle.d.ts +8 -9
  20. package/dist/client-bundle.js +1 -1
  21. package/dist/config-freeze-CyXvNqi2.d.ts +17 -0
  22. package/dist/config-freeze.d.ts +2 -0
  23. package/dist/config-freeze.js +54 -0
  24. package/dist/config-loader.d.ts +3 -4
  25. package/dist/connection-auth.d.ts +38 -0
  26. package/dist/connection-auth.js +31 -0
  27. package/dist/content-freeze-CyiW2fRK.d.ts +4 -0
  28. package/dist/content-freeze.d.ts +2 -0
  29. package/dist/content-freeze.js +83 -0
  30. package/dist/content.d.ts +22 -9
  31. package/dist/content.js +43 -3
  32. package/dist/core-version.d.ts +4 -0
  33. package/dist/core-version.js +12 -0
  34. package/dist/css-modules-ambient.d.ts +2 -1
  35. package/dist/css-modules.d.ts +10 -11
  36. package/dist/css.d.ts +9 -10
  37. package/dist/d1-migrate.d.ts +6 -7
  38. package/dist/d1-migrate.js +3 -2
  39. package/dist/db.d.ts +2 -2
  40. package/dist/deploy.d.ts +3 -4
  41. package/dist/deploy.js +3 -2
  42. package/dist/dev-reload.d.ts +3 -4
  43. package/dist/dev.d.ts +8 -5
  44. package/dist/dev.js +40 -9
  45. package/dist/favicon.d.ts +37 -0
  46. package/dist/favicon.js +248 -0
  47. package/dist/host.d.ts +6 -5
  48. package/dist/host.js +2 -1
  49. package/dist/icon-letter.d.ts +3 -0
  50. package/dist/icon-letter.js +8 -0
  51. package/dist/index.d.ts +11 -5
  52. package/dist/index.js +9 -3
  53. package/dist/instrumentation.d.ts +2 -3
  54. package/dist/instrumentation.js +2 -1
  55. package/dist/island-registry.d.ts +6 -7
  56. package/dist/isolate-local.d.ts +5 -0
  57. package/dist/isolate-local.js +12 -0
  58. package/dist/kv.d.ts +3 -4
  59. package/dist/llms-links.d.ts +9 -0
  60. package/dist/llms-links.js +65 -0
  61. package/dist/manifest-CwTT1zVs.d.ts +11 -0
  62. package/dist/manifest.d.ts +2 -0
  63. package/dist/manifest.js +113 -0
  64. package/dist/negotiate.d.ts +4 -3
  65. package/dist/negotiate.js +24 -14
  66. package/dist/{pipeline-CCp9idQ4.d.ts → pipeline-BHUjBO0G.d.ts} +13 -1
  67. package/dist/pipeline.d.ts +2 -2
  68. package/dist/pipeline.js +286 -35
  69. package/dist/resources.d.ts +5 -6
  70. package/dist/route-rank.d.ts +6 -7
  71. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  72. package/dist/route-scan.d.ts +2 -0
  73. package/dist/route-scan.js +100 -0
  74. package/dist/router.d.ts +12 -13
  75. package/dist/router.js +51 -38
  76. package/dist/rsc-build.d.ts +6 -7
  77. package/dist/rsc-bundle.d.ts +6 -7
  78. package/dist/rsc-manifest.d.ts +5 -6
  79. package/dist/rsc-runtime/flight-render-routes.d.ts +3 -4
  80. package/dist/rsc-runtime/flight-render.d.ts +2 -3
  81. package/dist/rsc-runtime/flight-to-html.d.ts +2 -3
  82. package/dist/rsc-runtime/flight-to-html.js +3 -2
  83. package/dist/rsc-runtime/rsc-worker.js +11 -8
  84. package/dist/rsc-runtime/rsdw-shims.d.ts +2 -1
  85. package/dist/segment.d.ts +4 -5
  86. package/dist/sqlite-driver.d.ts +11 -11
  87. package/dist/sqlite-driver.js +17 -4
  88. package/dist/static-files.d.ts +4 -5
  89. package/dist/tsconfig-jsx.d.ts +4 -4
  90. package/dist/tsconfig-jsx.js +1 -1
  91. package/dist/turn-events.d.ts +4 -0
  92. package/dist/turn-events.js +46 -0
  93. package/dist/web-manifest.d.ts +5 -0
  94. package/dist/web-manifest.js +42 -0
  95. package/dist/worker-2qbQjj2c.d.ts +64 -0
  96. package/dist/worker.d.ts +2 -57
  97. package/dist/worker.js +69 -27
  98. package/package.json +22 -9
  99. package/src/adapter.ts +84 -3
  100. package/src/agent-compile.ts +271 -0
  101. package/src/agent-discover.ts +77 -63
  102. package/src/agent-durable.ts +998 -30
  103. package/src/agent-native.ts +370 -35
  104. package/src/app.ts +75 -6
  105. package/src/build.ts +230 -446
  106. package/src/config-freeze.ts +109 -0
  107. package/src/connection-auth.ts +101 -0
  108. package/src/content-freeze.ts +151 -0
  109. package/src/content.ts +55 -4
  110. package/src/core-version.ts +34 -0
  111. package/src/dev.ts +41 -8
  112. package/src/favicon.ts +325 -0
  113. package/src/host.ts +4 -3
  114. package/src/icon-letter.ts +14 -0
  115. package/src/index.ts +22 -0
  116. package/src/isolate-local.ts +33 -0
  117. package/src/llms-links.ts +96 -0
  118. package/src/manifest.ts +130 -0
  119. package/src/negotiate.ts +39 -15
  120. package/src/pipeline.ts +494 -44
  121. package/src/route-scan.ts +161 -0
  122. package/src/router.ts +66 -54
  123. package/src/rsc-runtime/rsc-worker.tsx +5 -4
  124. package/src/sqlite-driver.ts +36 -6
  125. package/src/tsconfig-jsx.ts +2 -2
  126. package/src/turn-events.ts +43 -0
  127. package/src/web-manifest.ts +38 -0
  128. package/src/worker.ts +131 -35
@@ -3,26 +3,36 @@
3
3
  // SessionStore = a session-scoped view over one shared synchronous SQLite handle
4
4
  // (bun:sqlite under Bun, node:sqlite under Node — the same handle openLocalSqlite
5
5
  // wraps as the async JuneDb, opened here directly because the durability tx must
6
- // be synchronous). Broadcaster = an in-process subscriber set. Turn serialization
6
+ // be synchronous). EventSink = an in-process subscriber set. Turn serialization
7
7
  // comes from the AgentSession actor in core. On the edge target this same shape
8
- // is reimplemented over a Durable Object's ctx.storage.sql (build order step 5).
8
+ // is reimplemented over a Durable Object's ctx.storage.sql (agent-durable.ts).
9
9
 
10
10
  import {
11
11
  AgentSession,
12
+ grantAnswer,
12
13
  withSystem,
13
- type Broadcaster,
14
+ type ChannelPolicy,
15
+ type EventSink,
16
+ type InputAnnouncement,
17
+ type TurnEvent,
14
18
  type Model,
15
19
  type Msg,
16
20
  type Runtime,
17
21
  type SessionStore,
18
22
  type Tool,
19
23
  } from "@junejs/core/agent-runtime";
20
- import { channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
24
+ import { buildSystemPrompt, channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
25
+ import type { FileStore } from "@junejs/core/agent-files";
21
26
  import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
27
+ import { assertCoreRuntimeVersion } from "./core-version";
28
+ import { observeTurnEvents } from "./turn-events";
22
29
 
23
30
  function initSchema(db: SyncSqlite) {
24
31
  db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
25
32
  db.exec(`CREATE TABLE IF NOT EXISTS agent_messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, body TEXT)`);
33
+ // Every store read is scoped to one session; without this index each one scanned the
34
+ // messages of ALL sessions in the file (#168). IF NOT EXISTS: existing DBs gain it.
35
+ db.exec(`CREATE INDEX IF NOT EXISTS agent_messages_session ON agent_messages (session_id, seq)`);
26
36
  // NOTE: PRIMARY KEY (session_id, id) — never id alone. The store view scopes
27
37
  // every query by session, so a step id can't leak across sessions.
28
38
  db.exec(`CREATE TABLE IF NOT EXISTS agent_steps (session_id TEXT, id TEXT, output TEXT, PRIMARY KEY (session_id, id))`);
@@ -45,8 +55,11 @@ class SqliteSessionStore implements SessionStore {
45
55
  return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
46
56
  .map((r) => JSON.parse(r.body));
47
57
  }
48
- hasUserTurn(turnId: string): boolean {
49
- return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
58
+ // Asked once per turn start: probe for the one row instead of parsing the transcript.
59
+ hasOpeningMessage(turnId: string): boolean {
60
+ return this.db
61
+ .query("SELECT 1 FROM agent_messages WHERE session_id = ? AND json_extract(body, '$.turnId') = ? AND json_extract(body, '$.role') IN ('user', 'trigger') LIMIT 1")
62
+ .get(this.sid, turnId) != null; // bun:sqlite misses with null, node:sqlite with undefined
50
63
  }
51
64
  getStep(id: string): unknown | undefined {
52
65
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
@@ -55,6 +68,9 @@ class SqliteSessionStore implements SessionStore {
55
68
  putStep(id: string, output: unknown) {
56
69
  this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
57
70
  }
71
+ delStep(id: string) {
72
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
73
+ }
58
74
  getStatus(): string {
59
75
  return (this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid) as { status: string } | undefined)?.status ?? "new";
60
76
  }
@@ -74,43 +90,250 @@ class SqliteSessionStore implements SessionStore {
74
90
  throw e;
75
91
  }
76
92
  }
93
+ // Session reset (#129): ARCHIVE this session's messages/steps under the current
94
+ // generation (audit trail — never deleted), clear the live rows, status → "new".
95
+ // Archive tables + the generation counter are created lazily here, so existing
96
+ // databases stay untouched until the first reset.
97
+ reset(inTx?: () => void): number {
98
+ return this.tx(() => {
99
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
100
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
101
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
102
+ const r = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid) as { generation: number } | undefined;
103
+ const generation = r?.generation ?? 0;
104
+ 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);
105
+ this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
106
+ 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);
107
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
108
+ this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
109
+ 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);
110
+ inTx?.(); // writes into the fresh generation, committed with the archive (#260)
111
+ return generation;
112
+ });
113
+ }
77
114
  unwrap<H = unknown>(): H { return this.db as unknown as H; }
78
115
  }
79
116
 
80
- class InProcBroadcaster implements Broadcaster {
81
- private subs = new Set<(t: string) => void>();
82
- publish(turnId: string) { this.subs.forEach((cb) => { try { cb(turnId); } catch { /* a bad subscriber must not break publish */ } }); }
83
- subscribe(cb: (t: string) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
117
+ class InProcEventSink implements EventSink {
118
+ private subs = new Set<(e: TurnEvent) => void>();
119
+ emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
120
+ subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
121
+ get size(): number { return this.subs.size; }
84
122
  }
85
123
 
86
124
  // `instructions` (the agent's system prompt) is injected into the model per turn
87
125
  // by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
88
- export type AgentDef = { model: Model; tools: Tool[]; instructions?: string };
126
+ export type AgentDef = {
127
+ model: Model;
128
+ tools: Tool[];
129
+ instructions?: string;
130
+ channelInstructions?: Record<string, string | ChannelPolicy>;
131
+ // The app's receiver for input announcements (#260), installed on every session this
132
+ // runtime builds; undelivered announcements from an earlier process are flushed then.
133
+ onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
134
+ };
135
+
136
+ // Retries a failed announcement delivery on a timer (#260): the engine keeps an undelivered
137
+ // announcement and flushes again on the next announcement, settled turn or rebuild — but a
138
+ // session that goes quiet would hold it until then. 5 s doubling to 5 min, per session;
139
+ // unref'd, so a pending retry never holds the process open. Owned by the runtime, so the
140
+ // backoff survives an evicted-and-rebuilt actor.
141
+ class AnnouncementRetry {
142
+ // Explicit fields (not parameter properties) — keep the shipped source erasable.
143
+ private readonly flush: (agent: string, id: string) => void;
144
+ private readonly state = new Map<string, { failures: number; timer?: ReturnType<typeof setTimeout> }>();
145
+ private stopped = false;
146
+ constructor(flush: (agent: string, id: string) => void) {
147
+ this.flush = flush;
148
+ }
149
+ delivered(key: string) {
150
+ const s = this.state.get(key);
151
+ if (s?.timer) clearTimeout(s.timer);
152
+ this.state.delete(key);
153
+ }
154
+ failed(key: string, agent: string, id: string) {
155
+ if (this.stopped) return; // the runtime is closed: nothing retries into it
156
+ const s = this.state.get(key) ?? { failures: 0 };
157
+ this.state.set(key, s);
158
+ if (s.timer) return; // one retry pending at a time
159
+ const delay = Math.min(5_000 * 2 ** s.failures, 300_000);
160
+ s.failures++;
161
+ s.timer = setTimeout(() => { s.timer = undefined; this.flush(agent, id); }, delay);
162
+ (s.timer as { unref?: () => void }).unref?.();
163
+ }
164
+ // Cancel every pending retry, for good (#317): unref only keeps a timer from holding the
165
+ // process open — it still fires while the process lives, into a runtime (and a db) that
166
+ // is gone.
167
+ stop() {
168
+ this.stopped = true;
169
+ for (const s of this.state.values()) if (s.timer) clearTimeout(s.timer);
170
+ this.state.clear();
171
+ }
172
+ }
173
+
174
+ // SQLite handles a runtime opened itself (createNativeRuntime), so close() closes those and
175
+ // never one a caller passed in.
176
+ const ownedDbs = new WeakSet<SyncSqlite>();
177
+
178
+ // Install the announcement hook on a freshly built session and deliver what an earlier life
179
+ // of it recorded but never delivered (#260). The flush never rejects; it logs — and a
180
+ // failure schedules a retry.
181
+ function wireAnnouncements(session: AgentSession, def: AgentDef, retry: AnnouncementRetry): AgentSession {
182
+ const hook = def.onInputAnnouncement;
183
+ if (hook) {
184
+ const key = `${session.agent}:${session.id}`;
185
+ session.onAnnounce = async (a) => {
186
+ try {
187
+ await hook(a);
188
+ } catch (err) {
189
+ retry.failed(key, session.agent, session.id);
190
+ throw err; // the engine keeps the announcement and logs
191
+ }
192
+ retry.delivered(key);
193
+ };
194
+ void session.flushAnnouncements();
195
+ }
196
+ return session;
197
+ }
198
+
199
+ // The step the engine keeps its announcement outbox under (#260) — read by the native
200
+ // runtime's startup scan. Mirrors ANNOUNCE_OUTBOX in @junejs/core/agent-runtime.
201
+ const ANNOUNCE_OUTBOX_STEP = "announce-outbox";
202
+
203
+ // The runtime-side def for an assembled AgentDefinition (#173): the tools (channel
204
+ // capability tools and read_skill included), the system prompt (instructions + the
205
+ // skill index) and the per-surface policies all come from the ONE definition that
206
+ // mountAgent mounts, so the engine and the channels can't drift apart. Only the model
207
+ // is the host's choice.
208
+ // createNativeRuntime({ [agent.name]: toAgentDef(agent, anthropic({ … })) })
209
+ export function toAgentDef(agent: AgentDefinition, model: Model): AgentDef {
210
+ return {
211
+ model,
212
+ tools: agent.tools,
213
+ instructions: buildSystemPrompt(agent),
214
+ ...(agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}),
215
+ ...(agent.onInputAnnouncement ? { onInputAnnouncement: agent.onInputAnnouncement } : {}),
216
+ };
217
+ }
218
+
219
+ export type NativeRuntimeOptions = {
220
+ // Soft cap on memoized session actors (#174), default 1000. Past it, the least recently
221
+ // used IDLE actors (no turn running or queued, no reset pending, no live subscriber) are
222
+ // dropped; their state is in SQLite, so the next session() rebuilds them transparently.
223
+ // Busy actors are never dropped, so the count can exceed the cap while they run.
224
+ maxSessions?: number;
225
+ // Where the sessions' tools store the files they produce (docs/rfc-agent-files.md), and
226
+ // where mountAgent's channels read them back to deliver. Absent: ctx.files.put fails the
227
+ // turn naming the missing configuration.
228
+ files?: FileStore;
229
+ };
89
230
 
90
231
  // The native Runtime: a registry of agent definitions over one SQLite handle,
91
- // handing out (and memoizing) an AgentSession actor per (agent, id).
232
+ // handing out (and memoizing) an AgentSession actor per (agent, id). The memo is an
233
+ // LRU bounded by maxSessions — with slackChannel every thread is a session, so an
234
+ // unbounded memo grew by one actor per thread for the life of the process.
235
+ // Callers must not hold an AgentSession across an await and start turns on it later:
236
+ // if it was evicted meanwhile, a fresh actor for the same session would run turns
237
+ // unserialized with it. Call session() at the point of use (mountAgent does).
92
238
  export class NativeRuntime implements Runtime {
93
- private actors = new Map<string, AgentSession>();
239
+ private actors = new Map<string, { session: AgentSession; sink: InProcEventSink }>();
94
240
  private readonly agents: Record<string, AgentDef>;
95
241
  private readonly db: SyncSqlite;
242
+ // Retries failed announcement deliveries (#260), across actor rebuilds.
243
+ private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
244
+ private readonly maxSessions: number;
245
+ private closing: Promise<void> | undefined; // set by close() (#317)
246
+ readonly files: FileStore | undefined;
96
247
 
97
- constructor(agents: Record<string, AgentDef>, db: SyncSqlite) {
248
+ constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
249
+ assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
98
250
  this.agents = agents;
99
251
  this.db = db;
252
+ this.files = opts.files;
253
+ const max = opts.maxSessions ?? 1000;
254
+ // At least one actor is always retained (the one being handed out), so a cap below 1
255
+ // means nothing, and NaN would compare false and evict every idle actor on each miss.
256
+ // Infinity is allowed: an explicit "no cap".
257
+ if (!(Number.isInteger(max) && max >= 1) && max !== Infinity) {
258
+ throw new RangeError(`NativeRuntime: maxSessions must be an integer >= 1 (or Infinity for no cap), got ${max}`);
259
+ }
260
+ this.maxSessions = max;
100
261
  initSchema(db);
101
262
  }
102
263
 
264
+ agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
265
+
103
266
  session(agent: string, id: string): AgentSession {
104
267
  const key = `${agent}:${id}`;
105
- let a = this.actors.get(key);
106
- if (!a) {
107
- const def = this.agents[agent];
108
- if (!def) throw new Error(`unknown agent: ${agent}`);
109
- const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
110
- a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcBroadcaster(), model, def.tools, this);
111
- this.actors.set(key, a);
268
+ const hit = this.actors.get(key);
269
+ // A closing runtime still serves the actors it has (their turns are finishing), but
270
+ // builds no new one.
271
+ if (!hit && this.closing) throw new Error(`NativeRuntime is closed: cannot open session "${key}"`);
272
+ if (hit) {
273
+ // most recently used → the tail (Map iteration order is insertion order)
274
+ this.actors.delete(key);
275
+ this.actors.set(key, hit);
276
+ return hit.session;
277
+ }
278
+ const def = this.agents[agent];
279
+ if (!def) throw new Error(`unknown agent: ${agent}`);
280
+ const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
281
+ const sink = new InProcEventSink();
282
+ const built = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
283
+ built.files = this.files;
284
+ const session = wireAnnouncements(built, def, this.announceRetry);
285
+ this.evictIdle();
286
+ this.actors.set(key, { session, sink });
287
+ return session;
288
+ }
289
+
290
+ // Number of memoized actors (observability / tests).
291
+ get sessionCount(): number { return this.actors.size; }
292
+
293
+ // Deliver announcements an earlier process recorded but never delivered (#260), without
294
+ // waiting for their sessions to be used again: build every session whose outbox is not
295
+ // empty — building one flushes it. The agent and session come from the announcement
296
+ // itself, not from splitting the store key (an agent name may contain ":"). Called once
297
+ // by createNativeRuntime; safe to call again.
298
+ recoverAnnouncements(): void {
299
+ const rows = this.db.query("SELECT output FROM agent_steps WHERE id = ?").all(ANNOUNCE_OUTBOX_STEP) as { output: string }[];
300
+ for (const { output } of rows) {
301
+ const [first] = JSON.parse(output) as InputAnnouncement[];
302
+ if (first && this.agents[first.agent]?.onInputAnnouncement) this.session(first.agent, first.session);
303
+ }
304
+ }
305
+
306
+ // Shut the runtime down (#317). Pending announcement retries are cancelled and no new
307
+ // session opens at once; then it waits until every actor is idle — a running turn or an
308
+ // announcement delivery in flight still writes the store after its hook or model call
309
+ // returns — before dropping the actors and closing the SQLite handle createNativeRuntime
310
+ // opened (a db passed to the constructor stays the caller's). So it resolves only after
311
+ // in-flight work finishes. Call it before discarding a runtime in a process that keeps
312
+ // running (a test suite, a host that swaps runtimes), or a retry fires later against a
313
+ // closed or deleted database. Safe to call twice: both calls resolve together.
314
+ close(): Promise<void> {
315
+ this.closing ??= (async () => {
316
+ this.announceRetry.stop();
317
+ while ([...this.actors.values()].some((a) => !a.session.idle())) {
318
+ await new Promise((r) => setTimeout(r, 10));
319
+ }
320
+ this.actors.clear();
321
+ if (ownedDbs.has(this.db)) {
322
+ ownedDbs.delete(this.db);
323
+ this.db.close();
324
+ }
325
+ })();
326
+ return this.closing;
327
+ }
328
+
329
+ // Drop least recently used idle actors until one more fits under the cap. Runs before
330
+ // the new actor is inserted, so the actor being handed out is never a candidate.
331
+ private evictIdle() {
332
+ if (this.actors.size < this.maxSessions) return;
333
+ for (const [key, a] of this.actors) {
334
+ if (this.actors.size < this.maxSessions) break;
335
+ if (a.session.idle() && a.sink.size === 0) this.actors.delete(key);
112
336
  }
113
- return a;
114
337
  }
115
338
  }
116
339
 
@@ -120,27 +343,60 @@ export class NativeRuntime implements Runtime {
120
343
  export async function createNativeRuntime(
121
344
  agents: Record<string, AgentDef>,
122
345
  path = ":memory:",
346
+ opts: NativeRuntimeOptions = {},
123
347
  ): Promise<NativeRuntime> {
124
- return new NativeRuntime(agents, await openLocalSqliteSync(path));
348
+ const db = await openLocalSqliteSync(path);
349
+ ownedDbs.add(db);
350
+ let runtime: NativeRuntime | undefined;
351
+ try {
352
+ runtime = new NativeRuntime(agents, db, opts);
353
+ runtime.recoverAnnouncements(); // #260
354
+ return runtime;
355
+ } catch (err) {
356
+ // Nobody else holds this handle (a bad maxSessions, unreadable leftovers). A recovery
357
+ // that failed part-way may already have deliveries in flight, so go through close(),
358
+ // which waits for them before closing the db.
359
+ if (runtime) await runtime.close();
360
+ else {
361
+ ownedDbs.delete(db);
362
+ db.close();
363
+ }
364
+ throw err;
365
+ }
125
366
  }
126
367
 
127
368
  // ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
128
369
  // The lightest "no Durable Object, no persistence" option: great for dev, tests,
129
370
  // and stateless previews. Same engine + seams; state is a Map that dies with the
130
- // process. (For durability pick `native` on a long-running host or the Durable
371
+ // process — and is never evicted (the actor IS the state), so it grows with every
372
+ // session: not for a long-running host with unbounded sessions. (For durability pick `native` on a long-running host or the Durable
131
373
  // Object target on the edge.)
132
374
  class MemorySessionStore implements SessionStore {
133
375
  private msgs: Msg[] = [];
134
376
  private steps = new Map<string, unknown>();
135
377
  private status = "new";
378
+ private generation = 0;
379
+ // Retired generations (#129) — kept for the audit contract even in memory, so the
380
+ // backend behaves like the durable tiers within one process lifetime.
381
+ private readonly archives: { generation: number; msgs: Msg[]; steps: Map<string, unknown> }[] = [];
136
382
  appendMessage(m: Msg) { this.msgs.push(m); }
137
383
  messages(): Msg[] { return this.msgs.slice(); }
138
- hasUserTurn(turnId: string): boolean { return this.msgs.some((m) => m.role === "user" && m.turnId === turnId); }
384
+ hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
139
385
  getStep(id: string): unknown | undefined { return this.steps.has(id) ? this.steps.get(id) : undefined; }
140
386
  putStep(id: string, output: unknown) { this.steps.set(id, output); }
387
+ delStep(id: string) { this.steps.delete(id); }
141
388
  getStatus(): string { return this.status; }
142
389
  setStatus(s: string) { this.status = s; }
143
390
  tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
391
+ reset(inTx?: () => void): number {
392
+ const generation = this.generation++;
393
+ this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
394
+ this.msgs = [];
395
+ this.steps = new Map();
396
+ this.status = "new";
397
+ inTx?.(); // writes into the fresh generation (#260)
398
+ return generation;
399
+ }
144
400
  unwrap<H = unknown>(): H { return undefined as unknown as H; }
145
401
  }
146
402
 
@@ -148,22 +404,43 @@ export class MemoryRuntime implements Runtime {
148
404
  private actors = new Map<string, AgentSession>();
149
405
  private stores = new Map<string, MemorySessionStore>();
150
406
  private readonly agents: Record<string, AgentDef>;
151
- constructor(agents: Record<string, AgentDef>) {
407
+ private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
408
+ private closed = false; // set by close() (#317)
409
+ readonly files: FileStore | undefined;
410
+ constructor(agents: Record<string, AgentDef>, opts: { files?: FileStore } = {}) {
411
+ assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
152
412
  this.agents = agents;
413
+ this.files = opts.files;
153
414
  }
415
+ agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
154
416
  session(agent: string, id: string): AgentSession {
155
417
  const key = `${agent}:${id}`;
156
418
  let a = this.actors.get(key);
157
419
  if (!a) {
420
+ if (this.closed) throw new Error(`MemoryRuntime is closed: cannot open session "${key}"`);
158
421
  const def = this.agents[agent];
159
422
  if (!def) throw new Error(`unknown agent: ${agent}`);
160
423
  const store = new MemorySessionStore();
161
424
  this.stores.set(key, store);
162
- a = new AgentSession(agent, id, store, new InProcBroadcaster(), def.model, def.tools, this);
425
+ // Same def handling as NativeRuntime: switching backend must not change behavior.
426
+ const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
427
+ const built = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
428
+ built.files = this.files;
429
+ a = wireAnnouncements(built, def, this.announceRetry);
163
430
  this.actors.set(key, a);
164
431
  }
165
432
  return a;
166
433
  }
434
+ // Shut the runtime down (#317): cancel pending announcement retries and open no new
435
+ // session. The state is the actors themselves, so they are dropped too; with no store
436
+ // to close there is nothing to wait for — work still in flight finishes against its own
437
+ // in-memory store. Safe to call twice.
438
+ async close(): Promise<void> {
439
+ this.closed = true;
440
+ this.announceRetry.stop();
441
+ this.actors.clear();
442
+ this.stores.clear();
443
+ }
167
444
  }
168
445
 
169
446
  // The selectable agent-runtime backends. `native` (SQLite via june/host, durable
@@ -175,18 +452,44 @@ export type AgentBackend = "native" | "memory" | "durable";
175
452
  // Build an in-process runtime for the chosen backend. Throws for `durable` (that
176
453
  // target is the DO the worker constructs, not an in-process object) — so the
177
454
  // choice is explicit and a mis-selection fails loudly.
455
+ // An in-process runtime: a Runtime the host owns, so it can shut it down (#317).
456
+ export type InProcessRuntime = Runtime & { close(): Promise<void>; readonly files?: FileStore };
457
+
178
458
  export async function createAgentRuntime(
179
459
  agents: Record<string, AgentDef>,
180
- opts: { backend?: AgentBackend; path?: string } = {},
181
- ): Promise<Runtime> {
460
+ opts: { backend?: AgentBackend; path?: string; maxSessions?: number; files?: FileStore } = {},
461
+ ): Promise<InProcessRuntime> {
182
462
  const backend = opts.backend ?? "native";
183
- if (backend === "memory") return new MemoryRuntime(agents);
184
- if (backend === "native") return createNativeRuntime(agents, opts.path);
463
+ if (backend === "memory") return new MemoryRuntime(agents, { files: opts.files });
464
+ if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions, files: opts.files });
185
465
  throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
186
466
  }
187
467
 
188
- // Mount a discovered agent on a runtime. Builds the ChannelContext whose `run`
189
- // bridges to a durable turn, and exposes:
468
+ // #173: the engine runs the runtime's AgentDef, the channels see the AgentDefinition —
469
+ // declared separately by hand, they drift silently (a tool the model can't call, a
470
+ // channel capability tool the engine never got). Say so once at mount. Only the
471
+ // in-process runtimes expose their def; tools compare by name.
472
+ function warnOnDrift(agent: AgentDefinition, runtime: Runtime) {
473
+ const def = (runtime as { agentDef?: (name: string) => AgentDef | undefined }).agentDef?.(agent.name);
474
+ if (!def) return;
475
+ const names = (tools: Tool[]) => tools.map((t) => t.spec.name).sort();
476
+ const want = names(agent.tools), have = names(def.tools);
477
+ if (want.join("\0") !== have.join("\0")) {
478
+ const missing = want.filter((n) => !have.includes(n)), extra = have.filter((n) => !want.includes(n));
479
+ console.warn(
480
+ `[june] mountAgent("${agent.name}"): the runtime's tools differ from the agent definition's` +
481
+ (missing.length ? ` — missing from the runtime: ${missing.join(", ")}` : "") +
482
+ (extra.length ? ` — only in the runtime: ${extra.join(", ")}` : "") +
483
+ `. Build the runtime entry with toAgentDef(agent, model) so both come from one definition.`,
484
+ );
485
+ }
486
+ }
487
+
488
+ // Mount a discovered agent on a runtime. Builds the ChannelContext the channels drive
489
+ // turns through — run, runDetached, runStream, resumeStream and resetSession, each over
490
+ // the runtime's in-process session. Not provided: runDelivered/resumeDelivered, which
491
+ // exist to escape the edge waitUntil ceiling (a native host has none; channels fall back
492
+ // to runStream/resumeStream without them). Exposes:
190
493
  // • surface(req) — the composable agent surface for June's router: a framework
191
494
  // chat endpoint at `chatPath` (POST {message, session?} → a turn) PLUS the
192
495
  // discovered channels; returns null when the request isn't an agent route.
@@ -195,7 +498,7 @@ export async function createAgentRuntime(
195
498
  export function mountAgent(
196
499
  agent: AgentDefinition,
197
500
  runtime: Runtime,
198
- opts: { chatPath?: string; channels?: boolean } = {},
501
+ opts: { chatPath?: string; channels?: boolean; services?: unknown; files?: FileStore } = {},
199
502
  ): {
200
503
  surface: (req: Request) => Promise<Response | null>;
201
504
  fetch: (req: Request) => Promise<Response | null>;
@@ -204,10 +507,42 @@ export function mountAgent(
204
507
  } {
205
508
  const chatPath = opts.chatPath ?? "/message";
206
509
  const channelsOn = opts.channels ?? true;
510
+ warnOnDrift(agent, runtime);
207
511
  const ctx: ChannelContext = {
208
512
  agent,
513
+ services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
514
+ // The runtime's file store, so a channel can deliver the files the turn's tools stored.
515
+ files: opts.files ?? (runtime as { files?: FileStore }).files,
209
516
  run: (message, o) =>
210
- runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message }),
517
+ runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
518
+ // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
519
+ // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
520
+ // written against ctx.runDetached behaves identically on both targets.
521
+ runDetached: async (message, o) =>
522
+ runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
523
+ // LIVE (#169): the turn's event stream, so a channel renders as the turn runs —
524
+ // slackChannel({ stream: true }) edits in place here exactly as on the edge, instead of
525
+ // silently degrading to one post at the end. Lazy like the DO's (the turn starts on the
526
+ // first pull); start()/resume() and the subscription run with nothing awaited between,
527
+ // so no event can emit unobserved.
528
+ runStream: async function* (message, o) {
529
+ const session = runtime.session(agent.name, o?.session ?? "default");
530
+ const { turnId } = session.start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace });
531
+ yield* observeTurnEvents(session, turnId);
532
+ },
533
+ // HITL: answer a parked turn and stream its continuation (the approval-button path).
534
+ resumeStream: async function* (o) {
535
+ const session = runtime.session(agent.name, o.session ?? "default");
536
+ // A { policy } answerer is the app's call (#261): decided here, before the synchronous
537
+ // resume, so resume-then-subscribe stays free of awaits.
538
+ const granted = await grantAnswer(session, o, agent.authorizeAnswer);
539
+ const { turnId } = session.resume(o.turnId, o.inputId, o.input, { by: o.by, granted });
540
+ yield* observeTurnEvents(session, turnId);
541
+ },
542
+ // SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
543
+ // stores implement archival, so a channel written against ctx.resetSession behaves
544
+ // identically on both targets.
545
+ resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset(),
211
546
  };
212
547
  const channels = channelFetch(agent, ctx);
213
548
  const surface = async (req: Request): Promise<Response | null> => {