@zenera/server 0.0.0-stage → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -2
- package/dist/agui.d.ts +29 -0
- package/dist/agui.js +333 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +79 -0
- package/dist/config.d.ts +71 -0
- package/dist/config.js +44 -0
- package/dist/history.d.ts +8 -0
- package/dist/history.js +73 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.js +26 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +55 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +20 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +47 -0
- package/dist/localFileCors.d.ts +10 -0
- package/dist/localFileCors.js +34 -0
- package/dist/memory.d.ts +81 -0
- package/dist/memory.js +471 -0
- package/dist/plugins.d.ts +5 -0
- package/dist/plugins.js +29 -0
- package/dist/projectEnv.d.ts +3 -0
- package/dist/projectEnv.js +37 -0
- package/dist/runLog.d.ts +27 -0
- package/dist/runLog.js +153 -0
- package/dist/runRecords.d.ts +38 -0
- package/dist/runRecords.js +154 -0
- package/dist/runs.d.ts +12 -0
- package/dist/runs.js +376 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +105 -0
- package/dist/sessions.d.ts +13 -0
- package/dist/sessions.js +61 -0
- package/dist/state.d.ts +24 -0
- package/dist/state.js +55 -0
- package/dist/status.d.ts +56 -0
- package/dist/status.js +127 -0
- package/dist/store.d.ts +52 -0
- package/dist/store.js +203 -0
- package/dist/token.d.ts +14 -0
- package/dist/token.js +30 -0
- package/dist/trace.d.ts +84 -0
- package/dist/trace.js +389 -0
- package/package.json +56 -5
package/dist/runs.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import * as engineLib from '@zenera/engine';
|
|
2
|
+
import { runEngine } from '@zenera/engine';
|
|
3
|
+
import { turns } from '@zenera/neo';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { AguiTranslator } from "./agui.js";
|
|
8
|
+
import { settings } from "./config.js";
|
|
9
|
+
import { HttpError } from "./http.js";
|
|
10
|
+
import { runUser } from "./identity.js";
|
|
11
|
+
import { readRunLog, RunLog, threadRunLogs } from "./runLog.js";
|
|
12
|
+
import { listRunRecords, recordedReport, recordOutcome, saveRecord, startRecord, } from "./runRecords.js";
|
|
13
|
+
import { asHttpError, errorMessage, openEngineFor, usesMemory, } from "./store.js";
|
|
14
|
+
const MAX_INPUT_CHARS = 100_000;
|
|
15
|
+
const ID = /^[\w-]{1,80}$/;
|
|
16
|
+
// Why a run was stopped: a cancel ends it as cancelled, the others as a RUN_ERROR saying why.
|
|
17
|
+
class RunStop extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// By thread id.
|
|
25
|
+
const runs = new Map();
|
|
26
|
+
// Set by stopAllRuns: a run started after it would be cut off mid-turn by the exit that follows.
|
|
27
|
+
let stopping = false;
|
|
28
|
+
export function activeRuns() {
|
|
29
|
+
return runs.size;
|
|
30
|
+
}
|
|
31
|
+
export function currentRun(t) {
|
|
32
|
+
const run = runs.get(t.id);
|
|
33
|
+
return run && run.owner === t.owner ? { runId: run.runId } : null;
|
|
34
|
+
}
|
|
35
|
+
// The newest user message of an AG-UI RunAgentInput; the thread's history is the runtime's own.
|
|
36
|
+
function inputOf(body) {
|
|
37
|
+
const messages = body?.messages;
|
|
38
|
+
const last = Array.isArray(messages) ? messages.at(-1) : undefined;
|
|
39
|
+
if (!last || last.role !== 'user') {
|
|
40
|
+
throw new HttpError(400, 'the last message must be the user message to answer');
|
|
41
|
+
}
|
|
42
|
+
const parts = typeof last.content === 'string' ? [{ type: 'text', text: last.content }] : last.content;
|
|
43
|
+
if (!Array.isArray(parts))
|
|
44
|
+
throw new HttpError(400, 'message content must be text');
|
|
45
|
+
const text = parts
|
|
46
|
+
.map((p) => {
|
|
47
|
+
const part = p;
|
|
48
|
+
if (part?.type !== 'text' || typeof part.text !== 'string') {
|
|
49
|
+
throw new HttpError(400, 'only text messages are supported');
|
|
50
|
+
}
|
|
51
|
+
return part.text;
|
|
52
|
+
})
|
|
53
|
+
.join('\n')
|
|
54
|
+
.trim();
|
|
55
|
+
if (!text)
|
|
56
|
+
throw new HttpError(400, 'the message is empty');
|
|
57
|
+
if (text.length > MAX_INPUT_CHARS) {
|
|
58
|
+
throw new HttpError(413, `the message is longer than ${MAX_INPUT_CHARS} characters`);
|
|
59
|
+
}
|
|
60
|
+
return { id: typeof last.id === 'string' && ID.test(last.id) ? last.id : randomUUID(), text };
|
|
61
|
+
}
|
|
62
|
+
// The answers zenera-app sends to resume a suspended run (AG-UI ResumeEntry), checked.
|
|
63
|
+
function resumeOf(body) {
|
|
64
|
+
const resume = body?.resume;
|
|
65
|
+
if (resume === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
if (!Array.isArray(resume) || !resume.length) {
|
|
68
|
+
throw new HttpError(400, 'resume must be a non-empty list of answers');
|
|
69
|
+
}
|
|
70
|
+
return resume.map((r) => {
|
|
71
|
+
const e = r;
|
|
72
|
+
if (typeof e?.interruptId !== 'string' ||
|
|
73
|
+
(e.status !== 'resolved' && e.status !== 'cancelled')) {
|
|
74
|
+
throw new HttpError(400, 'each resume entry needs an interruptId and a status');
|
|
75
|
+
}
|
|
76
|
+
return { interruptId: e.interruptId, status: e.status, payload: e.payload };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Resume entries name interrupts; the engine answers calls. A suspension's data carries the
|
|
80
|
+
// interrupt id zenera-app gave it. resolved without a payload: what was needed is done, run the
|
|
81
|
+
// call again; with one: the payload is the call's result.
|
|
82
|
+
function answersFor(state, entries) {
|
|
83
|
+
const suspended = state?.suspended ?? [];
|
|
84
|
+
const answers = {};
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
const s = suspended.find((x) => (x.data?.interruptId ?? x.callId) === e.interruptId);
|
|
87
|
+
if (!s)
|
|
88
|
+
continue;
|
|
89
|
+
const payload = e.payload;
|
|
90
|
+
answers[s.callId] =
|
|
91
|
+
e.status === 'cancelled'
|
|
92
|
+
? {
|
|
93
|
+
status: 'cancelled',
|
|
94
|
+
message: typeof payload?.message === 'string' ? payload.message : undefined,
|
|
95
|
+
}
|
|
96
|
+
: e.payload === undefined
|
|
97
|
+
? { status: 'resolved', rerun: true }
|
|
98
|
+
: { status: 'resolved', payload: e.payload };
|
|
99
|
+
}
|
|
100
|
+
return answers;
|
|
101
|
+
}
|
|
102
|
+
// The interrupt ids a thread's saved state waits on; none when it is not suspended.
|
|
103
|
+
function waitingOn(store, t) {
|
|
104
|
+
const session = store.existingSession(t);
|
|
105
|
+
if (!session || !existsSync(session.state))
|
|
106
|
+
return new Set();
|
|
107
|
+
try {
|
|
108
|
+
const state = JSON.parse(readFileSync(session.state, 'utf8'));
|
|
109
|
+
return new Set((state.suspended ?? []).map((s) => typeof s.data?.interruptId === 'string' ? s.data.interruptId : s.callId));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return new Set();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Read at call time: an older @zenera/engine without it still loads, and says so on resume.
|
|
116
|
+
function resumeEngineFn() {
|
|
117
|
+
const fn = engineLib.resumeEngine;
|
|
118
|
+
if (!fn)
|
|
119
|
+
throw new Error('this zen version cannot resume a suspended run; update zen');
|
|
120
|
+
return fn;
|
|
121
|
+
}
|
|
122
|
+
function startStream(res) {
|
|
123
|
+
res.status(200).set({
|
|
124
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
125
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
126
|
+
Connection: 'keep-alive',
|
|
127
|
+
'X-Accel-Buffering': 'no',
|
|
128
|
+
});
|
|
129
|
+
res.flushHeaders();
|
|
130
|
+
res.socket?.setNoDelay(true);
|
|
131
|
+
return (events) => {
|
|
132
|
+
for (const e of events) {
|
|
133
|
+
if (!res.writableEnded)
|
|
134
|
+
res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Follows a run to its end, or until the browser leaves; leaving does not stop the run.
|
|
139
|
+
function follow(res, log) {
|
|
140
|
+
const send = startStream(res);
|
|
141
|
+
const heartbeat = setInterval(() => {
|
|
142
|
+
if (!res.writableEnded)
|
|
143
|
+
res.write(': ping\n\n');
|
|
144
|
+
}, 15_000);
|
|
145
|
+
const unfollow = log.follow({
|
|
146
|
+
events: send,
|
|
147
|
+
end: () => {
|
|
148
|
+
clearInterval(heartbeat);
|
|
149
|
+
res.end();
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
res.on('close', () => {
|
|
153
|
+
clearInterval(heartbeat);
|
|
154
|
+
unfollow();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Never throws: whatever ends the run is written to its log as the run's last event.
|
|
158
|
+
async function execute(store, t, run, turn, memory, user, mcpToken) {
|
|
159
|
+
const { runId, log, controller } = run;
|
|
160
|
+
let engine;
|
|
161
|
+
const translator = new AguiTranslator({
|
|
162
|
+
threadId: t.id,
|
|
163
|
+
runId,
|
|
164
|
+
resolve: async (p) => (engine ? engine.runner.services.payloads.get(p) : ''),
|
|
165
|
+
});
|
|
166
|
+
const input = {
|
|
167
|
+
threadId: t.id,
|
|
168
|
+
runId,
|
|
169
|
+
messages: 'message' in turn
|
|
170
|
+
? [{ id: turn.message.id, role: 'user', content: turn.message.text }]
|
|
171
|
+
: [],
|
|
172
|
+
...('resume' in turn ? { resume: turn.resume } : {}),
|
|
173
|
+
tools: [],
|
|
174
|
+
context: [],
|
|
175
|
+
forwardedProps: {},
|
|
176
|
+
};
|
|
177
|
+
log.append(translator.started(input));
|
|
178
|
+
const maxMinutes = settings().maxRunMinutes;
|
|
179
|
+
const timer = setTimeout(() => controller.abort(new RunStop('timeout', `the run took longer than ${maxMinutes} minutes`)), maxMinutes * 60_000);
|
|
180
|
+
const stopped = () => controller.signal.reason instanceof RunStop ? controller.signal.reason : undefined;
|
|
181
|
+
// runEngine calls back synchronously; translating may await payloads, so keep the order here.
|
|
182
|
+
let chain = Promise.resolve();
|
|
183
|
+
const onEvent = (ev) => {
|
|
184
|
+
chain = chain
|
|
185
|
+
.then(async () => {
|
|
186
|
+
const why = stopped();
|
|
187
|
+
// Stopped for anything but a cancel: the run did not finish, say why instead.
|
|
188
|
+
if (ev.type === 'run_finished' && !ev.branch && why && why.code !== 'cancelled') {
|
|
189
|
+
log.append(translator.fail(why, why.code));
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
log.append(await translator.translate(ev));
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
.catch((err) => console.error(`run ${runId}: translating ${ev.type}:`, err));
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
engine = await openEngineFor(store.sessionFor(t), memory ? await store.engineMemoryDir(t) : undefined, user, mcpToken);
|
|
199
|
+
const before = {
|
|
200
|
+
usage: engine.state?.usage,
|
|
201
|
+
modelCalls: engine.state ? turns(engine.state) : 0,
|
|
202
|
+
trajectoryLength: engine.state?.trajectory.length ?? 0,
|
|
203
|
+
};
|
|
204
|
+
const outcome = 'message' in turn
|
|
205
|
+
? await runEngine(engine, turn.message.text, onEvent, controller.signal)
|
|
206
|
+
: await resumeEngineFn()(engine, answersFor(engine.state, turn.resume), onEvent, controller.signal);
|
|
207
|
+
recordOutcome(run.record, outcome, before);
|
|
208
|
+
const why = stopped();
|
|
209
|
+
if (why) {
|
|
210
|
+
run.record.status = why.code;
|
|
211
|
+
run.record.error = why.message;
|
|
212
|
+
}
|
|
213
|
+
saveRecord(store.runsDir(t), run.record);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
await chain;
|
|
217
|
+
const why = stopped();
|
|
218
|
+
if (!why)
|
|
219
|
+
console.error(`run ${runId}:`, err);
|
|
220
|
+
run.record.status = why?.code ?? 'error';
|
|
221
|
+
run.record.error = why?.message ?? errorMessage(err);
|
|
222
|
+
log.append(translator.fail(why ?? new Error(errorMessage(err)), why?.code ?? 'error'));
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
await chain;
|
|
227
|
+
log.append(translator.fail(new Error('the run ended without a result')));
|
|
228
|
+
await engine?.close().catch((err) => console.error(`run ${runId}: closing:`, err));
|
|
229
|
+
run.record.finishedAt = new Date().toISOString();
|
|
230
|
+
run.record.durationMs ??= Date.now() - Date.parse(run.record.startedAt);
|
|
231
|
+
try {
|
|
232
|
+
saveRecord(store.runsDir(t), run.record);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
console.error(`run ${runId}: saving diagnostics:`, err);
|
|
236
|
+
}
|
|
237
|
+
await log.end().catch((err) => console.error(`run ${runId}: saving its log:`, err));
|
|
238
|
+
runs.delete(t.id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Runs may start again: a new server in a process whose previous one stopped. */
|
|
242
|
+
export function acceptRuns() {
|
|
243
|
+
stopping = false;
|
|
244
|
+
}
|
|
245
|
+
export async function stopAllRuns(timeoutMs) {
|
|
246
|
+
stopping = true;
|
|
247
|
+
const all = [...runs.values()];
|
|
248
|
+
for (const run of all) {
|
|
249
|
+
run.controller.abort(new RunStop('interrupted', 'the run was interrupted: the server is stopping'));
|
|
250
|
+
}
|
|
251
|
+
await Promise.race([
|
|
252
|
+
Promise.all(all.map((run) => run.done)),
|
|
253
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
export function runsRouter(store) {
|
|
257
|
+
const router = Router();
|
|
258
|
+
router.post('/:tid/runs', async (req, res) => {
|
|
259
|
+
const t = store.thread(req);
|
|
260
|
+
const resume = resumeOf(req.body);
|
|
261
|
+
const turn = resume ? { resume } : { message: inputOf(req.body) };
|
|
262
|
+
const runId = ID.test(req.body?.runId ?? '') ? String(req.body.runId) : randomUUID();
|
|
263
|
+
if (stopping) {
|
|
264
|
+
res.set('Retry-After', '5');
|
|
265
|
+
throw new HttpError(503, 'the server is stopping, try again shortly');
|
|
266
|
+
}
|
|
267
|
+
const running = runs.get(t.id);
|
|
268
|
+
// Answers to what the chat no longer waits on (a new message went first): nothing to run.
|
|
269
|
+
if (!running && resume) {
|
|
270
|
+
const waiting = waitingOn(store, t);
|
|
271
|
+
if (!resume.some((e) => waiting.has(e.interruptId))) {
|
|
272
|
+
res.status(409).json({
|
|
273
|
+
error: 'this chat is not waiting on those interrupts',
|
|
274
|
+
code: 'not_waiting',
|
|
275
|
+
});
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (running) {
|
|
280
|
+
res.status(409).json({ error: 'this chat is still answering', runId: running.runId });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (runs.size >= settings().maxRuns) {
|
|
284
|
+
res.set('Retry-After', '10');
|
|
285
|
+
throw new HttpError(429, 'too many chats are answering in this project, try again shortly');
|
|
286
|
+
}
|
|
287
|
+
// Whether the run gets the owner's memory; it no longer limits how many run at once.
|
|
288
|
+
let memory;
|
|
289
|
+
try {
|
|
290
|
+
memory = usesMemory();
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
throw asHttpError(err);
|
|
294
|
+
}
|
|
295
|
+
const mine = [...runs.values()].filter((r) => r.owner === t.owner).length;
|
|
296
|
+
if (mine >= store.maxOwnerRuns)
|
|
297
|
+
throw new HttpError(409, store.ownerBusy);
|
|
298
|
+
// Nothing awaited from the checks above to here: the thread's slot is taken at once.
|
|
299
|
+
const dir = store.runsDir(t);
|
|
300
|
+
const record = startRecord(dir, runId);
|
|
301
|
+
const run = {
|
|
302
|
+
runId,
|
|
303
|
+
owner: t.owner,
|
|
304
|
+
controller: new AbortController(),
|
|
305
|
+
log: RunLog.open(dir, runId, settings().runLogMaxBytes),
|
|
306
|
+
done: Promise.resolve(),
|
|
307
|
+
record,
|
|
308
|
+
};
|
|
309
|
+
runs.set(t.id, run);
|
|
310
|
+
// The caller's token for this run's MCP tools (zenera-app's broker, mcp/broker.ts there);
|
|
311
|
+
// whatever stands in front of the server decides who may send one.
|
|
312
|
+
const mcpToken = req.get('x-zenera-mcp-token') || null;
|
|
313
|
+
run.done = execute(store, t, run, turn, memory, runUser(req), mcpToken);
|
|
314
|
+
follow(res, run.log);
|
|
315
|
+
});
|
|
316
|
+
router.get('/:tid/runs/:runId/events', async (req, res) => {
|
|
317
|
+
const t = store.thread(req);
|
|
318
|
+
const runId = String(req.params.runId);
|
|
319
|
+
const run = runs.get(t.id);
|
|
320
|
+
if (run && run.runId === runId && run.owner === t.owner) {
|
|
321
|
+
follow(res, run.log);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const ended = (await threadRunLogs(store.runsDir(t))).find((r) => r.runId === runId);
|
|
325
|
+
if (!ended)
|
|
326
|
+
throw new HttpError(404, 'no such run');
|
|
327
|
+
const events = await readRunLog(ended.file);
|
|
328
|
+
startStream(res)(events);
|
|
329
|
+
res.end();
|
|
330
|
+
});
|
|
331
|
+
router.post('/:tid/runs/:runId/cancel', (req, res) => {
|
|
332
|
+
const t = store.thread(req);
|
|
333
|
+
const run = runs.get(t.id);
|
|
334
|
+
if (!run || run.runId !== req.params.runId || run.owner !== t.owner) {
|
|
335
|
+
throw new HttpError(404, 'no such run');
|
|
336
|
+
}
|
|
337
|
+
run.controller.abort(new RunStop('cancelled', 'cancelled'));
|
|
338
|
+
res.status(202).json({ ok: true });
|
|
339
|
+
});
|
|
340
|
+
router.delete('/:tid', async (req, res) => {
|
|
341
|
+
const t = store.thread(req);
|
|
342
|
+
const run = runs.get(t.id);
|
|
343
|
+
if (run && run.owner === t.owner) {
|
|
344
|
+
run.controller.abort(new RunStop('cancelled', 'the chat was deleted'));
|
|
345
|
+
await run.done;
|
|
346
|
+
}
|
|
347
|
+
await store.removeThread(t);
|
|
348
|
+
res.status(204).end();
|
|
349
|
+
});
|
|
350
|
+
return router;
|
|
351
|
+
}
|
|
352
|
+
export function runDebugRouter(store) {
|
|
353
|
+
const router = Router();
|
|
354
|
+
const records = (t) => listRunRecords(store.runsDir(t), store.existingSession(t), currentRun(t)?.runId);
|
|
355
|
+
router.get('/:tid/runs', async (req, res) => {
|
|
356
|
+
const t = store.thread(req);
|
|
357
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
358
|
+
res.json({ runs: await records(t) });
|
|
359
|
+
});
|
|
360
|
+
router.get('/:tid/runs/:runId', async (req, res) => {
|
|
361
|
+
const t = store.thread(req);
|
|
362
|
+
const run = (await records(t)).find((r) => r.runId === req.params.runId);
|
|
363
|
+
if (!run)
|
|
364
|
+
throw new HttpError(404, 'no such run');
|
|
365
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
366
|
+
res.json({ run });
|
|
367
|
+
});
|
|
368
|
+
router.get('/:tid/runs/:runId/report', async (req, res) => {
|
|
369
|
+
const t = store.thread(req);
|
|
370
|
+
const html = await recordedReport(store.runsDir(t), store.existingSession(t), String(req.params.runId));
|
|
371
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
372
|
+
res.json({ html });
|
|
373
|
+
});
|
|
374
|
+
return router;
|
|
375
|
+
}
|
|
376
|
+
//# sourceMappingURL=runs.js.map
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Express } from 'express';
|
|
2
|
+
import type { Server } from 'node:http';
|
|
3
|
+
import { type ServerSettings } from './config.ts';
|
|
4
|
+
export type ServerOptions = Partial<ServerSettings> & Pick<ServerSettings, 'projectId' | 'auth' | 'root'> & {
|
|
5
|
+
/** Absolute path to the CLI's compiled page; served publicly only in insecure mode. */
|
|
6
|
+
chatPage?: string;
|
|
7
|
+
};
|
|
8
|
+
/** TCP; loopback unless a host is named. */
|
|
9
|
+
export interface ListenOn {
|
|
10
|
+
port: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentServer {
|
|
14
|
+
app: Express;
|
|
15
|
+
settings: ServerSettings;
|
|
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>;
|
|
19
|
+
}
|
|
20
|
+
export declare function createServer(opts: ServerOptions): AgentServer;
|
|
21
|
+
/** The env `zen server start --container` sets on the container it runs, port on loopback. */
|
|
22
|
+
export declare const INSECURE_LOCAL_ENV = "ZEN_SERVER_INSECURE_LOCAL";
|
|
23
|
+
/**
|
|
24
|
+
* Inside the agent image an insecure server listens on 0.0.0.0 for the port to be published; only
|
|
25
|
+
* the host that runs it on this machine, its port on loopback, says that is all right.
|
|
26
|
+
*/
|
|
27
|
+
export declare function insecureHere(env?: NodeJS.ProcessEnv): boolean;
|
|
28
|
+
//# sourceMappingURL=server.d.ts.map
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// HTTP runtime assembled for `zen server start`, directly or inside the agent image.
|
|
2
|
+
// createServer configures Express and its routes, returning listen/stop lifecycle methods;
|
|
3
|
+
// insecureHere permits the CLI's loopback-published local test container.
|
|
4
|
+
// Every API request carries a JWT whose `sub` selects the user's private store (store.ts).
|
|
5
|
+
// Secure mode verifies a host signature bound to that request; insecure mode only decodes it.
|
|
6
|
+
// A sandboxed command can reach the port but cannot mint a valid JWT in secure mode.
|
|
7
|
+
// GET /healthz - for probes
|
|
8
|
+
// GET /chat - bundled test page, insecure mode only
|
|
9
|
+
// GET /api/status - versions, auth, sandbox, image identity, root, seed
|
|
10
|
+
// GET /api/activity - whether a run is going (idle stop asks this)
|
|
11
|
+
// GET /api/sessions - the caller's sessions, newest activity first
|
|
12
|
+
// /api/sessions/:tid... - history, runs (start, follow, replay, cancel), delete
|
|
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
|
|
15
|
+
import express, {} from 'express';
|
|
16
|
+
import { configure, DEFAULTS } from "./config.js";
|
|
17
|
+
import { historyRouter } from "./history.js";
|
|
18
|
+
import { errorHandler, notFound } from "./http.js";
|
|
19
|
+
import { requireCaller } from "./auth.js";
|
|
20
|
+
import { inAgentImage } from "./config.js";
|
|
21
|
+
import { openLayout } from "./layout.js";
|
|
22
|
+
import { localFileCors } from "./localFileCors.js";
|
|
23
|
+
import { memoryRouter } from "./memory.js";
|
|
24
|
+
import { acceptRuns, activeRuns, runDebugRouter, runsRouter, stopAllRuns } from "./runs.js";
|
|
25
|
+
import { serverStatus, setListening } from "./status.js";
|
|
26
|
+
import { privateStore } from "./store.js";
|
|
27
|
+
import { sessionsRouter } from "./sessions.js";
|
|
28
|
+
import { traceRouter } from "./trace.js";
|
|
29
|
+
export function createServer(opts) {
|
|
30
|
+
const { chatPage, ...runtime } = opts;
|
|
31
|
+
const settings = { ...DEFAULTS, ...runtime, hidden: [...(opts.hidden ?? [])] };
|
|
32
|
+
if (settings.auth.mode === 'jwt' && !settings.auth.publicKey.trim()) {
|
|
33
|
+
throw new Error('a jwt server needs the public key its tokens are signed for');
|
|
34
|
+
}
|
|
35
|
+
configure(settings);
|
|
36
|
+
openLayout(settings.root, settings.projectId);
|
|
37
|
+
acceptRuns();
|
|
38
|
+
const app = express();
|
|
39
|
+
app.disable('x-powered-by');
|
|
40
|
+
app.use(localFileCors);
|
|
41
|
+
if (settings.auth.mode === 'insecure' && chatPage) {
|
|
42
|
+
app.get('/chat', (_req, res, next) => {
|
|
43
|
+
res.set('Cache-Control', 'no-store');
|
|
44
|
+
res.sendFile(chatPage, (err) => {
|
|
45
|
+
if (err)
|
|
46
|
+
next(err);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
app.use(requireCaller());
|
|
51
|
+
app.use(express.json({ limit: '1mb' }));
|
|
52
|
+
app.get('/healthz', (_req, res) => {
|
|
53
|
+
res.type('text/plain').send('ok');
|
|
54
|
+
});
|
|
55
|
+
app.get('/api/status', async (_req, res) => {
|
|
56
|
+
res.json(await serverStatus());
|
|
57
|
+
});
|
|
58
|
+
app.get('/api/activity', (_req, res) => {
|
|
59
|
+
res.json({ busy: activeRuns() > 0, runs: activeRuns() });
|
|
60
|
+
});
|
|
61
|
+
app.use('/api/sessions', sessionsRouter());
|
|
62
|
+
app.use('/api/sessions', runsRouter(privateStore));
|
|
63
|
+
app.use('/api/sessions', historyRouter(privateStore));
|
|
64
|
+
if (settings.debug) {
|
|
65
|
+
app.use('/api/sessions', runDebugRouter(privateStore));
|
|
66
|
+
app.use('/api/sessions', traceRouter(privateStore));
|
|
67
|
+
app.use('/api/sessions', memoryRouter(privateStore));
|
|
68
|
+
}
|
|
69
|
+
app.use(notFound);
|
|
70
|
+
app.use(errorHandler);
|
|
71
|
+
let server;
|
|
72
|
+
return {
|
|
73
|
+
app,
|
|
74
|
+
settings,
|
|
75
|
+
listen: ({ port, host = '127.0.0.1' }) => new Promise((resolve, reject) => {
|
|
76
|
+
if (settings.auth.mode === 'insecure' && !LOOPBACK.has(host) && !insecureHere()) {
|
|
77
|
+
reject(new Error(`an insecure server listens on loopback only, not ${host}`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
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) => {
|
|
85
|
+
// No new connections, and no new runs on the ones still open, before the drain.
|
|
86
|
+
const closed = new Promise((resolve) => server ? server.close(() => resolve()) : resolve());
|
|
87
|
+
await stopAllRuns(graceMs);
|
|
88
|
+
// Followers of runs that have ended, idle keep-alives: nothing left to answer.
|
|
89
|
+
server?.closeAllConnections();
|
|
90
|
+
await closed;
|
|
91
|
+
setListening(undefined);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
96
|
+
/** The env `zen server start --container` sets on the container it runs, port on loopback. */
|
|
97
|
+
export const INSECURE_LOCAL_ENV = 'ZEN_SERVER_INSECURE_LOCAL';
|
|
98
|
+
/**
|
|
99
|
+
* Inside the agent image an insecure server listens on 0.0.0.0 for the port to be published; only
|
|
100
|
+
* the host that runs it on this machine, its port on loopback, says that is all right.
|
|
101
|
+
*/
|
|
102
|
+
export function insecureHere(env = process.env) {
|
|
103
|
+
return inAgentImage() && env[INSECURE_LOCAL_ENV] === '1';
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
export interface SessionInfo {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
running: {
|
|
8
|
+
runId: string;
|
|
9
|
+
} | null;
|
|
10
|
+
}
|
|
11
|
+
/** A user's real sessions, without opening engines or creating memory. */
|
|
12
|
+
export declare function sessionsRouter(): Router;
|
|
13
|
+
//# sourceMappingURL=sessions.d.ts.map
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user session-list endpoint mounted by server.ts at GET /api/sessions.
|
|
3
|
+
* sessionsRouter reads engine metadata under the authenticated JWT subject's folder,
|
|
4
|
+
* honors thread-scoped claims, adds current run ids and sorts by latest activity.
|
|
5
|
+
* The standalone chat uses this list without opening engines or creating user memory.
|
|
6
|
+
*/
|
|
7
|
+
import { readSessionMeta, sessionPathsAt } from '@zenera/engine';
|
|
8
|
+
import { Router } from 'express';
|
|
9
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { caller, isThreadId, userFolder } from "./identity.js";
|
|
12
|
+
import { currentRun } from "./runs.js";
|
|
13
|
+
import { userDir } from "./store.js";
|
|
14
|
+
/** A user's real sessions, without opening engines or creating memory. */
|
|
15
|
+
export function sessionsRouter() {
|
|
16
|
+
const router = Router();
|
|
17
|
+
router.get('/', async (req, res) => {
|
|
18
|
+
const claims = caller(req);
|
|
19
|
+
const owner = userFolder(claims.sub);
|
|
20
|
+
const dir = join(userDir(owner), 'sessions');
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.code !== 'ENOENT')
|
|
27
|
+
throw err;
|
|
28
|
+
res.json({ sessions: [] });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const sessions = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory() || !isThreadId(entry.name))
|
|
34
|
+
continue;
|
|
35
|
+
if (claims.thr !== undefined &&
|
|
36
|
+
String(claims.thr).toLowerCase() !== entry.name.toLowerCase())
|
|
37
|
+
continue;
|
|
38
|
+
const paths = sessionPathsAt(join(dir, entry.name));
|
|
39
|
+
try {
|
|
40
|
+
await stat(paths.meta);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT')
|
|
44
|
+
continue;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
const meta = await readSessionMeta(paths);
|
|
48
|
+
sessions.push({
|
|
49
|
+
id: entry.name,
|
|
50
|
+
title: meta.title || entry.name,
|
|
51
|
+
createdAt: meta.createdAt,
|
|
52
|
+
updatedAt: meta.lastRunAt ?? meta.createdAt,
|
|
53
|
+
running: currentRun({ id: entry.name, owner }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
|
|
57
|
+
res.json({ sessions });
|
|
58
|
+
});
|
|
59
|
+
return router;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=sessions.js.map
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|