@zenera/server 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,10 +28,10 @@ Requires Node.js 24+ and the [Zenera CLI](../cli/README.md).
28
28
 
29
29
  ## Quickstart
30
30
 
31
- From a configured project:
31
+ For local testing, run the server in the configured project's agent container:
32
32
 
33
33
  ```sh
34
- zen server start --insecure --user ann
34
+ zen server start --insecure --container --user andrey
35
35
  ```
36
36
 
37
37
  Open the printed HTTP `/chat` link to list sessions, send messages, follow active
@@ -43,17 +43,22 @@ impersonate any user. API requests still need a JWT, but signatures are not chec
43
43
  The test page is available only in this mode. Its token stays in memory; after a
44
44
  reload, reopen the printed link or enter a development token again.
45
45
 
46
- To run the server in the project's agent image:
46
+ To show full image build logs:
47
47
 
48
48
  ```sh
49
- zen server start --container --insecure --user ann
50
- zen server start --container --insecure --user ann --verbose # full build logs
49
+ zen server start --insecure --container --user andrey --verbose
51
50
  ```
52
51
 
53
52
  Podman builds and caches the image. Default build output shows a spinner, elapsed
54
53
  time, and the latest line; warnings remain visible. Use `--rebuild` to rebuild.
55
54
  Press Ctrl+C to stop gracefully and save active runs.
56
55
 
56
+ To run the server directly on the host instead, omit `--container`:
57
+
58
+ ```sh
59
+ zen server start --insecure --user andrey
60
+ ```
61
+
57
62
  ## Secure setup
58
63
 
59
64
  A trusted caller, usually Zenera-app, authenticates users, checks project access,
@@ -95,6 +100,7 @@ it expires. The server does not perform user login or project-membership checks.
95
100
  | `zen server status` | Inspect a running runtime; use `--token <jwt>` for secure mode. |
96
101
  | `zen server keygen --out <prefix>` | Generate private/public signing keys. |
97
102
  | `zen server token --user <id>` | Print an **unsigned** development JWT for insecure mode. |
103
+ | `zen server build` | Build (and push) a release image; see below. |
98
104
 
99
105
  Test an API without the browser:
100
106
 
@@ -134,6 +140,61 @@ credential files. The gateway must be reachable from the container; host
134
140
  Set `sandbox.keys: false` to withhold credentials from agent-written commands,
135
141
  while allowing the engine to use them for model calls.
136
142
 
143
+ ## Release images
144
+
145
+ `zen server build` bakes a project into an immutable image for Kubernetes and
146
+ prints its digest (or the local tag when not pushed):
147
+
148
+ ```sh
149
+ zen server build --project p1 --release r42 \
150
+ --push registry.local:5000/agent-p1:r42 [--authfile auth.json] [--insecure-registry]
151
+ ```
152
+
153
+ The image is the agent image plus the project at `/project`, its memory seed at
154
+ `/seed` (`--memory-seed <dir>` / `--no-memory-seed`), `ZEN_RELEASE` and
155
+ `ZEN_PROJECT_ID`. The release id defaults to `r-<content hash>`; `--json` prints
156
+ `{release, image, agentImage, base, built, ref, digest}`.
157
+
158
+ Never baked in: `.env*` (except `.env.example`), `sessions/`, `.git`,
159
+ `node_modules`, `.tmp/`, or the live memory directory. Deployment settings (JWT public key, MCP
160
+ plugin, Bifrost URL/key, storage) are passed at deploy time via environment
161
+ variables or a ConfigMap. Use `--add <src>:<dest>` (repeatable) to add large
162
+ content such as plugins, e.g. `--add ./plugins:/opt/zenera/plugins`.
163
+
164
+ ## Many servers per project
165
+
166
+ Several servers can serve one project when users' data lives in a store rather
167
+ than on one disk. Name a store module with `ZEN_STATE_STORE` (ZeneraLite ships an
168
+ S3 store as `state.mjs`). The server then:
169
+
170
+ - loads a user's data before their first request, and a chat's files when it is opened;
171
+ - saves them after every run, after deletions, every `ZEN_STATE_CHECKPOINT_SECONDS`
172
+ (300) while runs go on, when released, and on shutdown;
173
+ - refuses a token whose `gen` (lease generation) is older than the one it holds,
174
+ and drops its copy when the store reports that a newer lease saved the user.
175
+
176
+ The host manages users through `/api/admin` with tokens for its own subject
177
+ (`ZEN_SERVER_SYSTEM_SUBJECT`, default `zenera-app`):
178
+
179
+ | Route | Use |
180
+ | ------------------------------------- | --------------------------------------------------------- |
181
+ | `GET /api/admin/users[?usage=1]` | Users here: lease, runs, unsaved changes, disk use. |
182
+ | `POST /api/admin/users/:sub/release` | Save and let go of one user; `409 busy` while runs go on. |
183
+ | `POST /api/admin/drain[?timeout=600]` | No new users; release each one as their runs end. |
184
+ | `POST /api/admin/undrain` | Take new users again. |
185
+
186
+ Soft limits for many users per pod:
187
+
188
+ | Variable | Limit |
189
+ | -------------------------- | ---------------------------------------------------------- |
190
+ | `ZEN_RUNTIME_USER_DISK_MB` | Per-user disk; over it, no new runs and running ones stop. |
191
+ | `ZEN_RUNTIME_NICE` | Lower CPU priority for commands (default 10). |
192
+ | `ZEN_RUNTIME_MAX_FILE_MB` | Largest file a command may write. |
193
+ | `ZEN_RUNTIME_TMP_MB` | Size of each command's `/tmp` (it uses memory). |
194
+
195
+ These are soft. Hard limits come from the pod: CPU, memory, and a fixed-size
196
+ volume for `/state`.
197
+
137
198
  ## HTTP API
138
199
 
139
200
  Session routes operate only on the JWT subject's own sessions.
package/dist/auth.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface CallerClaims extends JWTPayload {
8
8
  role?: string;
9
9
  email?: string;
10
10
  name?: string;
11
+ /** the host's lease generation for this user on this server (userState.ts) */
12
+ gen?: number;
11
13
  }
12
14
  export declare function verifyCaller(req: Request): Promise<CallerClaims>;
13
15
  export declare function requireCaller(): (req: Request, res: Response, next: NextFunction) => Promise<void>;
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SandboxSetup, ZenPlugin } from '@zenera/engine';
2
- import { type SandboxFactory } from '@zenera/sandbox';
2
+ import { type CommandLimits, type SandboxFactory } from '@zenera/sandbox';
3
+ import type { UserStateStore } from './userState.ts';
3
4
  export type ServerAuth =
4
5
  /** EdDSA tokens signed per request by the host (zenera-app), checked with its public key */
