@junejs/server 1.0.0-dev.45 → 1.0.0-dev.47

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,6 @@
1
1
  import { Channel, ChannelFactory } from "@junejs/core/agent-config";
2
2
  import { AuthorizeAnswer, ChannelPolicy, InboundEvent, InputAnnouncement, Model, Msg, NoteKind, ProactiveTrigger, SessionStore, Tool, TurnError, TurnEvent, TurnFailurePhase } from "@junejs/core/agent-runtime";
3
+ import { FileStore } from "@junejs/core/agent-files";
3
4
  import { Connection } from "@junejs/core/connections";
4
5
  import { Resources } from "@junejs/core/resources";
5
6
  //#region src/agent-durable.d.ts
@@ -63,6 +64,7 @@ export type DoAgentDef = {
63
64
  }) => void | Promise<void>;
64
65
  authorizeAnswer?: AuthorizeAnswer;
65
66
  onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
67
+ files?: FileStore;
66
68
  };
67
69
  export declare class AgentDurableObject {
68
70
  private session;
@@ -83,6 +85,7 @@ export declare class AgentDurableObject {
83
85
  private readonly authorizeAnswer?;
84
86
  private readonly onInputAnnouncement?;
85
87
  private readonly storage;
88
+ private readonly files;
86
89
  private announceFailures;
87
90
  private resolveResources;
88
91
  private resolveConnections;
@@ -140,5 +143,6 @@ export declare function durableChannelSurface(getNamespace: () => DurableObjectN
140
143
  env: unknown;
141
144
  services?: (env: unknown) => unknown;
142
145
  waitUntil?: (p: Promise<unknown>) => void;
146
+ files?: FileStore;
143
147
  }): (req: Request) => Promise<Response | null>;
144
148
  //#endregion
@@ -2,7 +2,8 @@ import { observeTurnEvents } from "./turn-events.js";
2
2
  import { isolateLocal } from "./isolate-local.js";
3
3
  import { assertCoreRuntimeVersion } from "./core-version.js";
4
4
  import { DeliverUnsupportedError, actionToTool, channelDispatch, resolveChannel } from "@junejs/core/agent-config";
5
- import { AgentSession, ResumeAuthorizationError, grantAnswer, withSystem } from "@junejs/core/agent-runtime";
5
+ import { AgentSession, FatalToolError, ResumeAuthorizationError, grantAnswer, withSystem } from "@junejs/core/agent-runtime";
6
+ import { blobFileStore } from "@junejs/core/agent-files";
6
7
  import { connectAll } from "@junejs/core/connections";
7
8
  import { ensureScope, runInScope } from "@junejs/db";
8
9
  //#region src/agent-durable.ts
@@ -111,6 +112,7 @@ var AgentDurableObject = class {
111
112
  authorizeAnswer;
112
113
  onInputAnnouncement;
113
114
  storage;
115
+ files;
114
116
  announceFailures = 0;
115
117
  resolveResources() {
116
118
  return this.resourcesOpened ??= Promise.resolve(typeof this.resourcesInput === "function" ? this.resourcesInput(this.doEnv) : this.resourcesInput ?? {});
@@ -145,6 +147,11 @@ var AgentDurableObject = class {
145
147
  this.onInputAnnouncement = def.onInputAnnouncement;
146
148
  this.storage = state.storage;
147
149
  const name = def.name ?? "agent";
150
+ this.files = def.files ?? blobFileStore(async () => {
151
+ const blob = (await this.resolveResources()).blob;
152
+ if (!blob) throw new FatalToolError(`agent "${name}": no file store — declare a blob resource (DoAgentDef.resources) or pass DoAgentDef.files`);
153
+ return blob;
154
+ });
148
155
  const sink = new InProcEventSink();
149
156
  sink.subscribe((e) => {
150
157
  if (e.type === "turn.started") this.armWatchdog();
@@ -202,6 +209,7 @@ var AgentDurableObject = class {
202
209
  if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
203
210
  this.sessionKey = resolved;
204
211
  this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
212
+ this.session.files = this.files;
205
213
  this.session.onDequeue = (heldTurnId, held) => {
206
214
  if (held.hostContext?.deliver && held.event) this.deliverHeld(this.session, heldTurnId, held.event);
207
215
  };
@@ -260,7 +268,10 @@ var AgentDurableObject = class {
260
268
  this.ready().then((resources) => runInScope({
261
269
  resources,
262
270
  services: this.services
263
- }, () => channel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
271
+ }, () => channel.deliver(target, events, {
272
+ session: this.sessionKey,
273
+ files: this.files
274
+ }))).catch(logRenderFailure).finally(() => {
264
275
  it.return?.();
265
276
  });
266
277
  } catch (err) {
@@ -355,7 +366,10 @@ var AgentDurableObject = class {
355
366
  Promise.resolve().then(() => runInScope({
356
367
  resources,
357
368
  services: this.services
358
- }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
369
+ }, () => deliverChannel.deliver(target, events, {
370
+ session: this.sessionKey,
371
+ files: this.files
372
+ }))).catch(logRenderFailure).finally(() => {
359
373
  it.return?.();
360
374
  });
361
375
  } catch (err) {
@@ -419,7 +433,10 @@ var AgentDurableObject = class {
419
433
  Promise.resolve().then(() => runInScope({
420
434
  resources,
421
435
  services: this.services
422
- }, () => resumeChannel.deliverResume(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
436
+ }, () => resumeChannel.deliverResume(target, events, {
437
+ session: this.sessionKey,
438
+ files: this.files
439
+ }))).catch(logRenderFailure).finally(() => {
423
440
  it.return?.();
424
441
  });
425
442
  } catch (err) {
@@ -612,6 +629,7 @@ function durableChannelSurface(getNamespace, opts) {
612
629
  },
613
630
  services: resolveServices(opts.services, opts.env, opts.agentName),
614
631
  waitUntil: opts.waitUntil,
632
+ files: opts.files,
615
633
  run: async (message, o) => {
616
634
  const namespace = getNamespace();
617
635
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
@@ -1,6 +1,7 @@
1
1
  import { SyncSqlite } from "./sqlite-driver.js";
2
2
  import { AgentDefinition, ChannelContext } from "@junejs/core/agent-config";
3
3
  import { AgentSession, ChannelPolicy, InputAnnouncement, Model, Runtime, Tool } from "@junejs/core/agent-runtime";
4
+ import { FileStore } from "@junejs/core/agent-files";
4
5
  //#region src/agent-native.d.ts
5
6
  export type AgentDef = {
6
7
  model: Model;
@@ -12,6 +13,7 @@ export type AgentDef = {
12
13
  export declare function toAgentDef(agent: AgentDefinition, model: Model): AgentDef;
13
14
  export type NativeRuntimeOptions = {
14
15
  maxSessions?: number;
16
+ files?: FileStore;
15
17
  };
16
18
  export declare class NativeRuntime implements Runtime {
17
19
  private actors;
@@ -20,6 +22,7 @@ export declare class NativeRuntime implements Runtime {
20
22
  private readonly announceRetry;
21
23
  private readonly maxSessions;
22
24
  private closing;
25
+ readonly files: FileStore | undefined;
23
26
  constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts?: NativeRuntimeOptions);
24
27
  agentDef(name: string): AgentDef | undefined;
25
28
  session(agent: string, id: string): AgentSession;
@@ -35,7 +38,10 @@ export declare class MemoryRuntime implements Runtime {
35
38
  private readonly agents;
36
39
  private readonly announceRetry;
37
40
  private closed;
38
- constructor(agents: Record<string, AgentDef>);
41
+ readonly files: FileStore | undefined;
42
+ constructor(agents: Record<string, AgentDef>, opts?: {
43
+ files?: FileStore;
44
+ });
39
45
  agentDef(name: string): AgentDef | undefined;
40
46
  session(agent: string, id: string): AgentSession;
41
47
  close(): Promise<void>;
@@ -43,16 +49,19 @@ export declare class MemoryRuntime implements Runtime {
43
49
  export type AgentBackend = "native" | "memory" | "durable";
44
50
  export type InProcessRuntime = Runtime & {
45
51
  close(): Promise<void>;
52
+ readonly files?: FileStore;
46
53
  };
47
54
  export declare function createAgentRuntime(agents: Record<string, AgentDef>, opts?: {
48
55
  backend?: AgentBackend;
49
56
  path?: string;
50
57
  maxSessions?: number;
58
+ files?: FileStore;
51
59
  }): Promise<InProcessRuntime>;
52
60
  export declare function mountAgent(agent: AgentDefinition, runtime: Runtime, opts?: {
53
61
  chatPath?: string;
54
62
  channels?: boolean;
55
63
  services?: unknown;
64
+ files?: FileStore;
56
65
  }): {
57
66
  surface: (req: Request) => Promise<Response | null>;
58
67
  fetch: (req: Request) => Promise<Response | null>;
@@ -158,10 +158,12 @@ var NativeRuntime = class {
158
158
  });
159
159
  maxSessions;
160
160
  closing;
161
+ files;
161
162
  constructor(agents, db, opts = {}) {
162
163
  assertCoreRuntimeVersion("NativeRuntime");
163
164
  this.agents = agents;
164
165
  this.db = db;
166
+ this.files = opts.files;
165
167
  const max = opts.maxSessions ?? 1e3;
166
168
  if (!(Number.isInteger(max) && max >= 1) && max !== Infinity) throw new RangeError(`NativeRuntime: maxSessions must be an integer >= 1 (or Infinity for no cap), got ${max}`);
167
169
  this.maxSessions = max;
@@ -183,7 +185,9 @@ var NativeRuntime = class {
183
185
  if (!def) throw new Error(`unknown agent: ${agent}`);
184
186
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
185
187
  const sink = new InProcEventSink();
186
- const session = wireAnnouncements(new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions), def, this.announceRetry);
188
+ const built = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
189
+ built.files = this.files;
190
+ const session = wireAnnouncements(built, def, this.announceRetry);
187
191
  this.evictIdle();
188
192
  this.actors.set(key, {
189
193
  session,
@@ -294,9 +298,11 @@ var MemoryRuntime = class {
294
298
  this.session(agent, id).flushAnnouncements();
295
299
  });
296
300
  closed = false;
297
- constructor(agents) {
301
+ files;
302
+ constructor(agents, opts = {}) {
298
303
  assertCoreRuntimeVersion("MemoryRuntime");
299
304
  this.agents = agents;
305
+ this.files = opts.files;
300
306
  }
301
307
  agentDef(name) {
302
308
  return this.agents[name];
@@ -311,7 +317,9 @@ var MemoryRuntime = class {
311
317
  const store = new MemorySessionStore();
312
318
  this.stores.set(key, store);
313
319
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
314
- a = wireAnnouncements(new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions), def, this.announceRetry);
320
+ const built = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
321
+ built.files = this.files;
322
+ a = wireAnnouncements(built, def, this.announceRetry);
315
323
  this.actors.set(key, a);
316
324
  }
317
325
  return a;
@@ -325,8 +333,11 @@ var MemoryRuntime = class {
325
333
  };
326
334
  async function createAgentRuntime(agents, opts = {}) {
327
335
  const backend = opts.backend ?? "native";
328
- if (backend === "memory") return new MemoryRuntime(agents);
329
- if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions });
336
+ if (backend === "memory") return new MemoryRuntime(agents, { files: opts.files });
337
+ if (backend === "native") return createNativeRuntime(agents, opts.path, {
338
+ maxSessions: opts.maxSessions,
339
+ files: opts.files
340
+ });
330
341
  throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
331
342
  }
332
343
  function warnOnDrift(agent, runtime) {
@@ -346,6 +357,7 @@ function mountAgent(agent, runtime, opts = {}) {
346
357
  const ctx = {
347
358
  agent,
348
359
  services: opts.services,
360
+ files: opts.files ?? runtime.files,
349
361
  run: (message, o) => runtime.session(agent.name, o?.session ?? "default").turn({
350
362
  turnId: o?.turnId,
351
363
  userText: message,
package/dist/app.js CHANGED
@@ -5,6 +5,7 @@ import { routeProblems, scanRoutes } from "./route-scan.js";
5
5
  import { createPipeline } from "./pipeline.js";
6
6
  import { resolveBoundary } from "./segment.js";
7
7
  import { memoizeResources } from "./resources.js";
8
+ import { localBlob } from "./blob.js";
8
9
  import { MODULE_STYLES_URL, buildModuleCss, registerCssModules } from "./css-modules.js";
9
10
  import { CLIENT_SCRIPT_URL, bundleClientSplit, findClientEntry } from "./client-bundle.js";
10
11
  import { generateIslandRegistry } from "./island-registry.js";
@@ -15,6 +16,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
15
16
  import { readFile } from "node:fs/promises";
16
17
  import { dirname, join, resolve } from "node:path";
17
18
  import { pathToFileURL } from "node:url";
19
+ import { blobFileStore, memoryFileStore } from "@junejs/core/agent-files";
18
20
  import { routeFromModule } from "@junejs/core/route";
19
21
  import { deployOrigin, resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
20
22
  import { runWithTrace } from "@junejs/core/instrumentation";
@@ -139,7 +141,12 @@ function createApp({ appDir: appDirInput, config = {} }) {
139
141
  const def = await discoverAgent(agentDir);
140
142
  const backend = agent.runtime.backend === "durable" ? "native" : agent.runtime.backend;
141
143
  const model = anthropic({ model: def.model });
142
- const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend });
144
+ let blobOpened;
145
+ const files = backend === "memory" ? memoryFileStore() : blobFileStore(() => blobOpened ??= resources().then(async (r) => r.blob ?? await localBlob().open()));
146
+ const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, {
147
+ backend,
148
+ files
149
+ });
143
150
  agentRuntime = rt;
144
151
  const mounted = mountAgent(def, rt, {
145
152
  chatPath: agent.runtime.chat.path,
@@ -1,6 +1,6 @@
1
1
  import * as coreRuntime from "@junejs/core/agent-runtime";
2
2
  //#region src/core-version.ts
3
- const EXPECTED_CORE_RUNTIME_API = 6;
3
+ const EXPECTED_CORE_RUNTIME_API = 7;
4
4
  function assertCoreRuntimeVersion(surface) {
5
5
  checkCoreRuntimeVersion(surface, coreRuntime.RUNTIME_API_VERSION);
6
6
  }
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@ import { negotiate } from "./negotiate.js";
6
6
  import { createPipeline } from "./pipeline.js";
7
7
  import { d1 } from "./d1.js";
8
8
  import { bindWorkerResources, memoizeResources } from "./resources.js";
9
- import { createApp } from "./app.js";
10
9
  import { localBlob, r2 } from "./blob.js";
10
+ import { createApp } from "./app.js";
11
11
  import { loadJuneConfig } from "./config-loader.js";
12
12
  import { freezeConfig } from "./config-freeze.js";
13
13
  import { collection, entry } from "./content.js";
@@ -19,6 +19,7 @@ export declare function nodeSqliteHelp(nodeVersion: string): string;
19
19
  export declare function isNodeSqliteExperimentalWarning(type: unknown, message: string): boolean;
20
20
  type EmitWarning = (warning: string | Error, ...rest: unknown[]) => void;
21
21
  export declare function makeWarningFilter(original: EmitWarning): EmitWarning;
22
+ export declare function useDurableJournal(db: SyncSqlite, path: string): SyncSqlite;
22
23
  export declare function openLocalSqliteSync(path: string): Promise<SyncSqlite>;
23
24
  export declare function openLocalSqlite(path: string): Promise<JuneDb>;
24
25
  //#endregion
@@ -72,10 +72,23 @@ function makeWarningFilter(original) {
72
72
  original(warning, ...rest);
73
73
  };
74
74
  }
75
+ function useDurableJournal(db, path) {
76
+ if (path === ":memory:" || path === "") return db;
77
+ try {
78
+ db.exec("PRAGMA journal_mode=WAL");
79
+ db.exec("PRAGMA synchronous=FULL");
80
+ } catch (err) {
81
+ try {
82
+ db.close();
83
+ } catch {}
84
+ throw err;
85
+ }
86
+ return db;
87
+ }
75
88
  async function openLocalSqliteSync(path) {
76
89
  if (typeof Bun !== "undefined") {
77
90
  const { Database } = await import("bun:sqlite");
78
- return new Database(path, { create: true });
91
+ return useDurableJournal(new Database(path, { create: true }), path);
79
92
  }
80
93
  const specifier = "node:sqlite";
81
94
  let mod;
@@ -89,14 +102,14 @@ async function openLocalSqliteSync(path) {
89
102
  process.emitWarning = originalEmitWarning;
90
103
  }
91
104
  const db = new mod.DatabaseSync(path);
92
- return {
105
+ return useDurableJournal({
93
106
  query: (sql) => db.prepare(sql),
94
107
  exec: (sql) => db.exec(sql),
95
108
  close: () => db.close()
96
- };
109
+ }, path);
97
110
  }
98
111
  async function openLocalSqlite(path) {
99
112
  return asyncSqlite(await openLocalSqliteSync(path));
100
113
  }
101
114
  //#endregion
102
- export { NODE_SQLITE_MIN_LTS, NODE_SQLITE_MIN_ODD, isNodeSqliteExperimentalWarning, makeWarningFilter, nodeSqliteHelp, openLocalSqlite, openLocalSqliteSync };
115
+ export { NODE_SQLITE_MIN_LTS, NODE_SQLITE_MIN_ODD, isNodeSqliteExperimentalWarning, makeWarningFilter, nodeSqliteHelp, openLocalSqlite, openLocalSqliteSync, useDurableJournal };
package/dist/worker.js CHANGED
@@ -3,6 +3,7 @@ import { compareRoutePatterns, parseSegment } from "./route-rank.js";
3
3
  import { acceptTarget } from "./negotiate.js";
4
4
  import { createPipeline } from "./pipeline.js";
5
5
  import { RESERVED_PREFIX, contentTypeFor, safeRelativePath } from "./static-files.js";
6
+ import { blobFileStore } from "@junejs/core/agent-files";
6
7
  //#region src/worker.ts
7
8
  function compilePattern(pattern) {
8
9
  const names = [];
@@ -88,7 +89,12 @@ function createWorker(manifest, opts = {}) {
88
89
  channels: manifest.agentChannels,
89
90
  env: locals?.env,
90
91
  services: servicesProvider,
91
- waitUntil: locals?.ctx?.waitUntil?.bind(locals.ctx)
92
+ waitUntil: locals?.ctx?.waitUntil?.bind(locals.ctx),
93
+ files: provider ? blobFileStore(async () => {
94
+ const blob = (await provider(locals?.env)).blob;
95
+ if (!blob) throw new Error(`agent "${agentName}": no blob resource bound in this isolate — attached files can't be read`);
96
+ return blob;
97
+ }) : void 0
92
98
  })(req);
93
99
  };
94
100
  })() : void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.45",
3
+ "version": "1.0.0-dev.47",
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",
@@ -140,7 +140,7 @@
140
140
  "rolldown": "^1.1.0"
141
141
  },
142
142
  "peerDependencies": {
143
- "@junejs/core": ">=0.2.0-dev.65",
143
+ "@junejs/core": ">=0.2.0-dev.66",
144
144
  "react": "^19.2.0",
145
145
  "react-dom": "^19.2.0",
146
146
  "react-server-dom-webpack": "19.3.0",
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  AgentSession,
20
+ FatalToolError,
20
21
  grantAnswer,
21
22
  ResumeAuthorizationError,
22
23
  withSystem,
@@ -39,6 +40,7 @@ import {
39
40
  } from "@junejs/core/agent-runtime";
40
41
  import type { Principal } from "@junejs/core/context";
41
42
  import type { Resources } from "@junejs/core/resources";
43
+ import { blobFileStore, type FileStore } from "@junejs/core/agent-files";
42
44
  import { observeTurnEvents } from "./turn-events";
43
45
  import {
44
46
  channelDispatch,
@@ -255,6 +257,10 @@ export type DoAgentDef = {
255
257
  // Receives this session's input announcements (#260) — run inside the request scope, so it
256
258
  // can write the app's cross-session index with the ambient db.
257
259
  onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
260
+ // Where this session's tools store the files they produce (docs/rfc-agent-files.md), and
261
+ // where host-side delivery reads them back. Default: the `blob` of `resources` (an R2
262
+ // bucket), under agent-files/. Without either, a tool's ctx.files.put fails the turn.
263
+ files?: FileStore;
258
264
  };
259
265
 
260
266
  // The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
@@ -300,6 +306,7 @@ export class AgentDurableObject {
300
306
  private readonly authorizeAnswer?: AuthorizeAnswer;
301
307
  private readonly onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
302
308
  private readonly storage: DurableStorage;
309
+ private readonly files: FileStore;
303
310
  // Consecutive failed announcement deliveries (#260) — sets the alarm retry's backoff.
304
311
  private announceFailures = 0;
305
312
  private resolveResources(): Promise<Resources> {
@@ -347,6 +354,15 @@ export class AgentDurableObject {
347
354
  this.onInputAnnouncement = def.onInputAnnouncement;
348
355
  this.storage = state.storage;
349
356
  const name = def.name ?? "agent";
357
+ // The blob resource opens with the rest of resources, lazily — the store is installed
358
+ // on the session before then, and only a tool that stores a file opens it.
359
+ this.files =
360
+ def.files ??
361
+ blobFileStore(async () => {
362
+ const blob = (await this.resolveResources()).blob;
363
+ if (!blob) throw new FatalToolError(`agent "${name}": no file store — declare a blob resource (DoAgentDef.resources) or pass DoAgentDef.files`);
364
+ return blob;
365
+ });
350
366
  // Failure observability (#76): a turn that dies after the fast-ACK has no other
351
367
  // observable surface on the edge — the webhook already 200'd and runBackground
352
368
  // swallows the rejection unless the channel wired onError. Subscribing HERE (the
@@ -459,6 +475,7 @@ export class AgentDurableObject {
459
475
  if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
460
476
  this.sessionKey = resolved;
461
477
  this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
478
+ this.session.files = this.files;
462
479
  // A held turn (#263) that asked for delivery is rendered through its source channel when
463
480
  // it finally runs — possibly in a later life of this object, which is why the ask rides
464
481
  // in the turn's hostContext and this hook is installed on every rebuild.
@@ -537,7 +554,7 @@ export class AgentDurableObject {
537
554
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
538
555
  const target = { channelId: event.channelId, threadId: event.threadId, recipientUserId: event.user?.id, recipientTeamId: event.teamId };
539
556
  this.ready()
540
- .then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey })))
557
+ .then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey, files: this.files })))
541
558
  .catch(logRenderFailure)
542
559
  .finally(() => { void it.return?.(); });
543
560
  } catch (err) {
@@ -682,7 +699,7 @@ export class AgentDurableObject {
682
699
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
683
700
  const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
684
701
  Promise.resolve()
685
- .then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
702
+ .then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey, files: this.files })))
686
703
  .catch(logRenderFailure)
687
704
  .finally(() => { void it.return?.(); });
688
705
  } catch (err) {
@@ -770,7 +787,7 @@ export class AgentDurableObject {
770
787
  const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
771
788
  const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
772
789
  Promise.resolve()
773
- .then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
790
+ .then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey, files: this.files })))
774
791
  .catch(logRenderFailure)
775
792
  .finally(() => { void it.return?.(); });
776
793
  } catch (err) {
@@ -1079,6 +1096,9 @@ export function durableChannelSurface(
1079
1096
  // agentName must have one provider.
1080
1097
  services?: (env: unknown) => unknown;
1081
1098
  waitUntil?: (p: Promise<unknown>) => void;
1099
+ // Where the channels read the files a turn attached (docs/rfc-agent-files.md) — the
1100
+ // same storage the Durable Object's tools write, opened from this isolate's env.
1101
+ files?: FileStore;
1082
1102
  },
1083
1103
  ): (req: Request) => Promise<Response | null> {
1084
1104
  const resolved = opts.channels.map((c) => resolveChannel(c, opts.env));
@@ -1088,6 +1108,7 @@ export function durableChannelSurface(
1088
1108
  agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
1089
1109
  services: resolveServices(opts.services, opts.env, opts.agentName),
1090
1110
  waitUntil: opts.waitUntil,
1111
+ files: opts.files,
1091
1112
  run: async (message, o) => {
1092
1113
  const namespace = getNamespace();
1093
1114
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
@@ -3,9 +3,9 @@
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,
@@ -22,6 +22,7 @@ import {
22
22
  type Tool,
23
23
  } from "@junejs/core/agent-runtime";
24
24
  import { buildSystemPrompt, channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
25
+ import type { FileStore } from "@junejs/core/agent-files";
25
26
  import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
26
27
  import { assertCoreRuntimeVersion } from "./core-version";
27
28
  import { observeTurnEvents } from "./turn-events";
@@ -221,6 +222,10 @@ export type NativeRuntimeOptions = {
221
222
  // dropped; their state is in SQLite, so the next session() rebuilds them transparently.
222
223
  // Busy actors are never dropped, so the count can exceed the cap while they run.
223
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;
224
229
  };
225
230
 
226
231
  // The native Runtime: a registry of agent definitions over one SQLite handle,
@@ -238,11 +243,13 @@ export class NativeRuntime implements Runtime {
238
243
  private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
239
244
  private readonly maxSessions: number;
240
245
  private closing: Promise<void> | undefined; // set by close() (#317)
246
+ readonly files: FileStore | undefined;
241
247
 
242
248
  constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
243
249
  assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
244
250
  this.agents = agents;
245
251
  this.db = db;
252
+ this.files = opts.files;
246
253
  const max = opts.maxSessions ?? 1000;
247
254
  // At least one actor is always retained (the one being handed out), so a cap below 1
248
255
  // means nothing, and NaN would compare false and evict every idle actor on each miss.
@@ -272,7 +279,9 @@ export class NativeRuntime implements Runtime {
272
279
  if (!def) throw new Error(`unknown agent: ${agent}`);
273
280
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
274
281
  const sink = new InProcEventSink();
275
- const session = wireAnnouncements(new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions), def, this.announceRetry);
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);
276
285
  this.evictIdle();
277
286
  this.actors.set(key, { session, sink });
278
287
  return session;
@@ -397,9 +406,11 @@ export class MemoryRuntime implements Runtime {
397
406
  private readonly agents: Record<string, AgentDef>;
398
407
  private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
399
408
  private closed = false; // set by close() (#317)
400
- constructor(agents: Record<string, AgentDef>) {
409
+ readonly files: FileStore | undefined;
410
+ constructor(agents: Record<string, AgentDef>, opts: { files?: FileStore } = {}) {
401
411
  assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
402
412
  this.agents = agents;
413
+ this.files = opts.files;
403
414
  }
404
415
  agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
405
416
  session(agent: string, id: string): AgentSession {
@@ -413,7 +424,9 @@ export class MemoryRuntime implements Runtime {
413
424
  this.stores.set(key, store);
414
425
  // Same def handling as NativeRuntime: switching backend must not change behavior.
415
426
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
416
- a = wireAnnouncements(new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions), def, this.announceRetry);
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);
417
430
  this.actors.set(key, a);
418
431
  }
419
432
  return a;
@@ -440,15 +453,15 @@ export type AgentBackend = "native" | "memory" | "durable";
440
453
  // target is the DO the worker constructs, not an in-process object) — so the
441
454
  // choice is explicit and a mis-selection fails loudly.
442
455
  // An in-process runtime: a Runtime the host owns, so it can shut it down (#317).
443
- export type InProcessRuntime = Runtime & { close(): Promise<void> };
456
+ export type InProcessRuntime = Runtime & { close(): Promise<void>; readonly files?: FileStore };
444
457
 
445
458
  export async function createAgentRuntime(
446
459
  agents: Record<string, AgentDef>,
447
- opts: { backend?: AgentBackend; path?: string; maxSessions?: number } = {},
460
+ opts: { backend?: AgentBackend; path?: string; maxSessions?: number; files?: FileStore } = {},
448
461
  ): Promise<InProcessRuntime> {
449
462
  const backend = opts.backend ?? "native";
450
- if (backend === "memory") return new MemoryRuntime(agents);
451
- if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions });
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 });
452
465
  throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
453
466
  }
454
467
 
@@ -485,7 +498,7 @@ function warnOnDrift(agent: AgentDefinition, runtime: Runtime) {
485
498
  export function mountAgent(
486
499
  agent: AgentDefinition,
487
500
  runtime: Runtime,
488
- opts: { chatPath?: string; channels?: boolean; services?: unknown } = {},
501
+ opts: { chatPath?: string; channels?: boolean; services?: unknown; files?: FileStore } = {},
489
502
  ): {
490
503
  surface: (req: Request) => Promise<Response | null>;
491
504
  fetch: (req: Request) => Promise<Response | null>;
@@ -498,6 +511,8 @@ export function mountAgent(
498
511
  const ctx: ChannelContext = {
499
512
  agent,
500
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,
501
516
  run: (message, o) =>
502
517
  runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
503
518
  // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
package/src/app.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  } from "@junejs/core/config";
26
26
  import type { DocumentConfig } from "@junejs/core/document";
27
27
  import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
28
+ import { blobFileStore, memoryFileStore } from "@junejs/core/agent-files";
29
+ import type { JuneBlob } from "@junejs/core/resources";
28
30
 
29
31
  import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
30
32
  import { routeProblems, scanRoutes } from "./route-scan";
@@ -34,6 +36,7 @@ import { createAgentRuntime, mountAgent, toAgentDef, type InProcessRuntime } fro
34
36
  import { anthropic } from "@junejs/core/agent-models";
35
37
  import { resolveBoundary } from "./segment";
36
38
  import { memoizeResources } from "./resources";
39
+ import { localBlob } from "./blob";
37
40
  import {
38
41
  findClientEntry,
39
42
  bundleClientSplit,
@@ -270,7 +273,15 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
270
273
  const model = anthropic({ model: def.model });
271
274
  // toAgentDef: tools, prompt AND the per-surface policies (#149) from the one
272
275
  // definition — channelInstructions was previously dropped on this path (#173).
273
- const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend });
276
+ // Agent files (docs/rfc-agent-files.md) live in the app's blob resource; an app that
277
+ // declares none gets the local default directory. The memory backend keeps them in
278
+ // memory, like everything else it holds.
279
+ let blobOpened: Promise<JuneBlob> | undefined;
280
+ const files =
281
+ backend === "memory"
282
+ ? memoryFileStore()
283
+ : blobFileStore(() => (blobOpened ??= resources().then(async (r) => r.blob ?? (await localBlob().open()))));
284
+ const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend, files });
274
285
  agentRuntime = rt;
275
286
  const mounted = mountAgent(def, rt, { chatPath: agent.runtime.chat.path, channels: agent.runtime.channels });
276
287
  agentSurface = (req) => mounted.surface(req);
@@ -15,7 +15,7 @@ import * as coreRuntime from "@junejs/core/agent-runtime";
15
15
 
16
16
  // The contract number this server tree is built against. Bump in lockstep with
17
17
  // core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
18
- const EXPECTED_CORE_RUNTIME_API = 6;
18
+ const EXPECTED_CORE_RUNTIME_API = 7;
19
19
 
20
20
  export function assertCoreRuntimeVersion(surface: string): void {
21
21
  checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
@@ -135,6 +135,33 @@ export function makeWarningFilter(original: EmitWarning): EmitWarning {
135
135
  // runtime, which is exactly what the typeof guard checks.
136
136
  declare const Bun: unknown;
137
137
 
138
+ // A file database commits durably: WAL, fsynced on every commit. SQLite's default
139
+ // rollback journal (DELETE mode) commits by unlinking the journal, and at
140
+ // synchronous=FULL it does not fsync the directory after that unlink — so a power
141
+ // cut right after a commit can bring the journal back, and the next open rolls the
142
+ // committed transaction back. On a VM power cut that lost the last committed agent
143
+ // turn 5 times out of 5; with WAL + FULL, 0 of 5. FULL is set explicitly: it is a
144
+ // build-time default, and WAL at synchronous=NORMAL skips the per-commit fsync.
145
+ // journal_mode=WAL persists in the file; `:memory:` has no journal to change.
146
+ // A PRAGMA that throws (a locked file mid-switch) closes the handle it was handed,
147
+ // so a retry is not blocked by a descriptor nobody can reach; the original error
148
+ // is what surfaces. Exported for the unit test of that path.
149
+ export function useDurableJournal(db: SyncSqlite, path: string): SyncSqlite {
150
+ if (path === ":memory:" || path === "") return db;
151
+ try {
152
+ db.exec("PRAGMA journal_mode=WAL");
153
+ db.exec("PRAGMA synchronous=FULL");
154
+ } catch (err) {
155
+ try {
156
+ db.close();
157
+ } catch {
158
+ // the setup error is the one worth reporting
159
+ }
160
+ throw err;
161
+ }
162
+ return db;
163
+ }
164
+
138
165
  // Open a LOCAL *synchronous* sqlite handle on whichever runtime we're under —
139
166
  // bun:sqlite under Bun, node:sqlite under Node. This is the raw synchronous
140
167
  // surface: asyncSqlite() wraps it as the edge-safe async JuneDb (below), while
@@ -148,7 +175,7 @@ export async function openLocalSqliteSync(path: string): Promise<SyncSqlite> {
148
175
  const { Database } = (await import(specifier)) as {
149
176
  Database: new (p: string, o?: { create?: boolean }) => SyncSqlite;
150
177
  };
151
- return new Database(path, { create: true });
178
+ return useDurableJournal(new Database(path, { create: true }), path);
152
179
  }
153
180
 
154
181
  // Node: node:sqlite is a builtin, but only flag-free on a recent-enough
@@ -172,11 +199,14 @@ export async function openLocalSqliteSync(path: string): Promise<SyncSqlite> {
172
199
  }
173
200
  const db = new mod.DatabaseSync(path);
174
201
  // Adapt node:sqlite (prepare()) to the query()-shaped SyncSqlite surface.
175
- return {
176
- query: (sql) => db.prepare(sql),
177
- exec: (sql) => db.exec(sql),
178
- close: () => db.close(),
179
- };
202
+ return useDurableJournal(
203
+ {
204
+ query: (sql) => db.prepare(sql),
205
+ exec: (sql) => db.exec(sql),
206
+ close: () => db.close(),
207
+ },
208
+ path,
209
+ );
180
210
  }
181
211
 
182
212
  // Open a LOCAL sqlite file (or ":memory:") as the async JuneDb — the single seam
package/src/worker.ts CHANGED
@@ -18,6 +18,7 @@ import type { I18nConfig } from "@junejs/core/i18n";
18
18
  import type { Resources } from "@junejs/core/resources";
19
19
 
20
20
  import type { Channel, ChannelFactory } from "@junejs/core/agent-config";
21
+ import { blobFileStore } from "@junejs/core/agent-files";
21
22
 
22
23
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
23
24
  import { durableAgentSurface, durableChannelSurface, type DurableObjectNamespace } from "./agent-durable";
@@ -219,6 +220,15 @@ export function createWorker(
219
220
  env: locals?.env,
220
221
  services: servicesProvider,
221
222
  waitUntil: locals?.ctx?.waitUntil?.bind(locals.ctx),
223
+ // The blob resource the agent's Durable Object stores files in, opened from
224
+ // this isolate's env — the channels read attached files back from it.
225
+ files: provider
226
+ ? blobFileStore(async () => {
227
+ const blob = (await provider(locals?.env)).blob;
228
+ if (!blob) throw new Error(`agent "${agentName}": no blob resource bound in this isolate — attached files can't be read`);
229
+ return blob;
230
+ })
231
+ : undefined,
222
232
  });
223
233
  return channels(req);
224
234
  };