@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,200 @@
|
|
|
1
|
+
// Xudex runtime: the seam between what runs and where it runs.
|
|
2
|
+
//
|
|
3
|
+
// See docs/plans/xudex.md section 3.8. Every layer above this one (the engine, the
|
|
4
|
+
// verify loop, preview, git, the run tracker) talks to a WORKSPACE and must never
|
|
5
|
+
// know whether that workspace is a persistent VM, an ephemeral runner, a container
|
|
6
|
+
// or a microVM. Section 3.2 says the Proxmox VM is a v1 choice made because we
|
|
7
|
+
// already operate it, not the end state, and 3.9 puts a number on when it has to
|
|
8
|
+
// change. This file is what makes that change one implementation instead of a
|
|
9
|
+
// rewrite, so the rule is simple: nothing above here may reach past the workspace
|
|
10
|
+
// handle to a VM id, an ssh command or a host path.
|
|
11
|
+
//
|
|
12
|
+
// Built BEFORE any substrate, deliberately. An interface written after a VM exists
|
|
13
|
+
// is an interface shaped like a VM.
|
|
14
|
+
//
|
|
15
|
+
// ── The contracts ──────────────────────────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// A SUBSTRATE is a way of getting compute:
|
|
18
|
+
// { kind, available(), acquire(req) -> workspace }
|
|
19
|
+
//
|
|
20
|
+
// A WORKSPACE is one project's working area, already prepared:
|
|
21
|
+
// {
|
|
22
|
+
// id, kind, project_id, dir,
|
|
23
|
+
// exec(argv, opts) -> { exit_code, stdout, stderr, timed_out, ms }
|
|
24
|
+
// read_file(rel) -> string
|
|
25
|
+
// write_file(rel, data) -> true
|
|
26
|
+
// list_dir(rel) -> [names]
|
|
27
|
+
// expose_port(port) -> { url } | null
|
|
28
|
+
// release(opts) -> true // opts.destroy: keep nothing
|
|
29
|
+
// }
|
|
30
|
+
//
|
|
31
|
+
// exec NEVER throws on a non-zero exit: a failing build is data the verify loop
|
|
32
|
+
// needs, not an exception. It throws only when the command could not be run at all.
|
|
33
|
+
//
|
|
34
|
+
// ── Why the factory ────────────────────────────────────────────────────────────
|
|
35
|
+
// index.mjs already owns `run_process`, whose detached process-group kill is the
|
|
36
|
+
// only thing that reliably stops a Codex run (Node's own spawn timeout kills the
|
|
37
|
+
// shim and orphans the native binary). Duplicating that here would mean two copies
|
|
38
|
+
// of a subtle fix, so the primitive is injected instead.
|
|
39
|
+
|
|
40
|
+
import fs from 'fs';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
|
|
43
|
+
if (!global._conf) {
|
|
44
|
+
global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const xudex_conf = () => global._conf.xudex || {};
|
|
48
|
+
|
|
49
|
+
// Wall clock for a single command. This is abuse layer 3 in the plan (9.3) as much
|
|
50
|
+
// as it is a timeout: an ephemeral workspace that dies on schedule takes a miner
|
|
51
|
+
// with it, so no caller is allowed to run without a deadline.
|
|
52
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
const run_timeout_ms = () => Number(xudex_conf().run_timeout_ms) || DEFAULT_TIMEOUT_MS;
|
|
55
|
+
|
|
56
|
+
// ── Path safety ────────────────────────────────────────────────────────────────
|
|
57
|
+
// Every relative path from above this layer is attacker-influenced: the agent
|
|
58
|
+
// writes the file list, and the agent is driven by whatever the customer typed.
|
|
59
|
+
// So a workspace path is resolved and then proven to still be inside the workspace,
|
|
60
|
+
// which is the only check that survives `..`, an absolute path, and a symlink that
|
|
61
|
+
// was created during the run.
|
|
62
|
+
const safe_join = function (root, rel) {
|
|
63
|
+
const resolved = path.resolve(root, String(rel || ''));
|
|
64
|
+
const rooted = path.resolve(root) + path.sep;
|
|
65
|
+
if (resolved !== path.resolve(root) && !resolved.startsWith(rooted)) {
|
|
66
|
+
throw new Error(`path escapes the workspace: ${rel}`);
|
|
67
|
+
}
|
|
68
|
+
return resolved;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ── The local substrate ────────────────────────────────────────────────────────
|
|
72
|
+
// What UI-226 already does: a working copy on the region server's own filesystem,
|
|
73
|
+
// with the engine running against it in place. It is expressed through the
|
|
74
|
+
// interface so the seam has a real implementation from day one rather than a
|
|
75
|
+
// comment, and so the existing Code tab has somewhere to land.
|
|
76
|
+
//
|
|
77
|
+
// It is NOT a xudex customer substrate and must never become one. Section 3.1 is
|
|
78
|
+
// the reason: the verify loop installs arbitrary packages and runs arbitrary test
|
|
79
|
+
// suites, and this substrate's filesystem belongs to a box that also runs CouchDB
|
|
80
|
+
// and our cpi services. Hence the config gate below, off unless a box opts in.
|
|
81
|
+
const local_substrate = function ({ run_process }) {
|
|
82
|
+
return {
|
|
83
|
+
kind: 'local',
|
|
84
|
+
|
|
85
|
+
available() {
|
|
86
|
+
return xudex_conf().allow_local_substrate === true;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async acquire({ project_id, dir, timeout_ms }) {
|
|
90
|
+
if (!dir) throw new Error('the local substrate needs an existing working copy dir');
|
|
91
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
92
|
+
const deadline = Number(timeout_ms) || run_timeout_ms();
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: `local:${project_id}`,
|
|
96
|
+
kind: 'local',
|
|
97
|
+
project_id,
|
|
98
|
+
dir,
|
|
99
|
+
|
|
100
|
+
async exec(argv, opts = {}) {
|
|
101
|
+
if (!Array.isArray(argv) || !argv.length) throw new Error('exec needs an argv array');
|
|
102
|
+
const started = Date.now();
|
|
103
|
+
const ret = await run_process(argv[0], argv.slice(1), opts.input || null, {
|
|
104
|
+
cwd: opts.cwd ? safe_join(dir, opts.cwd) : dir,
|
|
105
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
106
|
+
timeout: Number(opts.timeout_ms) || deadline,
|
|
107
|
+
killSignal: 'SIGKILL',
|
|
108
|
+
onStdout: opts.onStdout,
|
|
109
|
+
onStderr: opts.onStderr,
|
|
110
|
+
});
|
|
111
|
+
const ms = Date.now() - started;
|
|
112
|
+
return {
|
|
113
|
+
exit_code: ret.exit_code,
|
|
114
|
+
stdout: ret.stdout || '',
|
|
115
|
+
stderr: ret.stderr || '',
|
|
116
|
+
// run_process reports a kill by appending to stderr rather than by a
|
|
117
|
+
// flag, so the deadline is re-derived here instead of being guessed by
|
|
118
|
+
// every caller.
|
|
119
|
+
timed_out: ms >= (Number(opts.timeout_ms) || deadline),
|
|
120
|
+
ms,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async read_file(rel) {
|
|
125
|
+
return await fs.promises.readFile(safe_join(dir, rel), 'utf8');
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async write_file(rel, data) {
|
|
129
|
+
const target = safe_join(dir, rel);
|
|
130
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
131
|
+
await fs.promises.writeFile(target, data);
|
|
132
|
+
return true;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async list_dir(rel = '.') {
|
|
136
|
+
return await fs.promises.readdir(safe_join(dir, rel));
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// No port can be published from a region server to the outside world, and
|
|
140
|
+
// pretending otherwise would let the preview layer build on something that
|
|
141
|
+
// only works here. Callers must handle null.
|
|
142
|
+
async expose_port() {
|
|
143
|
+
return null;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Nothing to hand back: the working copy is the record, exactly as UI-226
|
|
147
|
+
// designed it. `destroy` is honoured so the contract is uniform, but the
|
|
148
|
+
// caller has to mean it.
|
|
149
|
+
async release(opts = {}) {
|
|
150
|
+
if (opts.destroy === true) await fs.promises.rm(dir, { recursive: true, force: true });
|
|
151
|
+
return true;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ── The registry ───────────────────────────────────────────────────────────────
|
|
159
|
+
// Substrates register themselves here and callers ask for a workspace, not for a
|
|
160
|
+
// machine. `preferred_kind` exists for CI, which wants an ephemeral workspace even
|
|
161
|
+
// when the account owns a warm VM (plan 3.8: a long test suite must not block the
|
|
162
|
+
// user's next message).
|
|
163
|
+
export const create_runtime = function ({ run_process }) {
|
|
164
|
+
if (typeof run_process !== 'function') throw new Error('create_runtime needs run_process');
|
|
165
|
+
|
|
166
|
+
const substrates = new Map();
|
|
167
|
+
const register = (s) => { substrates.set(s.kind, s); return s; };
|
|
168
|
+
|
|
169
|
+
register(local_substrate({ run_process }));
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
register,
|
|
173
|
+
|
|
174
|
+
list() {
|
|
175
|
+
return [...substrates.values()].map((s) => ({ kind: s.kind, available: s.available() }));
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
get(kind) {
|
|
179
|
+
return substrates.get(kind) || null;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// Resolve a workspace for a run. Order of preference: what the caller asked
|
|
183
|
+
// for, then the account's own machine, then anything available. Returning a
|
|
184
|
+
// clear error beats falling back silently onto a substrate the caller did not
|
|
185
|
+
// expect, because "it ran somewhere else" is the hardest class of bug to see.
|
|
186
|
+
async acquire(req = {}) {
|
|
187
|
+
const wanted = req.preferred_kind ? [req.preferred_kind] : [];
|
|
188
|
+
const order = [...wanted, 'vm', 'ephemeral', 'local'];
|
|
189
|
+
for (const kind of order) {
|
|
190
|
+
const s = substrates.get(kind);
|
|
191
|
+
if (s && s.available()) return await s.acquire(req);
|
|
192
|
+
}
|
|
193
|
+
throw new Error(
|
|
194
|
+
`no xudex runtime is available (asked for ${req.preferred_kind || 'any'}, have ${[...substrates.keys()].join(', ') || 'none'})`,
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const _internal = { safe_join, run_timeout_ms };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Xudex run tracker: what a run cost, and whether it was a build at all.
|
|
2
|
+
//
|
|
3
|
+
// docs/plans/xudex.md 9.4. One component, three jobs, which is why it is built properly
|
|
4
|
+
// rather than bolted on as monitoring:
|
|
5
|
+
//
|
|
6
|
+
// 1. ABUSE DETECTION. A machine that runs arbitrary customer code is the classic mining
|
|
7
|
+
// target, and neither the membership floor nor the L2 gate stops someone who paid and
|
|
8
|
+
// verified from then mining. This is the layer that notices.
|
|
9
|
+
// 2. THE RUN METER. "50 runs a month" on the free tier is counted from these records.
|
|
10
|
+
// 3. THE BENCHMARK. Section 13 needs wall clock and tokens per task to make any
|
|
11
|
+
// efficiency claim falsifiable, and those are the same numbers.
|
|
12
|
+
//
|
|
13
|
+
// ── Why the detection rule is shaped the way it is ─────────────────────────────────────
|
|
14
|
+
// Not machine learning. The signatures are far apart and a rule you can read is a rule you
|
|
15
|
+
// can defend to a customer you just suspended.
|
|
16
|
+
//
|
|
17
|
+
// A BUILD: CPU spiky rather than flat, bounded in time, network front-loaded into a
|
|
18
|
+
// dependency install and then quiet, and a compiler, test runner or dev server
|
|
19
|
+
// visible in the process tree the whole time.
|
|
20
|
+
// MINING: sustained full CPU, flat, for the entire run, no build or test process, and
|
|
21
|
+
// either no network at all or a steady trickle to one destination.
|
|
22
|
+
//
|
|
23
|
+
// The rule deliberately looks for the ABSENCE OF LEGITIMATE WORK rather than the presence
|
|
24
|
+
// of a miner. Miners rename their binaries, so matching process names against a list of
|
|
25
|
+
// known miners catches only the lazy ones and fails silently forever after. "Full CPU for
|
|
26
|
+
// minutes with nothing that could be producing it" is a property the attacker cannot
|
|
27
|
+
// rename their way out of.
|
|
28
|
+
//
|
|
29
|
+
// ── A known evasion, measured rather than assumed ──────────────────────────────────────
|
|
30
|
+
// CPU here is SYSTEM WIDE, from /proc/stat, so on a multi-core box a miner that uses only
|
|
31
|
+
// some of the cores never reaches the threshold. Measured on dev 2026-08-14: a
|
|
32
|
+
// single-threaded busy loop peaked at 85% and would not have tripped a 90% line. It is
|
|
33
|
+
// much less of a gap than it first looks, because the xudex tiers are 1, 2 and 4 vCPU, so
|
|
34
|
+
// there are not many cores to hide behind, and an attacker throttling to stay under the
|
|
35
|
+
// line is an attacker earning proportionally less. It is a real gap all the same, so the
|
|
36
|
+
// core count is recorded on every run: thresholds cannot be tuned per box size without it,
|
|
37
|
+
// and tuning on real data is exactly what log-only mode is for.
|
|
38
|
+
//
|
|
39
|
+
// ── Log-only first, and this is not optional ───────────────────────────────────────────
|
|
40
|
+
// `xudex.tracker.enforce` is false. The tracker records a verdict and logs it and does
|
|
41
|
+
// nothing else, so thresholds are tuned against real builds before anything can act on
|
|
42
|
+
// them. The first false positive under enforcement suspends a paying customer in the
|
|
43
|
+
// middle of their work, and there is no good way to apologise for that.
|
|
44
|
+
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
|
|
47
|
+
if (!global._conf) {
|
|
48
|
+
global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tracker_conf = () => global._conf.xudex?.tracker || {};
|
|
52
|
+
|
|
53
|
+
// One shell command per sample, because sampling a workspace costs an exec and a run is
|
|
54
|
+
// sampled repeatedly. Every line is prefixed so the parser never has to guess what it is
|
|
55
|
+
// looking at, and each metric fails independently: a box without /proc/net/dev still
|
|
56
|
+
// yields CPU rather than the whole sample being lost.
|
|
57
|
+
export const SAMPLE_COMMAND = [
|
|
58
|
+
`awk '/^cpu /{print "cpu " ($2+$3+$4+$5+$6+$7+$8) " " $5}' /proc/stat 2>/dev/null`,
|
|
59
|
+
`awk -F'[: ]+' '/:/ && $1!~/lo/ {rx+=$3; tx+=$11} END{print "net " rx+0 " " tx+0}' /proc/net/dev 2>/dev/null`,
|
|
60
|
+
`awk '/^MemTotal/{t=$2} /^MemAvailable/{a=$2} END{print "mem " (t-a) " " t}' /proc/meminfo 2>/dev/null`,
|
|
61
|
+
`echo "cores $(nproc 2>/dev/null || echo 1)"`,
|
|
62
|
+
`ps -eo comm= 2>/dev/null | sort -u | tr '\\n' ',' | sed 's/^/proc /'`,
|
|
63
|
+
].join('; ');
|
|
64
|
+
|
|
65
|
+
// Process names that mean real work is happening. Xudex v1 is Node and TypeScript only
|
|
66
|
+
// (plan section 10), so this list is deliberately small: a wider list is a wider hole,
|
|
67
|
+
// because every name on it is a name a miner can adopt.
|
|
68
|
+
export const BUILD_PROCESSES = [
|
|
69
|
+
'node',
|
|
70
|
+
'npm',
|
|
71
|
+
'npx',
|
|
72
|
+
'pnpm',
|
|
73
|
+
'yarn',
|
|
74
|
+
'tsc',
|
|
75
|
+
'tsx',
|
|
76
|
+
'jest',
|
|
77
|
+
'vitest',
|
|
78
|
+
'vite',
|
|
79
|
+
'esbuild',
|
|
80
|
+
'webpack',
|
|
81
|
+
'rollup',
|
|
82
|
+
'next',
|
|
83
|
+
'eslint',
|
|
84
|
+
'prettier',
|
|
85
|
+
'git',
|
|
86
|
+
'codex',
|
|
87
|
+
'claude',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Parse one sample. Returns nulls rather than throwing for anything the box did not give
|
|
91
|
+
// us, because a partial sample is still worth having and a thrown error inside a sampler
|
|
92
|
+
// would take down the run it is supposed to be watching.
|
|
93
|
+
export const parse_sample = function (text) {
|
|
94
|
+
const out = { cpu_total: null, cpu_idle: null, cores: null, net_rx: null, net_tx: null, mem_used_kb: null, mem_total_kb: null, procs: [] };
|
|
95
|
+
for (const line of String(text || '').split('\n')) {
|
|
96
|
+
const parts = line.trim().split(/\s+/);
|
|
97
|
+
if (parts[0] === 'cpu' && parts.length >= 3) {
|
|
98
|
+
out.cpu_total = Number(parts[1]);
|
|
99
|
+
out.cpu_idle = Number(parts[2]);
|
|
100
|
+
} else if (parts[0] === 'cores' && parts.length >= 2) {
|
|
101
|
+
out.cores = Number(parts[1]) || null;
|
|
102
|
+
} else if (parts[0] === 'net' && parts.length >= 3) {
|
|
103
|
+
out.net_rx = Number(parts[1]);
|
|
104
|
+
out.net_tx = Number(parts[2]);
|
|
105
|
+
} else if (parts[0] === 'mem' && parts.length >= 3) {
|
|
106
|
+
out.mem_used_kb = Number(parts[1]);
|
|
107
|
+
out.mem_total_kb = Number(parts[2]);
|
|
108
|
+
} else if (parts[0] === 'proc') {
|
|
109
|
+
out.procs = line
|
|
110
|
+
.trim()
|
|
111
|
+
.slice(5)
|
|
112
|
+
.split(',')
|
|
113
|
+
.map((p) => p.trim())
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// CPU percentage between two /proc/stat readings. Jiffies are cumulative, so a single
|
|
121
|
+
// sample says nothing: the interesting number is always a delta.
|
|
122
|
+
export const cpu_pct_between = function (prev, cur) {
|
|
123
|
+
if (!prev || !cur || prev.cpu_total == null || cur.cpu_total == null) return null;
|
|
124
|
+
const total = cur.cpu_total - prev.cpu_total;
|
|
125
|
+
const idle = cur.cpu_idle - prev.cpu_idle;
|
|
126
|
+
if (!(total > 0)) return null;
|
|
127
|
+
return Math.max(0, Math.min(100, ((total - idle) / total) * 100));
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const has_build_process = function (procs) {
|
|
131
|
+
if (!Array.isArray(procs) || !procs.length) return false;
|
|
132
|
+
return procs.some((p) => BUILD_PROCESSES.includes(String(p).toLowerCase()));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// The verdict. Pure, so it can be argued with in a test rather than in production.
|
|
136
|
+
//
|
|
137
|
+
// Two independent triggers, both requiring a duration or a volume rather than an instant:
|
|
138
|
+
// a build absolutely does peg a CPU, and it absolutely does download a lot, so anything
|
|
139
|
+
// that fires on a moment rather than on a pattern would flag half our own customers.
|
|
140
|
+
export const evaluate_run = function (record, conf = tracker_conf()) {
|
|
141
|
+
const reasons = [];
|
|
142
|
+
const cpu_pct = Number(conf.cpu_sustained_pct ?? 90);
|
|
143
|
+
const cpu_seconds = Number(conf.cpu_sustained_seconds ?? 180);
|
|
144
|
+
const max_out_mb = Number(conf.max_outbound_mb ?? 2048);
|
|
145
|
+
|
|
146
|
+
const sustained = Number(record?.cpu?.sustained_high_seconds) || 0;
|
|
147
|
+
const build_seen = record?.build_seen === true;
|
|
148
|
+
|
|
149
|
+
// The core rule: full CPU for minutes with nothing in the tree that could be producing
|
|
150
|
+
// it. Either half alone is ordinary. A long compile is high CPU WITH a compiler; an
|
|
151
|
+
// idle machine has no build process and no CPU. Only the pair is strange.
|
|
152
|
+
if (sustained >= cpu_seconds && !build_seen) {
|
|
153
|
+
reasons.push(`cpu above ${cpu_pct}% for ${Math.round(sustained)}s with no build or test process running`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const out_mb = (Number(record?.net?.out_bytes) || 0) / (1024 * 1024);
|
|
157
|
+
if (out_mb > max_out_mb) {
|
|
158
|
+
reasons.push(`sent ${Math.round(out_mb)} MB outbound, over the ${max_out_mb} MB ceiling`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
verdict: reasons.length ? 'flag' : 'ok',
|
|
163
|
+
reasons,
|
|
164
|
+
// Whether anything is DONE about it is a separate question from whether it looked
|
|
165
|
+
// wrong, and it stays separate on purpose (see the log-only note at the top).
|
|
166
|
+
enforce: conf.enforce === true,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// A tracker for one run. `workspace` is the runtime interface's handle (xudex_runtime.mjs),
|
|
171
|
+
// so this works identically on a VM, an ephemeral runner or a container, which is the
|
|
172
|
+
// whole reason that seam exists.
|
|
173
|
+
export const create_run_tracker = function ({ workspace, conf = tracker_conf(), now = () => Date.now() }) {
|
|
174
|
+
const samples = [];
|
|
175
|
+
let prev = null;
|
|
176
|
+
let first = null;
|
|
177
|
+
let last = null;
|
|
178
|
+
let started_ts = null;
|
|
179
|
+
let sustained_high_ms = 0;
|
|
180
|
+
let peak_cpu = 0;
|
|
181
|
+
let peak_mem_mb = 0;
|
|
182
|
+
let build_seen = false;
|
|
183
|
+
let last_sample_ts = null;
|
|
184
|
+
let cores = null;
|
|
185
|
+
const cpu_threshold = Number(conf.cpu_sustained_pct ?? 90);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
async start() {
|
|
189
|
+
started_ts = now();
|
|
190
|
+
try {
|
|
191
|
+
const r = await workspace.exec(['sh', '-c', SAMPLE_COMMAND], { timeout_ms: 15000 });
|
|
192
|
+
prev = parse_sample(r.stdout);
|
|
193
|
+
first = prev;
|
|
194
|
+
last_sample_ts = started_ts;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// Sampling is observation. It must never be the reason a customer's run fails.
|
|
197
|
+
console.warn(`[xudex] tracker start sample failed: ${e.message}`);
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async sample() {
|
|
203
|
+
try {
|
|
204
|
+
const r = await workspace.exec(['sh', '-c', SAMPLE_COMMAND], { timeout_ms: 15000 });
|
|
205
|
+
const cur = parse_sample(r.stdout);
|
|
206
|
+
const ts = now();
|
|
207
|
+
const pct = cpu_pct_between(prev, cur);
|
|
208
|
+
|
|
209
|
+
if (pct != null) {
|
|
210
|
+
peak_cpu = Math.max(peak_cpu, pct);
|
|
211
|
+
// Credit the elapsed interval, not one tick, so the sustained figure means
|
|
212
|
+
// seconds of wall clock above the line rather than a count of samples.
|
|
213
|
+
if (pct >= cpu_threshold && last_sample_ts) sustained_high_ms += ts - last_sample_ts;
|
|
214
|
+
// A single sample below the line breaks the streak. Mining is flat by nature, so
|
|
215
|
+
// this costs an attacker real throughput to evade, while a spiky build resets it
|
|
216
|
+
// constantly, which is exactly the discrimination we want.
|
|
217
|
+
else sustained_high_ms = 0;
|
|
218
|
+
samples.push({ ts, cpu_pct: pct });
|
|
219
|
+
}
|
|
220
|
+
if (cur.mem_used_kb != null) peak_mem_mb = Math.max(peak_mem_mb, cur.mem_used_kb / 1024);
|
|
221
|
+
if (cur.cores != null) cores = cur.cores;
|
|
222
|
+
if (has_build_process(cur.procs)) build_seen = true;
|
|
223
|
+
|
|
224
|
+
prev = cur;
|
|
225
|
+
last = cur;
|
|
226
|
+
last_sample_ts = ts;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.warn(`[xudex] tracker sample failed: ${e.message}`);
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// Close the run and hand back the record. `usage` is the engine's own token count
|
|
234
|
+
// (normalize_codex_usage already produces this shape for codex), which is what makes
|
|
235
|
+
// this the same instrument the benchmark needs.
|
|
236
|
+
finish({ usage = {}, engine = null, model = null, meta = {} } = {}) {
|
|
237
|
+
const ended_ts = now();
|
|
238
|
+
const cpu_values = samples.map((s) => s.cpu_pct);
|
|
239
|
+
const mean = cpu_values.length ? cpu_values.reduce((a, b) => a + b, 0) / cpu_values.length : 0;
|
|
240
|
+
|
|
241
|
+
const record = {
|
|
242
|
+
docType: 'xudex_run',
|
|
243
|
+
...meta,
|
|
244
|
+
engine,
|
|
245
|
+
model,
|
|
246
|
+
started_ts,
|
|
247
|
+
ended_ts,
|
|
248
|
+
wall_ms: started_ts ? ended_ts - started_ts : 0,
|
|
249
|
+
cpu: {
|
|
250
|
+
mean_pct: Math.round(mean * 10) / 10,
|
|
251
|
+
peak_pct: Math.round(peak_cpu * 10) / 10,
|
|
252
|
+
sustained_high_seconds: Math.round(sustained_high_ms / 1000),
|
|
253
|
+
samples: samples.length,
|
|
254
|
+
// Recorded so a threshold can later be reasoned about per box size. A system-wide
|
|
255
|
+
// percentage means something different on 1 vCPU than on 4, and without this the
|
|
256
|
+
// tuning pass would have no way to tell those runs apart.
|
|
257
|
+
cores: cores ?? first?.cores ?? null,
|
|
258
|
+
},
|
|
259
|
+
mem: { peak_mb: Math.round(peak_mem_mb) },
|
|
260
|
+
net: {
|
|
261
|
+
out_bytes: first && last && last.net_tx != null && first.net_tx != null ? Math.max(0, last.net_tx - first.net_tx) : 0,
|
|
262
|
+
in_bytes: first && last && last.net_rx != null && first.net_rx != null ? Math.max(0, last.net_rx - first.net_rx) : 0,
|
|
263
|
+
},
|
|
264
|
+
build_seen,
|
|
265
|
+
tokens: { input: Number(usage.input_tokens) || 0, output: Number(usage.output_tokens) || 0 },
|
|
266
|
+
ts: ended_ts,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const verdict = evaluate_run(record, conf);
|
|
270
|
+
record.verdict = verdict.verdict;
|
|
271
|
+
record.reasons = verdict.reasons;
|
|
272
|
+
record.enforced = verdict.verdict === 'flag' && verdict.enforce === true;
|
|
273
|
+
return record;
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
};
|