5
6
  {
@@ -50,6 +51,19 @@ export interface ServerSettings {
50
51
  maxUserRuns: number;
51
52
  maxRunMinutes: number;
52
53
  runLogMaxBytes: number;
54
+ /**
55
+ * Where users' data lives beyond this disk (userState.ts): loaded before their first request,
56
+ * saved at safe points, released on the host's word. Unset: only here (`localStore`).
57
+ */
58
+ stateStore?: UserStateStore;
59
+ /** how often users with runs going (or a failed save) are saved again; 0: never */
60
+ checkpointSeconds: number;
61
+ /** the `sub` of the host's own calls, the only one /api/admin answers */
62
+ systemSubject: string;
63
+ /** soft limit on one user's folder; over it, no new runs and running ones stop; 0: none */
64
+ userDiskQuotaBytes: number;
65
+ /** what each bwrap command is held to: priority, largest file, size of its /tmp; 0: none */
66
+ commandLimits: CommandLimits;
53
67
  }
54
68
  /**
55
69
  * bwrap inside the agent image, which ships it at /opt/zenera/bin/bwrap; podman anywhere else,
package/dist/config.js CHANGED
@@ -30,6 +30,10 @@ export const DEFAULTS = {
30
30
  maxUserRuns: 2,
31
31
  maxRunMinutes: 120,
32
32
  runLogMaxBytes: 50 * 1024 * 1024,
33
+ checkpointSeconds: 300,
34
+ systemSubject: DEFAULT_ISSUER,
35
+ userDiskQuotaBytes: 0,
36
+ commandLimits: { nice: 10, fileSizeBytes: 0, tmpBytes: 0 },
33
37
  };
34
38
  let current;
35
39
  export function configure(settings) {
package/dist/http.js CHANGED
@@ -21,6 +21,10 @@ export function errorHandler(err, req, res, _next) {
21
21
  const status = err instanceof HttpError ? err.status : (err?.status ?? 500);
22
22
  if (status >= 500)
23
23
  console.error(`${req.method} ${req.originalUrl}:`, err);
24
- res.status(status).json({ error: errorMessage(err) });
24
+ const code = err?.code;
25
+ res.status(status).json({
26
+ error: errorMessage(err),
27
+ ...(err instanceof HttpError && typeof code === 'string' ? { code } : {}),
28
+ });
25
29
  }
26
30
  //# sourceMappingURL=http.js.map
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { requireCaller, unsignedToken, verifyCaller, type CallerClaims } from '.
4
4
  export { identify, runUser, userFolder, type Identity } from './identity.ts';
5
5
  export { LAYOUT_VERSION, LayoutError, openLayout, type Layout } from './layout.ts';
6
6
  export { privateStore, memoryDir, memorySeed, userDir, userMemory, SEED_FILE, usesMemory, imageReady, openEngineFor, statusFor, errorMessage, asHttpError, type ChatStore, type ChatThread, } from './store.ts';
7
- export { runsRouter, runDebugRouter, activeRuns, currentRun, stopAllRuns } from './runs.ts';
7
+ export { runsRouter, runDebugRouter, activeRuns, currentRun, ownerRuns, stopAllRuns, stopOwnerRuns, } from './runs.ts';
8
8
  export { historyRouter, toMessages } from './history.ts';
9
9
  export { traceRouter, openTrace, stepParam } from './trace.ts';
10
10
  export { memoryRouter } from './memory.ts';
@@ -13,6 +13,7 @@ export { RunLog, compact, readRunLog, threadRunLogs } from './runLog.ts';
13
13
  export * from './runRecords.ts';
14
14
  export { applyProjectEnv, withoutProjectEnv } from './projectEnv.ts';
15
15
  export { HttpError, errorHandler, notFound } from './http.ts';
16
- export { envRunPlugins } from './plugins.ts';
16
+ export { envRunPlugins, envServerLimits, envStateStore, type StateStoreOptions, } from './plugins.ts';
17
+ export { localStore, StaleGenerationError, USER_LAYOUT, userStates, userUsageBytes, type CheckpointReason, type UserInfo, type UserRef, type UserSnapshot, type UserStateStore, } from './userState.ts';
17
18
  export { serverStatus, startupErrors, type ServerStatus } from './status.ts';
18
19
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ export { requireCaller, unsignedToken, verifyCaller } from "./auth.js";
6
6
  export { identify, runUser, userFolder } from "./identity.js";
7
7
  export { LAYOUT_VERSION, LayoutError, openLayout } from "./layout.js";
8
8
  export { privateStore, memoryDir, memorySeed, userDir, userMemory, SEED_FILE, usesMemory, imageReady, openEngineFor, statusFor, errorMessage, asHttpError, } from "./store.js";
9
- export { runsRouter, runDebugRouter, activeRuns, currentRun, stopAllRuns } from "./runs.js";
9
+ export { runsRouter, runDebugRouter, activeRuns, currentRun, ownerRuns, stopAllRuns, stopOwnerRuns, } from "./runs.js";
10
10
  export { historyRouter, toMessages } from "./history.js";
11
11
  export { traceRouter, openTrace, stepParam } from "./trace.js";
12
12
  export { memoryRouter } from "./memory.js";
@@ -15,6 +15,7 @@ export { RunLog, compact, readRunLog, threadRunLogs } from "./runLog.js";
15
15
  export * from "./runRecords.js";
16
16
  export { applyProjectEnv, withoutProjectEnv } from "./projectEnv.js";
17
17
  export { HttpError, errorHandler, notFound } from "./http.js";
18
- export { envRunPlugins } from "./plugins.js";
18
+ export { envRunPlugins, envServerLimits, envStateStore, } from "./plugins.js";
19
+ export { localStore, StaleGenerationError, USER_LAYOUT, userStates, userUsageBytes, } from "./userState.js";
19
20
  export { serverStatus, startupErrors } from "./status.js";
20
21
  //# sourceMappingURL=index.js.map
package/dist/plugins.d.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  import type { ZenPlugin } from '@zenera/engine';
2
+ import { type ServerSettings } from './config.ts';
3
+ import { USER_LAYOUT, type UserStateStore } from './userState.ts';
2
4
  export declare function envRunPlugins(env?: NodeJS.ProcessEnv): (run: {
3
5
  mcpToken: string | null;
4
6
  }) => Promise<ZenPlugin[]>;
7
+ /** What a state store module is given: who it serves, and the user folder's layout. */
8
+ export interface StateStoreOptions {
9
+ projectId: string;
10
+ root: string;
11
+ layout: typeof USER_LAYOUT;
12
+ env: NodeJS.ProcessEnv;
13
+ log: {
14
+ info(message: string): void;
15
+ warn(message: string): void;
16
+ };
17
+ }
18
+ /** The store `ZEN_STATE_STORE` names, made and checked; a broken one stops the server starting. */
19
+ export declare function envStateStore(env: NodeJS.ProcessEnv, at: {
20
+ projectId: string;
21
+ root: string;
22
+ }): Promise<UserStateStore | undefined>;
23
+ export declare function envServerLimits(env: NodeJS.ProcessEnv): Pick<ServerSettings, 'checkpointSeconds' | 'systemSubject' | 'userDiskQuotaBytes' | 'commandLimits'>;
5
24
  //# sourceMappingURL=plugins.d.ts.map
package/dist/plugins.js CHANGED
@@ -7,6 +7,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { pathToFileURL } from 'node:url';
10
+ import { DEFAULTS } from "./config.js";
11
+ import { USER_LAYOUT } from "./userState.js";
10
12
  export function envRunPlugins(env = process.env) {
11
13
  const plugin = (env.ZEN_PLUGINS ?? '')
12
14
  .split(',')
@@ -26,4 +28,49 @@ export function envRunPlugins(env = process.env) {
26
28
  return mcpPlugin ? [mcpPlugin({ brokerUrl, token: mcpToken })] : [];
27
29
  };
28
30
  }
31
+ /** The store `ZEN_STATE_STORE` names, made and checked; a broken one stops the server starting. */
32
+ export async function envStateStore(env, at) {
33
+ const path = env.ZEN_STATE_STORE?.trim();
34
+ if (!path)
35
+ return undefined;
36
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
37
+ if (typeof mod.userStateStore !== 'function') {
38
+ throw new Error(`${path} exports no userStateStore(options)`);
39
+ }
40
+ const store = await mod.userStateStore({
41
+ ...at,
42
+ layout: USER_LAYOUT,
43
+ env,
44
+ log: {
45
+ info: (message) => console.log(`[state] ${message}`),
46
+ warn: (message) => console.warn(`[state] ${message}`),
47
+ },
48
+ });
49
+ const ok = store &&
50
+ typeof store.name === 'string' &&
51
+ typeof store.durable === 'boolean' &&
52
+ typeof store.load === 'function' &&
53
+ typeof store.checkpoint === 'function';
54
+ if (!ok)
55
+ throw new Error(`${path}: userStateStore() did not return a store`);
56
+ return store;
57
+ }
58
+ function whole(text, fallback, max = Number.MAX_SAFE_INTEGER) {
59
+ const n = Number(text?.trim());
60
+ return text?.trim() && Number.isInteger(n) && n >= 0 ? Math.min(n, max) : fallback;
61
+ }
62
+ const MB = 2 ** 20;
63
+ export function envServerLimits(env) {
64
+ const limits = DEFAULTS.commandLimits;
65
+ return {
66
+ checkpointSeconds: whole(env.ZEN_STATE_CHECKPOINT_SECONDS, DEFAULTS.checkpointSeconds),
67
+ systemSubject: env.ZEN_SERVER_SYSTEM_SUBJECT?.trim() || DEFAULTS.systemSubject,
68
+ userDiskQuotaBytes: whole(env.ZEN_RUNTIME_USER_DISK_MB, 0) * MB,
69
+ commandLimits: {
70
+ nice: whole(env.ZEN_RUNTIME_NICE, limits.nice ?? 0, 19),
71
+ fileSizeBytes: whole(env.ZEN_RUNTIME_MAX_FILE_MB, 0) * MB,
72
+ tmpBytes: whole(env.ZEN_RUNTIME_TMP_MB, 0) * MB,
73
+ },
74
+ };
75
+ }
29
76
  //# sourceMappingURL=plugins.js.map
package/dist/runs.d.ts CHANGED
@@ -4,6 +4,14 @@ export declare function activeRuns(): number;
4
4
  export declare function currentRun(t: ChatThread): {
5
5
  runId: string;
6
6
  } | null;
7
+ /** One owner's runs going now, with the thread each is on and when it ends. */
8
+ export declare function ownerRuns(owner: string): {
9
+ threadId: string;
10
+ runId: string;
11
+ done: Promise<void>;
12
+ }[];
13
+ /** Ends one owner's runs as interrupted, saying why; resolves once they have saved. */
14
+ export declare function stopOwnerRuns(owner: string, message: string): Promise<void>;
7
15
  /** Runs may start again: a new server in a process whose previous one stopped. */
8
16
  export declare function acceptRuns(): void;
9
17
  export declare function stopAllRuns(timeoutMs: number): Promise<void>;
package/dist/runs.js CHANGED
@@ -32,6 +32,19 @@ export function currentRun(t) {
32
32
  const run = runs.get(t.id);
33
33
  return run && run.owner === t.owner ? { runId: run.runId } : null;
34
34
  }
35
+ /** One owner's runs going now, with the thread each is on and when it ends. */
36
+ export function ownerRuns(owner) {
37
+ return [...runs.entries()]
38
+ .filter(([, run]) => run.owner === owner)
39
+ .map(([threadId, run]) => ({ threadId, runId: run.runId, done: run.done }));
40
+ }
41
+ /** Ends one owner's runs as interrupted, saying why; resolves once they have saved. */
42
+ export async function stopOwnerRuns(owner, message) {
43
+ const mine = [...runs.values()].filter((run) => run.owner === owner);
44
+ for (const run of mine)
45
+ run.controller.abort(new RunStop('interrupted', message));
46
+ await Promise.all(mine.map((run) => run.done));
47
+ }
35
48
  // The newest user message of an AG-UI RunAgentInput; the thread's history is the runtime's own.
36
49
  function inputOf(body) {
37
50
  const messages = body?.messages;
@@ -236,6 +249,7 @@ async function execute(store, t, run, turn, memory, user, mcpToken) {
236
249
  }
237
250
  await log.end().catch((err) => console.error(`run ${runId}: saving its log:`, err));
238
251
  runs.delete(t.id);
252
+ store.changed?.(t, 'run-end');
239
253
  }
240
254
  }
