@zenera/server 1.2.0 → 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 +204 -231
- 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/dist/state.d.ts +0 -24
- package/dist/state.js +0 -55
- package/dist/token.d.ts +0 -14
- package/dist/token.js +0 -30
|
@@ -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
|
},
|
package/dist/state.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** 1: data/{sessions,runs,memory}, one user's. */
|
|
2
|
-
export declare const STATE_VERSION = 1;
|
|
3
|
-
export interface StateInfo {
|
|
4
|
-
version: number;
|
|
5
|
-
createdAt: string;
|
|
6
|
-
/** whose state it is */
|
|
7
|
-
userId: string;
|
|
8
|
-
projectId: string;
|
|
9
|
-
}
|
|
10
|
-
export declare class StateError extends Error {
|
|
11
|
-
name: string;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* The state directory's description, checked against who opens it: the same project and the
|
|
15
|
-
* same user. `create: false` only reads (a check must not claim a directory): a missing file
|
|
16
|
-
* is then `undefined`.
|
|
17
|
-
*/
|
|
18
|
-
export declare function openState(dir: string, owner: {
|
|
19
|
-
userId: string;
|
|
20
|
-
projectId: string;
|
|
21
|
-
}, opts?: {
|
|
22
|
-
create?: boolean;
|
|
23
|
-
}): StateInfo | undefined;
|
|
24
|
-
//# sourceMappingURL=state.d.ts.map
|
package/dist/state.js
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// The state directory's own description: which layout it is in, so a server never reads (or
|
|
2
|
-
// writes over) a layout it does not know. Written on first start; a newer one is refused.
|
|
3
|
-
// STATE_VERSION - the layout this server writes
|
|
4
|
-
// openState - create or check <state>/state.json, answering what it says
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
/** 1: data/{sessions,runs,memory}, one user's. */
|
|
8
|
-
export const STATE_VERSION = 1;
|
|
9
|
-
export class StateError extends Error {
|
|
10
|
-
name = 'StateError';
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* The state directory's description, checked against who opens it: the same project and the
|
|
14
|
-
* same user. `create: false` only reads (a check must not claim a directory): a missing file
|
|
15
|
-
* is then `undefined`.
|
|
16
|
-
*/
|
|
17
|
-
export function openState(dir, owner, opts = {}) {
|
|
18
|
-
const file = join(dir, 'state.json');
|
|
19
|
-
if (!existsSync(file)) {
|
|
20
|
-
if (opts.create === false)
|
|
21
|
-
return undefined;
|
|
22
|
-
// The user's chats: nobody else on the machine reads them.
|
|
23
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
-
const info = {
|
|
25
|
-
version: STATE_VERSION,
|
|
26
|
-
createdAt: new Date().toISOString(),
|
|
27
|
-
userId: owner.userId,
|
|
28
|
-
projectId: owner.projectId,
|
|
29
|
-
};
|
|
30
|
-
writeFileSync(`${file}.tmp`, `${JSON.stringify(info, null, 2)}\n`);
|
|
31
|
-
renameSync(`${file}.tmp`, file);
|
|
32
|
-
return info;
|
|
33
|
-
}
|
|
34
|
-
let info;
|
|
35
|
-
try {
|
|
36
|
-
info = JSON.parse(readFileSync(file, 'utf8'));
|
|
37
|
-
}
|
|
38
|
-
catch (err) {
|
|
39
|
-
throw new StateError(`${file} is not readable: ${err instanceof Error ? err.message : err}`);
|
|
40
|
-
}
|
|
41
|
-
if (!Number.isInteger(info.version) || info.version < 1) {
|
|
42
|
-
throw new StateError(`${file} names no layout version`);
|
|
43
|
-
}
|
|
44
|
-
if (info.version > STATE_VERSION) {
|
|
45
|
-
throw new StateError(`${dir} is in layout ${info.version}, written by a newer server; this one knows ${STATE_VERSION}`);
|
|
46
|
-
}
|
|
47
|
-
if (info.projectId !== owner.projectId) {
|
|
48
|
-
throw new StateError(`${dir} belongs to project ${info.projectId}, not ${owner.projectId}`);
|
|
49
|
-
}
|
|
50
|
-
if (info.userId !== owner.userId) {
|
|
51
|
-
throw new StateError(`${dir} belongs to user ${info.userId}, not ${owner.userId}`);
|
|
52
|
-
}
|
|
53
|
-
return info;
|
|
54
|
-
}
|
|
55
|
-
//# sourceMappingURL=state.js.map
|
package/dist/token.d.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export declare const TOKEN_ENV = "ZEN_SERVER_TOKEN";
|
|
2
|
-
export declare const MIN_TOKEN_LENGTH = 32;
|
|
3
|
-
export interface ServerToken {
|
|
4
|
-
token: string;
|
|
5
|
-
/** where it came from: the environment, or the file */
|
|
6
|
-
from: 'env' | string;
|
|
7
|
-
}
|
|
8
|
-
/** Where a project's server for a user keeps its token. Both are single path segments here. */
|
|
9
|
-
export declare function tokenFile(project: string, user: string): string;
|
|
10
|
-
export declare function serverToken(file: string, opts?: {
|
|
11
|
-
create?: boolean;
|
|
12
|
-
env?: NodeJS.ProcessEnv;
|
|
13
|
-
}): ServerToken | undefined;
|
|
14
|
-
//# sourceMappingURL=token.d.ts.map
|
package/dist/token.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// The server's bearer token, when its host gave none: one per project and user, in the zen home
|
|
2
|
-
// (which no sandboxed command can see), readable by its owner only.
|
|
3
|
-
// tokenFile - <zen home>/servers/<project>/<user>.token
|
|
4
|
-
// serverToken - $ZEN_SERVER_TOKEN, else the file, else a new one written there (`create`)
|
|
5
|
-
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { home } from '@zenera/engine';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
export const TOKEN_ENV = 'ZEN_SERVER_TOKEN';
|
|
10
|
-
export const MIN_TOKEN_LENGTH = 32;
|
|
11
|
-
/** Where a project's server for a user keeps its token. Both are single path segments here. */
|
|
12
|
-
export function tokenFile(project, user) {
|
|
13
|
-
const segment = (s) => s.replace(/[^\w.@-]/g, '_');
|
|
14
|
-
return join(home(), 'servers', segment(project), `${segment(user)}.token`);
|
|
15
|
-
}
|
|
16
|
-
export function serverToken(file, opts = {}) {
|
|
17
|
-
const given = (opts.env ?? process.env)[TOKEN_ENV]?.trim();
|
|
18
|
-
if (given)
|
|
19
|
-
return { token: given, from: 'env' };
|
|
20
|
-
if (existsSync(file))
|
|
21
|
-
return { token: readFileSync(file, 'utf8').trim(), from: file };
|
|
22
|
-
if (!opts.create)
|
|
23
|
-
return undefined;
|
|
24
|
-
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
25
|
-
const token = randomBytes(32).toString('base64url');
|
|
26
|
-
writeFileSync(file, `${token}\n`, { mode: 0o600, flag: 'wx' });
|
|
27
|
-
chmodSync(file, 0o600);
|
|
28
|
-
return { token, from: file };
|
|
29
|
-
}
|
|
30
|
-
//# sourceMappingURL=token.js.map
|