@xuda.io/ai_module 1.1.5656 → 1.1.5658
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/index.mjs +1294 -41
- package/index_ms.mjs +32 -0
- package/index_msa.mjs +32 -0
- package/package.json +2 -2
- package/xudex_engines.mjs +323 -0
- package/xudex_mirror.mjs +200 -0
- package/xudex_preview.mjs +263 -0
- package/xudex_run.mjs +231 -0
- package/xudex_runtime.mjs +200 -0
- package/xudex_tracker.mjs +276 -0
- package/xudex_verify.mjs +272 -0
- package/xudex_vm.mjs +218 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Xudex preview: the project, actually running.
|
|
2
|
+
//
|
|
3
|
+
// docs/plans/xudex.md section 7, and the last gate of the verify loop in section 6. The verify
|
|
4
|
+
// loop already left the seam open (`runtime_check`); this is what plugs into it.
|
|
5
|
+
//
|
|
6
|
+
// ── Why this is a gate and not a feature ───────────────────────────────────────────────
|
|
7
|
+
// A change can typecheck, pass its tests and build cleanly, and still take the app down the
|
|
8
|
+
// moment it loads: a bad import that only the bundler resolves, a missing environment variable,
|
|
9
|
+
// a component that throws on mount. Those are exactly the failures a customer notices first and
|
|
10
|
+
// the ones a terminal-shaped agent never sees. So "does it still run" is a gate like any other,
|
|
11
|
+
// and its output goes back to the engine through the same repair path as a failing test.
|
|
12
|
+
//
|
|
13
|
+
// ── Two layers, and only one of them exists yet ────────────────────────────────────────
|
|
14
|
+
// SERVER SIDE (built here): we start the dev server ourselves, so a process that dies, refuses
|
|
15
|
+
// to bind, or prints a compile error is visible without a browser at all. That catches most
|
|
16
|
+
// breakage, and it works today on every substrate.
|
|
17
|
+
//
|
|
18
|
+
// BROWSER SIDE (not built): console errors and failed requests from the loaded page, which is
|
|
19
|
+
// what `utils/_autodebug_detect.mjs` already does for deployed apps. It needs a URL the outside
|
|
20
|
+
// world can reach, and `expose_port` returns null until the VM substrate and its proxy land
|
|
21
|
+
// (build order 3.8 and section 7). The seam is here and reports "not checked" rather than
|
|
22
|
+
// pretending a page nobody loaded was fine.
|
|
23
|
+
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
if (!global._conf) {
|
|
27
|
+
global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const preview_conf = () => global._conf.xudex?.preview || {};
|
|
31
|
+
const num = (key, fallback) => Number(preview_conf()[key]) || fallback;
|
|
32
|
+
|
|
33
|
+
// Where a project's dev server is told to listen. Fixed per project rather than random, so a
|
|
34
|
+
// restart lands on the same port and anything holding a URL keeps working.
|
|
35
|
+
export const preview_port = function (project_id, base = num('base_port', 4300), span = num('port_span', 200)) {
|
|
36
|
+
let h = 0;
|
|
37
|
+
const s = String(project_id || '');
|
|
38
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
39
|
+
return base + (h % span);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// What starts this project. `dev` first because that is the one with hot reload, which is the
|
|
43
|
+
// difference between a preview and a screenshot.
|
|
44
|
+
export const detect_dev_command = function (manifest, package_manager = 'npm') {
|
|
45
|
+
const scripts = manifest?.scripts || {};
|
|
46
|
+
const script = scripts.dev ? 'dev' : scripts.start ? 'start' : null;
|
|
47
|
+
if (!script) return null;
|
|
48
|
+
return { script, argv: package_manager === 'npm' ? ['npm', 'run', script] : [package_manager, 'run', script] };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Signatures that mean the app is broken rather than merely noisy. Deliberately a small list of
|
|
52
|
+
// things that are unambiguous: a dev server prints a great deal at startup, and treating any
|
|
53
|
+
// mention of the word "error" as a failure would make this gate cry wolf until somebody turned
|
|
54
|
+
// it off, which is the worst outcome available.
|
|
55
|
+
const FATAL_PATTERNS = [
|
|
56
|
+
// Not `Error: Cannot find module`. Node's ESM loader says
|
|
57
|
+
// `Error [ERR_MODULE_NOT_FOUND]: Cannot find module '...' imported from ...`, so anchoring on
|
|
58
|
+
// `Error:` misses the single most common way a broken import kills a dev server. CommonJS says
|
|
59
|
+
// `Error: Cannot find module`, so matching the phrase alone covers both.
|
|
60
|
+
/Cannot find module/,
|
|
61
|
+
/ERR_MODULE_NOT_FOUND/,
|
|
62
|
+
/\bModule not found\b/,
|
|
63
|
+
/\bSyntaxError\b/,
|
|
64
|
+
/\bReferenceError\b/,
|
|
65
|
+
/\bTypeError\b.*\n\s+at /,
|
|
66
|
+
/\bEADDRINUSE\b/,
|
|
67
|
+
/Failed to compile/i,
|
|
68
|
+
/\[vite\].*error/i,
|
|
69
|
+
/error during build/i,
|
|
70
|
+
/Cannot resolve/i,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Collapse near-identical lines, the same way the autodebug detector does, so a server that
|
|
74
|
+
// prints its one error forty times reports one error rather than forty.
|
|
75
|
+
const normalize = (line) =>
|
|
76
|
+
String(line)
|
|
77
|
+
.replace(/:\d+:\d+/g, ':L:C')
|
|
78
|
+
.replace(/\?t=\d+/g, '?t=*')
|
|
79
|
+
.replace(/\b0x[0-9a-f]+\b/gi, '0x*')
|
|
80
|
+
.trim();
|
|
81
|
+
|
|
82
|
+
// Lines that are part of a failure but are not the STATEMENT of it: stack frames, the caret Node
|
|
83
|
+
// prints under an offending token, and its own internals. They are still captured, because they
|
|
84
|
+
// arrive in the block below the anchor, but anchoring ON one produces a second entry for a failure
|
|
85
|
+
// already reported and leads with `throw new ERR_MODULE_NOT_FOUND(`, which tells the engine
|
|
86
|
+
// nothing it can act on.
|
|
87
|
+
const NOISE_LINE = /^\s*(at\s|throw\s|\^+\s*$)|node:internal\//;
|
|
88
|
+
|
|
89
|
+
export const extract_errors = function (log, max = num('max_errors', 8)) {
|
|
90
|
+
const lines = String(log || '').split('\n');
|
|
91
|
+
const found = new Map();
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const line = lines[i];
|
|
94
|
+
if (NOISE_LINE.test(line)) continue;
|
|
95
|
+
if (!FATAL_PATTERNS.some((p) => p.test(line))) continue;
|
|
96
|
+
// Keep the following lines too: the message names what broke, the stack says where.
|
|
97
|
+
const block = lines
|
|
98
|
+
.slice(i, i + 4)
|
|
99
|
+
.join('\n')
|
|
100
|
+
.trim();
|
|
101
|
+
const key = normalize(line);
|
|
102
|
+
if (!found.has(key)) found.set(key, block);
|
|
103
|
+
if (found.size >= max) break;
|
|
104
|
+
}
|
|
105
|
+
return [...found.values()];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Single-quote for the shell, the same helper shape index.mjs already uses for detached code runs.
|
|
109
|
+
// Anything built with JSON.stringify goes through the outer shell's expansion first, which is how
|
|
110
|
+
// `$$` in the launch line silently became the wrong pid.
|
|
111
|
+
const shq = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
112
|
+
|
|
113
|
+
export const create_preview = function ({ conf = preview_conf, now = () => Date.now() } = {}) {
|
|
114
|
+
const log_path = (project_id) => `/tmp/xudex-preview-${String(project_id).replace(/[^a-zA-Z0-9_-]/g, '')}.log`;
|
|
115
|
+
const pid_path = (project_id) => `/tmp/xudex-preview-${String(project_id).replace(/[^a-zA-Z0-9_-]/g, '')}.pid`;
|
|
116
|
+
|
|
117
|
+
const is_listening = async function (workspace, port) {
|
|
118
|
+
// Node is guaranteed present (this is a Node project by definition) and curl is not, so the
|
|
119
|
+
// check is a one-liner rather than a dependency.
|
|
120
|
+
const probe = `node -e "const n=require('net');const s=n.connect(${port},'127.0.0.1');s.on('connect',()=>{console.log('up');s.end();process.exit(0)});s.on('error',()=>{console.log('down');process.exit(0)});setTimeout(()=>{console.log('down');process.exit(0)},1500)"`;
|
|
121
|
+
const r = await workspace.exec(['sh', '-c', probe], { timeout_ms: 10000 });
|
|
122
|
+
return (r.stdout || '').includes('up');
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
preview_port,
|
|
127
|
+
detect_dev_command,
|
|
128
|
+
extract_errors,
|
|
129
|
+
|
|
130
|
+
async status(workspace, project_id, port) {
|
|
131
|
+
const p = port || preview_port(project_id);
|
|
132
|
+
return { port: p, listening: await is_listening(workspace, p) };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Stop, and WAIT until it has actually stopped.
|
|
136
|
+
//
|
|
137
|
+
// Two things are easy to get wrong here and both were caught by the tests rather than reasoned
|
|
138
|
+
// about. First, kill the process GROUP: a dev server spawns a bundler and a watcher, and
|
|
139
|
+
// signalling only the recorded pid leaves them holding the port, which then looks like the
|
|
140
|
+
// next start failing for no reason. Second, a signal is ASYNCHRONOUS: returning as soon as it
|
|
141
|
+
// is sent means `stop()` can be followed immediately by a `start()` that cannot bind. On Linux
|
|
142
|
+
// that race lost consistently; on macOS it happened to win, which is the worst kind of bug.
|
|
143
|
+
async stop(workspace, project_id, port) {
|
|
144
|
+
const pf = pid_path(project_id);
|
|
145
|
+
const p = port || preview_port(project_id);
|
|
146
|
+
// Signal the recorded process group AND whatever is actually holding the port. The pid file
|
|
147
|
+
// is the normal path; the port sweep is what handles a machine that has been left with an
|
|
148
|
+
// orphan by a crashed run, a reboot, or a bug like the quoting one above. The PORT is the
|
|
149
|
+
// resource being managed, so "stop whatever is on it" is the honest definition of stop, and
|
|
150
|
+
// without it a single orphan makes that project unusable until somebody logs in.
|
|
151
|
+
const signal = async (sig) => {
|
|
152
|
+
const by_pidfile = `[ -f ${pf} ] && kill -${sig} -$(cat ${pf}) 2>/dev/null; [ -f ${pf} ] && kill -${sig} $(cat ${pf}) 2>/dev/null`;
|
|
153
|
+
// fuser and lsof are each missing on plenty of images, so try both and shrug if neither is
|
|
154
|
+
// there: the pid-file path above is still the primary.
|
|
155
|
+
const by_port = `(command -v fuser >/dev/null 2>&1 && fuser -k -${sig} ${p}/tcp 2>/dev/null) || (command -v lsof >/dev/null 2>&1 && kill -${sig} $(lsof -t -i:${p} 2>/dev/null) 2>/dev/null)`;
|
|
156
|
+
await workspace.exec(['sh', '-c', `${by_pidfile}; ${by_port}; true`], { timeout_ms: 20000 });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
await signal('TERM');
|
|
160
|
+
const deadline = now() + num('stop_timeout_ms', 8000);
|
|
161
|
+
while (now() < deadline) {
|
|
162
|
+
if (!(await is_listening(workspace, p))) {
|
|
163
|
+
await workspace.exec(['sh', '-c', `rm -f ${pf}`], { timeout_ms: 10000 });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// It ignored the polite request. A dev server that will not exit is holding a port another
|
|
170
|
+
// run needs, so it does not get a vote.
|
|
171
|
+
await signal('KILL');
|
|
172
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
173
|
+
const still = await is_listening(workspace, p);
|
|
174
|
+
await workspace.exec(['sh', '-c', `rm -f ${pf}`], { timeout_ms: 10000 });
|
|
175
|
+
return !still;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// Start the dev server and wait for it to answer. Detached with its output redirected to a
|
|
179
|
+
// file, because a dev server outlives the call that started it: waiting on it would hang the
|
|
180
|
+
// run forever, which is precisely what `exec` is designed not to allow.
|
|
181
|
+
async start(workspace, { project_id, manifest, package_manager = 'npm', port, timeout_ms = num('start_timeout_ms', 90000) } = {}) {
|
|
182
|
+
const cmd = detect_dev_command(manifest, package_manager);
|
|
183
|
+
if (!cmd) return { started: false, why: 'this project has no dev or start script' };
|
|
184
|
+
|
|
185
|
+
const p = port || preview_port(project_id);
|
|
186
|
+
const lf = log_path(project_id);
|
|
187
|
+
const pf = pid_path(project_id);
|
|
188
|
+
|
|
189
|
+
if (await is_listening(workspace, p)) return { started: true, already: true, port: p, log: lf };
|
|
190
|
+
|
|
191
|
+
await workspace.exec(['sh', '-c', `rm -f ${lf}`], { timeout_ms: 10000 });
|
|
192
|
+
// Same shape UI-220 already proved for detached code runs, and for the same two reasons.
|
|
193
|
+
//
|
|
194
|
+
// `setsid` makes the launched shell a SESSION LEADER, which is what lets `kill -pid` reach
|
|
195
|
+
// the dev server plus the bundler and watcher it spawns. Backgrounding alone does not: the
|
|
196
|
+
// recorded pid would be the wrapper, and killing it leaves children holding the port, which
|
|
197
|
+
// then looks like the next start failing for no reason.
|
|
198
|
+
//
|
|
199
|
+
// The shell writes its own `$$` and then EXECs, so the recorded pid IS the server rather
|
|
200
|
+
// than a parent that is about to disappear.
|
|
201
|
+
//
|
|
202
|
+
// `setsid` is util-linux and is absent on macOS, so it is resolved rather than assumed. The
|
|
203
|
+
// fleet has it; a developer's laptop running the tests does not, and a preview that only
|
|
204
|
+
// works on Linux is a preview nobody can test before deploying.
|
|
205
|
+
const argv = cmd.argv.join(' ');
|
|
206
|
+
// SINGLE quotes, and this is not a style choice. The inner script has to reach `sh -c`
|
|
207
|
+
// with `$$` intact so the new shell expands it to ITS OWN pid. Building this with
|
|
208
|
+
// JSON.stringify produces double quotes, which means the OUTER shell expands `$$` first and
|
|
209
|
+
// the pid file ends up holding the pid of a shell that exits a moment later. Everything
|
|
210
|
+
// still appears to work: the server starts, the file exists, and `stop` then signals a dead
|
|
211
|
+
// pid and silently leaves the dev server running forever. It passed on macOS by luck of
|
|
212
|
+
// process-group layout and left orphans holding ports on Linux.
|
|
213
|
+
const inner = `echo $$ > ${pf}; exec ${argv}`;
|
|
214
|
+
const launch = `cd ${shq(workspace.dir)} && SETSID=$(command -v setsid || true); PORT=${p} $SETSID sh -c ${shq(inner)} > ${shq(lf)} 2>&1 &`;
|
|
215
|
+
await workspace.exec(['sh', '-c', launch], { timeout_ms: 30000 });
|
|
216
|
+
|
|
217
|
+
const deadline = now() + timeout_ms;
|
|
218
|
+
while (now() < deadline) {
|
|
219
|
+
if (await is_listening(workspace, p)) return { started: true, port: p, log: lf, command: cmd.argv.join(' ') };
|
|
220
|
+
// A server that has already printed a fatal error is not going to come up, and waiting
|
|
221
|
+
// the full ninety seconds to say so wastes the customer's time for no information.
|
|
222
|
+
const tail = await workspace.exec(['sh', '-c', `tail -c 20000 ${lf} 2>/dev/null || true`], { timeout_ms: 10000 });
|
|
223
|
+
const errors = extract_errors(tail.stdout);
|
|
224
|
+
if (errors.length) return { started: false, port: p, log: lf, errors, why: 'the dev server failed to start' };
|
|
225
|
+
await new Promise((r) => setTimeout(r, num('poll_interval_ms', 1000)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tail = await workspace.exec(['sh', '-c', `tail -c 20000 ${lf} 2>/dev/null || true`], { timeout_ms: 10000 });
|
|
229
|
+
return { started: false, port: p, log: lf, errors: extract_errors(tail.stdout), why: `the dev server did not answer on port ${p} within ${Math.round(timeout_ms / 1000)}s` };
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// The verify loop's `runtime_check`. Returns the shape that loop expects, and returns
|
|
233
|
+
// ok:true with a plain explanation when there is nothing to check, because a project with no
|
|
234
|
+
// dev server has not passed a preview check, it simply has not got one.
|
|
235
|
+
runtime_check({ project_id, manifest, package_manager = 'npm' } = {}) {
|
|
236
|
+
return async (workspace) => {
|
|
237
|
+
const cmd = detect_dev_command(manifest, package_manager);
|
|
238
|
+
if (!cmd) return { ok: true, skipped: true, output: 'this project has no dev server to check' };
|
|
239
|
+
|
|
240
|
+
const started = await this.start(workspace, { project_id, manifest, package_manager });
|
|
241
|
+
if (!started.started) {
|
|
242
|
+
return { ok: false, output: [started.why, '', ...(started.errors || [])].join('\n').trim() };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// It is listening. Now look at what it said on the way up: a dev server can bind its port
|
|
246
|
+
// and still be serving a compile error, which is exactly the failure a customer sees.
|
|
247
|
+
const tail = await workspace.exec(['sh', '-c', `tail -c 20000 ${started.log} 2>/dev/null || true`], { timeout_ms: 10000 });
|
|
248
|
+
const errors = extract_errors(tail.stdout);
|
|
249
|
+
if (errors.length) return { ok: false, output: ['the app is running but reported errors', '', ...errors].join('\n') };
|
|
250
|
+
|
|
251
|
+
// The browser leg. `expose_port` is null on every substrate that cannot publish a port,
|
|
252
|
+
// so this reports honestly instead of claiming a page nobody loaded was fine.
|
|
253
|
+
let url = null;
|
|
254
|
+
try {
|
|
255
|
+
const exposed = await workspace.expose_port(started.port);
|
|
256
|
+
url = exposed?.url || null;
|
|
257
|
+
} catch (e) {}
|
|
258
|
+
|
|
259
|
+
return { ok: true, port: started.port, url, browser_checked: false, output: url ? `running at ${url}` : 'running, no public url on this machine yet' };
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
};
|
package/xudex_run.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// A xudex run: the piece that makes the other five worth having.
|
|
2
|
+
//
|
|
3
|
+
// docs/plans/xudex.md. Everything before this was a component with a test. This is the loop
|
|
4
|
+
// the customer actually experiences, and it is the whole product in one function:
|
|
5
|
+
//
|
|
6
|
+
// mirror -> workspace -> engine -> capture -> verify -> repair -> answer
|
|
7
|
+
//
|
|
8
|
+
// Read it as a sequence of promises kept:
|
|
9
|
+
//
|
|
10
|
+
// the token never leaves the region server (the mirror, 9.2)
|
|
11
|
+
// the machine is swappable (the runtime interface, 3.8)
|
|
12
|
+
// nothing above knows which CLI ran (the engine seam, 5.1)
|
|
13
|
+
// the change set is the repository's opinion (capture, from UI-226)
|
|
14
|
+
// nobody is told "done" over a broken build (the verify loop, 6)
|
|
15
|
+
// we know what the run cost and whether it mined (the tracker, 9.4)
|
|
16
|
+
//
|
|
17
|
+
// ── Why everything is injected ─────────────────────────────────────────────────────────
|
|
18
|
+
// Every dependency arrives as a parameter, which is not ceremony: it is what lets the whole
|
|
19
|
+
// chain be driven end to end in a test with a scripted engine, against a real git repository
|
|
20
|
+
// and a real workspace, without spending an API call or waiting on a model. The one thing
|
|
21
|
+
// that must be right here is the ORDER and the ERROR HANDLING, and both are only testable if
|
|
22
|
+
// the engine can be made to behave on demand.
|
|
23
|
+
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
if (!global._conf) {
|
|
27
|
+
global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Same branch contract as the Code tab: a chat owns a branch, and the default branch is never
|
|
31
|
+
// checked out for writing, so nothing a run does can reach main without a person merging it.
|
|
32
|
+
export const run_branch = function (conversation_id) {
|
|
33
|
+
return `xuda/${String(conversation_id || '').replace(/^cov_/, '').slice(0, 12)}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// git status porcelain, minus whatever was already dirty when the run started. This is UI-226's
|
|
37
|
+
// decision and it is load-bearing: the change set is what the REPOSITORY says changed, never what
|
|
38
|
+
// the agent claims it changed. An agent that forgets to mention a file, or invents one, cannot
|
|
39
|
+
// make the file list wrong, and the same code works for every engine.
|
|
40
|
+
export const parse_status = function (stdout) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
43
|
+
if (!line.trim()) continue;
|
|
44
|
+
// Porcelain is `XY <path>`, and the runner trims output, which removes the leading space of
|
|
45
|
+
// the FIRST line only. A fixed column-3 slice therefore ate the first character of the first
|
|
46
|
+
// path, which is exactly the bug UI-226 found (`app.js` came back as `pp.js`). Matching the
|
|
47
|
+
// status codes instead of counting columns cannot regress that way.
|
|
48
|
+
const m = line.match(/^\s*([A-Z?!ADMRCU ]{1,2})\s+(.+)$/);
|
|
49
|
+
if (!m) continue;
|
|
50
|
+
const rel = m[2].includes(' -> ') ? m[2].split(' -> ').pop() : m[2];
|
|
51
|
+
out.push({ status: m[1].trim(), rel: rel.replace(/^"|"$/g, '') });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const create_runner = function ({ runtime, mirror, engines, verify, tracker_factory, run_process, git_exec, conf = () => global._conf.xudex || {}, now = () => Date.now() }) {
|
|
57
|
+
for (const [name, dep] of Object.entries({ runtime, mirror, engines, verify, tracker_factory, run_process, git_exec })) {
|
|
58
|
+
if (!dep) throw new Error(`create_runner needs ${name}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const projects_root = () => conf().projects_root || '/srv/xudex';
|
|
62
|
+
const sample_interval_ms = () => Number(conf().tracker?.sample_interval_ms) || 15000;
|
|
63
|
+
|
|
64
|
+
// One engine turn. Everything about spawning a CLI lives here, so the run below reads as a
|
|
65
|
+
// sequence of decisions rather than a pile of process handling.
|
|
66
|
+
const one_turn = async function ({ workspace, launch, prompt, emit }) {
|
|
67
|
+
const state = engines.create_run_state();
|
|
68
|
+
const feed = engines.create_stream(launch.engine, (event) => {
|
|
69
|
+
state.accept(event);
|
|
70
|
+
if (emit) emit(event);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const ret = await workspace.exec([launch.command, ...launch.args, prompt], {
|
|
74
|
+
env: launch.env,
|
|
75
|
+
timeout_ms: Number(conf().run_timeout_ms) || 30 * 60 * 1000,
|
|
76
|
+
onStdout: feed,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A CLI that dies without emitting a usable error still has to produce one, or the customer
|
|
80
|
+
// sees a run that simply stopped. stderr is the only thing left to say why.
|
|
81
|
+
if (ret.exit_code !== 0 && !state.state.error) {
|
|
82
|
+
state.state.error = ret.timed_out ? 'The engine ran out of time and was stopped.' : (ret.stderr || '').trim().split('\n').slice(-3).join('\n') || `the engine exited with code ${ret.exit_code}`;
|
|
83
|
+
}
|
|
84
|
+
return { ...state.state, exit_code: ret.exit_code, timed_out: ret.timed_out === true };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
run_branch,
|
|
89
|
+
parse_status,
|
|
90
|
+
|
|
91
|
+
async run({ uid, repo, project_id, conversation_id, prompt, launch, workspace_dir, emit = null, verify_ctx = {}, max_repair_attempts } = {}) {
|
|
92
|
+
const fail = (message, extra = {}) => ({ code: -1, data: message, ...extra });
|
|
93
|
+
if (!prompt) return fail('There is nothing to do: the request was empty.');
|
|
94
|
+
if (!launch || launch.error) return fail(launch?.error || 'That engine is not available.', { needs_key: launch?.needs_key || null });
|
|
95
|
+
|
|
96
|
+
const branch = run_branch(conversation_id);
|
|
97
|
+
|
|
98
|
+
// ── 1. The repository, through the mirror ────────────────────────────────────────
|
|
99
|
+
// The mirror is the only thing that ever authenticates upstream. Everything after this
|
|
100
|
+
// point runs with no credential at all, which is what makes it safe to run on a machine
|
|
101
|
+
// executing code an AI just wrote.
|
|
102
|
+
if (emit) emit({ type: 'phase', text: 'Preparing the repository' });
|
|
103
|
+
const mirrored = await mirror.ensure({ uid, repo });
|
|
104
|
+
if (mirrored.error) return fail(mirrored.error);
|
|
105
|
+
|
|
106
|
+
const dir = workspace_dir || path.join(projects_root(), String(project_id));
|
|
107
|
+
const prepared = await mirror.sync_workspace({ mirror_dir: mirrored.dir, dest: dir, branch, default_branch: repo.default_branch });
|
|
108
|
+
if (prepared.error) return fail(prepared.error);
|
|
109
|
+
|
|
110
|
+
// ── 2. A workspace to run in ─────────────────────────────────────────────────────
|
|
111
|
+
let workspace;
|
|
112
|
+
try {
|
|
113
|
+
workspace = await runtime.acquire({ project_id, dir: prepared.dir });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return fail('Xuda could not prepare a machine for this project. Please try again.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// What was already dirty before we started. Without this the run gets credited with a
|
|
120
|
+
// change somebody made by hand, and revert would then throw away their work.
|
|
121
|
+
const before = await workspace.exec(['git', 'status', '--porcelain', '--untracked-files=all'], { timeout_ms: 60000 });
|
|
122
|
+
const pre_dirty = new Set(parse_status(before.stdout).map((f) => f.rel));
|
|
123
|
+
|
|
124
|
+
// ── 3. The engine ──────────────────────────────────────────────────────────────
|
|
125
|
+
const tracker = tracker_factory({ workspace });
|
|
126
|
+
await tracker.start();
|
|
127
|
+
const sampler = setInterval(() => {
|
|
128
|
+
tracker.sample().catch(() => {});
|
|
129
|
+
}, sample_interval_ms());
|
|
130
|
+
|
|
131
|
+
let turn;
|
|
132
|
+
try {
|
|
133
|
+
if (emit) emit({ type: 'phase', text: `Running ${launch.label}` });
|
|
134
|
+
turn = await one_turn({ workspace, launch, prompt, emit });
|
|
135
|
+
} finally {
|
|
136
|
+
clearInterval(sampler);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A provider error is the customer's provider talking, not us, and it is shown verbatim
|
|
140
|
+
// (plan 5.2). With BYOK we cannot see their balance or their rate limits, so dressing a
|
|
141
|
+
// 429 up as "something went wrong" would send them looking in the wrong place.
|
|
142
|
+
if (turn.error && !turn.messages.length) {
|
|
143
|
+
const record = tracker.finish({ usage: turn.usage, engine: launch.engine, meta: { uid, project_id, conversation_id, branch } });
|
|
144
|
+
return { code: -1, data: turn.error, engine_error: true, record };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── 4. What actually changed, according to git ──────────────────────────────────
|
|
148
|
+
const after = await workspace.exec(['git', 'status', '--porcelain', '--untracked-files=all'], { timeout_ms: 60000 });
|
|
149
|
+
const files = parse_status(after.stdout).filter((f) => !pre_dirty.has(f.rel));
|
|
150
|
+
|
|
151
|
+
// ── 5. Verification, and repair ─────────────────────────────────────────────────
|
|
152
|
+
// The repair callback is what closes the loop: a failing gate goes back to the SAME
|
|
153
|
+
// engine thread, so the fix arrives with the context of the change that broke it.
|
|
154
|
+
let repair_turns = 0;
|
|
155
|
+
const repair = async (repair_text) => {
|
|
156
|
+
repair_turns++;
|
|
157
|
+
if (emit) emit({ type: 'phase', text: `Fixing what the checks caught (attempt ${repair_turns})` });
|
|
158
|
+
const resumed = engines.prepare_launch({
|
|
159
|
+
engine: launch.engine,
|
|
160
|
+
model: launch.model,
|
|
161
|
+
// Resuming matters more here than anywhere else: a cold engine would try to fix a
|
|
162
|
+
// failure it has never seen, in code it did not write.
|
|
163
|
+
resume_session_id: turn.session_id,
|
|
164
|
+
api_key: launch.env?.ANTHROPIC_API_KEY || launch.env?.OPENAI_API_KEY,
|
|
165
|
+
sandbox: launch.sandbox,
|
|
166
|
+
});
|
|
167
|
+
if (resumed.error) return false;
|
|
168
|
+
const again = await one_turn({ workspace, launch: { ...resumed, label: launch.label }, prompt: repair_text, emit });
|
|
169
|
+
return !again.error;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (emit) emit({ type: 'phase', text: 'Checking the change against your build and tests' });
|
|
173
|
+
const verified = await verify.verify_with_repair({
|
|
174
|
+
workspace,
|
|
175
|
+
// Repair whenever the tree is red, not only when the run has files to its name. Those
|
|
176
|
+
// are different questions: a run that edits a file somebody had ALREADY left dirty
|
|
177
|
+
// breaks the build while owning nothing in the change list, and gating repair on
|
|
178
|
+
// attribution would hand that back broken with no attempt to fix it. Attribution
|
|
179
|
+
// decides what we SHOW; the tree being red decides what we DO.
|
|
180
|
+
repair,
|
|
181
|
+
max_attempts: max_repair_attempts,
|
|
182
|
+
on_step: (step) => emit && emit({ type: 'phase', text: `${step.name}: ${step.ran ? (step.ok ? 'passed' : 'failed') : 'not configured'}` }),
|
|
183
|
+
...verify_ctx,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Files are re-read after repair, because a repair changes more of them.
|
|
187
|
+
const final_status = repair_turns ? await workspace.exec(['git', 'status', '--porcelain', '--untracked-files=all'], { timeout_ms: 60000 }) : after;
|
|
188
|
+
const final_files = parse_status(final_status.stdout).filter((f) => !pre_dirty.has(f.rel));
|
|
189
|
+
|
|
190
|
+
// ── 6. Close the books ──────────────────────────────────────────────────────────
|
|
191
|
+
const record = tracker.finish({
|
|
192
|
+
usage: turn.usage,
|
|
193
|
+
engine: launch.engine,
|
|
194
|
+
meta: { uid, project_id, conversation_id, branch, files: final_files.length, verified: verified.ok, repair_turns },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
code: 1,
|
|
199
|
+
data: {
|
|
200
|
+
// The answer is the engine's own words, and the verdict is ours. They are kept
|
|
201
|
+
// separate on purpose: an engine that says "done" over a red build must not be able
|
|
202
|
+
// to borrow our credibility for it.
|
|
203
|
+
message: turn.messages.join('\n\n').trim(),
|
|
204
|
+
verdict: verified.verdict,
|
|
205
|
+
ok: verified.ok,
|
|
206
|
+
files: final_files,
|
|
207
|
+
branch,
|
|
208
|
+
base_sha: prepared.base_sha,
|
|
209
|
+
session_id: turn.session_id,
|
|
210
|
+
steps: verified.steps,
|
|
211
|
+
failed: verified.failed || null,
|
|
212
|
+
repair_turns,
|
|
213
|
+
// A red tree with nothing attributable to this run almost always means the project
|
|
214
|
+
// was already failing when the customer got here. Worth saying: being told "your
|
|
215
|
+
// change broke the build" about a change you did not make is how a customer decides
|
|
216
|
+
// the verification cannot be trusted.
|
|
217
|
+
pre_existing_failure: !verified.ok && final_files.length === 0,
|
|
218
|
+
},
|
|
219
|
+
record,
|
|
220
|
+
};
|
|
221
|
+
} finally {
|
|
222
|
+
// Releasing without destroy: on a persistent machine the working copy IS the warmth the
|
|
223
|
+
// customer is paying for, and on an ephemeral one the substrate throws the whole thing
|
|
224
|
+
// away regardless.
|
|
225
|
+
try {
|
|
226
|
+
await workspace.release();
|
|
227
|
+
} catch (e) {}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
};
|