@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/dist/status.js ADDED
@@ -0,0 +1,127 @@
1
+ // What the server can say about itself without being asked anything: `/api/status`, and the
2
+ // checks `zen server start` makes before it listens (`zen server check` prints them).
3
+ // serverStatus - versions, auth, sandbox, image identity, where users' things and the seed are
4
+ // startupErrors - the reasons not to start: nowhere to run commands, the wrong image, a root
5
+ // that is not this server's
6
+ import { resolveBuild } from '@zenera/engine';
7
+ import { readProjectConfig } from '@zenera/neo';
8
+ import { IMAGE_BASE_ENV, IMAGE_BUILD_ENV, probeBwrap, runProcess, } from '@zenera/sandbox';
9
+ import { readFileSync } from 'node:fs';
10
+ import { createRequire } from 'node:module';
11
+ import { settings } from "./config.js";
12
+ import { LAYOUT_VERSION, openLayout } from "./layout.js";
13
+ import { memorySeed } from "./store.js";
14
+ const require = createRequire(import.meta.url);
15
+ function versionOf(pkg) {
16
+ try {
17
+ const at = require.resolve(`${pkg}/package.json`);
18
+ return JSON.parse(readFileSync(at, 'utf8')).version;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ let listening;
25
+ /** Where the server listens, for the report. */
26
+ export function setListening(where) {
27
+ listening = where;
28
+ }
29
+ /** Whether podman answers: the machine running, on the platforms that have one. */
30
+ async function probePodman() {
31
+ try {
32
+ const info = await runProcess('podman', ['info', '--format', '{{.Host.OS}}'], {
33
+ timeoutMs: 30_000,
34
+ });
35
+ if (info.code === 0)
36
+ return { ok: true };
37
+ return {
38
+ ok: false,
39
+ error: `podman is not responding: ${(info.stderr.trim() || `exit ${info.code}`).split('\n')[0]}`,
40
+ hint: process.platform === 'linux' ? undefined : 'podman machine start',
41
+ };
42
+ }
43
+ catch (err) {
44
+ return {
45
+ ok: false,
46
+ error: err instanceof Error ? err.message : String(err),
47
+ hint: 'install podman, or run the server in the agent image (bwrap)',
48
+ };
49
+ }
50
+ }
51
+ export async function serverStatus(env = process.env) {
52
+ const s = settings();
53
+ const sandbox = { provider: s.sandboxProvider, ok: true };
54
+ if (s.sandbox === 'podman') {
55
+ Object.assign(sandbox, await probePodman());
56
+ }
57
+ else if (s.sandbox === 'bwrap') {
58
+ const probe = await probeBwrap();
59
+ Object.assign(sandbox, {
60
+ ok: probe.ok,
61
+ proc: probe.proc,
62
+ error: probe.error,
63
+ hint: probe.hint,
64
+ });
65
+ }
66
+ const image = {
67
+ build: env[IMAGE_BUILD_ENV]?.trim() || undefined,
68
+ base: env[IMAGE_BASE_ENV]?.trim() || undefined,
69
+ release: env.ZEN_RELEASE?.trim() || undefined,
70
+ wants: {},
71
+ };
72
+ try {
73
+ const { root, config } = readProjectConfig(s.projectDir);
74
+ const build = resolveBuild(root, config.sandbox);
75
+ image.wants = build ? { build: build.hash } : { image: config.sandbox?.image };
76
+ if (image.build || image.base) {
77
+ image.matches = build
78
+ ? image.build === build.hash
79
+ : image.base === (config.sandbox?.image ?? image.base);
80
+ }
81
+ }
82
+ catch (err) {
83
+ image.error = err instanceof Error ? err.message : String(err);
84
+ }
85
+ const root = { dir: s.root, supported: LAYOUT_VERSION };
86
+ try {
87
+ root.version = openLayout(s.root, s.projectId, { create: false })?.version;
88
+ }
89
+ catch (err) {
90
+ root.error = err instanceof Error ? err.message : String(err);
91
+ }
92
+ return {
93
+ server: versionOf('@zenera/server') ?? 'unknown',
94
+ zen: {
95
+ engine: versionOf('@zenera/engine'),
96
+ neo: versionOf('@zenera/neo'),
97
+ sandbox: versionOf('@zenera/sandbox'),
98
+ },
99
+ node: process.versions.node,
100
+ project: { id: s.projectId, dir: s.projectDir },
101
+ auth: s.auth.mode,
102
+ ...(listening ? { listen: listening } : {}),
103
+ debug: s.debug,
104
+ sandbox,
105
+ image,
106
+ root,
107
+ ...(memorySeed() ? { memorySeed: memorySeed() } : {}),
108
+ };
109
+ }
110
+ /** Why the server must not start, if anything. An image that was never labelled is not one. */
111
+ export function startupErrors(status) {
112
+ const errors = [];
113
+ if (!status.sandbox.ok) {
114
+ errors.push(`commands have nowhere to run: ${status.sandbox.error}` +
115
+ (status.sandbox.hint ? ` (${status.sandbox.hint})` : ''));
116
+ }
117
+ if (status.image.error)
118
+ errors.push(`the project cannot be read: ${status.image.error}`);
119
+ if (status.root.error)
120
+ errors.push(status.root.error);
121
+ if (status.image.matches === false) {
122
+ errors.push(`the image was built for another project environment: ${status.image.build ?? status.image.base}, ` +
123
+ `the project asks for ${status.image.wants.build ?? status.image.wants.image} (rebuild it: zen image build)`);
124
+ }
125
+ return errors;
126
+ }
127
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1,52 @@
1
+ import { type Engine, type RunIdentity, type SandboxSetup, type SessionPaths } from '@zenera/engine';
2
+ import type { Request } from 'express';
3
+ export interface ChatThread {
4
+ id: string;
5
+ /** who the thread's runs count against: a user, or the project for shared chats */
6
+ owner: string;
7
+ }
8
+ export interface ChatStore {
9
+ /** The thread a request names, checked to be the caller's; throws an HttpError otherwise. */
10
+ thread(req: Request): ChatThread;
11
+ existingSession(t: ChatThread): SessionPaths | null;
12
+ /** The session, created on the thread's first turn. */
13
+ sessionFor(t: ChatThread): SessionPaths;
14
+ /** The thread's run logs and run records. */
15
+ runsDir(t: ChatThread): string;
16
+ /** The graph the memory explorer reads; undefined when the project declares none. */
17
+ memoryDir(t: ChatThread): string | undefined;
18
+ /** The graph a run remembers into, ready; undefined lets the engine use the project's. */
19
+ engineMemoryDir(t: ChatThread): Promise<string | undefined>;
20
+ removeThread(t: ChatThread): Promise<void>;
21
+ /**
22
+ * How many runs one owner may have going at once. Memory does not lower it: the graph is a
23
+ * SQLite database that several runs remember into at the same time.
24
+ */
25
+ maxOwnerRuns: number;
26
+ /** Why a run is refused when the owner has that many going. */
27
+ ownerBusy: string;
28
+ }
29
+ /** Where one user's things are. */
30
+ export declare function userDir(user: string): string;
31
+ /** The user's graph, whether or not it has been made yet. */
32
+ export declare function memoryDir(user: string): string;
33
+ /** What a user's memory starts as: the server's seed, else the project's own graph. */
34
+ export declare function memorySeed(): string | undefined;
35
+ /**
36
+ * The user's graph, made first from the seed when they have none: a consistent copy of it
37
+ * (`copyMemory`, the seed opened read-only) into a directory of its own, renamed into place, so
38
+ * a crash never leaves half a graph and two first runs at once make one. No seed, or one not made
39
+ * yet: the user starts with nothing, and their first run makes their graph.
40
+ */
41
+ export declare function userMemory(user: string): Promise<string>;
42
+ /** Beside a user's graph: which seed it was copied from, and when. */
43
+ export declare const SEED_FILE = "seed.json";
44
+ export declare const privateStore: ChatStore;
45
+ export declare function usesMemory(): boolean;
46
+ export declare function imageReady(setup: SandboxSetup): Promise<void>;
47
+ export declare function openEngineFor(session: SessionPaths, memoryDir: string | undefined, user: RunIdentity | null, mcpToken?: string | null): Promise<Engine>;
48
+ /** The HTTP status for a zen exit code or EngineError code. */
49
+ export declare function statusFor(code: number): number;
50
+ export declare function errorMessage(err: unknown): string;
51
+ export declare function asHttpError(err: unknown): unknown;
52
+ //# sourceMappingURL=store.d.ts.map
package/dist/store.js ADDED
@@ -0,0 +1,203 @@
1
+ // Where a chat's session, run logs and memory live, and the engine opened on one for a turn.
2
+ // The chat code (runs.ts, history.ts, trace.ts, memory.ts) is the same for every kind of chat; a
3
+ // ChatStore says where a thread's things are and who it belongs to.
4
+ // privateStore - each user's chats, under that user's own folder:
5
+ // <root>/users/<user>/sessions/<thread id>/ - one zen session per chat thread, as `zen run`
6
+ // keeps one: .data/ (state), runs/ (the engine's
7
+ // records), workspace/, and agui/ (its runs'
8
+ // AG-UI events, runLog.ts — never in the
9
+ // sandbox's view)
10
+ // <root>/users/<user>/memory/ - the user's graph: a copy of the seed, made the
11
+ // first time a run of theirs remembers, shared by
12
+ // all their sessions and nobody else's
13
+ // A thread id is only ever looked up under its caller's own folder, so another user's
14
+ // thread is simply not there.
15
+ // userMemory - the user's graph, copied from the seed first if it has to be
16
+ // ChatStore, ChatThread - the contract (a host may serve other chats with it: the Test tab)
17
+ // usesMemory - whether any agent remembers, so the memory explorer has a graph
18
+ // openEngineFor - the project wired to a session, memory and the user the run acts
19
+ // for, its env file applied
20
+ // errorMessage, asHttpError - what an EngineError says, and means to an HTTP caller
21
+ import { createSessionAt, home, EngineError, EXIT, openEngine, sessionPathsAt, } from '@zenera/engine';
22
+ import { copyMemory, isMemory, LEGACY_GRAPH_FILE, memoryDir as projectMemoryDir, readProjectConfig, } from '@zenera/neo';
23
+ import { existsSync } from 'node:fs';
24
+ import { rename, rm, writeFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { BwrapSandbox } from '@zenera/sandbox';
27
+ import { settings } from "./config.js";
28
+ import { HttpError } from "./http.js";
29
+ import { identify } from "./identity.js";
30
+ import { applyProjectEnv } from "./projectEnv.js";
31
+ /** Where one user's things are. */
32
+ export function userDir(user) {
33
+ return join(settings().root, 'users', user);
34
+ }
35
+ const sessionDir = (t) => join(userDir(t.owner), 'sessions', t.id);
36
+ /** The user's graph, whether or not it has been made yet. */
37
+ export function memoryDir(user) {
38
+ return join(userDir(user), 'memory');
39
+ }
40
+ /** What a user's memory starts as: the server's seed, else the project's own graph. */
41
+ export function memorySeed() {
42
+ const s = settings();
43
+ if (s.memorySeed === null)
44
+ return undefined;
45
+ if (s.memorySeed)
46
+ return s.memorySeed;
47
+ try {
48
+ const { root, config } = readProjectConfig(s.projectDir);
49
+ return projectMemoryDir(root, config);
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ const copying = new Map();
56
+ /**
57
+ * The user's graph, made first from the seed when they have none: a consistent copy of it
58
+ * (`copyMemory`, the seed opened read-only) into a directory of its own, renamed into place, so
59
+ * a crash never leaves half a graph and two first runs at once make one. No seed, or one not made
60
+ * yet: the user starts with nothing, and their first run makes their graph.
61
+ */
62
+ export function userMemory(user) {
63
+ const dir = memoryDir(user);
64
+ const pending = copying.get(dir);
65
+ if (pending)
66
+ return pending;
67
+ const made = (async () => {
68
+ if (existsSync(dir))
69
+ return dir;
70
+ const seed = memorySeed();
71
+ if (seed && !isMemory(seed) && existsSync(join(seed, LEGACY_GRAPH_FILE))) {
72
+ throw new EngineError(`the memory seed ${seed} holds a version 1 memory`, EXIT.invalid, `convert it first: zen memory migrate --dir ${seed}`);
73
+ }
74
+ if (!seed || !isMemory(seed))
75
+ return dir;
76
+ const tmp = `${dir}.copy-${process.pid}-${Date.now()}`;
77
+ try {
78
+ await copyMemory(seed, tmp);
79
+ await writeFile(join(tmp, SEED_FILE), `${JSON.stringify({ seed, copiedAt: new Date().toISOString() }, null, 2)}\n`);
80
+ await rename(tmp, dir);
81
+ }
82
+ finally {
83
+ await rm(tmp, { recursive: true, force: true });
84
+ }
85
+ return dir;
86
+ })().finally(() => copying.delete(dir));
87
+ copying.set(dir, made);
88
+ return made;
89
+ }
90
+ /** Beside a user's graph: which seed it was copied from, and when. */
91
+ export const SEED_FILE = 'seed.json';
92
+ export const privateStore = {
93
+ thread(req) {
94
+ const id = identify(req);
95
+ return { id: id.threadId, owner: id.userId };
96
+ },
97
+ existingSession(t) {
98
+ const p = sessionPathsAt(sessionDir(t));
99
+ return existsSync(p.meta) ? p : null;
100
+ },
101
+ sessionFor(t) {
102
+ const dir = sessionDir(t);
103
+ return this.existingSession(t) ?? createSessionAt(dir, join(dir, 'workspace'));
104
+ },
105
+ runsDir: (t) => join(sessionDir(t), 'agui'),
106
+ memoryDir: (t) => memoryDir(t.owner),
107
+ engineMemoryDir: (t) => userMemory(t.owner),
108
+ async removeThread(t) {
109
+ await rm(sessionDir(t), { recursive: true, force: true });
110
+ },
111
+ get maxOwnerRuns() {
112
+ return settings().maxUserRuns;
113
+ },
114
+ ownerBusy: 'your other chats are still answering',
115
+ };
116
+ export function usesMemory() {
117
+ try {
118
+ return readProjectConfig(settings().projectDir).config.agents.some((a) => Boolean(a.memory));
119
+ }
120
+ catch (err) {
121
+ throw new EngineError(err instanceof Error ? err.message : String(err), EXIT.invalid);
122
+ }
123
+ }
124
+ // A turn must not start a build of its own: the image is built ahead (the editor, `zen image
125
+ // build`). Whichever sandbox the run gets, it says how its image is made ready.
126
+ export async function imageReady(setup) {
127
+ try {
128
+ await setup.sandbox.prepare({ allowBuild: false });
129
+ }
130
+ catch (err) {
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ const notBuilt = /is not built/.test(message);
133
+ throw new EngineError(notBuilt
134
+ ? `the sandbox image ${setup.image} is not built`
135
+ : `the sandbox image is not ready: ${message}`, EXIT.sandbox, notBuilt ? 'rebuild it from the project editor' : err.hint);
136
+ }
137
+ }
138
+ /** Where the agent image installs zen and the server. */
139
+ const AGENT_APP_DIR = '/opt/zenera/app';
140
+ /** The factory a run's engine opens its sandbox with. */
141
+ function sandboxFactory(s) {
142
+ if (s.sandbox !== 'bwrap')
143
+ return s.sandbox;
144
+ // Every user's chats and graphs, the seed (the server's to read and write, through its
145
+ // tools), the zen home (its keyring), the project (its .env) and, in the agent image, the
146
+ // server's own install are not the command's; its own workspace and home are bound back in.
147
+ const seed = memorySeed();
148
+ const hidden = [
149
+ s.root,
150
+ ...(seed ? [seed] : []),
151
+ home(),
152
+ s.projectDir,
153
+ ...(existsSync(AGENT_APP_DIR) ? [AGENT_APP_DIR] : []),
154
+ ...s.hidden,
155
+ ];
156
+ return (opts) => new BwrapSandbox(opts, { hidden });
157
+ }
158
+ export async function openEngineFor(session, memoryDir,
159
+ // Who the run acts for; null is nobody, and stops the engine reading ZEN_USER_* from this process.
160
+ user,
161
+ // The run's MCP broker token from zenera-app, if it gave one.
162
+ mcpToken = null) {
163
+ const s = settings();
164
+ applyProjectEnv();
165
+ const plugins = await s.plugins({ mcpToken });
166
+ return openEngine({
167
+ project: { dir: s.projectDir, name: s.projectId },
168
+ session,
169
+ memoryDir,
170
+ user,
171
+ quiet: true,
172
+ yes: true,
173
+ // Each turn's sandbox goes away; the sandbox HOME is in the session folder and stays.
174
+ disposable: true,
175
+ logger: { warn: (message) => console.warn(`[engine] ${message}`) },
176
+ preflight: s.preflight ?? imageReady,
177
+ // The sandbox is the server's to choose: a plugin in ZEN_PLUGINS does not get to replace it.
178
+ sandboxPlugins: false,
179
+ ...(s.sandbox === 'podman' ? {} : { newSandbox: sandboxFactory(s) }),
180
+ ...(plugins.length ? { plugins } : {}),
181
+ });
182
+ }
183
+ const STATUS = {
184
+ [EXIT.usage]: 400,
185
+ [EXIT.invalid]: 422,
186
+ [EXIT.credentials]: 503,
187
+ [EXIT.sandbox]: 503,
188
+ };
189
+ /** The HTTP status for a zen exit code or EngineError code. */
190
+ export function statusFor(code) {
191
+ return STATUS[code] ?? 500;
192
+ }
193
+ export function errorMessage(err) {
194
+ if (err instanceof EngineError)
195
+ return err.hint ? `${err.message} (${err.hint})` : err.message;
196
+ return err instanceof Error ? err.message : String(err);
197
+ }
198
+ export function asHttpError(err) {
199
+ if (!(err instanceof EngineError))
200
+ return err;
201
+ return new HttpError(statusFor(err.code), errorMessage(err));
202
+ }
203
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,84 @@
1
+ import { PayloadResolver, type AgentState, type TokenUsage, type TrajectoryNode } from '@zenera/neo';
2
+ import { Router, type Request } from 'express';
3
+ import type { ChatStore } from './store.ts';
4
+ export declare const MAX_PART_CHARS: number;
5
+ export declare const MAX_REQUEST_CHARS: number;
6
+ export interface TraceStep {
7
+ /** position in the whole session, flattened; the address of the step */
8
+ index: number;
9
+ id: string;
10
+ type: TrajectoryNode['type'];
11
+ agent: string;
12
+ ts: string;
13
+ /** 0 on the trunk, one more per fork */
14
+ depth: number;
15
+ /** fork branch path, e.g. `research › web` */
16
+ branch?: string;
17
+ /** the step's id in `zen inspect graph|node|ask` */
18
+ node: string;
19
+ title: string;
20
+ model?: string;
21
+ tools?: string[];
22
+ usage?: TokenUsage;
23
+ stopReason?: string;
24
+ isError?: boolean;
25
+ durationMs?: number;
26
+ synthetic?: boolean;
27
+ }
28
+ export interface TracePage {
29
+ runId: string;
30
+ engineRunId: string;
31
+ scope: 'turn' | 'session';
32
+ /** false for runs recorded before turn boundaries were kept: only the session can be shown */
33
+ turnAvailable: boolean;
34
+ total: number;
35
+ offset: number;
36
+ limit: number;
37
+ counts: Partial<Record<TrajectoryNode['type'], number>>;
38
+ steps: TraceStep[];
39
+ }
40
+ export interface TracePart {
41
+ key: string;
42
+ label: string;
43
+ kind: 'text' | 'json';
44
+ text?: string;
45
+ json?: unknown;
46
+ /** recorded size, for a payload */
47
+ bytes?: number;
48
+ truncated?: boolean;
49
+ /** too large to send with the step: fetch it with ?part=<key> */
50
+ deferred?: boolean;
51
+ error?: string;
52
+ }
53
+ export interface TraceStepDetail {
54
+ step: TraceStep;
55
+ parts: TracePart[];
56
+ }
57
+ interface Flat {
58
+ node: TrajectoryNode;
59
+ depth: number;
60
+ branch?: string;
61
+ /** index of the trunk node it belongs to */
62
+ top: number;
63
+ /** `zen inspect` id */
64
+ key: string;
65
+ }
66
+ export declare function flatten(trajectory: TrajectoryNode[]): Flat[];
67
+ export declare function toStep(f: Flat, index: number): TraceStep;
68
+ export declare function openTrace(store: ChatStore, req: Request): Promise<{
69
+ thread: import("./store.ts").ChatThread;
70
+ record: import("./runRecords.ts").RunRecord;
71
+ runDir: string;
72
+ state: AgentState;
73
+ payloads: PayloadResolver;
74
+ flat: Flat[];
75
+ }>;
76
+ /** The step a route names by its index, or 404. */
77
+ export declare function stepParam(flat: Flat[], raw: string): {
78
+ f: Flat;
79
+ index: number;
80
+ };
81
+ export declare function stepParts(n: TrajectoryNode, payloads: PayloadResolver): Promise<TracePart[]>;
82
+ export declare function traceRouter(store: ChatStore): Router;
83
+ export {};
84
+ //# sourceMappingURL=trace.d.ts.map