lynkeus 0.3.0 → 0.5.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/lib/cases/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +58 -1
- package/lib/cases/runner.js +322 -39
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +27 -4
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +154 -25
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +189 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +156 -20
- package/lib/config.d.ts +41 -0
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/device/driver.js +2 -2
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +45 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +372 -42
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +27 -4
- package/src/commands/cases.ts +165 -23
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +197 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +162 -18
- package/src/config.ts +48 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/device/driver.ts +2 -2
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +45 -4
- package/src/runtime.ts +24 -3
package/src/commands/report.ts
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { define } from '../registry.js';
|
|
5
|
+
import { ageOf, megabytes, pruneRuns } from '../cases/runDirs.js';
|
|
6
|
+
import { keptOrDeleted } from './cases.js';
|
|
5
7
|
|
|
6
8
|
type Fetch = typeof fetch;
|
|
7
9
|
|
|
@@ -22,11 +24,54 @@ const answer = async (response: Response, doing: string) => {
|
|
|
22
24
|
throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
|
|
23
25
|
};
|
|
24
26
|
|
|
27
|
+
const UPLOADS_AT_ONCE = 4;
|
|
28
|
+
const CASES_PART = 256 * 1024;
|
|
29
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
30
|
+
|
|
31
|
+
// A ustar header holds a name of up to 100 bytes and a folder of up to 155 before it.
|
|
32
|
+
const fitsTar = (file: string) => {
|
|
33
|
+
const cut = file.lastIndexOf('/');
|
|
34
|
+
return Buffer.byteLength(file.slice(cut + 1)) <= 100 && Buffer.byteLength(cut > 0 ? file.slice(0, cut) : '') <= 155;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
|
|
38
|
+
export const tarOf = (entries: [string, Uint8Array][]): Uint8Array => {
|
|
39
|
+
const blocks: Uint8Array[] = [];
|
|
40
|
+
for (const [file, data] of entries) {
|
|
41
|
+
const header = Buffer.alloc(512);
|
|
42
|
+
const cut = file.lastIndexOf('/');
|
|
43
|
+
header.write(file.slice(cut + 1), 0, 'utf8');
|
|
44
|
+
header.write('0000644\0', 100);
|
|
45
|
+
header.write('0000000\0', 108);
|
|
46
|
+
header.write('0000000\0', 116);
|
|
47
|
+
header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124);
|
|
48
|
+
header.write(
|
|
49
|
+
`${Math.floor(Date.now() / 1000)
|
|
50
|
+
.toString(8)
|
|
51
|
+
.padStart(11, '0')}\0`,
|
|
52
|
+
136,
|
|
53
|
+
);
|
|
54
|
+
header.write('0', 156);
|
|
55
|
+
header.write('ustar\0', 257);
|
|
56
|
+
header.write('00', 263);
|
|
57
|
+
if (cut > 0) header.write(file.slice(0, cut), 345, 'utf8');
|
|
58
|
+
header.fill(' ', 148, 156);
|
|
59
|
+
const sum = header.reduce((n, b) => n + b, 0);
|
|
60
|
+
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
|
|
61
|
+
blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
|
62
|
+
}
|
|
63
|
+
blocks.push(Buffer.alloc(1024));
|
|
64
|
+
return Buffer.concat(blocks);
|
|
65
|
+
};
|
|
66
|
+
|
|
25
67
|
/**
|
|
26
68
|
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
27
69
|
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
28
70
|
*/
|
|
29
|
-
export const pushRun = async (
|
|
71
|
+
export const pushRun = async (
|
|
72
|
+
dir: string,
|
|
73
|
+
o: { url: string; key: string; project: string; fetch?: Fetch; onReady?: (url: string) => void; onProgress?: (line: string) => void },
|
|
74
|
+
): Promise<Pushed> => {
|
|
30
75
|
const call = o.fetch ?? fetch;
|
|
31
76
|
const auth = { authorization: `Bearer ${o.key}` };
|
|
32
77
|
const base = o.url.replace(/\/+$/, '');
|
|
@@ -42,31 +87,98 @@ export const pushRun = async (dir: string, o: { url: string; key: string; projec
|
|
|
42
87
|
'creating the run',
|
|
43
88
|
);
|
|
44
89
|
const id = String(created.id);
|
|
45
|
-
|
|
90
|
+
// A dropped connection, a 5xx or a 429 is tried again, waiting longer each time.
|
|
91
|
+
const sendWithRetries = async (target: string, body: Uint8Array, doing: string): Promise<Response> => {
|
|
92
|
+
for (let attempt = 1; ; attempt++) {
|
|
93
|
+
const response = await call(target, { method: 'PUT', headers: auth, body }).catch((error: unknown) => {
|
|
94
|
+
if (attempt >= UPLOAD_ATTEMPTS) throw new Error(`${doing}: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
});
|
|
97
|
+
if (response && ((response.status < 500 && response.status !== 429) || attempt >= UPLOAD_ATTEMPTS)) return response;
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
// The run file in parts of about 256 KB: with every request's headers and bodies it runs to megabytes, more
|
|
102
|
+
// than the service reads in one request. A service from before parts answers without `part`, and then
|
|
103
|
+
// takes the whole file in one.
|
|
104
|
+
const parts: string[] = [];
|
|
105
|
+
for (const line of cases.split('\n').filter((l) => l.trim())) {
|
|
106
|
+
const last = parts.length - 1;
|
|
107
|
+
if (last >= 0 && Buffer.byteLength(parts[last]!) + Buffer.byteLength(line) < CASES_PART) parts[last] += `\n${line}`;
|
|
108
|
+
else parts.push(line);
|
|
109
|
+
}
|
|
110
|
+
for (const [n, part] of parts.entries()) {
|
|
111
|
+
const sent = await answer(await sendWithRetries(`${base}/v1/runs/${id}/cases?part=${n}`, Buffer.from(part), 'sending its cases'), 'sending its cases');
|
|
112
|
+
if (sent.part === undefined) {
|
|
113
|
+
await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// The report opens as soon as its cases are in: their steps, results and requests show at once, and each
|
|
118
|
+
// frame as it arrives.
|
|
119
|
+
const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
|
|
120
|
+
o.onReady?.(String(finished.url));
|
|
46
121
|
|
|
47
|
-
|
|
122
|
+
// Per case: its frames, sent as one tar, and its film, sent on its own (a film can be bigger than a request).
|
|
123
|
+
const work: { frames: string[]; film?: string }[] = [];
|
|
48
124
|
for (const line of cases.split('\n')) {
|
|
49
125
|
if (!line.trim()) continue;
|
|
50
126
|
const c = JSON.parse(line) as { video?: string; steps?: { screenshot?: string }[] };
|
|
51
|
-
|
|
127
|
+
const frames = (c.steps ?? []).map((s) => s.screenshot).filter((p): p is string => !!p && fs.existsSync(path.join(dir, p)));
|
|
128
|
+
const film = c.video && fs.existsSync(path.join(dir, c.video)) ? c.video : undefined;
|
|
129
|
+
work.push({ frames: [...new Set(frames)], ...(film ? { film } : {}) });
|
|
52
130
|
}
|
|
53
131
|
let files = 0;
|
|
54
132
|
const refused: string[] = [];
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
|
|
59
|
-
method: 'PUT',
|
|
60
|
-
headers: auth,
|
|
61
|
-
body: fs.readFileSync(local),
|
|
62
|
-
});
|
|
133
|
+
const sendFile = async (file: string) => {
|
|
134
|
+
const target = `${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`;
|
|
135
|
+
const response = await sendWithRetries(target, fs.readFileSync(path.join(dir, file)), `sending ${file}`);
|
|
63
136
|
if (response.status === 402) refused.push(file);
|
|
64
137
|
else {
|
|
65
138
|
await answer(response, `sending ${file}`);
|
|
66
139
|
files += 1;
|
|
67
140
|
}
|
|
68
|
-
}
|
|
69
|
-
|
|
141
|
+
};
|
|
142
|
+
// A suite with screenshots is thousands of frames: a request per frame took one over 20 minutes to send. A
|
|
143
|
+
// service from before bundles answers 404, and the frames then go one by one, as they used to.
|
|
144
|
+
let bundles = true;
|
|
145
|
+
const sendFrames = async (frames: string[]) => {
|
|
146
|
+
const fits = frames.filter(fitsTar);
|
|
147
|
+
if (bundles && fits.length > 0) {
|
|
148
|
+
const response = await sendWithRetries(
|
|
149
|
+
`${base}/v1/runs/${id}/bundle`,
|
|
150
|
+
tarOf(fits.map((f) => [f, fs.readFileSync(path.join(dir, f))])),
|
|
151
|
+
`sending ${fits.length} frames`,
|
|
152
|
+
);
|
|
153
|
+
if (response.status === 404 || response.status === 405) bundles = false;
|
|
154
|
+
else {
|
|
155
|
+
await answer(response, `sending ${fits.length} frames`);
|
|
156
|
+
files += fits.length;
|
|
157
|
+
for (const f of frames.filter((f) => !fitsTar(f))) await sendFile(f);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const f of frames) await sendFile(f);
|
|
162
|
+
};
|
|
163
|
+
const queue = [...work];
|
|
164
|
+
const total = work.length;
|
|
165
|
+
let sent = 0;
|
|
166
|
+
let bytes = 0;
|
|
167
|
+
const started = Date.now();
|
|
168
|
+
const sizeOf = (c: { frames: string[]; film?: string }) =>
|
|
169
|
+
[...c.frames, ...(c.film ? [c.film] : [])].reduce((n, f) => n + fs.statSync(path.join(dir, f)).size, 0);
|
|
170
|
+
await Promise.all(
|
|
171
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, queue.length) }, async () => {
|
|
172
|
+
for (let c = queue.shift(); c !== undefined; c = queue.shift()) {
|
|
173
|
+
await sendFrames(c.frames);
|
|
174
|
+
if (c.film) await sendFile(c.film);
|
|
175
|
+
sent += 1;
|
|
176
|
+
bytes += sizeOf(c);
|
|
177
|
+
if (sent === total || sent % Math.max(1, Math.ceil(total / 10)) === 0)
|
|
178
|
+
o.onProgress?.(`frames: ${sent}/${total} cases, ${(bytes / 1024 / 1024).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(0)} s`);
|
|
179
|
+
}
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
70
182
|
const changes = finished.changes as Pushed['changes'] | undefined;
|
|
71
183
|
return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
|
|
72
184
|
};
|
|
@@ -118,12 +230,40 @@ export const plan = async (o: Service & { go?: 'upgrade' | 'manage' }) => {
|
|
|
118
230
|
};
|
|
119
231
|
|
|
120
232
|
export const service = (): Service | string => {
|
|
121
|
-
const url = process.env.LYNKEUS_REPORTS_URL;
|
|
233
|
+
const url = process.env.LYNKEUS_REPORTS_URL || 'https://lynkeus.io';
|
|
122
234
|
const key = process.env.LYNKEUS_REPORTS_KEY;
|
|
123
|
-
return
|
|
235
|
+
return key ? { url, key } : 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); LYNKEUS_REPORTS_URL names another service';
|
|
124
236
|
};
|
|
125
237
|
|
|
126
238
|
export const reportCommands = [
|
|
239
|
+
define({
|
|
240
|
+
name: 'report prune',
|
|
241
|
+
group: 'runs',
|
|
242
|
+
summary: 'Delete the runs `case run --out` left in a folder that ended more than a while ago',
|
|
243
|
+
details:
|
|
244
|
+
'Looks at the folder given, or each folder right under it, and deletes the ones that hold a run (`run.json` and `cases.jsonl`) older than --older. Nothing else is touched. Pushed reports stay on the service; this only frees the disk.',
|
|
245
|
+
positionals: [{ name: 'dir', help: 'a run folder, or the folder that holds them' }],
|
|
246
|
+
flags: {
|
|
247
|
+
older: { type: 'string', help: 'how old a run must be: 7d, 12h, 30m', value: 'age', default: '7d' },
|
|
248
|
+
'dry-run': { type: 'boolean', help: 'list what would go, delete nothing' },
|
|
249
|
+
},
|
|
250
|
+
needs: 'nothing',
|
|
251
|
+
session: false,
|
|
252
|
+
mcp: false,
|
|
253
|
+
run: async (ctx) => {
|
|
254
|
+
const root = path.resolve(ctx.args[0] ?? '.');
|
|
255
|
+
if (!fs.existsSync(root)) return { text: `${ctx.args[0]}: no such folder`, code: 2 };
|
|
256
|
+
const older = ageOf(String(ctx.flags.older ?? '7d'));
|
|
257
|
+
if (older === undefined) return { text: `--older ${ctx.flags.older}: give it as 7d, 12h or 30m`, code: 2 };
|
|
258
|
+
const dry = !!ctx.flags['dry-run'];
|
|
259
|
+
const { removed, bytes } = pruneRuns(root, older, dry);
|
|
260
|
+
if (removed.length === 0) return { text: 'no run old enough to delete', json: { removed, bytes } };
|
|
261
|
+
return {
|
|
262
|
+
text: [...removed.map((d) => `${dry ? 'would delete' : 'deleted'} ${d}`), `${dry ? 'would free' : 'freed'} ${megabytes(bytes)}`].join('\n'),
|
|
263
|
+
json: { removed, bytes },
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
127
267
|
define({
|
|
128
268
|
name: 'report push',
|
|
129
269
|
group: 'runs',
|
|
@@ -145,8 +285,12 @@ export const reportCommands = [
|
|
|
145
285
|
if (typeof to === 'string') return { text: to, code: 2 };
|
|
146
286
|
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
147
287
|
try {
|
|
148
|
-
const pushed = await pushRun(dir, { ...to, project });
|
|
149
|
-
const notes = [
|
|
288
|
+
const pushed = await pushRun(dir, { ...to, project, onReady: (url) => ctx.err(`report: ${url} (its frames are on their way)`), onProgress: ctx.err });
|
|
289
|
+
const notes = [
|
|
290
|
+
...describeChanges(pushed.changes),
|
|
291
|
+
...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : []),
|
|
292
|
+
keptOrDeleted(dir, ctx.config.report?.afterPush === 'delete' ? 'delete' : 'keep', pushed.refused.length === 0),
|
|
293
|
+
];
|
|
150
294
|
return { text: [pushed.url, ...notes].join('\n'), json: pushed };
|
|
151
295
|
} catch (error) {
|
|
152
296
|
return { text: (error as Error).message, code: 1 };
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -10,6 +11,33 @@ export const graphFile = (root?: string) => path.join(lynkeusDir(root), 'graph.j
|
|
|
10
11
|
export const edgesFile = (root?: string) => path.join(lynkeusDir(root), 'edges.jsonl');
|
|
11
12
|
export const frontierFile = (root?: string) => path.join(lynkeusDir(root), 'frontier.jsonl');
|
|
12
13
|
export const runsDir = (root?: string) => path.join(lynkeusDir(root), 'runs');
|
|
14
|
+
export const tokenFile = (root?: string) => path.join(lynkeusDir(root), 'token');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
|
|
18
|
+
* `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
|
|
19
|
+
*/
|
|
20
|
+
export const readToken = (root?: string): string | undefined => {
|
|
21
|
+
if (process.env.LYNKEUS_TOKEN) return process.env.LYNKEUS_TOKEN;
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(tokenFile(root), 'utf8').trim() || undefined;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
|
|
30
|
+
export const ensureToken = (root?: string): { token: string; made: boolean } => {
|
|
31
|
+
const had = readToken(root);
|
|
32
|
+
if (had) return { token: had, made: false };
|
|
33
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
34
|
+
fs.mkdirSync(lynkeusDir(root), { recursive: true });
|
|
35
|
+
fs.writeFileSync(tokenFile(root), `${token}\n`, { mode: 0o600 });
|
|
36
|
+
const ignore = path.join(lynkeusDir(root), '.gitignore');
|
|
37
|
+
const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
|
|
38
|
+
if (!ignored.split('\n').includes('token')) fs.writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith('\n') ? '\n' : ''}token\n`);
|
|
39
|
+
return { token, made: true };
|
|
40
|
+
};
|
|
13
41
|
|
|
14
42
|
export const loadGraph = (root?: string): ScreenGraph | null => {
|
|
15
43
|
const file = graphFile(root);
|
|
@@ -23,16 +51,25 @@ export const loadGraph = (root?: string): ScreenGraph | null => {
|
|
|
23
51
|
*/
|
|
24
52
|
export type LynkeusConfig = {
|
|
25
53
|
cases?: CasesConfig;
|
|
54
|
+
/** What `lynkeus ci` brings up around a suite: the project's backend and the app's hosts. */
|
|
55
|
+
ci?: CiConfig;
|
|
26
56
|
/** The headless entry (default `qa/headless.tsx`). */
|
|
27
57
|
entry?: string;
|
|
28
58
|
/** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
|
|
29
59
|
apiEnv?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The local backend `headless start` hands the app under `apiEnv` when the environment sets none: an agent
|
|
62
|
+
* starting the host over MCP cannot export a variable.
|
|
63
|
+
*/
|
|
64
|
+
apiUrl?: string;
|
|
30
65
|
/** The driver port the app dials. */
|
|
31
66
|
port?: number;
|
|
32
67
|
/** Simulator udid or name. */
|
|
33
68
|
device?: string;
|
|
34
69
|
/** Bundle id or application id. */
|
|
35
70
|
app?: string;
|
|
71
|
+
/** What a pushed run's local folder becomes: kept (the default), or deleted once the service has every file. */
|
|
72
|
+
report?: { afterPush?: 'keep' | 'delete' };
|
|
36
73
|
/**
|
|
37
74
|
* The language this app speaks, passed on and never interpreted: `os
|
|
38
75
|
* language` defaults to it, the headless entry mocks this locale, and an MCP
|
|
@@ -93,6 +130,17 @@ export const loadConfig = (root?: string): LynkeusConfig => {
|
|
|
93
130
|
return { ...own, ...extra };
|
|
94
131
|
};
|
|
95
132
|
|
|
133
|
+
export type CiConfig = {
|
|
134
|
+
/** Shell commands that bring the project's backend up and down, and a URL that answers once it can serve. */
|
|
135
|
+
backend?: { up: string; down?: string; ready?: string; timeoutMs?: number };
|
|
136
|
+
/** How many hosts of the app, and the command that starts one (`{port}` is where it dials). Default: headless. */
|
|
137
|
+
hosts?: { count?: number; command?: string; timeoutMs?: number };
|
|
138
|
+
/** Upload the run (needs LYNKEUS_REPORTS_KEY). */
|
|
139
|
+
push?: boolean;
|
|
140
|
+
/** Extra `case run` flags, as on the command line. */
|
|
141
|
+
run?: string[];
|
|
142
|
+
};
|
|
143
|
+
|
|
96
144
|
export const portFromEnv = (config: LynkeusConfig = {}): number | undefined => (process.env.LYNKEUS_PORT ? Number(process.env.LYNKEUS_PORT) : config.port);
|
|
97
145
|
|
|
98
146
|
/** A list of regular expressions from the configuration, as one that matches any of them. */
|
|
@@ -13,3 +13,20 @@ export const agentMismatch = (hello: Pick<HelloEvent, 'agentVersion'>, cli = CLI
|
|
|
13
13
|
const agent = hello.agentVersion ? `lynkeus-agent ${hello.agentVersion}` : 'a lynkeus-agent too old to say its version';
|
|
14
14
|
return `⚠️ The app runs ${agent}, this CLI is lynkeus ${cli}: they can misread each other without an error. Install lynkeus-agent@${cli} in the app and rebuild it (or run the lynkeus CLI of the agent's version).`;
|
|
15
15
|
};
|
|
16
|
+
|
|
17
|
+
/** Who said hello: the app and where it runs, enough to tell two apps dialing the same port apart. */
|
|
18
|
+
export const helloIdentity = (hello: Pick<HelloEvent, 'platform' | 'native' | 'app'>): string => {
|
|
19
|
+
const app = hello.app ?? {};
|
|
20
|
+
const name = typeof app.name === 'string' ? app.name : 'an app';
|
|
21
|
+
const runtime = typeof app.runtime === 'string' ? app.runtime : hello.native ? 'native' : 'hosted';
|
|
22
|
+
return `${name} (${hello.platform}, ${runtime})`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The driver keeps the app that dialed in last. Two apps or hosts dialing the same port take turns
|
|
27
|
+
* without an error, each step going to whichever came last: say so the first time a different one comes in.
|
|
28
|
+
*/
|
|
29
|
+
export const appSwitched = (before: string | undefined, now: string, port: number): string | undefined =>
|
|
30
|
+
before && before !== now
|
|
31
|
+
? `⚠️ Another app attached on port ${port}: ${before} gave way to ${now}. Two apps or hosts are dialing this port and steps go to whichever dialed last; stop one, or give each its own LYNKEUS_PORT.`
|
|
32
|
+
: undefined;
|
package/src/device/devices.ts
CHANGED
|
@@ -63,6 +63,11 @@ export const pickAgentDevice = async (wanted?: string): Promise<DeviceInfo> => {
|
|
|
63
63
|
const devices = await bootedSimulators();
|
|
64
64
|
const id = wanted ?? process.env.LYNKEUS_DEVICE;
|
|
65
65
|
const device = id ? devices.find((d) => d.id === id || d.name.includes(id)) : devices[0];
|
|
66
|
+
if (!device && id) {
|
|
67
|
+
// An adb serial names an Android device; which platform the app is on is only known once it dials in.
|
|
68
|
+
const android = (await androidDevices()).find((d) => d.id === id);
|
|
69
|
+
if (android) return android;
|
|
70
|
+
}
|
|
66
71
|
if (!device) {
|
|
67
72
|
// A headless host dials the driver itself: nothing to boot or attach to.
|
|
68
73
|
if (!id && devices.length === 0) return HEADLESS_HOST;
|
package/src/device/driver.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { AgentServer } from 'lynkeus-client';
|
|
7
7
|
import type { CoreMethods, Screen, TraceEvent } from 'lynkeus-protocol';
|
|
8
8
|
|
|
9
|
-
import { portFromEnv } from '../config.js';
|
|
9
|
+
import { portFromEnv, readToken } from '../config.js';
|
|
10
10
|
import { withoutHost } from '../knowledge/problems.js';
|
|
11
11
|
import type { DeviceInfo } from './devices.js';
|
|
12
12
|
import { lifecycleFor, type PlatformLifecycle } from './platform.js';
|
|
@@ -23,7 +23,7 @@ export class Device {
|
|
|
23
23
|
this.info = info;
|
|
24
24
|
this.#resolving = options.resolving;
|
|
25
25
|
// The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
|
|
26
|
-
this.server = options.server ?? new AgentServer({ token:
|
|
26
|
+
this.server = options.server ?? new AgentServer({ token: readToken(), port: portFromEnv() });
|
|
27
27
|
this.#lifecycle = lifecycleFor(info.platform);
|
|
28
28
|
this.#appId = options.appId ?? null;
|
|
29
29
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The analytics, crash and attribution services an app talks to over HTTP, known by where it sends: a JS
|
|
2
|
+
// SDK (Segment, PostHog, Amplitude's browser SDK) never touches a native module, so its events show up only
|
|
3
|
+
// as requests. Each request becomes the events its body carries, or one event named after the call.
|
|
4
|
+
import type { RequestRecord } from 'lynkeus-protocol';
|
|
5
|
+
|
|
6
|
+
export type TrafficEvent = { provider: string; name: string; props?: Record<string, unknown> };
|
|
7
|
+
|
|
8
|
+
type Plain = Record<string, unknown>;
|
|
9
|
+
// `provider` is the service the request went to: a reader of one shape serves every service that copies it.
|
|
10
|
+
type Reader = (body: unknown, path: string, provider: string) => TrafficEvent[] | undefined;
|
|
11
|
+
|
|
12
|
+
const isPlain = (v: unknown): v is Plain => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
const plain = (v: unknown): Plain | undefined => (isPlain(v) ? v : undefined);
|
|
14
|
+
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v === undefined ? [] : [v]);
|
|
15
|
+
|
|
16
|
+
const named = (provider: string, items: unknown[], name: (item: Plain) => unknown, props: (item: Plain) => unknown): TrafficEvent[] =>
|
|
17
|
+
items.filter(isPlain).flatMap((item) => {
|
|
18
|
+
const n = name(item);
|
|
19
|
+
if (typeof n !== 'string' || !n) return [];
|
|
20
|
+
const p = plain(props(item));
|
|
21
|
+
return [{ provider, name: n, ...(p ? { props: p } : {}) }];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const segment: Reader = (body, _path, provider) => {
|
|
25
|
+
const b = plain(body);
|
|
26
|
+
return named(
|
|
27
|
+
provider,
|
|
28
|
+
list(b?.batch ?? b),
|
|
29
|
+
(i) => i.event ?? (i.type === 'screen' || i.type === 'page' ? `${i.type}: ${i.name}` : i.type),
|
|
30
|
+
(i) => i.properties ?? i.traits,
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
const amplitude: Reader = (body) =>
|
|
34
|
+
named(
|
|
35
|
+
'amplitude',
|
|
36
|
+
list(plain(body)?.events),
|
|
37
|
+
(i) => i.event_type,
|
|
38
|
+
(i) => i.event_properties,
|
|
39
|
+
);
|
|
40
|
+
const posthog: Reader = (body) => {
|
|
41
|
+
const b = plain(body);
|
|
42
|
+
return named(
|
|
43
|
+
'posthog',
|
|
44
|
+
list(b?.batch ?? (Array.isArray(body) ? body : b)),
|
|
45
|
+
(i) => i.event,
|
|
46
|
+
(i) => i.properties,
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
const mixpanel: Reader = (body) =>
|
|
50
|
+
named(
|
|
51
|
+
'mixpanel',
|
|
52
|
+
list(body),
|
|
53
|
+
(i) => i.event ?? (i.$set ? '$set' : undefined),
|
|
54
|
+
(i) => i.properties ?? i.$set,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Host pattern → the service, and how to read its body when there is a known shape. */
|
|
58
|
+
const SERVICES: { host: RegExp; provider: string; read?: Reader }[] = [
|
|
59
|
+
{ host: /(^|\.)segment\.(io|com)$/, provider: 'segment', read: segment },
|
|
60
|
+
{ host: /(^|\.)amplitude\.com$/, provider: 'amplitude', read: amplitude },
|
|
61
|
+
{ host: /(^|\.)posthog\.com$/, provider: 'posthog', read: posthog },
|
|
62
|
+
{ host: /(^|\.)mixpanel\.com$/, provider: 'mixpanel', read: mixpanel },
|
|
63
|
+
{ host: /(^|\.)sentry\.io$|^sentry\./, provider: 'sentry' },
|
|
64
|
+
{ host: /(^|\.)(datadoghq\.(com|eu)|ddog-gov\.com)$/, provider: 'datadog' },
|
|
65
|
+
{ host: /(^|\.)app-measurement\.com$|^firebaselogging/, provider: 'firebase' },
|
|
66
|
+
{ host: /(^|\.)appsflyer(sdk)?\.com$/, provider: 'appsflyer' },
|
|
67
|
+
{ host: /(^|\.)branch\.io$/, provider: 'branch' },
|
|
68
|
+
{ host: /(^|\.)braze\.(com|eu)$/, provider: 'braze' },
|
|
69
|
+
{ host: /(^|\.)onesignal\.com$/, provider: 'onesignal' },
|
|
70
|
+
{ host: /(^|\.)intercom\.(io|com)$/, provider: 'intercom' },
|
|
71
|
+
{ host: /(^|\.)bugsnag\.com$/, provider: 'bugsnag' },
|
|
72
|
+
{ host: /(^|\.)adjust\.(com|net|world)$/, provider: 'adjust' },
|
|
73
|
+
{ host: /(^|\.)clevertap-prod\.com$|(^|\.)wzrkt\.com$/, provider: 'clevertap' },
|
|
74
|
+
{ host: /(^|\.)heapanalytics\.com$/, provider: 'heap' },
|
|
75
|
+
{ host: /(^|\.)statsig(api)?\.(com|net)$/, provider: 'statsig' },
|
|
76
|
+
{ host: /(^|\.)launchdarkly\.com$/, provider: 'launchdarkly' },
|
|
77
|
+
// Segment-compatible: the same batch shape.
|
|
78
|
+
{ host: /(^|\.)rudderstack\.com$|(^|\.)rudderlabs\.com$/, provider: 'rudderstack', read: segment },
|
|
79
|
+
{ host: /^(track|cdp|track-eu|cdp-eu)\.customer\.io$/, provider: 'customerio', read: segment },
|
|
80
|
+
{ host: /(^|\.)google-analytics\.com$|(^|\.)analytics\.google\.com$/, provider: 'googleanalytics' },
|
|
81
|
+
{ host: /(^|\.)googletagmanager\.com$/, provider: 'googletagmanager' },
|
|
82
|
+
{ host: /^graph\.facebook\.com$/, provider: 'meta' },
|
|
83
|
+
{ host: /(^|\.)tiktok(v)?\.com$|(^|\.)tiktokapis\.com$/, provider: 'tiktok' },
|
|
84
|
+
{ host: /(^|\.)iterable\.com$/, provider: 'iterable' },
|
|
85
|
+
{ host: /(^|\.)moengage\.com$/, provider: 'moengage' },
|
|
86
|
+
{ host: /(^|\.)kochava\.com$/, provider: 'kochava' },
|
|
87
|
+
{ host: /(^|\.)singular\.net$/, provider: 'singular' },
|
|
88
|
+
{ host: /(^|\.)emb-api\.com$|(^|\.)embrace\.io$/, provider: 'embrace' },
|
|
89
|
+
{ host: /(^|\.)instabug\.com$|(^|\.)luciq\.ai$/, provider: 'instabug' },
|
|
90
|
+
{ host: /(^|\.)smartlook\.(cloud|com)$/, provider: 'smartlook' },
|
|
91
|
+
{ host: /(^|\.)urbanairship\.com$|(^|\.)airship\.(com|eu)$|(^|\.)asnapieu\.com$/, provider: 'airship' },
|
|
92
|
+
{ host: /(^|\.)snplow\.net$|(^|\.)snowplowanalytics\.com$/, provider: 'snowplow' },
|
|
93
|
+
{ host: /(^|\.)fullstory\.com$/, provider: 'fullstory' },
|
|
94
|
+
{ host: /(^|\.)mparticle\.com$/, provider: 'mparticle' },
|
|
95
|
+
{ host: /(^|\.)pendo\.io$/, provider: 'pendo' },
|
|
96
|
+
{ host: /(^|\.)lr-ingest\.(io|com)$|(^|\.)logrocket\.(io|com)$/, provider: 'logrocket' },
|
|
97
|
+
{ host: /(^|\.)newrelic\.com$|(^|\.)nr-data\.net$/, provider: 'newrelic' },
|
|
98
|
+
{ host: /(^|\.)hotjar\.(com|io)$/, provider: 'hotjar' },
|
|
99
|
+
{ host: /(^|\.)count\.ly$/, provider: 'countly' },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const parse = (text: string | undefined): unknown => {
|
|
103
|
+
if (!text) return undefined;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(text);
|
|
106
|
+
} catch {
|
|
107
|
+
// Mixpanel's classic endpoint sends `data=<base64 JSON>` as a form.
|
|
108
|
+
const data = /(?:^|&)data=([^&]+)/.exec(text)?.[1];
|
|
109
|
+
if (!data) return undefined;
|
|
110
|
+
try {
|
|
111
|
+
const raw = decodeURIComponent(data);
|
|
112
|
+
return JSON.parse(raw.startsWith('[') || raw.startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** The service a request went to, when it is one of the known ones. */
|
|
120
|
+
export const serviceOf = (url: string): string | undefined => {
|
|
121
|
+
try {
|
|
122
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
123
|
+
return SERVICES.find((s) => s.host.test(host))?.provider;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
|
|
130
|
+
export const trafficEvents = (requests: RequestRecord[]): TrafficEvent[] =>
|
|
131
|
+
requests.flatMap((r) => {
|
|
132
|
+
let url: URL;
|
|
133
|
+
try {
|
|
134
|
+
url = new URL(r.url);
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const service = SERVICES.find((s) => s.host.test(url.hostname.toLowerCase()));
|
|
139
|
+
if (!service) return [];
|
|
140
|
+
const events = service.read?.(parse(r.request?.body), url.pathname, service.provider);
|
|
141
|
+
return events?.length ? events : [{ provider: service.provider, name: `${r.method} ${url.pathname}` }];
|
|
142
|
+
});
|
package/src/knowledge/errors.ts
CHANGED
|
@@ -47,6 +47,12 @@ const RULES: Rule[] = [
|
|
|
47
47
|
cause: (m) => `nothing is listening on ${m[1]}:${m[2]}`,
|
|
48
48
|
fix: () => `start the backend (or the lynkeus driver, if this is the agent's socket) and check the port the entry dials.`,
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
// Babel loads the project's config with require(); a plugin shipped as ESM only loads that way from Node 22.12.
|
|
52
|
+
re: /ERR_REQUIRE_ESM(?:[^\n]*?require\(\) of ES Module (\S+))?|require\(\) of ES Module (\S+)/,
|
|
53
|
+
cause: (m) => `Node ${process.versions.node} cannot require() an ES module${m[1] || m[2] ? ` (${m[1] || m[2]})` : ''}, which the babel config loads`,
|
|
54
|
+
fix: () => `run with Node 22.12 or newer (lynkeus-headless needs >=22), where require() loads ES modules.`,
|
|
55
|
+
},
|
|
50
56
|
{
|
|
51
57
|
re: /Test environment jest-environment-\w+ cannot be found|Unexpected token 'export'|SyntaxError: Cannot use import statement/,
|
|
52
58
|
cause: () => `jest is not transforming a dependency (ESM in node_modules)`,
|
package/src/knowledge/events.ts
CHANGED
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import { readJsonl } from '../files.js';
|
|
5
5
|
|
|
6
6
|
export type AppEvent = { name: string; props?: Record<string, unknown> };
|
|
7
|
-
|
|
7
|
+
/** `provider`: the service the app's own list feeds, when the project says (`cases.appEvents`). */
|
|
8
|
+
export type RunEvent = AppEvent & { step: number; route?: string; provider?: string };
|
|
8
9
|
|
|
9
10
|
type Drainable = { command<T = unknown>(name: string, params?: unknown): Promise<T> };
|
|
10
11
|
|
package/src/knowledge/why.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type Evidence = {
|
|
|
14
14
|
trace?: TraceEvent[];
|
|
15
15
|
/** What the failed command was looking for. */
|
|
16
16
|
target?: { testId?: string; text?: string; route?: string };
|
|
17
|
+
/** Whether the app made any request before `requests` begins: `requests` may be only those of the failed command. */
|
|
18
|
+
fetchedBefore?: boolean;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type Finding = { rule: string; message: string };
|
|
@@ -159,7 +161,7 @@ export const explain = (e: Evidence): Finding[] => {
|
|
|
159
161
|
message: `${e.inFlight} request(s) still in flight when the command gave up: the screen had not finished loading. A longer timeout, or wait for the request it needs.`,
|
|
160
162
|
});
|
|
161
163
|
|
|
162
|
-
if (done.length === 0 && e.inFlight === 0 && e.target?.route)
|
|
164
|
+
if (done.length === 0 && e.inFlight === 0 && e.target?.route && !e.fetchedBefore)
|
|
163
165
|
out.push({
|
|
164
166
|
rule: 'no-requests',
|
|
165
167
|
message: 'no request left the app while this ran: nothing was fetched. Is the user logged in, and is the app talking to the backend it should?',
|
package/src/mcp/server.ts
CHANGED
|
@@ -60,9 +60,25 @@ const argvFor = (c: Command, input: Record<string, unknown>): string[] => {
|
|
|
60
60
|
return av;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
|
|
64
|
+
// a test for this" need nothing more from the person asking.
|
|
65
|
+
const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
|
|
66
|
+
|
|
67
|
+
In a project you do not know, call status first: it lists what is set up and, for what is not, the one thing that fixes it.
|
|
68
|
+
|
|
69
|
+
To set lynkeus up: call init (it installs lynkeus-agent, mounts it from the app's entry and makes the project's token; nobody sets a variable). The agent has native code, so the app must then be built again (npx react-native run-ios or run-android; npx expo run:ios or run:android, never Expo Go) with Metro restarted with --reset-cache. Do that with your shell if you have one, or ask the person to. Then status until an app is attached.
|
|
70
|
+
|
|
71
|
+
To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there (press with settle: true answers with the screen once the app has settled; a screen read right after a press can still be the one it left); prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
|
|
72
|
+
|
|
73
|
+
To test without a simulator: call headless_init (it installs what the Node host needs and writes its entry; an Expo Router app works too), then headless_doctor for the native modules still to mock, then case_run with host. The backend the app talks to must be local: set apiUrl in lynkeus.config.json.
|
|
74
|
+
|
|
75
|
+
To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.
|
|
76
|
+
|
|
77
|
+
For a run with everything (frames, film, requests, analytics, backend): case_run with out, screenshots, video (film needs fastsim), traces and push. When the run ends it warns about evidence it could not keep: pass that on.`;
|
|
78
|
+
|
|
63
79
|
export const serve = async (session: Session, options: { full?: boolean } = {}) => {
|
|
64
80
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
|
|
65
|
-
const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
|
|
81
|
+
const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
66
82
|
const served = commands().filter((c) => overMcp(c, !!options.full));
|
|
67
83
|
const byName = new Map(served.map((c) => [identifier(c.name), c]));
|
|
68
84
|
// So a model reading the app's screens knows their language before it starts guessing.
|
|
@@ -101,9 +117,34 @@ export const serve = async (session: Session, options: { full?: boolean } = {})
|
|
|
101
117
|
if (request.params.name === 'app_locale' && locale) return text(locale);
|
|
102
118
|
const c = byName.get(request.params.name);
|
|
103
119
|
if (!c) return { ...text(`✗ no tool ${request.params.name}`), isError: true };
|
|
104
|
-
const
|
|
120
|
+
const input = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
121
|
+
// An argument the tool does not take is dropped by argvFor; a run would then go on without it (every case
|
|
122
|
+
// instead of the one named), so it is refused, with what the tool does take.
|
|
123
|
+
const known = Object.keys(schemaFor(c).properties);
|
|
124
|
+
const unknown = Object.keys(input).filter((key) => !known.includes(key));
|
|
125
|
+
if (unknown.length)
|
|
126
|
+
return { ...text(`✗ ${request.params.name} takes no ${unknown.join(', ')}; it takes ${known.join(', ') || 'no arguments'}`), isError: true };
|
|
127
|
+
const r = await execute(argvFor(c, input), session.ctx);
|
|
105
128
|
if (r.error) return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
|
|
106
|
-
|
|
129
|
+
const body = text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
|
|
130
|
+
// A command that ended with a non-zero code (a failed case, a server that does not keep the contract) is
|
|
131
|
+
// reported as such, with everything it said, so a model does not read it as success.
|
|
132
|
+
return r.code === 0 ? body : { ...body, isError: true };
|
|
107
133
|
});
|
|
108
|
-
|
|
134
|
+
// The client going away ends the server: the session holds the driver's port open, and a server left behind
|
|
135
|
+
// keeps the next one (or a case run) from binding it.
|
|
136
|
+
const transport = new StdioServerTransport();
|
|
137
|
+
let stopping = false;
|
|
138
|
+
const stop = () => {
|
|
139
|
+
if (stopping) return;
|
|
140
|
+
stopping = true;
|
|
141
|
+
void session
|
|
142
|
+
.stop()
|
|
143
|
+
.catch(() => undefined)
|
|
144
|
+
.finally(() => process.exit(0));
|
|
145
|
+
};
|
|
146
|
+
process.stdin.once('end', stop);
|
|
147
|
+
process.stdin.once('close', stop);
|
|
148
|
+
transport.onclose = stop;
|
|
149
|
+
await server.connect(transport);
|
|
109
150
|
};
|