241
255
  /** Runs may start again: a new server in a process whose previous one stopped. */
@@ -260,6 +274,8 @@ export function runsRouter(store) {
260
274
  const resume = resumeOf(req.body);
261
275
  const turn = resume ? { resume } : { message: inputOf(req.body) };
262
276
  const runId = ID.test(req.body?.runId ?? '') ? String(req.body.runId) : randomUUID();
277
+ // Over its storage quota, a user gets no new run (checked before the slot is taken).
278
+ await store.admit?.(t);
263
279
  if (stopping) {
264
280
  res.set('Retry-After', '5');
265
281
  throw new HttpError(503, 'the server is stopping, try again shortly');
@@ -345,6 +361,7 @@ export function runsRouter(store) {
345
361
  await run.done;
346
362
  }
347
363
  await store.removeThread(t);
364
+ store.changed?.(t, 'changed');
348
365
  res.status(204).end();
349
366
  });
350
367
  return router;
package/dist/server.d.ts CHANGED
@@ -14,8 +14,11 @@ export interface AgentServer {
14
14
  app: Express;
15
15
  settings: ServerSettings;
16
16
  listen(on: ListenOn): Promise<Server>;
17
- /** stops taking runs, ends the running ones as interrupted (they save), closes the listener */
18
- stop(graceMs?: number): Promise<void>;
17
+ /**
18
+ * Stops taking runs, ends the running ones as interrupted (they save), saves every user
19
+ * (within `saveMs`), closes the listener.
20
+ */
21
+ stop(graceMs?: number, saveMs?: number): Promise<void>;
19
22
  }
