@amenophis1er/foreman 0.1.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/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { ServiceRegistry, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
|
|
5
|
+
|
|
6
|
+
test('service paths round-trip and reject junk', () => {
|
|
7
|
+
assert.equal(servicePath('run-1', 8934), '/svc/run-1/8934/');
|
|
8
|
+
assert.deepEqual(parseServicePath('/svc/run-1/8934/'), { runId: 'run-1', port: 8934, rest: '/' });
|
|
9
|
+
assert.deepEqual(parseServicePath('/svc/run-1/8934/css/a.css'), { runId: 'run-1', port: 8934, rest: '/css/a.css' });
|
|
10
|
+
assert.deepEqual(parseServicePath('/svc/run-1/8934'), { runId: 'run-1', port: 8934, rest: '/' });
|
|
11
|
+
assert.equal(parseServicePath('/svc/run-1/abc/'), null);
|
|
12
|
+
assert.equal(parseServicePath('/svc/run-1/70000/'), null);
|
|
13
|
+
assert.equal(parseServicePath('/svcx/run-1/80/'), null);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('registry: declared pairs only, one entry per port, lookup by port', () => {
|
|
17
|
+
const r = new ServiceRegistry();
|
|
18
|
+
const a = r.register('run-1', 8934, 'preview');
|
|
19
|
+
assert.equal(a.path, '/svc/run-1/8934/');
|
|
20
|
+
r.register('run-1', 8934, 'preview again');
|
|
21
|
+
assert.equal(r.list('run-1').length, 1);
|
|
22
|
+
assert.equal(r.list('run-1')[0].label, 'preview again');
|
|
23
|
+
assert.equal(r.has('run-1', 8934), true);
|
|
24
|
+
assert.equal(r.has('run-1', 9000), false);
|
|
25
|
+
assert.deepEqual(r.runsFor(8934), ['run-1']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('proxyToService streams a response and reports a dead port as 502', async () => {
|
|
29
|
+
const upstream = http.createServer((req, res) => {
|
|
30
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'x-seen-prefix': String(req.headers['x-forwarded-prefix']) });
|
|
31
|
+
res.end(`hello ${req.url}`);
|
|
32
|
+
});
|
|
33
|
+
await new Promise<void>((r) => upstream.listen(0, '127.0.0.1', r));
|
|
34
|
+
const port = (upstream.address() as { port: number }).port;
|
|
35
|
+
assert.equal(await portOpen(port), true);
|
|
36
|
+
const front = http.createServer((req, res) => proxyToService(req, res, port, '/a/b', '?x=1', '/svc/run-1/' + port + '/'));
|
|
37
|
+
await new Promise<void>((r) => front.listen(0, '127.0.0.1', r));
|
|
38
|
+
const fport = (front.address() as { port: number }).port;
|
|
39
|
+
try {
|
|
40
|
+
const r = await fetch(`http://127.0.0.1:${fport}/anything`);
|
|
41
|
+
assert.equal(r.status, 200);
|
|
42
|
+
assert.equal(await r.text(), 'hello /a/b?x=1');
|
|
43
|
+
assert.equal(r.headers.get('x-seen-prefix'), '/svc/run-1/' + port + '/');
|
|
44
|
+
upstream.close();
|
|
45
|
+
await new Promise((r2) => setTimeout(r2, 50));
|
|
46
|
+
const dead = await fetch(`http://127.0.0.1:${fport}/anything`);
|
|
47
|
+
assert.equal(dead.status, 502);
|
|
48
|
+
assert.match(await dead.text(), /Nothing is answering/);
|
|
49
|
+
assert.equal(await portOpen(port), false);
|
|
50
|
+
} finally {
|
|
51
|
+
front.close();
|
|
52
|
+
}
|
|
53
|
+
});
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services the crew exposes: a dev server it started to test its work, made
|
|
3
|
+
* reachable through Foreman's own address — so the human on the phone can
|
|
4
|
+
* open it over the tailnet without the agent opening a port to the world.
|
|
5
|
+
*
|
|
6
|
+
* The proxy is deliberately narrow. Only ports a run declared, only on
|
|
7
|
+
* loopback, only under `/svc/<run>/<port>/`. Nothing is guessed: a request
|
|
8
|
+
* for an undeclared pair is a 404, a declared service that is down is a 502
|
|
9
|
+
* with a sentence, never a hang.
|
|
10
|
+
*/
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import net from 'node:net';
|
|
13
|
+
|
|
14
|
+
export interface ExposedService {
|
|
15
|
+
port: number;
|
|
16
|
+
label: string;
|
|
17
|
+
/** `/svc/<runId>/<port>/` — the path under Foreman's origin. */
|
|
18
|
+
path: string;
|
|
19
|
+
since: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const SVC_PREFIX = '/svc/';
|
|
23
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host']);
|
|
24
|
+
|
|
25
|
+
export function servicePath(runId: string, port: number): string {
|
|
26
|
+
return `${SVC_PREFIX}${encodeURIComponent(runId)}/${port}/`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `/svc/<run>/<port>/rest` → its parts, or null. The rest keeps its leading slash. */
|
|
30
|
+
export function parseServicePath(pathname: string): { runId: string; port: number; rest: string } | null {
|
|
31
|
+
const m = /^\/svc\/([^/]+)\/(\d{2,5})(\/.*)?$/.exec(pathname);
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
const port = Number(m[2]);
|
|
34
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
35
|
+
return { runId: decodeURIComponent(m[1]), port, rest: m[3] ?? '/' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Something is accepting connections on 127.0.0.1:port right now. */
|
|
39
|
+
export function portOpen(port: number, timeoutMs = 800): Promise<boolean> {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const s = net.connect({ host: '127.0.0.1', port });
|
|
42
|
+
const done = (v: boolean) => { s.destroy(); resolve(v); };
|
|
43
|
+
s.setTimeout(timeoutMs, () => done(false));
|
|
44
|
+
s.once('connect', () => done(true));
|
|
45
|
+
s.once('error', () => done(false));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ServiceRegistry {
|
|
50
|
+
private byRun = new Map<string, ExposedService[]>();
|
|
51
|
+
|
|
52
|
+
register(runId: string, port: number, label: string): ExposedService {
|
|
53
|
+
const list = this.byRun.get(runId) ?? [];
|
|
54
|
+
const existing = list.find((s) => s.port === port);
|
|
55
|
+
if (existing) { existing.label = label || existing.label; return existing; }
|
|
56
|
+
const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now() };
|
|
57
|
+
this.byRun.set(runId, [...list, svc]);
|
|
58
|
+
return svc;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list(runId: string): ExposedService[] { return this.byRun.get(runId) ?? []; }
|
|
62
|
+
|
|
63
|
+
has(runId: string, port: number): boolean { return this.list(runId).some((s) => s.port === port); }
|
|
64
|
+
|
|
65
|
+
/** Which run declared this port, if any — for sub-resource requests that only carry a Referer. */
|
|
66
|
+
runsFor(port: number): string[] {
|
|
67
|
+
return [...this.byRun.entries()].filter(([, l]) => l.some((s) => s.port === port)).map(([id]) => id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Streams one request to 127.0.0.1:port and its response back. `rest` is the
|
|
73
|
+
* path the service sees; Foreman's prefix is passed along in a header for
|
|
74
|
+
* apps that know how to honour it.
|
|
75
|
+
*/
|
|
76
|
+
export function proxyToService(
|
|
77
|
+
req: http.IncomingMessage, res: http.ServerResponse,
|
|
78
|
+
port: number, rest: string, search: string, prefix: string,
|
|
79
|
+
): void {
|
|
80
|
+
const headers: Record<string, string | string[]> = {};
|
|
81
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
82
|
+
if (v !== undefined && !HOP_BY_HOP.has(k.toLowerCase())) headers[k] = v;
|
|
83
|
+
}
|
|
84
|
+
headers.host = `127.0.0.1:${port}`;
|
|
85
|
+
headers['x-forwarded-prefix'] = prefix;
|
|
86
|
+
headers['x-forwarded-host'] = String(req.headers.host ?? '');
|
|
87
|
+
const up = http.request({ host: '127.0.0.1', port, method: req.method, path: rest + search, headers }, (r) => {
|
|
88
|
+
const out: Record<string, string | string[] | number> = {};
|
|
89
|
+
for (const [k, v] of Object.entries(r.headers)) {
|
|
90
|
+
if (v !== undefined && !HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
|
91
|
+
}
|
|
92
|
+
res.writeHead(r.statusCode ?? 502, out);
|
|
93
|
+
r.pipe(res);
|
|
94
|
+
});
|
|
95
|
+
up.setTimeout(30_000, () => up.destroy(new Error('timeout')));
|
|
96
|
+
up.on('error', (err) => {
|
|
97
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
98
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
99
|
+
res.end(`Nothing is answering on 127.0.0.1:${port} (${err.message}). The service the crew exposed may have stopped with its run.`);
|
|
100
|
+
});
|
|
101
|
+
req.pipe(up);
|
|
102
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every event the server emits must be one the client listens for.
|
|
3
|
+
*
|
|
4
|
+
* An EventSource delivers only the named events it has a listener for. A
|
|
5
|
+
* name the server emits but the client never subscribed to is not an error
|
|
6
|
+
* anywhere — the frame arrives, is dropped, and the UI shows it only after a
|
|
7
|
+
* reload replays the log. Thirteen events shipped that way in a single day,
|
|
8
|
+
* each looking fine on replay, before a human noticed a picker that never
|
|
9
|
+
* appeared. This test is the contract that stops it recurring: emit a new
|
|
10
|
+
* name, and `SSE_EVENTS` in ui/src/sse.ts must grow in the same change.
|
|
11
|
+
*/
|
|
12
|
+
import test from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const SRC = here;
|
|
20
|
+
const SSE_TS = path.resolve(here, '..', 'ui', 'src', 'sse.ts');
|
|
21
|
+
|
|
22
|
+
/** Server files that can emit: everything under src/ except tests and the ported gateway. */
|
|
23
|
+
function serverFiles(dir: string): string[] {
|
|
24
|
+
const out: string[] = [];
|
|
25
|
+
for (const name of readdirSync(dir)) {
|
|
26
|
+
const p = path.join(dir, name);
|
|
27
|
+
if (statSync(p).isDirectory()) {
|
|
28
|
+
if (name === 'gateway') continue; // proxies bytes; emits nothing to the UI
|
|
29
|
+
out.push(...serverFiles(p));
|
|
30
|
+
} else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) {
|
|
31
|
+
out.push(p);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Names passed as the first argument to an emitter. Matches the shapes in use:
|
|
39
|
+
* `this.emit('x'`, `turn.emit('x'`, `emit('x'`, `makeChatEmitter(id)('x'`,
|
|
40
|
+
* `broadcastChat(id, 'x'` (a frame without a log line), and
|
|
41
|
+
* the ternary `emit(isResume ? 'run_resumed' : 'run_started'` — the whole
|
|
42
|
+
* first-argument expression is taken and every quoted name in it counts. A
|
|
43
|
+
* name assembled from strings would not be caught; none exist, and the
|
|
44
|
+
* comment on SSE_EVENTS says not to start.
|
|
45
|
+
*/
|
|
46
|
+
function emittedNames(): Set<string> {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
const re = /(?:\bemit|makeChatEmitter\([^)]*\))\(\s*([^,]+),|\bbroadcastChat\(\s*[^,]+,\s*([^,]+),/g;
|
|
49
|
+
for (const file of serverFiles(SRC)) {
|
|
50
|
+
const text = readFileSync(file, 'utf8');
|
|
51
|
+
for (const m of text.matchAll(re)) {
|
|
52
|
+
for (const q of (m[1] ?? m[2] ?? '').matchAll(/'([a-z_]+)'/g)) names.add(q[1]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function subscribedNames(): Set<string> {
|
|
59
|
+
const text = readFileSync(SSE_TS, 'utf8');
|
|
60
|
+
const block = text.slice(text.indexOf('SSE_EVENTS = ['), text.indexOf('] as const'));
|
|
61
|
+
return new Set([...block.matchAll(/'([a-z_]+)'/g)].map((m) => m[1]));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('every event the server emits has a client listener', () => {
|
|
65
|
+
const emitted = emittedNames();
|
|
66
|
+
const subscribed = subscribedNames();
|
|
67
|
+
assert.ok(emitted.size > 20, `sanity: expected to find many emits, found ${emitted.size}`);
|
|
68
|
+
const missing = [...emitted].filter((n) => !subscribed.has(n)).sort();
|
|
69
|
+
assert.deepEqual(missing, [],
|
|
70
|
+
`emitted by the server but never delivered to the UI (add to SSE_EVENTS in ui/src/sse.ts): ${missing.join(', ')}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the client does not listen for names nothing emits', () => {
|
|
74
|
+
// The reverse is only a hygiene check, so it warns through the message
|
|
75
|
+
// rather than failing on a name emitted from somewhere this scan cannot see.
|
|
76
|
+
const emitted = emittedNames();
|
|
77
|
+
const subscribed = subscribedNames();
|
|
78
|
+
const dead = [...subscribed].filter((n) => !emitted.has(n)).sort();
|
|
79
|
+
// Known: `models_changed` is emitted from a place outside the emit() shapes
|
|
80
|
+
// (a server broadcast helper). Anything else is worth a look.
|
|
81
|
+
const unexplained = dead.filter((n) => n !== 'models_changed');
|
|
82
|
+
assert.deepEqual(unexplained, [], `subscribed but apparently never emitted: ${unexplained.join(', ')}`);
|
|
83
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { RunStore, newRunId } from './store.js';
|
|
7
|
+
import type { RunMeta } from './types.js';
|
|
8
|
+
|
|
9
|
+
function meta(id: string, over: Partial<RunMeta> = {}): RunMeta {
|
|
10
|
+
return {
|
|
11
|
+
id, folder: '/tmp/x', mission: 'test', budgetUsd: 5,
|
|
12
|
+
status: 'running', costUsd: 0, createdAt: Date.now(), workers: [],
|
|
13
|
+
...over,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function tmpStore(): Promise<{ store: RunStore; root: string }> {
|
|
18
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'foreman-store-'));
|
|
19
|
+
return { store: new RunStore(root), root };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('newRunId is sortable by creation time and well-formed', () => {
|
|
23
|
+
const a = newRunId(1000);
|
|
24
|
+
const b = newRunId(2000);
|
|
25
|
+
assert.match(a, /^[0-9]{13}-[0-9a-f]{8}$/);
|
|
26
|
+
assert.ok(a < b);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('create, append, read round-trip preserves order', async () => {
|
|
30
|
+
const { store, root } = await tmpStore();
|
|
31
|
+
const id = newRunId();
|
|
32
|
+
await store.createRun(meta(id));
|
|
33
|
+
// Fire-and-forget appends must still land in order.
|
|
34
|
+
for (let i = 0; i < 20; i++) void store.append(id, { ts: i, event: 'e', data: { i } });
|
|
35
|
+
await store.append(id, { ts: 99, event: 'last', data: null });
|
|
36
|
+
|
|
37
|
+
const events = await store.readEvents(id);
|
|
38
|
+
assert.equal(events.length, 21);
|
|
39
|
+
assert.deepEqual(events.map((e) => e.ts).slice(0, 5), [0, 1, 2, 3, 4]);
|
|
40
|
+
assert.equal(events.at(-1)?.event, 'last');
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('listRuns returns newest first and skips junk', async () => {
|
|
45
|
+
const { store, root } = await tmpStore();
|
|
46
|
+
const a = newRunId(1000);
|
|
47
|
+
const b = newRunId(2000);
|
|
48
|
+
await store.createRun(meta(a));
|
|
49
|
+
await store.createRun(meta(b));
|
|
50
|
+
const runs = await store.listRuns();
|
|
51
|
+
assert.deepEqual(runs.map((r) => r.id), [b, a]);
|
|
52
|
+
await rm(root, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('sweepOrphans finishes running runs and appends a terminal event', async () => {
|
|
56
|
+
const { store, root } = await tmpStore();
|
|
57
|
+
const orphan = newRunId(1000);
|
|
58
|
+
const finished = newRunId(2000);
|
|
59
|
+
await store.createRun(meta(orphan));
|
|
60
|
+
await store.createRun(meta(finished, { status: 'done' }));
|
|
61
|
+
|
|
62
|
+
const swept = await store.sweepOrphans();
|
|
63
|
+
assert.deepEqual(swept, [orphan]);
|
|
64
|
+
|
|
65
|
+
const m = await store.readMeta(orphan);
|
|
66
|
+
assert.equal(m?.status, 'interrupted');
|
|
67
|
+
assert.ok(m?.endedAt);
|
|
68
|
+
const events = await store.readEvents(orphan);
|
|
69
|
+
assert.equal(events.at(-1)?.event, 'run_finished');
|
|
70
|
+
|
|
71
|
+
// Idempotent: nothing left to sweep.
|
|
72
|
+
assert.deepEqual(await store.sweepOrphans(), []);
|
|
73
|
+
await rm(root, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('readEvents drops a torn trailing line', async () => {
|
|
77
|
+
const { store, root } = await tmpStore();
|
|
78
|
+
const id = newRunId();
|
|
79
|
+
await store.createRun(meta(id));
|
|
80
|
+
await store.append(id, { ts: 1, event: 'ok', data: null });
|
|
81
|
+
const { appendFile } = await import('node:fs/promises');
|
|
82
|
+
await appendFile(path.join(root, 'runs', id, 'events.jsonl'), '{"ts":2,"event":"torn'); // no newline, invalid JSON
|
|
83
|
+
const events = await store.readEvents(id);
|
|
84
|
+
assert.equal(events.length, 1);
|
|
85
|
+
assert.equal(events[0].event, 'ok');
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('run ids are validated before touching the filesystem', async () => {
|
|
90
|
+
const { store } = await tmpStore();
|
|
91
|
+
await assert.rejects(() => store.readEvents('../../etc/passwd' as string));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('projects: add is idempotent per folder, remove keeps others', async () => {
|
|
95
|
+
const { store, root } = await tmpStore();
|
|
96
|
+
const a = await store.addProject('/tmp/proj-a');
|
|
97
|
+
const b = await store.addProject('/tmp/proj-b', 'Custom Name');
|
|
98
|
+
const aAgain = await store.addProject('/tmp/proj-a');
|
|
99
|
+
|
|
100
|
+
assert.equal(a.id, aAgain.id);
|
|
101
|
+
assert.equal(a.name, 'proj-a');
|
|
102
|
+
assert.equal(b.name, 'Custom Name');
|
|
103
|
+
assert.equal((await store.listProjects()).length, 2);
|
|
104
|
+
assert.deepEqual(await store.getProject(a.id), a);
|
|
105
|
+
|
|
106
|
+
assert.equal(await store.removeProject(a.id), true);
|
|
107
|
+
assert.equal(await store.removeProject(a.id), false);
|
|
108
|
+
assert.deepEqual((await store.listProjects()).map((p) => p.id), [b.id]);
|
|
109
|
+
await rm(root, { recursive: true, force: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('projects: concurrent adds do not lose writes', async () => {
|
|
113
|
+
const { store, root } = await tmpStore();
|
|
114
|
+
await Promise.all(
|
|
115
|
+
Array.from({ length: 8 }, (_, i) => store.addProject(`/tmp/conc-${i}`)),
|
|
116
|
+
);
|
|
117
|
+
assert.equal((await store.listProjects()).length, 8);
|
|
118
|
+
await rm(root, { recursive: true, force: true });
|
|
119
|
+
});
|