@proagentstore/cli 0.4.75 → 0.4.77
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/dist/browser-runner/coding/headless.js +4 -0
- package/dist/browser-runner/coding/inspect.js +34 -4
- package/dist/browser-runner/coding/runtime.js +5 -1
- package/dist/browser-runner/coding/session-resources.js +67 -0
- package/dist/browser-runner/server.js +5 -0
- package/dist/index.js +85 -2
- package/package.json +1 -1
|
@@ -295,6 +295,10 @@ export class HeadlessSession {
|
|
|
295
295
|
get lastTurn() {
|
|
296
296
|
return this.turnReport;
|
|
297
297
|
}
|
|
298
|
+
/** The engine process's pid while one is running — what per-session resource attribution reads (#924). */
|
|
299
|
+
get pid() {
|
|
300
|
+
return this.procAlive ? (this.proc?.pid ?? null) : null;
|
|
301
|
+
}
|
|
298
302
|
/** Is a process running THIS instant? The persistent engine's liveness, and the spawn guard. */
|
|
299
303
|
get procAlive() {
|
|
300
304
|
return this.proc !== null && this.proc.exitCode === null && this.proc.signalCode === null;
|
|
@@ -159,8 +159,15 @@ export class InspectError extends Error {
|
|
|
159
159
|
}
|
|
160
160
|
const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
|
|
161
161
|
const HARD_MAX_FILE_BYTES = 128 * 1024;
|
|
162
|
-
/**
|
|
163
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Read a text file inside the repo. Rejects traversal and binary files; returns at most `maxBytes`.
|
|
164
|
+
*
|
|
165
|
+
* Ranged from #954: `startLine`/`startColumn` say where the returned bytes begin, so no part of a file
|
|
166
|
+
* is out of reach — before it, a read always began at byte 0 and nothing past the cap could be read.
|
|
167
|
+
* `firstLine`/`firstColumn`/`totalLines` are always present, which is how the cloud tells a runner that
|
|
168
|
+
* honours the range from an older one that silently started at the top.
|
|
169
|
+
*/
|
|
170
|
+
export function readRepoFile(workDir, relPath, maxBytes, from = {}) {
|
|
164
171
|
const abs = resolveInside(workDir, relPath, { checkSymlink: true });
|
|
165
172
|
const st = statSync(abs);
|
|
166
173
|
if (!st.isFile())
|
|
@@ -171,8 +178,31 @@ export function readRepoFile(workDir, relPath, maxBytes) {
|
|
|
171
178
|
const head = buf.subarray(0, 8192);
|
|
172
179
|
if (head.includes(0))
|
|
173
180
|
return { path: relPath, size: st.size, truncated: false, binary: true };
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
let newlines = 0;
|
|
182
|
+
for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, i + 1))
|
|
183
|
+
newlines++;
|
|
184
|
+
const totalLines = newlines + (buf.length > 0 && buf[buf.length - 1] !== 10 ? 1 : 0);
|
|
185
|
+
const firstLine = Math.max(1, Math.trunc(Number(from.startLine) || 1));
|
|
186
|
+
const firstColumn = Math.max(1, Math.trunc(Number(from.startColumn) || 1));
|
|
187
|
+
const ranged = { firstLine, firstColumn, totalLines };
|
|
188
|
+
if (firstLine > totalLines)
|
|
189
|
+
return { path: relPath, size: st.size, truncated: false, content: "", ...ranged };
|
|
190
|
+
// The byte where line `firstLine` begins…
|
|
191
|
+
let start = 0;
|
|
192
|
+
for (let line = 1; line < firstLine; line++)
|
|
193
|
+
start = buf.indexOf(10, start) + 1;
|
|
194
|
+
// …and, for a column past the first, where that CHARACTER begins — counted on the decoded line, so
|
|
195
|
+
// a multi-byte character is never split.
|
|
196
|
+
if (firstColumn > 1) {
|
|
197
|
+
const eol = buf.indexOf(10, start);
|
|
198
|
+
const lineText = buf.subarray(start, eol === -1 ? buf.length : eol).toString("utf-8");
|
|
199
|
+
start += Buffer.byteLength(lineText.slice(0, firstColumn - 1), "utf-8");
|
|
200
|
+
}
|
|
201
|
+
let end = Math.min(buf.length, start + cap);
|
|
202
|
+
// Never end inside a multi-byte character: it would decode as U+FFFD at the cut.
|
|
203
|
+
while (end < buf.length && end > start && (buf[end] & 0xc0) === 0x80)
|
|
204
|
+
end--;
|
|
205
|
+
return { path: relPath, size: st.size, truncated: end < buf.length, content: buf.subarray(start, end).toString("utf-8"), ...ranged };
|
|
176
206
|
}
|
|
177
207
|
/** Run a whitelisted read-only git command in the repo. Never uses a shell. */
|
|
178
208
|
export function runRepoGit(workDir, cmd, opts = {}) {
|
|
@@ -53,7 +53,7 @@ export class CodingRuntime {
|
|
|
53
53
|
}
|
|
54
54
|
/** Read one file inside the session's repo (traversal-guarded, size-capped). */
|
|
55
55
|
readFile(input) {
|
|
56
|
-
return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes);
|
|
56
|
+
return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes, { startLine: input.startLine, startColumn: input.startColumn });
|
|
57
57
|
}
|
|
58
58
|
/** Run a whitelisted read-only git command in the session's repo. */
|
|
59
59
|
git(input) {
|
|
@@ -286,6 +286,10 @@ export class CodingRuntime {
|
|
|
286
286
|
runState: s.runState(),
|
|
287
287
|
}));
|
|
288
288
|
}
|
|
289
|
+
/** Every session with a live engine process, and its pid — the roots resource attribution walks (#924). */
|
|
290
|
+
enginePids() {
|
|
291
|
+
return [...this.sessions.entries()].flatMap(([sessionId, s]) => (s.pid ? [{ sessionId, engineLabel: s.engineLabel, pid: s.pid }] : []));
|
|
292
|
+
}
|
|
289
293
|
/** Rich diagnostics for every tracked session — the console's transparency view. */
|
|
290
294
|
diagnostics() {
|
|
291
295
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which coding session is using the machine (#924).
|
|
3
|
+
*
|
|
4
|
+
* The heartbeat says a machine is loaded; this says WHICH agent is loading it. Each session's engine
|
|
5
|
+
* is a child process that spawns its own (an engine's tools, a test runner, a build), so a session's
|
|
6
|
+
* share is its engine process plus every descendant. One `ps` per heartbeat reads the whole process
|
|
7
|
+
* table — the attribution is computed here from it, never by a `ps` per session.
|
|
8
|
+
*
|
|
9
|
+
* Not on Windows (no `ps`): the reading is absent there, which the platform reports as "not
|
|
10
|
+
* reported", never as zero.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
/** `ps -A -o pid=,ppid=,rss=,pcpu=` output → rows. A line that does not parse is skipped. */
|
|
14
|
+
export function parsePs(text) {
|
|
15
|
+
const rows = [];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
const [pid, ppid, rss, cpu] = line.trim().split(/\s+/).map(Number);
|
|
18
|
+
if ([pid, ppid, rss, cpu].every((n) => Number.isFinite(n)))
|
|
19
|
+
rows.push({ pid, ppid, rssKb: rss, cpuPct: cpu });
|
|
20
|
+
}
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
/** Each root's own row plus all its descendants', summed. Pure, so the tree walk is testable. */
|
|
24
|
+
export function attribute(rows, roots) {
|
|
25
|
+
const children = new Map();
|
|
26
|
+
const byPid = new Map();
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
byPid.set(r.pid, r);
|
|
29
|
+
children.set(r.ppid, [...(children.get(r.ppid) ?? []), r]);
|
|
30
|
+
}
|
|
31
|
+
return roots.flatMap((root) => {
|
|
32
|
+
const top = byPid.get(root.pid);
|
|
33
|
+
if (!top)
|
|
34
|
+
return [];
|
|
35
|
+
let processes = 0;
|
|
36
|
+
let rssKb = 0;
|
|
37
|
+
let cpuPct = 0;
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const stack = [top];
|
|
40
|
+
while (stack.length) {
|
|
41
|
+
const r = stack.pop();
|
|
42
|
+
if (seen.has(r.pid))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(r.pid);
|
|
45
|
+
processes++;
|
|
46
|
+
rssKb += r.rssKb;
|
|
47
|
+
cpuPct += r.cpuPct;
|
|
48
|
+
stack.push(...(children.get(r.pid) ?? []));
|
|
49
|
+
}
|
|
50
|
+
return [{ sessionId: root.sessionId, engineLabel: root.engineLabel, pid: root.pid, processes, rssBytes: rssKb * 1024, cpuPct: Math.round(cpuPct * 10) / 10 }];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Read the process table once and attribute it to the live engines. Null when it cannot be read. */
|
|
54
|
+
export function readSessionResources(roots) {
|
|
55
|
+
if (process.platform === "win32")
|
|
56
|
+
return null;
|
|
57
|
+
if (!roots.length)
|
|
58
|
+
return [];
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync("ps", ["-A", "-o", "pid=,ppid=,rss=,pcpu="], { encoding: "utf-8", timeout: 2000, maxBuffer: 8 * 1024 * 1024 });
|
|
61
|
+
return attribute(parsePs(out), roots);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// A `ps` that failed or timed out is "not read this beat", which the null says — never zeros.
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -199,6 +199,11 @@ async function route(runner, req, res) {
|
|
|
199
199
|
if (req.method === "GET" && path === "/coding/sessions") {
|
|
200
200
|
return json(res, 200, { sessions: runner.coding.list() });
|
|
201
201
|
}
|
|
202
|
+
// Per-session CPU and memory, for the heartbeat (#924): which agent is loading this machine.
|
|
203
|
+
if (req.method === "GET" && path === "/coding/resources") {
|
|
204
|
+
const { readSessionResources } = await import("./coding/session-resources.js");
|
|
205
|
+
return json(res, 200, { sessions: readSessionResources(runner.coding.enginePids()) });
|
|
206
|
+
}
|
|
202
207
|
if ((req.method === "GET" || req.method === "POST") && path === "/coding/diagnostics") {
|
|
203
208
|
// No tmux figures here any more (#247). The coding engine spawns a child process
|
|
204
209
|
// directly, so `pagsTmuxTotal` was structurally always 0 and `tmuxTotal` counted the
|
package/dist/index.js
CHANGED
|
@@ -1205,10 +1205,40 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
|
|
|
1205
1205
|
import { hostname as hostname3 } from "os";
|
|
1206
1206
|
|
|
1207
1207
|
// src/commands/runner/resources.ts
|
|
1208
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync5, statfsSync, writeFileSync as writeFileSync4 } from "fs";
|
|
1208
1209
|
import os from "os";
|
|
1209
|
-
|
|
1210
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1211
|
+
var CHECKOUT_ROOT = join6(os.homedir(), ".config", "proagentstore", "repos");
|
|
1212
|
+
function sampleDisk(path = CHECKOUT_ROOT, statfs = statfsSync) {
|
|
1213
|
+
for (const p of [path, os.homedir()]) {
|
|
1214
|
+
try {
|
|
1215
|
+
const s = statfs(p);
|
|
1216
|
+
return { path: p, totalBytes: Number(s.blocks) * Number(s.bsize), freeBytes: Number(s.bavail) * Number(s.bsize), inodesTotal: Number(s.files), inodesFree: Number(s.ffree) };
|
|
1217
|
+
} catch {
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
var STARTS_FILE = join6(os.homedir(), ".config", "proagentstore", "runner-starts.json");
|
|
1223
|
+
function recordRunnerStart(now = Date.now(), file = STARTS_FILE) {
|
|
1224
|
+
let starts = [];
|
|
1225
|
+
try {
|
|
1226
|
+
const parsed = JSON.parse(readFileSync5(file, "utf-8"));
|
|
1227
|
+
if (Array.isArray(parsed)) starts = parsed.filter((t) => typeof t === "number" && Number.isFinite(t));
|
|
1228
|
+
} catch {
|
|
1229
|
+
}
|
|
1230
|
+
starts = [...starts.filter((t) => now - t < 24 * 60 * 60 * 1e3), now].slice(-200);
|
|
1231
|
+
try {
|
|
1232
|
+
mkdirSync4(dirname2(file), { recursive: true });
|
|
1233
|
+
writeFileSync4(file, JSON.stringify(starts));
|
|
1234
|
+
} catch {
|
|
1235
|
+
}
|
|
1236
|
+
return starts.length;
|
|
1237
|
+
}
|
|
1238
|
+
function sampleResources(read = os, now = Date.now(), extra = {}) {
|
|
1210
1239
|
const [l1 = 0, l5 = 0, l15 = 0] = read.loadavg();
|
|
1211
1240
|
return {
|
|
1241
|
+
...Object.fromEntries(Object.entries(extra).filter(([, v]) => v !== void 0)),
|
|
1212
1242
|
loadAvg: [l1, l5, l15],
|
|
1213
1243
|
// `availableParallelism` would be better, but `cpus()` is what every supported Node has.
|
|
1214
1244
|
cpus: Math.max(1, read.cpus().length),
|
|
@@ -1413,6 +1443,9 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1413
1443
|
}
|
|
1414
1444
|
};
|
|
1415
1445
|
const attached = /* @__PURE__ */ new Map();
|
|
1446
|
+
const processStartedAt = Date.now();
|
|
1447
|
+
const starts24h = recordRunnerStart(processStartedAt);
|
|
1448
|
+
let relayReconnects = 0;
|
|
1416
1449
|
const blocked = /* @__PURE__ */ new Set();
|
|
1417
1450
|
let syncing = Promise.resolve();
|
|
1418
1451
|
const forceNext = /* @__PURE__ */ new Set();
|
|
@@ -1528,6 +1561,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1528
1561
|
// and taken when it did not — which is how a register lost to a boot-time
|
|
1529
1562
|
// `fetch failed` finally gets a second chance.
|
|
1530
1563
|
async (openedId, reconnect) => {
|
|
1564
|
+
if (reconnect) relayReconnects++;
|
|
1531
1565
|
if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
|
|
1532
1566
|
await registerRuntime(openedId, reconnect ? false : force);
|
|
1533
1567
|
reportRegistration();
|
|
@@ -1559,7 +1593,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1559
1593
|
const heartbeat = () => {
|
|
1560
1594
|
const timer = setTimeout(async () => {
|
|
1561
1595
|
let failure = null;
|
|
1562
|
-
const
|
|
1596
|
+
const first = attached.values().next().value;
|
|
1597
|
+
const [relayRttMs, sessions] = await Promise.all([first ? first.probeRtt() : Promise.resolve(null), readSessionResources(localUrl, runnerToken)]);
|
|
1598
|
+
const resources = sampleResources(void 0, void 0, {
|
|
1599
|
+
disk: sampleDisk() ?? void 0,
|
|
1600
|
+
runner: { startedAt: processStartedAt, uptimeSec: Math.round(process.uptime()), starts24h, relayReconnects },
|
|
1601
|
+
relayRttMs,
|
|
1602
|
+
sessions: sessions ?? void 0
|
|
1603
|
+
});
|
|
1563
1604
|
for (const id of [...attached.keys()]) {
|
|
1564
1605
|
try {
|
|
1565
1606
|
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, { runnerNode, resources });
|
|
@@ -1639,6 +1680,18 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1639
1680
|
tick();
|
|
1640
1681
|
}
|
|
1641
1682
|
}
|
|
1683
|
+
async function readSessionResources(localUrl, runnerToken) {
|
|
1684
|
+
try {
|
|
1685
|
+
const res = await fetch(`${localUrl}/coding/resources`, { headers: { Authorization: `Bearer ${runnerToken}` }, signal: AbortSignal.timeout(3e3) });
|
|
1686
|
+
if (!res.ok) return null;
|
|
1687
|
+
const body = await res.json();
|
|
1688
|
+
return Array.isArray(body.sessions) ? body.sessions : null;
|
|
1689
|
+
} catch {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
var RTT_PROBE_PREFIX = "rtt:";
|
|
1694
|
+
var RTT_ECHO_PREFIX = "rtt-echo:";
|
|
1642
1695
|
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3()) {
|
|
1643
1696
|
let backoffMs = 1e3;
|
|
1644
1697
|
let reconnecting = false;
|
|
@@ -1646,6 +1699,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1646
1699
|
let opened = false;
|
|
1647
1700
|
let socket = null;
|
|
1648
1701
|
let retryTimer = null;
|
|
1702
|
+
const rttWaiters = /* @__PURE__ */ new Map();
|
|
1649
1703
|
const connect = async () => {
|
|
1650
1704
|
if (closed) return;
|
|
1651
1705
|
let relayToken;
|
|
@@ -1686,6 +1740,10 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1686
1740
|
}
|
|
1687
1741
|
return;
|
|
1688
1742
|
}
|
|
1743
|
+
if (text.startsWith(RTT_ECHO_PREFIX)) {
|
|
1744
|
+
rttWaiters.get(text.slice(RTT_ECHO_PREFIX.length))?.(Date.now());
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1689
1747
|
let cmd;
|
|
1690
1748
|
try {
|
|
1691
1749
|
cmd = JSON.parse(text);
|
|
@@ -1766,6 +1824,31 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1766
1824
|
} catch {
|
|
1767
1825
|
}
|
|
1768
1826
|
socket = null;
|
|
1827
|
+
},
|
|
1828
|
+
probeRtt(timeoutMs = 5e3) {
|
|
1829
|
+
const ws = socket;
|
|
1830
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
1831
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1832
|
+
const sentAt = Date.now();
|
|
1833
|
+
return new Promise((resolveRtt) => {
|
|
1834
|
+
const timer = setTimeout(() => {
|
|
1835
|
+
rttWaiters.delete(id);
|
|
1836
|
+
resolveRtt(null);
|
|
1837
|
+
}, timeoutMs);
|
|
1838
|
+
timer.unref?.();
|
|
1839
|
+
rttWaiters.set(id, (echoedAt) => {
|
|
1840
|
+
clearTimeout(timer);
|
|
1841
|
+
rttWaiters.delete(id);
|
|
1842
|
+
resolveRtt(echoedAt - sentAt);
|
|
1843
|
+
});
|
|
1844
|
+
try {
|
|
1845
|
+
ws.send(`${RTT_PROBE_PREFIX}${id}`);
|
|
1846
|
+
} catch {
|
|
1847
|
+
clearTimeout(timer);
|
|
1848
|
+
rttWaiters.delete(id);
|
|
1849
|
+
resolveRtt(null);
|
|
1850
|
+
}
|
|
1851
|
+
});
|
|
1769
1852
|
}
|
|
1770
1853
|
};
|
|
1771
1854
|
}
|