20
23
  export declare function createServer(opts: ServerOptions): AgentServer;
21
24
  /** The env `zen server start --container` sets on the container it runs, port on loopback. */
package/dist/server.js CHANGED
@@ -11,7 +11,11 @@
11
11
  // GET /api/sessions - the caller's sessions, newest activity first
12
12
  // /api/sessions/:tid... - history, runs (start, follow, replay, cancel), delete
13
13
  // /api/sessions/:tid/{runs,trace,memory}... with `debug` - the Test tab's views of a chat
14
- // createServer - the app, configured; `listen` starts it, `stop` ends runs and closes it
14
+ // /api/admin/{users,users/:sub/release,drain,undrain} - the host's view of this server's users
15
+ // Every /api/sessions request first has the caller's data loaded (userState.ts): a store plugin
16
+ // may keep users elsewhere and move them between servers; the token's `gen` is their lease.
17
+ // createServer - the app, configured; `listen` recovers users and starts it, `stop` ends runs,
18
+ // saves every user and closes it
15
19
  import express, {} from 'express';
16
20
  import { configure, DEFAULTS } from "./config.js";
17
21
  import { historyRouter } from "./history.js";
@@ -26,6 +30,7 @@ import { serverStatus, setListening } from "./status.js";
26
30
  import { privateStore } from "./store.js";
27
31
  import { sessionsRouter } from "./sessions.js";
28
32
  import { traceRouter } from "./trace.js";
