aiki-cli 0.3.2 → 0.3.5
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/CHANGELOG.md +68 -0
- package/README.md +64 -4
- package/dist/bench/idea-v3-bench.js +104 -5
- package/dist/bench/idea-v3-rating.js +158 -3
- package/dist/cli/bench.js +5 -5
- package/dist/cli/index.js +12 -2
- package/dist/cli/resume.js +20 -0
- package/dist/cli/run.js +75 -4
- package/dist/cli/serve.js +48 -0
- package/dist/config/config.js +7 -2
- package/dist/council/view.js +13 -4
- package/dist/orchestration/auto-profile.js +97 -0
- package/dist/orchestration/claim-groups.js +56 -0
- package/dist/orchestration/context.js +66 -3
- package/dist/orchestration/decision-dossier.js +42 -10
- package/dist/orchestration/decision-graph.js +2 -2
- package/dist/orchestration/engine.js +8 -4
- package/dist/orchestration/evidence-origin.js +17 -0
- package/dist/orchestration/jsonStage.js +47 -5
- package/dist/orchestration/modes.js +4 -2
- package/dist/orchestration/preflight.js +19 -0
- package/dist/orchestration/quick-analysis.js +31 -6
- package/dist/orchestration/sanitize-paths.js +10 -0
- package/dist/orchestration/stages/s10-render.js +31 -7
- package/dist/orchestration/stages/s4-analyze.js +7 -1
- package/dist/orchestration/stages/s4b-challenge.js +97 -0
- package/dist/orchestration/stages/s5-drift.js +13 -3
- package/dist/orchestration/stages/s8-verify.js +44 -7
- package/dist/orchestration/stages/s9-judge.js +20 -5
- package/dist/orchestration/stages/s9b-plan.js +44 -15
- package/dist/orchestration/url-sources.js +21 -0
- package/dist/providers/adapter-core.js +1 -1
- package/dist/providers/claude.js +18 -0
- package/dist/schemas/index.js +46 -0
- package/dist/serve/flight-deck.js +830 -0
- package/dist/serve/followup.js +50 -0
- package/dist/serve/frames.js +168 -0
- package/dist/serve/gates.js +72 -0
- package/dist/serve/projections.js +283 -0
- package/dist/serve/server.js +219 -0
- package/dist/serve/threads.js +145 -0
- package/dist/serve-ui/Five.png +0 -0
- package/dist/serve-ui/app.js +820 -0
- package/dist/serve-ui/index.html +171 -0
- package/dist/serve-ui/workspace.css +662 -0
- package/dist/storage/runs.js +2 -1
- package/dist/workflows/idea-refinement.js +94 -16
- package/package.json +2 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// `aiki serve` HTTP server (plan §3.1). Native node:http, static files + JSON API + (HD3) SSE.
|
|
2
|
+
// Zero new dependencies. Security posture (kept from the v1 rules):
|
|
3
|
+
// - binds 127.0.0.1 only (never a public interface);
|
|
4
|
+
// - Host header must be localhost/127.0.0.1 (blocks DNS-rebinding);
|
|
5
|
+
// - a per-boot random deckToken is injected into the shell and required on every mutating POST/PATCH;
|
|
6
|
+
// - one active run at a time — a second Convene returns 409 (HD3 sets the lock).
|
|
7
|
+
// Nothing path-bearing is ever serialized out: every response body is a projections.ts view.
|
|
8
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { extname, join, normalize } from 'node:path';
|
|
12
|
+
import { z, ZodError } from 'zod';
|
|
13
|
+
import { ConfigError } from '../config/config.js';
|
|
14
|
+
import { DeckAction, SendInput, SettingsPatch } from './projections.js';
|
|
15
|
+
import { DeckError } from './flight-deck.js';
|
|
16
|
+
import { encodeSse } from './frames.js';
|
|
17
|
+
const DEFAULT_PORT = 4173;
|
|
18
|
+
const PORT_SCAN_END = 4183;
|
|
19
|
+
const HOST = '127.0.0.1';
|
|
20
|
+
const MIME = {
|
|
21
|
+
'.html': 'text/html; charset=utf-8',
|
|
22
|
+
'.css': 'text/css; charset=utf-8',
|
|
23
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
24
|
+
'.json': 'application/json; charset=utf-8',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.svg': 'image/svg+xml',
|
|
27
|
+
'.ico': 'image/x-icon',
|
|
28
|
+
};
|
|
29
|
+
/** Build the request handler around a FlightDeck. Exported for tests (drive it without a socket). */
|
|
30
|
+
export function createHandler(opts) {
|
|
31
|
+
const { flightDeck, staticDir, deckToken, port } = opts;
|
|
32
|
+
return async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
if (!hostAllowed(req, port))
|
|
35
|
+
return send(res, 403, { error: 'forbidden host' });
|
|
36
|
+
const url = new URL(req.url ?? '/', `http://${HOST}`);
|
|
37
|
+
const path = url.pathname;
|
|
38
|
+
const method = req.method ?? 'GET';
|
|
39
|
+
// Mutating requests must carry the per-boot deck token (same-origin CSRF guard).
|
|
40
|
+
if (method === 'POST' || method === 'PATCH') {
|
|
41
|
+
if (req.headers['x-deck-token'] !== deckToken)
|
|
42
|
+
return send(res, 403, { error: 'bad deck token' });
|
|
43
|
+
}
|
|
44
|
+
if (method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
45
|
+
return await sendShell(res, staticDir, deckToken);
|
|
46
|
+
}
|
|
47
|
+
if (method === 'GET' && path === '/api/bootstrap') {
|
|
48
|
+
return send(res, 200, await flightDeck.bootstrap());
|
|
49
|
+
}
|
|
50
|
+
if (method === 'GET' && path === '/api/settings') {
|
|
51
|
+
return send(res, 200, await flightDeck.settings());
|
|
52
|
+
}
|
|
53
|
+
if (method === 'PATCH' && path === '/api/settings') {
|
|
54
|
+
return send(res, 200, await flightDeck.updateSettings(SettingsPatch.parse(await readJson(req))));
|
|
55
|
+
}
|
|
56
|
+
if (method === 'GET' && path.startsWith('/api/threads/')) {
|
|
57
|
+
const id = decodeURIComponent(path.slice('/api/threads/'.length));
|
|
58
|
+
const detail = await flightDeck.thread(id);
|
|
59
|
+
return detail ? send(res, 200, detail) : send(res, 404, { error: 'no such thread' });
|
|
60
|
+
}
|
|
61
|
+
if (method === 'POST' && path === '/api/providers/check') {
|
|
62
|
+
const { fresh } = z.object({ fresh: z.boolean() }).strict().parse(await readJson(req));
|
|
63
|
+
return send(res, 200, await flightDeck.checkProviders(fresh));
|
|
64
|
+
}
|
|
65
|
+
if (method === 'POST' && path === '/api/messages') {
|
|
66
|
+
return send(res, 202, await flightDeck.send(SendInput.parse(await readJson(req))));
|
|
67
|
+
}
|
|
68
|
+
const runRoute = path.match(/^\/api\/runs\/([^/]+)\/(events|actions|report)$/);
|
|
69
|
+
if (runRoute) {
|
|
70
|
+
const runId = decodeURIComponent(runRoute[1]);
|
|
71
|
+
const route = runRoute[2];
|
|
72
|
+
if (method === 'GET' && route === 'events')
|
|
73
|
+
return await sendEvents(req, res, flightDeck, runId);
|
|
74
|
+
if (method === 'POST' && route === 'actions') {
|
|
75
|
+
const outcome = await flightDeck.act(runId, DeckAction.parse(await readJson(req)));
|
|
76
|
+
return send(res, 200, outcome ?? { ok: true });
|
|
77
|
+
}
|
|
78
|
+
if (method === 'GET' && route === 'report')
|
|
79
|
+
return send(res, 200, await flightDeck.report(runId));
|
|
80
|
+
return send(res, 405, { error: 'method not allowed' });
|
|
81
|
+
}
|
|
82
|
+
if (path.startsWith('/api/'))
|
|
83
|
+
return send(res, 404, { error: 'not found' });
|
|
84
|
+
// Static asset (css/js/png) from the serve-ui dir.
|
|
85
|
+
if (method === 'GET')
|
|
86
|
+
return await sendStatic(res, staticDir, path);
|
|
87
|
+
return send(res, 405, { error: 'method not allowed' });
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (e instanceof DeckError)
|
|
91
|
+
return send(res, e.status, { error: e.message });
|
|
92
|
+
if (e instanceof ConfigError)
|
|
93
|
+
return send(res, 409, { error: 'Existing config is invalid. Fix it before saving settings.' });
|
|
94
|
+
if (e instanceof ZodError)
|
|
95
|
+
return send(res, 400, { error: 'invalid request' });
|
|
96
|
+
send(res, 500, { error: 'internal error' });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Start listening on 127.0.0.1. Explicit port that is busy fails loud; otherwise scan 4173–4183. */
|
|
101
|
+
export async function startServer(opts) {
|
|
102
|
+
const deckToken = randomBytes(16).toString('hex');
|
|
103
|
+
const host = opts.host ?? HOST;
|
|
104
|
+
const handler = (port) => createHandler({ flightDeck: opts.flightDeck, staticDir: opts.staticDir, deckToken, port });
|
|
105
|
+
const tryPort = (port) => new Promise((resolve, reject) => {
|
|
106
|
+
const server = createHttpServer(handler(port));
|
|
107
|
+
server.once('error', (err) => {
|
|
108
|
+
if (err.code === 'EADDRINUSE')
|
|
109
|
+
resolve(null);
|
|
110
|
+
else
|
|
111
|
+
reject(err);
|
|
112
|
+
});
|
|
113
|
+
server.listen(port, host, () => resolve(server));
|
|
114
|
+
});
|
|
115
|
+
if (opts.port !== undefined) {
|
|
116
|
+
const server = await tryPort(opts.port);
|
|
117
|
+
if (!server)
|
|
118
|
+
throw new Error(`port ${opts.port} is already in use`);
|
|
119
|
+
return running(server, opts.port, deckToken, opts.flightDeck);
|
|
120
|
+
}
|
|
121
|
+
for (let port = DEFAULT_PORT; port <= PORT_SCAN_END; port++) {
|
|
122
|
+
const server = await tryPort(port);
|
|
123
|
+
if (server)
|
|
124
|
+
return running(server, port, deckToken, opts.flightDeck);
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`no free port in ${DEFAULT_PORT}–${PORT_SCAN_END}`);
|
|
127
|
+
}
|
|
128
|
+
function running(server, port, deckToken, flightDeck) {
|
|
129
|
+
return {
|
|
130
|
+
port,
|
|
131
|
+
deckToken,
|
|
132
|
+
close: async () => {
|
|
133
|
+
await flightDeck.close();
|
|
134
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
139
|
+
/** Host allowlist: localhost / 127.0.0.1, with or without the served port. Blocks DNS-rebinding. */
|
|
140
|
+
export function hostAllowed(req, port) {
|
|
141
|
+
const host = req.headers.host;
|
|
142
|
+
if (!host)
|
|
143
|
+
return false;
|
|
144
|
+
const ok = new Set([`127.0.0.1:${port}`, `localhost:${port}`, '127.0.0.1', 'localhost']);
|
|
145
|
+
return ok.has(host);
|
|
146
|
+
}
|
|
147
|
+
function send(res, status, body) {
|
|
148
|
+
const payload = JSON.stringify(body);
|
|
149
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'x-content-type-options': 'nosniff' });
|
|
150
|
+
res.end(payload);
|
|
151
|
+
}
|
|
152
|
+
async function sendShell(res, staticDir, deckToken) {
|
|
153
|
+
let html;
|
|
154
|
+
try {
|
|
155
|
+
html = await readFile(join(staticDir, 'index.html'), 'utf8');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return send(res, 500, { error: 'serve-ui not built (run npm run build)' });
|
|
159
|
+
}
|
|
160
|
+
const injected = html.replace('__DECK_TOKEN__', deckToken);
|
|
161
|
+
res.writeHead(200, { 'content-type': MIME['.html'], 'x-content-type-options': 'nosniff' });
|
|
162
|
+
res.end(injected);
|
|
163
|
+
}
|
|
164
|
+
async function sendStatic(res, staticDir, path) {
|
|
165
|
+
// Contain the read inside staticDir (no traversal out via ../).
|
|
166
|
+
const rel = normalize(path).replace(/^(\.\.[/\\])+/, '');
|
|
167
|
+
const file = join(staticDir, rel);
|
|
168
|
+
if (!file.startsWith(staticDir))
|
|
169
|
+
return send(res, 403, { error: 'forbidden path' });
|
|
170
|
+
let data;
|
|
171
|
+
try {
|
|
172
|
+
data = await readFile(file);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return send(res, 404, { error: 'not found' });
|
|
176
|
+
}
|
|
177
|
+
res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream', 'x-content-type-options': 'nosniff' });
|
|
178
|
+
res.end(data);
|
|
179
|
+
}
|
|
180
|
+
async function readJson(req) {
|
|
181
|
+
const chunks = [];
|
|
182
|
+
let size = 0;
|
|
183
|
+
for await (const chunk of req) {
|
|
184
|
+
const part = chunk;
|
|
185
|
+
size += part.length;
|
|
186
|
+
if (size > 1_000_000)
|
|
187
|
+
throw new DeckError(413, 'request body too large');
|
|
188
|
+
chunks.push(part);
|
|
189
|
+
}
|
|
190
|
+
if (!chunks.length)
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new DeckError(400, 'invalid JSON');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function sendEvents(req, res, flightDeck, runId) {
|
|
200
|
+
const raw = req.headers['last-event-id'];
|
|
201
|
+
const parsed = Number.parseInt(Array.isArray(raw) ? raw[0] ?? '0' : raw ?? '0', 10);
|
|
202
|
+
const after = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
203
|
+
const abort = new AbortController();
|
|
204
|
+
const frames = flightDeck.frames(runId, after, abort.signal);
|
|
205
|
+
const first = await frames.next(); // surface an unknown run before committing a 200 SSE response
|
|
206
|
+
res.once('close', () => abort.abort());
|
|
207
|
+
res.writeHead(200, {
|
|
208
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
209
|
+
'cache-control': 'no-cache, no-transform',
|
|
210
|
+
connection: 'keep-alive',
|
|
211
|
+
'x-content-type-options': 'nosniff',
|
|
212
|
+
});
|
|
213
|
+
res.flushHeaders?.();
|
|
214
|
+
if (!first.done)
|
|
215
|
+
res.write(encodeSse(first.value));
|
|
216
|
+
for await (const frame of frames)
|
|
217
|
+
res.write(encodeSse(frame));
|
|
218
|
+
res.end();
|
|
219
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Conversation threads for `aiki serve` (plan §1.2). A thread is a titled sequence of turns that
|
|
2
|
+
// starts from a decision run. Two append-only files, same last-line-wins pattern as sessions.jsonl:
|
|
3
|
+
// <root>/threads.jsonl — the registry (one ThreadEntry line per update)
|
|
4
|
+
// <root>/threads/<id>.jsonl — the per-thread turn log
|
|
5
|
+
// Old sessions.jsonl runs are projected read-only as single-run "legacy" threads so existing users
|
|
6
|
+
// see their history on first launch (HD2). New live threads land in HD3.
|
|
7
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { readSessions } from '../storage/sessions.js';
|
|
11
|
+
import { readFinalReport } from '../storage/runs-read.js';
|
|
12
|
+
import { runDir } from '../storage/runs-read.js';
|
|
13
|
+
import { sanitizeLocalPaths } from '../orchestration/sanitize-paths.js';
|
|
14
|
+
import { threadTitle } from './projections.js';
|
|
15
|
+
export const ThreadStatus = z.enum(['idle', 'running', 'failed', 'cancelled']);
|
|
16
|
+
export const ThreadEntry = z
|
|
17
|
+
.object({
|
|
18
|
+
id: z.string().min(1),
|
|
19
|
+
title: z.string(),
|
|
20
|
+
created_at: z.string(),
|
|
21
|
+
updated_at: z.string(),
|
|
22
|
+
status: ThreadStatus,
|
|
23
|
+
run_ids: z.array(z.string()),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
26
|
+
/** Per-thread turn log (append-only). */
|
|
27
|
+
export const ThreadTurn = z.discriminatedUnion('kind', [
|
|
28
|
+
z.object({ kind: z.literal('user_message'), text: z.string(), attachments: z.array(z.string()), mode: z.string() }).strict(),
|
|
29
|
+
z.object({ kind: z.literal('run_ref'), run_id: z.string(), mode: z.string() }).strict(),
|
|
30
|
+
z.object({ kind: z.literal('followup'), question: z.string(), provider: z.enum(['claude', 'codex', 'agy']), answer: z.string(), call_ms: z.number().nonnegative() }).strict(),
|
|
31
|
+
z.object({ kind: z.literal('gate_receipt'), gate_kind: z.string(), summary: z.string(), decision: z.string() }).strict(),
|
|
32
|
+
z.object({ kind: z.literal('error'), message: z.string() }).strict(),
|
|
33
|
+
]);
|
|
34
|
+
function registryPath(root) {
|
|
35
|
+
return join(root, 'threads.jsonl');
|
|
36
|
+
}
|
|
37
|
+
function threadLogPath(root, id) {
|
|
38
|
+
return join(root, 'threads', `${id}.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
export async function readThreads(root) {
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = await readFile(registryPath(root), 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const byId = new Map();
|
|
49
|
+
for (const line of raw.split('\n')) {
|
|
50
|
+
if (!line.trim())
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const e = ThreadEntry.parse(JSON.parse(line));
|
|
54
|
+
byId.set(e.id, e);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* skip a malformed/legacy line */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [...byId.values()].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
61
|
+
}
|
|
62
|
+
export async function appendThread(root, entry) {
|
|
63
|
+
await mkdir(root, { recursive: true });
|
|
64
|
+
await appendFile(registryPath(root), `${JSON.stringify(ThreadEntry.parse(entry))}\n`);
|
|
65
|
+
}
|
|
66
|
+
export async function appendTurn(root, id, turn) {
|
|
67
|
+
await mkdir(join(root, 'threads'), { recursive: true });
|
|
68
|
+
await appendFile(threadLogPath(root, id), `${JSON.stringify(ThreadTurn.parse(turn))}\n`);
|
|
69
|
+
}
|
|
70
|
+
export async function readTurns(root, id) {
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = await readFile(threadLogPath(root, id), 'utf8');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const turns = [];
|
|
79
|
+
for (const line of raw.split('\n')) {
|
|
80
|
+
if (!line.trim())
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
turns.push(ThreadTurn.parse(JSON.parse(line)));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* skip a malformed line */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return turns;
|
|
90
|
+
}
|
|
91
|
+
const SESSION_TO_THREAD_STATUS = {
|
|
92
|
+
running: 'running',
|
|
93
|
+
ok: 'complete',
|
|
94
|
+
failed: 'failed',
|
|
95
|
+
aborted: 'cancelled',
|
|
96
|
+
};
|
|
97
|
+
/** Project the legacy sessions.jsonl into read-only single-run thread rows. Titles come from the
|
|
98
|
+
* run's own input (idea text) when available; code-review runs (a diff) get a synthesized label. */
|
|
99
|
+
export async function legacyThreads() {
|
|
100
|
+
const sessions = await readSessions();
|
|
101
|
+
return Promise.all(sessions.map(sessionToThreadItem));
|
|
102
|
+
}
|
|
103
|
+
async function sessionToThreadItem(s) {
|
|
104
|
+
return {
|
|
105
|
+
id: s.id,
|
|
106
|
+
title: await legacyTitle(s),
|
|
107
|
+
updatedAt: s.startedAt,
|
|
108
|
+
status: SESSION_TO_THREAD_STATUS[s.status],
|
|
109
|
+
mode: null,
|
|
110
|
+
legacy: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function legacyTitle(s) {
|
|
114
|
+
if (s.workflow === 'code-review')
|
|
115
|
+
return `Code review · ${s.startedAt.slice(0, 10)}`;
|
|
116
|
+
try {
|
|
117
|
+
const dir = runDir(s.id, s.runsRoot);
|
|
118
|
+
const original = await readFile(join(dir, '00-original.md'), 'utf8');
|
|
119
|
+
const firstLine = original.split('\n').find((l) => l.trim()) ?? '';
|
|
120
|
+
return threadTitle(firstLine) || `${s.workflow} · ${s.startedAt.slice(0, 10)}`;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return `${s.workflow} · ${s.startedAt.slice(0, 10)}`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Read-only detail for a legacy thread: the run's final report, path-sanitized. Returns null when
|
|
127
|
+
* the run id isn't a known legacy session. Live threads (HD3) render from their turn log instead. */
|
|
128
|
+
export async function legacyThreadDetail(id) {
|
|
129
|
+
const sessions = await readSessions();
|
|
130
|
+
const s = sessions.find((x) => x.id === id);
|
|
131
|
+
if (!s)
|
|
132
|
+
return null;
|
|
133
|
+
const dir = runDir(s.id, s.runsRoot);
|
|
134
|
+
const report = await readFinalReport(dir);
|
|
135
|
+
const title = await legacyTitle(s);
|
|
136
|
+
return {
|
|
137
|
+
id: s.id,
|
|
138
|
+
title,
|
|
139
|
+
legacy: true,
|
|
140
|
+
resumeRunId: null,
|
|
141
|
+
turns: report
|
|
142
|
+
? [{ kind: 'report_md', markdown: sanitizeLocalPaths(report) }]
|
|
143
|
+
: [{ kind: 'note', text: 'This run left no final report (it did not complete).' }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
Binary file
|