@zenera/server 0.0.0-stage → 1.2.1
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 +157 -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/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/trace.d.ts +84 -0
- package/dist/trace.js +389 -0
- package/package.json +56 -5
package/dist/runLog.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// A run's AG-UI events, kept on disk with the user's chats, so whoever comes back to a thread -
|
|
2
|
+
// after a reload, a lost connection or hours later with the pod restarted - sees the whole run.
|
|
3
|
+
// One file per run: /data/users/<user>/runs/<thread>/<started ms>-<run id>.ndjson, one event a line.
|
|
4
|
+
// RunLog - a run being written: append, follow (all so far, then live), end
|
|
5
|
+
// compact - the deltas of each message merged into one event, which is what a replay sends
|
|
6
|
+
// threadRunLogs - a thread's run files, oldest first
|
|
7
|
+
// readRunLog - one file's events, ended as interrupted if the run never finished
|
|
8
|
+
import { EventType } from '@ag-ui/core';
|
|
9
|
+
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
10
|
+
import { readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
const FILE = /^(\d+)-([\w-]+)\.ndjson$/;
|
|
13
|
+
function deltaKey(e) {
|
|
14
|
+
switch (e.type) {
|
|
15
|
+
case EventType.TEXT_MESSAGE_CONTENT:
|
|
16
|
+
return `t:${e.messageId}`;
|
|
17
|
+
case EventType.REASONING_MESSAGE_CONTENT:
|
|
18
|
+
return `r:${e.messageId}`;
|
|
19
|
+
case EventType.TOOL_CALL_ARGS:
|
|
20
|
+
return `a:${e.toolCallId}`;
|
|
21
|
+
default:
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Each stream's deltas only ever concatenate, so they can all sit at the first one's place.
|
|
26
|
+
export function compact(events) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const at = new Map();
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
const key = deltaKey(e);
|
|
31
|
+
if (key) {
|
|
32
|
+
const i = at.get(key);
|
|
33
|
+
if (i !== undefined) {
|
|
34
|
+
const first = out[i];
|
|
35
|
+
out[i] = {
|
|
36
|
+
...first,
|
|
37
|
+
delta: first.delta + e.delta,
|
|
38
|
+
};
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
at.set(key, out.length);
|
|
42
|
+
}
|
|
43
|
+
out.push(e);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function isEnd(e) {
|
|
48
|
+
return e?.type === EventType.RUN_FINISHED || e?.type === EventType.RUN_ERROR;
|
|
49
|
+
}
|
|
50
|
+
export class RunLog {
|
|
51
|
+
runId;
|
|
52
|
+
file;
|
|
53
|
+
#maxBytes;
|
|
54
|
+
#out;
|
|
55
|
+
#events = [];
|
|
56
|
+
#followers = new Set();
|
|
57
|
+
#bytes = 0;
|
|
58
|
+
#ended = false;
|
|
59
|
+
constructor(file, runId, maxBytes) {
|
|
60
|
+
this.file = file;
|
|
61
|
+
this.runId = runId;
|
|
62
|
+
this.#maxBytes = maxBytes;
|
|
63
|
+
this.#out = createWriteStream(file, { flags: 'a' });
|
|
64
|
+
this.#out.on('error', (err) => console.error(`run log ${file}:`, err));
|
|
65
|
+
}
|
|
66
|
+
static open(dir, runId, maxBytes) {
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
return new RunLog(join(dir, `${Date.now()}-${runId}.ndjson`), runId, maxBytes);
|
|
69
|
+
}
|
|
70
|
+
get ended() {
|
|
71
|
+
return this.#ended;
|
|
72
|
+
}
|
|
73
|
+
append(events) {
|
|
74
|
+
if (this.#ended || events.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
for (const e of events) {
|
|
77
|
+
const line = `${JSON.stringify(e)}\n`;
|
|
78
|
+
// Past the cap a runaway run keeps its structure and loses its deltas.
|
|
79
|
+
if (this.#bytes + line.length > this.#maxBytes && deltaKey(e))
|
|
80
|
+
continue;
|
|
81
|
+
this.#bytes += line.length;
|
|
82
|
+
this.#events.push(e);
|
|
83
|
+
this.#out.write(line);
|
|
84
|
+
}
|
|
85
|
+
for (const f of this.#followers)
|
|
86
|
+
f.events(events);
|
|
87
|
+
}
|
|
88
|
+
/** Everything so far at once, then each batch as it comes; returns the unfollow. */
|
|
89
|
+
follow(f) {
|
|
90
|
+
f.events(compact(this.#events));
|
|
91
|
+
if (this.#ended) {
|
|
92
|
+
f.end();
|
|
93
|
+
return () => { };
|
|
94
|
+
}
|
|
95
|
+
this.#followers.add(f);
|
|
96
|
+
return () => this.#followers.delete(f);
|
|
97
|
+
}
|
|
98
|
+
/** Lets the followers go, then rewrites the file compacted. */
|
|
99
|
+
async end() {
|
|
100
|
+
if (this.#ended)
|
|
101
|
+
return;
|
|
102
|
+
this.#ended = true;
|
|
103
|
+
for (const f of this.#followers)
|
|
104
|
+
f.end();
|
|
105
|
+
this.#followers.clear();
|
|
106
|
+
await new Promise((resolve) => this.#out.end(resolve));
|
|
107
|
+
const tmp = `${this.file}.tmp`;
|
|
108
|
+
await writeFile(tmp, compact(this.#events)
|
|
109
|
+
.map((e) => `${JSON.stringify(e)}\n`)
|
|
110
|
+
.join(''));
|
|
111
|
+
await rename(tmp, this.file);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function threadRunLogs(dir) {
|
|
115
|
+
let names;
|
|
116
|
+
try {
|
|
117
|
+
names = await readdir(dir);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.code === 'ENOENT')
|
|
121
|
+
return [];
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
return names
|
|
125
|
+
.map((name) => FILE.exec(name))
|
|
126
|
+
.filter((m) => m !== null)
|
|
127
|
+
.sort((a, b) => Number(a[1]) - Number(b[1]))
|
|
128
|
+
.map((m) => ({ runId: m[2], file: join(dir, m[0]) }));
|
|
129
|
+
}
|
|
130
|
+
/** Only for a run no longer going: one without an end was cut off by a restart. */
|
|
131
|
+
export async function readRunLog(file) {
|
|
132
|
+
const events = [];
|
|
133
|
+
for (const line of (await readFile(file, 'utf8')).split('\n')) {
|
|
134
|
+
if (!line)
|
|
135
|
+
continue;
|
|
136
|
+
try {
|
|
137
|
+
events.push(JSON.parse(line));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// the torn last line of a run cut off mid-write
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const out = compact(events);
|
|
144
|
+
if (!isEnd(out.at(-1))) {
|
|
145
|
+
out.push({
|
|
146
|
+
type: EventType.RUN_ERROR,
|
|
147
|
+
message: 'the run was interrupted: the project restarted',
|
|
148
|
+
code: 'interrupted',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=runLog.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type RunOutcome, type SessionPaths } from '@zenera/engine';
|
|
2
|
+
import { type TokenUsage } from '@zenera/neo';
|
|
3
|
+
export interface RunRecord {
|
|
4
|
+
runId: string;
|
|
5
|
+
startedAt: string;
|
|
6
|
+
finishedAt?: string;
|
|
7
|
+
status: 'running' | 'finished' | 'error' | 'cancelled' | 'timeout' | 'interrupted' | 'unknown';
|
|
8
|
+
engineRunId?: string;
|
|
9
|
+
durationMs?: number;
|
|
10
|
+
agent?: string;
|
|
11
|
+
stopReason?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
usage?: TokenUsage;
|
|
14
|
+
modelCalls?: number;
|
|
15
|
+
sessionModelCalls?: number;
|
|
16
|
+
/** session trajectory length before this run; absent on records written before it was kept */
|
|
17
|
+
trajectoryStart?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface RunSummary extends RunRecord {
|
|
20
|
+
recording: 'available' | 'pending' | 'unavailable';
|
|
21
|
+
reportAvailable: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function recordOutcome(record: RunRecord, outcome: RunOutcome, before: {
|
|
24
|
+
usage?: TokenUsage;
|
|
25
|
+
modelCalls: number;
|
|
26
|
+
trajectoryLength?: number;
|
|
27
|
+
}): void;
|
|
28
|
+
export declare function startRecord(dir: string, runId: string): RunRecord;
|
|
29
|
+
export declare function saveRecord(dir: string, record: RunRecord): void;
|
|
30
|
+
export declare function listRunRecords(dir: string, session: SessionPaths | null, activeRunId?: string): Promise<RunSummary[]>;
|
|
31
|
+
export declare function recordedReport(dir: string, session: SessionPaths | null, runId: string): Promise<string>;
|
|
32
|
+
/** The run's record and its recorded engine state file, or 404 when it was never recorded. */
|
|
33
|
+
export declare function recordedRun(dir: string, session: SessionPaths | null, runId: string): {
|
|
34
|
+
record: RunRecord;
|
|
35
|
+
session: SessionPaths;
|
|
36
|
+
statePath: string;
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=runRecords.d.ts.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { isStamp, runPaths } from '@zenera/engine';
|
|
2
|
+
import { turns } from '@zenera/neo';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { HttpError } from "./http.js";
|
|
7
|
+
import { threadRunLogs } from "./runLog.js";
|
|
8
|
+
const ID = /^[\w-]{1,80}$/;
|
|
9
|
+
export function recordOutcome(record, outcome, before) {
|
|
10
|
+
const usage = outcome.result.usage;
|
|
11
|
+
Object.assign(record, {
|
|
12
|
+
engineRunId: outcome.run.id,
|
|
13
|
+
durationMs: outcome.durationMs,
|
|
14
|
+
agent: outcome.result.agent,
|
|
15
|
+
stopReason: outcome.result.stopReason,
|
|
16
|
+
usage: {
|
|
17
|
+
inputTokens: usage.inputTokens - (before.usage?.inputTokens ?? 0),
|
|
18
|
+
cachedInputTokens: usage.cachedInputTokens - (before.usage?.cachedInputTokens ?? 0),
|
|
19
|
+
outputTokens: usage.outputTokens - (before.usage?.outputTokens ?? 0),
|
|
20
|
+
reasoningTokens: usage.reasoningTokens - (before.usage?.reasoningTokens ?? 0),
|
|
21
|
+
},
|
|
22
|
+
modelCalls: turns(outcome.result.state) - before.modelCalls,
|
|
23
|
+
sessionModelCalls: turns(outcome.result.state),
|
|
24
|
+
trajectoryStart: before.trajectoryLength ?? 0,
|
|
25
|
+
status: outcome.result.stopReason === 'failed'
|
|
26
|
+
? 'error'
|
|
27
|
+
: outcome.result.stopReason === 'aborted'
|
|
28
|
+
? 'cancelled'
|
|
29
|
+
: 'finished',
|
|
30
|
+
error: outcome.result.state.error,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function recordFile(dir, runId) {
|
|
34
|
+
if (!ID.test(runId))
|
|
35
|
+
throw new HttpError(400, 'invalid run id');
|
|
36
|
+
return join(dir, `${runId}.json`);
|
|
37
|
+
}
|
|
38
|
+
export function startRecord(dir, runId) {
|
|
39
|
+
const file = recordFile(dir, runId);
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
if (readdirSync(dir).some((name) => new RegExp(`^\\d+-${runId}\\.ndjson$`).test(name))) {
|
|
42
|
+
throw new HttpError(409, 'this run id has already been used');
|
|
43
|
+
}
|
|
44
|
+
const record = { runId, startedAt: new Date().toISOString(), status: 'running' };
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(file, JSON.stringify(record), { flag: 'wx' });
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err.code === 'EEXIST') {
|
|
50
|
+
throw new HttpError(409, 'this run id has already been used');
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
export function saveRecord(dir, record) {
|
|
57
|
+
const file = recordFile(dir, record.runId);
|
|
58
|
+
const tmp = `${file}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(record));
|
|
60
|
+
renameSync(tmp, file);
|
|
61
|
+
}
|
|
62
|
+
function readRecord(dir, runId) {
|
|
63
|
+
const record = JSON.parse(readFileSync(recordFile(dir, runId), 'utf8'));
|
|
64
|
+
if (record.runId !== runId || (record.engineRunId && !isStamp(record.engineRunId))) {
|
|
65
|
+
throw new HttpError(500, 'invalid recorded run identity');
|
|
66
|
+
}
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
async function reportExists(session, record) {
|
|
70
|
+
if (!session || !record.engineRunId)
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
return (await stat(runPaths(session, record.engineRunId).report)).isFile();
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code === 'ENOENT')
|
|
77
|
+
return false;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function listRunRecords(dir, session, activeRunId) {
|
|
82
|
+
let names;
|
|
83
|
+
try {
|
|
84
|
+
names = await readdir(dir);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err.code === 'ENOENT')
|
|
88
|
+
return [];
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
const records = new Map();
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
if (!name.endsWith('.json') || !ID.test(name.slice(0, -5)))
|
|
94
|
+
continue;
|
|
95
|
+
const record = readRecord(dir, name.slice(0, -5));
|
|
96
|
+
records.set(record.runId, record);
|
|
97
|
+
}
|
|
98
|
+
// Older chats have event logs but no reliable engine-run mapping.
|
|
99
|
+
for (const log of await threadRunLogs(dir)) {
|
|
100
|
+
if (!records.has(log.runId)) {
|
|
101
|
+
records.set(log.runId, {
|
|
102
|
+
runId: log.runId,
|
|
103
|
+
startedAt: new Date(Number(basename(log.file).split('-')[0])).toISOString(),
|
|
104
|
+
status: 'unknown',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return Promise.all([...records.values()]
|
|
109
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
110
|
+
.map(async (record) => {
|
|
111
|
+
const active = record.runId === activeRunId;
|
|
112
|
+
const interrupted = record.status === 'running' && !active;
|
|
113
|
+
return {
|
|
114
|
+
...record,
|
|
115
|
+
...(interrupted
|
|
116
|
+
? {
|
|
117
|
+
status: 'interrupted',
|
|
118
|
+
error: 'the run was interrupted before its recording completed',
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
recording: record.engineRunId
|
|
122
|
+
? 'available'
|
|
123
|
+
: active
|
|
124
|
+
? 'pending'
|
|
125
|
+
: 'unavailable',
|
|
126
|
+
reportAvailable: await reportExists(session, record),
|
|
127
|
+
};
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
export async function recordedReport(dir, session, runId) {
|
|
131
|
+
const file = recordFile(dir, runId);
|
|
132
|
+
if (!existsSync(file))
|
|
133
|
+
throw new HttpError(404, 'no recording for this run');
|
|
134
|
+
const record = readRecord(dir, runId);
|
|
135
|
+
if (!(await reportExists(session, record)) || !session || !record.engineRunId) {
|
|
136
|
+
throw new HttpError(404, 'the inspector report is unavailable for this run');
|
|
137
|
+
}
|
|
138
|
+
return readFile(runPaths(session, record.engineRunId).report, 'utf8');
|
|
139
|
+
}
|
|
140
|
+
/** The run's record and its recorded engine state file, or 404 when it was never recorded. */
|
|
141
|
+
export function recordedRun(dir, session, runId) {
|
|
142
|
+
const file = recordFile(dir, runId);
|
|
143
|
+
if (!existsSync(file))
|
|
144
|
+
throw new HttpError(404, 'no recording for this run');
|
|
145
|
+
const record = readRecord(dir, runId);
|
|
146
|
+
if (!session || !record.engineRunId) {
|
|
147
|
+
throw new HttpError(404, 'this run has no engine recording');
|
|
148
|
+
}
|
|
149
|
+
const statePath = runPaths(session, record.engineRunId).state;
|
|
150
|
+
if (!existsSync(statePath))
|
|
151
|
+
throw new HttpError(404, 'the recorded run state is missing');
|
|
152
|
+
return { record, session, statePath };
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=runRecords.js.map
|
package/dist/runs.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { type ChatStore, type ChatThread } from './store.ts';
|
|
3
|
+
export declare function activeRuns(): number;
|
|
4
|
+
export declare function currentRun(t: ChatThread): {
|
|
5
|
+
runId: string;
|
|
6
|
+
} | null;
|
|
7
|
+
/** Runs may start again: a new server in a process whose previous one stopped. */
|
|
8
|
+
export declare function acceptRuns(): void;
|
|
9
|
+
export declare function stopAllRuns(timeoutMs: number): Promise<void>;
|
|
10
|
+
export declare function runsRouter(store: ChatStore): Router;
|
|
11
|
+
export declare function runDebugRouter(store: ChatStore): Router;
|
|
12
|
+
//# sourceMappingURL=runs.d.ts.map
|