@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 +66 -5
- package/dist/auth.d.ts +2 -0
- package/dist/config.d.ts +15 -1
- package/dist/config.js +4 -0
- package/dist/http.js +5 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/plugins.d.ts +19 -0
- package/dist/plugins.js +47 -0
- package/dist/runs.d.ts +8 -0
- package/dist/runs.js +17 -0
- package/dist/server.d.ts +5 -2
- package/dist/server.js +21 -9
- package/dist/status.d.ts +7 -0
- package/dist/status.js +7 -0
- package/dist/store.d.ts +4 -0
- package/dist/store.js +4 -1
- package/dist/userState.d.ts +99 -0
- package/dist/userState.js +461 -0
- package/package.json +4 -4
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
|
-
|
|
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
|
|
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
|
|
46
|
+
To show full image build logs:
|
|
47
47
|
|
|
48
48
|
```sh
|
|
49
|
-
zen server start --
|
|
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
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
-
//
|
|
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' }) =>
|
|
83
|
+
listen: async ({ port, host = '127.0.0.1' }) => {
|
|
76
84
|
if (settings.auth.mode === 'insecure' && !LOOPBACK.has(host) && !insecureHere()) {
|
|
77
|
-
|
|
78
|
-
return;
|
|
85
|
+
throw new Error(`an insecure server listens on loopback only, not ${host}`);
|
|
79
86
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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.
|
|
49
|
-
"@zenera/neo": "^1.2.
|
|
50
|
-
"@zenera/sandbox": "^1.2.
|
|
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
|
},
|