33
+ import { loadCaller, loadThread, userStateRouter, userStates } from "./userState.js";
29
34
  export function createServer(opts) {
30
35
  const { chatPage, ...runtime } = opts;
31
36
  const settings = { ...DEFAULTS, ...runtime, hidden: [...(opts.hidden ?? [])] };
@@ -58,6 +63,9 @@ export function createServer(opts) {
58
63
  app.get('/api/activity', (_req, res) => {
59
64
  res.json({ busy: activeRuns() > 0, runs: activeRuns() });
60
65
  });
66
+ app.use('/api/admin', userStateRouter());
67
+ app.use('/api/sessions', loadCaller);
68
+ app.use('/api/sessions/:tid', loadThread);
61
69
  app.use('/api/sessions', sessionsRouter());
62
70
  app.use('/api/sessions', runsRouter(privateStore));
63
71
  app.use('/api/sessions', historyRouter(privateStore));
@@ -72,19 +80,23 @@ export function createServer(opts) {
72
80
  return {
73
81
  app,
74
82
  settings,
75
- listen: ({ port, host = '127.0.0.1' }) => new Promise((resolve, reject) => {
83
+ listen: async ({ port, host = '127.0.0.1' }) => {
76
84
  if (settings.auth.mode === 'insecure' && !LOOPBACK.has(host) && !insecureHere()) {
77
- reject(new Error(`an insecure server listens on loopback only, not ${host}`));
78
- return;
85
+ throw new Error(`an insecure server listens on loopback only, not ${host}`);
79
86
  }
80
- setListening(`${host}:${port}`);
81
- // Express 5 hands a listen error (EADDRINUSE) to this callback instead of throwing it.
82
- server = app.listen(port, host, (err) => err ? reject(err) : resolve(server));
83
- }),
84
- stop: async (graceMs = 20_000) => {
87
+ // Users whose folders survived a restart are known (and saved) before anyone calls.
88
+ await userStates.start();
89
+ return new Promise((resolve, reject) => {
90
+ setListening(`${host}:${port}`);
91
+ // Express 5 hands a listen error (EADDRINUSE) to this callback instead of throwing it.
92
+ server = app.listen(port, host, (err) => err ? reject(err) : resolve(server));
93
+ });
94
+ },
95
+ stop: async (graceMs = 20_000, saveMs = 60_000) => {
85
96
  // No new connections, and no new runs on the ones still open, before the drain.
86
97
  const closed = new Promise((resolve) => server ? server.close(() => resolve()) : resolve());
87
98
  await stopAllRuns(graceMs);
99
+ await userStates.shutdown(saveMs);
88
100
  // Followers of runs that have ended, idle keep-alives: nothing left to answer.
89
101
  server?.closeAllConnections();
90
102
  await closed;
package/dist/status.d.ts CHANGED
@@ -47,6 +47,13 @@ export interface ServerStatus {
47
47
  };
48
48
  /** what each user's memory starts as, when there is anything */
49
49
  memorySeed?: string;
50
+ /** where users' data is kept, and how many are on this server now */
51
+ state: {
52
+ store: string;
53
+ durable: boolean;
54
+ users: number;
55
+ draining: boolean;
56
+ };
50
57
  }
51
58
  /** Where the server listens, for the report. */
52
59
  export declare function setListening(where: string | undefined): void;
package/dist/status.js CHANGED
@@ -11,6 +11,7 @@ import { createRequire } from 'node:module';
11
11
  import { settings } from "./config.js";
12
12
  import { LAYOUT_VERSION, openLayout } from "./layout.js";
13
13
  import { memorySeed } from "./store.js";
14
+ import { userStates } from "./userState.js";
14
15
  const require = createRequire(import.meta.url);
15
16
  function versionOf(pkg) {
16
17
  try {
@@ -105,6 +106,12 @@ export async function serverStatus(env = process.env) {
105
106
  image,
106
107
  root,
107
108
  ...(memorySeed() ? { memorySeed: memorySeed() } : {}),
109
+ state: {
110
+ store: s.stateStore?.name ?? 'local',
111
+ durable: s.stateStore?.durable ?? false,
112
+ users: userStates.size,
113
+ draining: userStates.draining,
114
+ },
108
115
  };
109
116
  }
110
117
  /** Why the server must not start, if anything. An image that was never labelled is not one. */
package/dist/store.d.ts CHANGED
@@ -25,6 +25,10 @@ export interface ChatStore {
25
25
  maxOwnerRuns: number;
26
26
  /** Why a run is refused when the owner has that many going. */
27
27
  ownerBusy: string;
28
+ /** Before a run takes its slot: throw an HttpError to refuse it (e.g. storage over quota). */
29
+ admit?(t: ChatThread): Promise<void>;
30
+ /** After a run ended or the thread was deleted: the owner's data changed. */
31
+ changed?(t: ChatThread, reason: 'run-end' | 'changed'): void;
28
32
  }
29
33
  /** Where one user's things are. */
30
34
  export declare function userDir(user: string): string;
package/dist/store.js CHANGED
@@ -28,6 +28,7 @@ import { settings } from "./config.js";
28
28
  import { HttpError } from "./http.js";
29
29
  import { identify } from "./identity.js";
30
30
  import { applyProjectEnv } from "./projectEnv.js";
31
+ import { userStates } from "./userState.js";
31
32
  /** Where one user's things are. */
32
33
  export function userDir(user) {
33
34
  return join(settings().root, 'users', user);
@@ -112,6 +113,8 @@ export const privateStore = {
112
113
  return settings().maxUserRuns;
113
114
  },
114
115
  ownerBusy: 'your other chats are still answering',
116
+ admit: (t) => userStates.admit(t.owner),
117
+ changed: (t, reason) => userStates.changed(t.owner, reason),
115
118
  };
116
119
  export function usesMemory() {
117
120
  try {
@@ -153,7 +156,7 @@ function sandboxFactory(s) {
153
156
  ...(existsSync(AGENT_APP_DIR) ? [AGENT_APP_DIR] : []),
154
157
  ...s.hidden,
155
158
  ];
156
- return (opts) => new BwrapSandbox(opts, { hidden });
159
+ return (opts) => new BwrapSandbox(opts, { hidden, limits: s.commandLimits });
157
160
  }
158
161
  export async function openEngineFor(session, memoryDir,
159
162
  // Who the run acts for; null is nobody, and stops the engine reading ZEN_USER_* from this process.
@@ -0,0 +1,99 @@
1
+ import { Router, type NextFunction, type Request, type Response } from 'express';
2
+ /** Where things are in a user's folder, for stores that cannot import this package. */
3
+ export declare const USER_LAYOUT: {
4
+ /** the user's memory graph; checkpoints carry a consistent copy instead (`memoryDir`) */
5
+ readonly memory: "memory";
6
+ /** one folder per chat thread */
7
+ readonly sessions: "sessions";
8
+ /** inside a session folder: its metadata, which the session list reads */
9
+ readonly sessionMeta: ".data/session.json";
10
+ /** the server's own record of the user's lease; never a store's to save */
11
+ readonly marker: ".zen-user.json";
12
+ };
13
+ export interface UserRef {
14
+ /** the user's folder name under `<root>/users/` */
15
+ id: string;
16
+ /** that folder: everything of theirs on this server */
17
+ dir: string;
18
+ /** the host's lease generation (the token's `gen`); undefined when the host does not lease */
19
+ generation?: number;
20
+ }
21
+ export type CheckpointReason = 'run-end' | 'changed' | 'periodic' | 'release' | 'shutdown' | 'recover';
22
+ export interface UserSnapshot {
23
+ /** a consistent copy of the user's memory made for this call, removed after it; none: no memory */
24
+ memoryDir?: string;
25
+ /** threads with a run going: their files may change while they are read */
26
+ activeThreads: string[];
27
+ }
28
+ export interface UserStateStore {
29
+ readonly name: string;
30
+ /** true: the store keeps users elsewhere, so a released user's local copy is deleted */
31
+ readonly durable: boolean;
32
+ /** Before the user's first request here: fetch what they need into `user.dir`. */
33
+ load(user: UserRef): Promise<void>;
34
+ /** Before a request names one thread: fetch it if `load` left it for later. */
35
+ loadSession?(user: UserRef, threadId: string): Promise<void>;
36
+ /** Save the user's folder as it is now (memory from `snapshot.memoryDir`). */
37
+ checkpoint(user: UserRef, reason: CheckpointReason, snapshot: UserSnapshot): Promise<void>;
38
+ /** After the final checkpoint, before the local copy goes. */
39
+ release?(user: UserRef): Promise<void>;
40
+ /** On start, for users whose folders survived a restart of this process. */
41
+ recover?(users: UserRef[]): Promise<void>;
42
+ close?(): Promise<void>;
43
+ }
44
+ /** Thrown by a store when a newer lease generation owns the user: this copy must go. */
45
+ export declare class StaleGenerationError extends Error {
46
+ readonly code = "stale_generation";
47
+ }
48
+ export declare const localStore: UserStateStore;
49
+ type Status = 'loading' | 'ready' | 'releasing';
50
+ export interface UserInfo {
51
+ id: string;
52
+ generation?: number;
53
+ status: Status;
54
+ activeRuns: number;
55
+ unsaved: boolean;
56
+ lastUsed: string;
57
+ lastCheckpointAt?: string;
58
+ lastError?: string;
59
+ usageBytes?: number;
60
+ }
61
+ declare class UserStates {
62
+ #private;
63
+ get draining(): boolean;
64
+ get size(): number;
65
+ /** The user's data, ready; their folder is theirs to use once this resolves. */
66
+ acquire(id: string, generation?: number): Promise<void>;
67
+ /** Before a request names a thread: the store fetches it if it left it for later. */
68
+ session(id: string, threadId: string): Promise<void>;
69
+ /** Something of the user's changed (a run ended, a thread went): save it. */
70
+ changed(id: string, reason?: CheckpointReason): void;
71
+ /** Saves the user now, after any save already queued; one at a time per user. */
72
+ checkpoint(id: string, reason: CheckpointReason): Promise<void>;
73
+ /** Saves the user for the last time and lets them go; refused while their runs go on. */
74
+ release(id: string): Promise<'released' | 'absent'>;
75
+ /** No new users; each one here released once their runs end. */
76
+ drain(timeoutMs: number): Promise<{
77
+ released: string[];
78
+ failed: Record<string, string>;
79
+ }>;
80
+ /** Takes users again: a drain that was called off. */
81
+ undrain(): void;
82
+ list(withUsage?: boolean): Promise<UserInfo[]>;
83
+ /** Over the soft quota: refuse new runs (checked before one starts). */
84
+ admit(id: string): Promise<void>;
85
+ /** Recovers users whose folders survived a restart, and starts the timers. */
86
+ start(): Promise<void>;
87
+ /** After the runs stopped: everyone saved once more, as far as the time allows. */
88
+ shutdown(timeoutMs: number): Promise<void>;
89
+ }
90
+ export declare const userStates: UserStates;
91
+ /** Every user-scoped route: the caller's data loaded first, and their thread when one is named. */
92
+ export declare function loadCaller(req: Request, _res: Response, next: NextFunction): Promise<void>;
93
+ export declare function loadThread(req: Request, _res: Response, next: NextFunction): Promise<void>;
94
+ /** /api/admin: only for the host's own calls (its system subject, never a user's thread token). */
95
+ export declare function userStateRouter(): Router;
96
+ /** Bytes under a user's folder, links not followed. */
97
+ export declare function userUsageBytes(id: string): Promise<number>;
98
+ export {};
99
+ //# sourceMappingURL=userState.d.ts.map
@@ -0,0 +1,461 @@
1
+ // Each user's data on a server that may be one of many: loaded before their first request, saved
2
+ // at safe points, released when the host moves them elsewhere. The server decides *when* (it
3
+ // alone knows when runs end and how to copy a live memory graph); a UserStateStore plugin decides
4
+ // *where* (ZEN_STATE_STORE, e.g. ZeneraLite's S3 store). Without one, the local store keeps
5
+ // everything on this disk, as a single server always has.
6
+ // UserStateStore, UserRef, UserSnapshot, USER_LAYOUT - the plugin contract
7
+ // StaleGenerationError - what a store throws when a newer lease owns the user
8
+ // localStore - the default: nothing to load or save
9
+ // userStates - the server's manager: acquire (middleware), session, changed,
10
+ // release, drain, list, start (recovery, timers), shutdown
11
+ // userStateRouter - /api/admin: the host's view of this server's users (system token only)
12
+ // userUsageBytes - a user's local disk use, for the soft quota
13
+ // Used by server.ts (middleware, admin routes, listen/stop), store.ts (privateStore hooks) and
14
+ // runs.ts (quota at run start).
15
+ import { copyMemory, isMemory } from '@zenera/neo';
16
+ import { Router } from 'express';
17
+ import { randomBytes } from 'node:crypto';
18
+ import { existsSync } from 'node:fs';
19
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { settings } from "./config.js";
22
+ import { HttpError } from "./http.js";
23
+ import { caller, userFolder } from "./identity.js";
24
+ import { ownerRuns, stopOwnerRuns } from "./runs.js";
25
+ import { memoryDir, userDir } from "./store.js";
26
+ /** Where things are in a user's folder, for stores that cannot import this package. */
27
+ export const USER_LAYOUT = {
28
+ /** the user's memory graph; checkpoints carry a consistent copy instead (`memoryDir`) */
29
+ memory: 'memory',
30
+ /** one folder per chat thread */
31
+ sessions: 'sessions',
32
+ /** inside a session folder: its metadata, which the session list reads */
33
+ sessionMeta: '.data/session.json',
34
+ /** the server's own record of the user's lease; never a store's to save */
35
+ marker: '.zen-user.json',
36
+ };
37
+ /** Thrown by a store when a newer lease generation owns the user: this copy must go. */
38
+ export class StaleGenerationError extends Error {
39
+ code = 'stale_generation';
40
+ }
41
+ export const localStore = {
42
+ name: 'local',
43
+ durable: false,
44
+ load: async () => undefined,
45
+ checkpoint: async () => undefined,
46
+ };
47
+ function store() {
48
+ return settings().stateStore ?? localStore;
49
+ }
50
+ function generationOf(req) {
51
+ const gen = caller(req).gen;
52
+ if (gen === undefined)
53
+ return undefined;
54
+ if (!Number.isSafeInteger(gen) || gen < 0) {
55
+ throw new HttpError(401, 'the token names no valid lease generation (gen)');
56
+ }
57
+ return gen;
58
+ }
59
+ function refOf(entry) {
60
+ return { id: entry.id, dir: userDir(entry.id), generation: entry.generation };
61
+ }
62
+ const markerOf = (id) => join(userDir(id), USER_LAYOUT.marker);
63
+ async function writeMarker(entry) {
64
+ // Only a durable store recovers users after a restart; local users need no record, and
65
+ // listing sessions must not create a user's folder.
66
+ if (!store().durable)
67
+ return;
68
+ await mkdir(userDir(entry.id), { recursive: true });
69
+ await writeFile(markerOf(entry.id), `${JSON.stringify({ generation: entry.generation, store: store().name, loadedAt: new Date().toISOString(), lastCheckpointAt: entry.lastCheckpointAt }, null, 2)}\n`);
70
+ }
71
+ class UserStates {
72
+ #users = new Map();
73
+ #draining = false;
74
+ #timer;
75
+ #quotaTimer;
76
+ #usage = new Map();
77
+ get draining() {
78
+ return this.#draining;
79
+ }
80
+ get size() {
81
+ return this.#users.size;
82
+ }
83
+ /** The user's data, ready; their folder is theirs to use once this resolves. */
84
+ async acquire(id, generation) {
85
+ let entry = this.#users.get(id);
86
+ if (entry && generation !== undefined && entry.generation !== undefined) {
87
+ if (generation < entry.generation) {
88
+ throw Object.assign(new HttpError(409, 'this lease is no longer current: the user moved'), { code: 'stale_lease' });
89
+ }
90
+ if (generation > entry.generation) {
91
+ // Leased elsewhere and back: what is here may be older than what was saved there.
92
+ if (ownerRuns(id).length) {
93
+ throw new HttpError(409, 'the user moved while a run is still going here');
94
+ }
95
+ await this.#forget(entry, 'a newer lease');
96
+ entry = undefined;
97
+ }
98
+ }
99
+ if (!entry) {
100
+ if (this.#draining)
101
+ throw new HttpError(503, 'this server is draining');
102
+ entry = this.#load(id, generation);
103
+ }
104
+ if (entry.status === 'releasing')
105
+ throw new HttpError(503, 'the user is being released');
106
+ entry.lastUsed = Date.now();
107
+ await entry.loaded;
108
+ }
109
+ #load(id, generation) {
110
+ const entry = {
111
+ id,
112
+ generation,
113
+ status: 'loading',
114
+ loaded: Promise.resolve(),
115
+ sessions: new Map(),
116
+ changes: 0,
117
+ saved: 0,
118
+ queue: Promise.resolve(),
119
+ lastUsed: Date.now(),
120
+ };
121
+ this.#users.set(id, entry);
122
+ const s = store();
123
+ entry.loaded = (async () => {
124
+ // A durable store's user is whatever it holds: never mix that with a stale local copy.
125
+ if (s.durable)
126
+ await rm(userDir(id), { recursive: true, force: true });
127
+ await s.load(refOf(entry));
128
+ entry.status = 'ready';
129
+ await writeMarker(entry);
130
+ })().catch((err) => {
131
+ this.#users.delete(id);
132
+ throw new HttpError(503, `could not load the user's data: ${messageOf(err)}`);
133
+ });
134
+ return entry;
135
+ }
136
+ /** Before a request names a thread: the store fetches it if it left it for later. */
137
+ async session(id, threadId) {
138
+ const load = store().loadSession;
139
+ const entry = this.#users.get(id);
140
+ if (!load || !entry)
141
+ return;
142
+ let pending = entry.sessions.get(threadId);
143
+ if (!pending) {
144
+ pending = load.call(store(), refOf(entry), threadId).catch((err) => {
145
+ entry.sessions.delete(threadId);
146
+ throw new HttpError(503, `could not load the chat: ${messageOf(err)}`);
147
+ });
148
+ entry.sessions.set(threadId, pending);
149
+ }
150
+ await pending;
151
+ }
152
+ /** Something of the user's changed (a run ended, a thread went): save it. */
153
+ changed(id, reason = 'changed') {
154
+ this.#usage.delete(id);
155
+ const entry = this.#users.get(id);
156
+ if (!entry)
157
+ return;
158
+ entry.changes++;
159
+ void this.checkpoint(id, reason).catch(() => undefined);
160
+ }
161
+ /** Saves the user now, after any save already queued; one at a time per user. */
162
+ checkpoint(id, reason) {
163
+ const entry = this.#users.get(id);
164
+ if (!entry || !store().durable)
165
+ return Promise.resolve();
166
+ const run = entry.queue.then(() => this.#save(entry, reason));
167
+ entry.queue = run.catch(() => undefined);
168
+ return run;
169
+ }
170
+ async #save(entry, reason) {
171
+ if (this.#users.get(entry.id) !== entry)
172
+ return;
173
+ const upTo = entry.changes;
174
+ const tmp = join(settings().root, '.checkpoints', `${entry.id}-${randomBytes(4).toString('hex')}`);
175
+ try {
176
+ const memory = memoryDir(entry.id);
177
+ let snapshotDir;
178
+ if (isMemory(memory)) {
179
+ await mkdir(join(settings().root, '.checkpoints'), { recursive: true });
180
+ await copyMemory(memory, tmp);
181
+ snapshotDir = tmp;
182
+ }
183
+ await store().checkpoint(refOf(entry), reason, {
184
+ memoryDir: snapshotDir,
185
+ activeThreads: ownerRuns(entry.id).map((r) => r.threadId),
186
+ });
187
+ entry.saved = Math.max(entry.saved, upTo);
188
+ entry.lastCheckpointAt = new Date().toISOString();
189
+ entry.lastError = undefined;
190
+ await writeMarker(entry);
191
+ }
192
+ catch (err) {
193
+ if (err instanceof StaleGenerationError ||
194
+ err?.code === 'stale_generation') {
195
+ console.warn(`user ${entry.id}: ${messageOf(err)}; dropping this copy`);
196
+ await this.#forget(entry, 'a newer lease', true);
197
+ return;
198
+ }
199
+ entry.lastError = messageOf(err);
200
+ console.error(`user ${entry.id}: checkpoint (${reason}) failed: ${entry.lastError}`);
201
+ throw err;
202
+ }
203
+ finally {
204
+ await rm(tmp, { recursive: true, force: true });
205
+ }
206
+ }
207
+ /** Drops the user here: their runs stopped, their local copy gone when the store keeps them. */
208
+ async #forget(entry, why, stopRuns = false) {
209
+ this.#users.delete(entry.id);
210
+ if (stopRuns)
211
+ await stopOwnerRuns(entry.id, `the run was stopped: ${why} owns this user`);
212
+ if (store().durable)
213
+ await rm(userDir(entry.id), { recursive: true, force: true });
214
+ this.#usage.delete(entry.id);
215
+ }
216
+ /** Saves the user for the last time and lets them go; refused while their runs go on. */
217
+ async release(id) {
218
+ const entry = this.#users.get(id);
219
+ if (!entry)
220
+ return 'absent';
221
+ await entry.loaded.catch(() => undefined);
222
+ if (!this.#users.has(id))
223
+ return 'absent';
224
+ if (ownerRuns(id).length) {
225
+ throw Object.assign(new HttpError(409, 'the user has runs going'), { code: 'busy' });
226
+ }
227
+ entry.status = 'releasing';
228
+ try {
229
+ await this.checkpoint(id, 'release');
230
+ await store().release?.(refOf(entry));
231
+ }
232
+ catch (err) {
233
+ entry.status = 'ready';
234
+ throw new HttpError(503, `could not save the user: ${messageOf(err)}`);
235
+ }
236
+ if (this.#users.get(id) === entry)
237
+ await this.#forget(entry, 'release');
238
+ return 'released';
239
+ }
240
+ /** No new users; each one here released once their runs end. */
241
+ async drain(timeoutMs) {
242
+ this.#draining = true;
243
+ const released = [];
244
+ const failed = {};
245
+ const deadline = Date.now() + timeoutMs;
246
+ await Promise.all([...this.#users.keys()].map(async (id) => {
247
+ const runs = ownerRuns(id).map((r) => r.done);
248
+ const left = Math.max(0, deadline - Date.now());
249
+ await Promise.race([
250
+ Promise.all(runs),
251
+ new Promise((resolve) => setTimeout(resolve, left).unref()),
252
+ ]);
253
+ try {
254
+ if ((await this.release(id)) === 'released')
255
+ released.push(id);
256
+ }
257
+ catch (err) {
258
+ failed[id] = messageOf(err);
259
+ }
260
+ }));
261
+ return { released, failed };
262
+ }
263
+ /** Takes users again: a drain that was called off. */
264
+ undrain() {
265
+ this.#draining = false;
266
+ }
267
+ async list(withUsage = false) {
268
+ return Promise.all([...this.#users.values()].map(async (e) => ({
269
+ id: e.id,
270
+ generation: e.generation,
271
+ status: e.status,
272
+ activeRuns: ownerRuns(e.id).length,
273
+ unsaved: store().durable && e.changes > e.saved,
274
+ lastUsed: new Date(e.lastUsed).toISOString(),
275
+ ...(e.lastCheckpointAt ? { lastCheckpointAt: e.lastCheckpointAt } : {}),
276
+ ...(e.lastError ? { lastError: e.lastError } : {}),
277
+ ...(withUsage ? { usageBytes: await userUsageBytes(e.id) } : {}),
278
+ })));
279
+ }
280
+ /** Over the soft quota: refuse new runs (checked before one starts). */
281
+ async admit(id) {
282
+ const quota = settings().userDiskQuotaBytes;
283
+ if (!quota)
284
+ return;
285
+ const used = await this.#cachedUsage(id);
286
+ if (used >= quota) {
287
+ throw new HttpError(507, `your storage is full: ${mb(used)} of ${mb(quota)} MB; delete chats or files to continue`);
288
+ }
289
+ }
290
+ async #cachedUsage(id, maxAgeMs = 15_000) {
291
+ const hit = this.#usage.get(id);
292
+ if (hit && Date.now() - hit.at < maxAgeMs)
293
+ return hit.bytes;
294
+ const bytes = await userUsageBytes(id);
295
+ this.#usage.set(id, { bytes, at: Date.now() });
296
+ return bytes;
297
+ }
298
+ /** Recovers users whose folders survived a restart, and starts the timers. */
299
+ async start() {
300
+ this.#draining = false;
301
+ const s = store();
302
+ const recovered = [];
303
+ const users = join(settings().root, 'users');
304
+ if (s.durable && existsSync(users)) {
305
+ for (const id of await readdir(users)) {
306
+ let marker;
307
+ try {
308
+ marker = JSON.parse(await readFile(markerOf(id), 'utf8'));
309
+ }
310
+ catch {
311
+ continue;
312
+ }
313
+ const entry = {
314
+ id,
315
+ generation: marker.generation,
316
+ status: 'ready',
317
+ loaded: Promise.resolve(),
318
+ sessions: new Map(),
319
+ // Whatever happened before the restart may not be saved yet.
320
+ changes: 1,
321
+ saved: 0,
322
+ queue: Promise.resolve(),
323
+ lastUsed: Date.now(),
324
+ };
325
+ this.#users.set(id, entry);
326
+ recovered.push(entry);
327
+ }
328
+ }
329
+ if (recovered.length) {
330
+ // A store that cannot recover still lets the server start; its saves will say why.
331
+ await s.recover?.(recovered.map(refOf)).catch((err) => {
332
+ console.error(`recovering ${recovered.length} users: ${messageOf(err)}`);
333
+ });
334
+ for (const entry of recovered)
335
+ void this.checkpoint(entry.id, 'recover').catch(() => undefined);
336
+ }
337
+ const every = settings().checkpointSeconds * 1000;
338
+ if (s.durable && every > 0) {
339
+ this.#timer = setInterval(() => this.#periodic(), every);
340
+ this.#timer.unref();
341
+ }
342
+ if (settings().userDiskQuotaBytes) {
343
+ this.#quotaTimer = setInterval(() => void this.#enforceQuota(), 30_000);
344
+ this.#quotaTimer.unref();
345
+ }
346
+ }
347
+ // Users with runs going (their files keep changing) or a save that failed are saved again.
348
+ #periodic() {
349
+ for (const entry of this.#users.values()) {
350
+ if (entry.status !== 'ready')
351
+ continue;
352
+ if (ownerRuns(entry.id).length)
353
+ entry.changes++;
354
+ if (entry.changes > entry.saved) {
355
+ void this.checkpoint(entry.id, 'periodic').catch(() => undefined);
356
+ }
357
+ }
358
+ }
359
+ // A run that pushes its user far over the quota is stopped, before the pod's disk fills.
360
+ async #enforceQuota() {
361
+ const quota = settings().userDiskQuotaBytes;
362
+ for (const id of this.#users.keys()) {
363
+ if (!ownerRuns(id).length)
364
+ continue;
365
+ const used = await this.#cachedUsage(id, 0).catch(() => 0);
366
+ if (used > quota) {
367
+ await stopOwnerRuns(id, `the run was stopped: your storage is over its limit (${mb(used)} of ${mb(quota)} MB)`);
368
+ }
369
+ }
370
+ }
371
+ /** After the runs stopped: everyone saved once more, as far as the time allows. */
372
+ async shutdown(timeoutMs) {
373
+ clearInterval(this.#timer);
374
+ clearInterval(this.#quotaTimer);
375
+ const all = [...this.#users.keys()].map((id) => {
376
+ this.changed(id, 'shutdown');
377
+ return this.#users.get(id)?.queue ?? Promise.resolve();
378
+ });
379
+ await Promise.race([
380
+ Promise.all(all),
381
+ new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()),
382
+ ]);
383
+ await store().close?.();
384
+ this.#users.clear();
385
+ this.#usage.clear();
386
+ }
387
+ }
388
+ export const userStates = new UserStates();
389
+ /** Every user-scoped route: the caller's data loaded first, and their thread when one is named. */
390
+ export async function loadCaller(req, _res, next) {
391
+ const id = userFolder(caller(req).sub);
392
+ await userStates.acquire(id, generationOf(req));
393
+ next();
394
+ }
395
+ export async function loadThread(req, _res, next) {
396
+ const tid = String(req.params.tid ?? '');
397
+ if (tid)
398
+ await userStates.session(userFolder(caller(req).sub), tid);
399
+ next();
400
+ }
401
+ /** /api/admin: only for the host's own calls (its system subject, never a user's thread token). */
402
+ export function userStateRouter() {
403
+ const router = Router();
404
+ router.use((req, _res, next) => {
405
+ const claims = caller(req);
406
+ if (claims.sub !== settings().systemSubject || claims.thr !== undefined) {
407
+ throw new HttpError(403, 'for the host only');
408
+ }
409
+ next();
410
+ });
411
+ router.get('/users', async (req, res) => {
412
+ res.setHeader('Cache-Control', 'no-store');
413
+ res.json({
414
+ store: store().name,
415
+ durable: store().durable,
416
+ draining: userStates.draining,
417
+ users: await userStates.list(req.query.usage === '1'),
418
+ });
419
+ });
420
+ router.post('/users/:sub/release', async (req, res) => {
421
+ const result = await userStates.release(userFolder(String(req.params.sub)));
422
+ res.json({ result });
423
+ });
424
+ router.post('/drain', async (req, res) => {
425
+ const seconds = Number(req.query.timeout ?? 600);
426
+ const timeout = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 600_000;
427
+ res.json(await userStates.drain(timeout));
428
+ });
429
+ router.post('/undrain', (_req, res) => {
430
+ userStates.undrain();
431
+ res.json({ draining: false });
432
+ });
433
+ return router;
434
+ }
435
+ /** Bytes under a user's folder, links not followed. */
436
+ export async function userUsageBytes(id) {
437
+ let total = 0;
438
+ const walk = async (dir) => {
439
+ let entries;
440
+ try {
441
+ entries = await readdir(dir, { withFileTypes: true });
442
+ }
443
+ catch {
444
+ return;
445
+ }
446
+ for (const entry of entries) {
447
+ const path = join(dir, entry.name);
448
+ if (entry.isDirectory())
449
+ await walk(path);
450
+ else if (entry.isFile())
451
+ total += (await lstat(path).catch(() => ({ size: 0 }))).size;
452
+ }
453
+ };
454
+ await walk(userDir(id));
455
+ return total;
456
+ }
457
+ const mb = (bytes) => Math.round(bytes / 2 ** 20);
458
+ function messageOf(err) {
459
+ return err instanceof Error ? err.message : String(err);
460
+ }
461
+ //# sourceMappingURL=userState.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/server",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "One user's chats with a project over HTTP: the server in the agent image, commands isolated with bubblewrap. Every user its host vouches for with a signed JWT, each in a folder of their own.",
5
5
  "keywords": [
6
6
  "agents",
@@ -45,9 +45,9 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@ag-ui/core": "^1.0.3",
48
- "@zenera/engine": "^1.2.1",
49
- "@zenera/neo": "^1.2.1",
50
- "@zenera/sandbox": "^1.2.1",
48
+ "@zenera/engine": "^1.2.2",
49
+ "@zenera/neo": "^1.2.2",
50
+ "@zenera/sandbox": "^1.2.2",
51
51
  "express": "^5.3.0",
52
52
  "jose": "^6.2.13"
53
53
  },