@agimon-ai/doompi-runner 0.0.1-alpha.76 → 0.0.1-alpha.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/extensions/mcp.cjs +4179 -0
- package/dist/extensions/mcp.cjs.map +1 -0
- package/dist/extensions/mcp.d.cts +5 -0
- package/dist/extensions/mcp.d.cts.map +1 -0
- package/dist/extensions/mcp.d.mts +5 -0
- package/dist/extensions/mcp.d.mts.map +1 -0
- package/dist/extensions/mcp.mjs +4147 -0
- package/dist/extensions/mcp.mjs.map +1 -0
- package/dist/src/tui/runnerRuntime.cjs +1 -1
- package/dist/src/tui/runnerRuntime.cjs.map +1 -1
- package/dist/src/tui/runnerRuntime.mjs +1 -1
- package/dist/src/tui/runnerRuntime.mjs.map +1 -1
- package/package.json +24 -16
|
@@ -0,0 +1,4179 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
//#region \0rolldown/runtime.js
|
|
6
|
+
var __create = Object.create;
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
11
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
14
|
+
key = keys[i];
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
16
|
+
get: ((k) => from[k]).bind(null, key),
|
|
17
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
//#endregion
|
|
27
|
+
let _agimon_ai_doompi_core_mcp_facet = require("@agimon-ai/doompi-core/mcp-facet");
|
|
28
|
+
let _agimon_ai_doompi_core_server_facet = require("@agimon-ai/doompi-core/server-facet");
|
|
29
|
+
let typebox = require("typebox");
|
|
30
|
+
let node_path = require("node:path");
|
|
31
|
+
node_path = __toESM(node_path, 1);
|
|
32
|
+
let node_fs = require("node:fs");
|
|
33
|
+
node_fs = __toESM(node_fs, 1);
|
|
34
|
+
let node_string_decoder = require("node:string_decoder");
|
|
35
|
+
let node_crypto = require("node:crypto");
|
|
36
|
+
let node_url = require("node:url");
|
|
37
|
+
let node_net = require("node:net");
|
|
38
|
+
node_net = __toESM(node_net, 1);
|
|
39
|
+
let node_os = require("node:os");
|
|
40
|
+
node_os = __toESM(node_os, 1);
|
|
41
|
+
let _xterm_headless = require("@xterm/headless");
|
|
42
|
+
_xterm_headless = __toESM(_xterm_headless, 1);
|
|
43
|
+
let node_module = require("node:module");
|
|
44
|
+
let _rmux_sdk = require("@rmux/sdk");
|
|
45
|
+
let node_child_process = require("node:child_process");
|
|
46
|
+
let node_util = require("node:util");
|
|
47
|
+
let _agimon_ai_doompi_core_child_process = require("@agimon-ai/doompi-core/child-process");
|
|
48
|
+
let _agimon_ai_foundation_process_registry = require("@agimon-ai/foundation-process-registry");
|
|
49
|
+
//#region src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp.ts
|
|
50
|
+
var doompi_use_runner_mcp_default = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpSkill)({
|
|
51
|
+
name: "doompi-use-runner",
|
|
52
|
+
description: "Use Doom Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.",
|
|
53
|
+
read: () => (0, _agimon_ai_doompi_core_server_facet.readPackageResource)(require("url").pathToFileURL(__filename).href, "src/prompts/doompi-use-runner/SKILL.md")
|
|
54
|
+
});
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/constants/bashTool.ts
|
|
57
|
+
const BASH_TOOL_NAME = "bash";
|
|
58
|
+
const BASH_TOOL_LABEL = "bash";
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/constants/headless.ts
|
|
61
|
+
const MS_PER_SECOND = 1e3;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/schemas/bashTool.ts
|
|
64
|
+
/** Replaces pi's built-in tool of the same name, so hooks keyed on `bash` keep working. */
|
|
65
|
+
/**
|
|
66
|
+
* Built-in bash parameters plus the backgrounding controls.
|
|
67
|
+
*
|
|
68
|
+
* `command` and `timeout` keep their original names and wording so the tool is
|
|
69
|
+
* a drop-in replacement. Every `description` is prompt copy the model reads.
|
|
70
|
+
*/
|
|
71
|
+
const BashParamsSchema = typebox.Type.Object({
|
|
72
|
+
command: typebox.Type.String({ description: "Bash command to execute" }),
|
|
73
|
+
timeout: typebox.Type.Optional(typebox.Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
|
74
|
+
background: typebox.Type.Optional(typebox.Type.Boolean({ description: "Start the command in the background immediately instead of waiting. Use for dev servers, watchers and other commands you already know will not finish." })),
|
|
75
|
+
interactive: typebox.Type.Optional(typebox.Type.Boolean({ description: "Run under a pseudo terminal so the command can prompt. Use only when the command asks for confirmation or input; answer it with doom-runner input. Logs from interactive runs are noisier." })),
|
|
76
|
+
name: typebox.Type.Optional(typebox.Type.String({ description: "Preferred runner name if the command ends up in the background. A name in use gets a suffix." }))
|
|
77
|
+
});
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/constants/bashResult.ts
|
|
80
|
+
const BYTES_PER_KB = 1024;
|
|
81
|
+
const BYTES_PER_MB = 1048576;
|
|
82
|
+
const PRAGMA_PATTERN = /^\s*(?:#|\/\/)\s*@doom:\s*(\{.*\})\s*$/u;
|
|
83
|
+
const PRAGMA_BYTES_CEILING = 262144;
|
|
84
|
+
const PRAGMA_LINES_CEILING = 5e3;
|
|
85
|
+
const PRAGMA_TOKENS_CEILING = 1e5;
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/constants/ansiScrub.ts
|
|
88
|
+
const CONTROL_PATTERN = /[\u0007\u0008]/g;
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/services/ansiScrub/index.ts
|
|
91
|
+
/**
|
|
92
|
+
* Turns terminal output into text worth grepping.
|
|
93
|
+
*
|
|
94
|
+
* PTY output is written for a screen, not a file: colours, cursor moves and
|
|
95
|
+
* carriage-return redraws all end up in the byte stream. Without this a single
|
|
96
|
+
* progress bar becomes thousands of near-identical log lines.
|
|
97
|
+
*/
|
|
98
|
+
/**
|
|
99
|
+
* CSI sequences (colours, cursor moves), OSC strings, the two-byte Fe escapes,
|
|
100
|
+
* and the keypad-mode escapes shells emit around every prompt.
|
|
101
|
+
*/
|
|
102
|
+
const ANSI_PATTERN = new RegExp([
|
|
103
|
+
"\\u001B\\[[0-9;?]*[ -/]*[@-~]",
|
|
104
|
+
"\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
|
|
105
|
+
"\\u001B[@-Z\\\\-_]",
|
|
106
|
+
"\\u001B[=><]"
|
|
107
|
+
].join("|"), "g");
|
|
108
|
+
/** Bell and backspace, which a terminal consumes rather than displays. */
|
|
109
|
+
/**
|
|
110
|
+
* The same set minus SGR, the sequence that only sets colour and weight.
|
|
111
|
+
*
|
|
112
|
+
* The final byte class omits `m`, so `ESC[32m` survives while cursor moves,
|
|
113
|
+
* OSC strings and keypad escapes are still removed.
|
|
114
|
+
*/
|
|
115
|
+
const ANSI_PATTERN_KEEPING_COLOUR = new RegExp([
|
|
116
|
+
"\\u001B\\[[0-9;?]*[ -/]*[@-ln-~]",
|
|
117
|
+
"\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
|
|
118
|
+
"\\u001B[@-Z\\\\-_]",
|
|
119
|
+
"\\u001B[=><]"
|
|
120
|
+
].join("|"), "g");
|
|
121
|
+
/** Removes escape sequences, leaving the characters a user would have seen. */
|
|
122
|
+
function stripAnsi(text) {
|
|
123
|
+
return text.replace(ANSI_PATTERN, "").replace(CONTROL_PATTERN, "");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Removes everything `stripAnsi` does except colour.
|
|
127
|
+
*
|
|
128
|
+
* Logs are read back by a terminal renderer as well as by grep, and a command
|
|
129
|
+
* that colours its own output has already said how it should look.
|
|
130
|
+
*/
|
|
131
|
+
function stripAnsiKeepingColour(text) {
|
|
132
|
+
return text.replace(ANSI_PATTERN_KEEPING_COLOUR, "").replace(CONTROL_PATTERN, "");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Collapses carriage-return redraws.
|
|
136
|
+
*
|
|
137
|
+
* A bare `\r` returns the cursor to the start of the current line, so whatever
|
|
138
|
+
* follows overwrites it. Only the final state of each line is kept. The `\r` of
|
|
139
|
+
* a CRLF pair is a line terminator rather than a redraw, so it is normalised
|
|
140
|
+
* away first: a pseudo terminal ends every line that way, and treating it as a
|
|
141
|
+
* redraw would blank the whole log.
|
|
142
|
+
*/
|
|
143
|
+
function collapseCarriageReturns(text) {
|
|
144
|
+
return text.replace(/\r\n/g, "\n").split("\n").map((line) => line.includes("\r") ? line.split("\r").at(-1) ?? "" : line).join("\n");
|
|
145
|
+
}
|
|
146
|
+
/** Full scrub applied to PTY output before it reaches the log file. */
|
|
147
|
+
function scrubTerminalOutput(text) {
|
|
148
|
+
return collapseCarriageReturns(stripAnsiKeepingColour(text));
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/constants/runnerConfig.ts
|
|
152
|
+
const BG_THRESHOLD_MS_ENV = "DOOM_RUNNER_BG_THRESHOLD_MS";
|
|
153
|
+
const RESULT_MAX_BYTES_ENV = "DOOM_RUNNER_RESULT_MAX_BYTES";
|
|
154
|
+
const SUCCESS_RESULT_MAX_BYTES_ENV = "DOOM_RUNNER_SUCCESS_RESULT_MAX_BYTES";
|
|
155
|
+
const RESULT_MAX_TOKENS_ENV = "DOOM_RUNNER_RESULT_MAX_TOKENS";
|
|
156
|
+
const SUCCESS_RESULT_MAX_TOKENS_ENV = "DOOM_RUNNER_SUCCESS_RESULT_MAX_TOKENS";
|
|
157
|
+
const LOG_MAX_BYTES_ENV = "DOOM_RUNNER_LOG_MAX_BYTES";
|
|
158
|
+
const MAX_COMPLETED_RUNNERS_ENV = "DOOM_RUNNER_MAX_COMPLETED_RUNNERS";
|
|
159
|
+
const LOG_DIR_ENV = "DOOM_RUNNER_LOG_DIR";
|
|
160
|
+
const DEFAULT_BG_THRESHOLD_MS = 6e4;
|
|
161
|
+
const DEFAULT_LOG_MAX_BYTES = 5242880;
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/models/runnerSettings.ts
|
|
164
|
+
const runnerSettingsState = { value: {} };
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/services/runnerConfig/index.ts
|
|
167
|
+
/**
|
|
168
|
+
* Project settings sit below the environment on purpose: a checked-in file is
|
|
169
|
+
* the project's baseline, while an environment variable is set for one run.
|
|
170
|
+
*/
|
|
171
|
+
/** A bash call still running at this point is promoted to a named runner. */
|
|
172
|
+
/** Tool results past this size are excerpted and spilled to the log file. */
|
|
173
|
+
/** Line ceiling for a single tool result, applied alongside the byte ceiling. */
|
|
174
|
+
/**
|
|
175
|
+
* A successful command gets a much smaller budget than a failing one. Exiting 0
|
|
176
|
+
* has already reported the outcome, so its output buys far less than a failure's.
|
|
177
|
+
*/
|
|
178
|
+
/**
|
|
179
|
+
* Token ceilings sit alongside the byte ones because bytes do not track context
|
|
180
|
+
* cost: 8 KiB of prose is roughly 2k tokens, while 8 KiB of base64 or CJK is
|
|
181
|
+
* several times that. Whichever ceiling binds first wins.
|
|
182
|
+
*/
|
|
183
|
+
/** Share of the budget spent on the leading excerpt; the remainder goes to the tail. */
|
|
184
|
+
/** Share reserved for errors rescued from the elided middle, when any exist. */
|
|
185
|
+
/** Distinct failures rescued from the elided middle. */
|
|
186
|
+
/** Variants joined inside one bracket before the rest become a count. */
|
|
187
|
+
/** Legacy rotation value retained so existing configuration imports remain compatible. */
|
|
188
|
+
/** Retention window for logs with no live registry record. */
|
|
189
|
+
/**
|
|
190
|
+
* Completed runners a single session keeps on disk.
|
|
191
|
+
*
|
|
192
|
+
* A count bound, not just an age one, because a session that stays open for a
|
|
193
|
+
* working day never ages its own records past the TTL: they would accumulate
|
|
194
|
+
* for as long as the session lives.
|
|
195
|
+
*/
|
|
196
|
+
function positiveNumber(raw, fallback) {
|
|
197
|
+
const value = Number(raw);
|
|
198
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
199
|
+
}
|
|
200
|
+
/** Age at which a bash call becomes a background runner. */
|
|
201
|
+
function getBackgroundThresholdMs(env = process.env) {
|
|
202
|
+
return positiveNumber(env[BG_THRESHOLD_MS_ENV], DEFAULT_BG_THRESHOLD_MS);
|
|
203
|
+
}
|
|
204
|
+
/** Byte ceiling for a single tool result before tail truncation kicks in. */
|
|
205
|
+
function getResultMaxBytes(env = process.env) {
|
|
206
|
+
return positiveNumber(env[RESULT_MAX_BYTES_ENV], runnerSettingsState.value.maxResultBytes ?? 8192);
|
|
207
|
+
}
|
|
208
|
+
/** Legacy rotation setting retained for public API compatibility. */
|
|
209
|
+
function getLogMaxBytes(env = process.env) {
|
|
210
|
+
return positiveNumber(env[LOG_MAX_BYTES_ENV], DEFAULT_LOG_MAX_BYTES);
|
|
211
|
+
}
|
|
212
|
+
/** Completed runners one session retains before the oldest are swept. */
|
|
213
|
+
function getMaxCompletedRunners(env = process.env) {
|
|
214
|
+
return positiveNumber(env[MAX_COMPLETED_RUNNERS_ENV], 100);
|
|
215
|
+
}
|
|
216
|
+
/** Byte ceiling for the result of a command that succeeded. */
|
|
217
|
+
function getSuccessResultMaxBytes(env = process.env) {
|
|
218
|
+
return positiveNumber(env[SUCCESS_RESULT_MAX_BYTES_ENV], runnerSettingsState.value.successMaxResultBytes ?? 2048);
|
|
219
|
+
}
|
|
220
|
+
/** Token ceiling for a single tool result. */
|
|
221
|
+
function getResultMaxTokens(env = process.env) {
|
|
222
|
+
return positiveNumber(env[RESULT_MAX_TOKENS_ENV], runnerSettingsState.value.maxResultTokens ?? 2048);
|
|
223
|
+
}
|
|
224
|
+
/** Token ceiling for the result of a command that succeeded. */
|
|
225
|
+
function getSuccessResultMaxTokens(env = process.env) {
|
|
226
|
+
return positiveNumber(env[SUCCESS_RESULT_MAX_TOKENS_ENV], runnerSettingsState.value.successMaxResultTokens ?? 512);
|
|
227
|
+
}
|
|
228
|
+
/** Line ceiling for a single tool result. */
|
|
229
|
+
function getResultMaxLines() {
|
|
230
|
+
return runnerSettingsState.value.maxResultLines ?? 120;
|
|
231
|
+
}
|
|
232
|
+
function getHeadRatio() {
|
|
233
|
+
return runnerSettingsState.value.headRatio ?? .2;
|
|
234
|
+
}
|
|
235
|
+
function getErrorBudgetRatio() {
|
|
236
|
+
return runnerSettingsState.value.errorBudgetRatio ?? .2;
|
|
237
|
+
}
|
|
238
|
+
function getErrorMaxEntries() {
|
|
239
|
+
return runnerSettingsState.value.errorMaxEntries ?? 10;
|
|
240
|
+
}
|
|
241
|
+
function getErrorMaxVariantsJoined() {
|
|
242
|
+
return runnerSettingsState.value.errorMaxVariantsJoined ?? 6;
|
|
243
|
+
}
|
|
244
|
+
/** Extra severity patterns appended to the built-in matcher. */
|
|
245
|
+
function getErrorPatterns() {
|
|
246
|
+
return runnerSettingsState.value.errorPatterns ?? [];
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/constants/tokenEstimate.ts
|
|
250
|
+
const MIXED_CHARS_PER_TOKEN = 2.1;
|
|
251
|
+
const ALPHANUMERIC_RUN = /([^\p{L}\p{N}]+)/u;
|
|
252
|
+
const HAS_ALPHANUMERIC = /[\p{L}\p{N}]/u;
|
|
253
|
+
const HAS_DIGIT = /\d/u;
|
|
254
|
+
const HAS_LETTER = /[A-Za-z]/u;
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/services/tokenEstimate/index.ts
|
|
257
|
+
/**
|
|
258
|
+
* Estimates BPE tokens without carrying a vocabulary.
|
|
259
|
+
*
|
|
260
|
+
* Bytes are a poor proxy for what a result costs: measured against
|
|
261
|
+
* `gpt-tokenizer`, real text ranges from 1.4 characters per token for base64 to
|
|
262
|
+
* 4.7 for English prose, a spread of more than three. Truncation runs on every
|
|
263
|
+
* command and is synchronous, so loading a real tokenizer here would charge
|
|
264
|
+
* every session for a table most of them never need.
|
|
265
|
+
*
|
|
266
|
+
* The shape below follows how BPE actually behaves: punctuation forces token
|
|
267
|
+
* boundaries, natural words are cheap, and runs mixing letters with digits are
|
|
268
|
+
* expensive. Calibrated against `gpt-tokenizer` over build logs, source, JSON,
|
|
269
|
+
* stack traces, CJK, base64, hex, and UUIDs, the worst error is a factor of
|
|
270
|
+
* 1.46 either way. It stays an estimate, so the byte ceiling remains the hard
|
|
271
|
+
* bound behind it.
|
|
272
|
+
*/
|
|
273
|
+
/** Characters per token for a natural word. */
|
|
274
|
+
/** Characters per token for a run mixing letters and digits, which tokenizes poorly. */
|
|
275
|
+
/** Characters per token for a run of punctuation or whitespace. */
|
|
276
|
+
/** Dense scripts such as CJK cost about one token per character. */
|
|
277
|
+
function estimateTokens(text) {
|
|
278
|
+
let tokens = 0;
|
|
279
|
+
for (const run of text.split(ALPHANUMERIC_RUN)) {
|
|
280
|
+
if (run.length === 0) continue;
|
|
281
|
+
if (!HAS_ALPHANUMERIC.test(run)) {
|
|
282
|
+
tokens += run.length / 2;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
let ascii = 0;
|
|
286
|
+
let dense = 0;
|
|
287
|
+
let astral = 0;
|
|
288
|
+
for (const character of run) {
|
|
289
|
+
const code = character.codePointAt(0) ?? 0;
|
|
290
|
+
if (code > 65535) astral += 1;
|
|
291
|
+
else if (code > 12287) dense += 1;
|
|
292
|
+
else ascii += 1;
|
|
293
|
+
}
|
|
294
|
+
tokens += astral * 2 + dense;
|
|
295
|
+
if (ascii > 0) {
|
|
296
|
+
const mixed = HAS_DIGIT.test(run) && HAS_LETTER.test(run);
|
|
297
|
+
tokens += Math.max(1, ascii / (mixed ? MIXED_CHARS_PER_TOKEN : 5));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return Math.ceil(tokens);
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/services/bashResult/index.ts
|
|
304
|
+
/** Variants retained per group while scanning; the rest only add to the count. */
|
|
305
|
+
/** Below this much shared text, joining reads worse than separate lines. */
|
|
306
|
+
/**
|
|
307
|
+
* Severity anywhere in the opening of the line, not only at its start. Real
|
|
308
|
+
* tooling buries it behind a tag or a timestamp: `[widget] build: error TS2322`.
|
|
309
|
+
*/
|
|
310
|
+
const SEVERITY_TOKEN = /(?<![\w\-/=.])(?:errors?|fatal|panic|exception|traceback|assertion|assert|fail(?:ed|ure|ures)?)\b/iu;
|
|
311
|
+
/** `0 errors` and `no failures` are the success case wearing the same words. */
|
|
312
|
+
const NEGATED_SEVERITY = /\b(?:no|not|without|zero|0)\s+$/iu;
|
|
313
|
+
function textResult(text, details = {}) {
|
|
314
|
+
return {
|
|
315
|
+
content: [{
|
|
316
|
+
type: "text",
|
|
317
|
+
text
|
|
318
|
+
}],
|
|
319
|
+
details
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/** Human-readable byte count, matching how pi reports output sizes. */
|
|
323
|
+
function formatSize(bytes) {
|
|
324
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
325
|
+
if (bytes < 1048576) return `${(bytes / BYTES_PER_KB).toFixed(1)} KB`;
|
|
326
|
+
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`;
|
|
327
|
+
}
|
|
328
|
+
function formatCount(value) {
|
|
329
|
+
return value.toLocaleString("en-US");
|
|
330
|
+
}
|
|
331
|
+
function countLines(text) {
|
|
332
|
+
if (text.length === 0) return 0;
|
|
333
|
+
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
|
334
|
+
}
|
|
335
|
+
/** Accumulates grouped error lines while a log streams past, without holding it. */
|
|
336
|
+
var ErrorScan = class {
|
|
337
|
+
maxEntries;
|
|
338
|
+
byKey = /* @__PURE__ */ new Map();
|
|
339
|
+
constructor(maxEntries = getErrorMaxEntries()) {
|
|
340
|
+
this.maxEntries = maxEntries;
|
|
341
|
+
}
|
|
342
|
+
push(line) {
|
|
343
|
+
if (!isErrorLine(line)) return;
|
|
344
|
+
const trimmed = line.trim();
|
|
345
|
+
const key = normalizeErrorLine(line);
|
|
346
|
+
const existing = this.byKey.get(key);
|
|
347
|
+
if (existing) {
|
|
348
|
+
existing.count += 1;
|
|
349
|
+
if (!existing.variants.includes(trimmed) && existing.variants.length < 12) existing.variants.push(trimmed);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (this.byKey.size >= this.maxEntries) return;
|
|
353
|
+
this.byKey.set(key, {
|
|
354
|
+
variants: [trimmed],
|
|
355
|
+
count: 1
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
entries() {
|
|
359
|
+
return [...this.byKey.values()];
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
function createErrorScanner(maxEntries = getErrorMaxEntries()) {
|
|
363
|
+
return new ErrorScan(maxEntries);
|
|
364
|
+
}
|
|
365
|
+
/** Codex-style first-line pragma, for the one command that needs a wider result. */
|
|
366
|
+
function positiveInteger(value) {
|
|
367
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Reads `# @doom: {"maxResultBytes": 32768}` from the command's first line.
|
|
371
|
+
*
|
|
372
|
+
* The pragma is a shell comment, so it stays harmless if this parse is ever
|
|
373
|
+
* skipped, and it survives verbatim into the log where a wider result can be
|
|
374
|
+
* explained after the fact.
|
|
375
|
+
*/
|
|
376
|
+
function parseResultPragma(command) {
|
|
377
|
+
const match = PRAGMA_PATTERN.exec(command.split("\n", 1)[0] ?? "");
|
|
378
|
+
if (!match?.[1]) return {};
|
|
379
|
+
let parsed;
|
|
380
|
+
try {
|
|
381
|
+
parsed = JSON.parse(match[1]);
|
|
382
|
+
} catch {
|
|
383
|
+
return {};
|
|
384
|
+
}
|
|
385
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
386
|
+
const source = parsed;
|
|
387
|
+
const maxBytes = positiveInteger(source.maxResultBytes);
|
|
388
|
+
const maxLines = positiveInteger(source.maxResultLines);
|
|
389
|
+
const maxTokens = positiveInteger(source.maxResultTokens);
|
|
390
|
+
return {
|
|
391
|
+
...maxBytes === void 0 ? {} : { maxBytes: Math.min(maxBytes, PRAGMA_BYTES_CEILING) },
|
|
392
|
+
...maxLines === void 0 ? {} : { maxLines: Math.min(maxLines, PRAGMA_LINES_CEILING) },
|
|
393
|
+
...maxTokens === void 0 ? {} : { maxTokens: Math.min(maxTokens, PRAGMA_TOKENS_CEILING) }
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function isErrorLine(line) {
|
|
397
|
+
const opening = line.slice(0, 60);
|
|
398
|
+
const match = SEVERITY_TOKEN.exec(opening);
|
|
399
|
+
if (match && !NEGATED_SEVERITY.test(opening.slice(0, match.index))) return true;
|
|
400
|
+
return getErrorPatterns().some((pattern) => matchesCustomPattern(pattern, line));
|
|
401
|
+
}
|
|
402
|
+
const customPatterns = /* @__PURE__ */ new Map();
|
|
403
|
+
function matchesCustomPattern(pattern, line) {
|
|
404
|
+
if (!customPatterns.has(pattern)) try {
|
|
405
|
+
customPatterns.set(pattern, new RegExp(pattern, "iu"));
|
|
406
|
+
} catch {
|
|
407
|
+
customPatterns.set(pattern, void 0);
|
|
408
|
+
}
|
|
409
|
+
return customPatterns.get(pattern)?.test(line) ?? false;
|
|
410
|
+
}
|
|
411
|
+
/** Masks the parts that differ between repeats of the same underlying failure. */
|
|
412
|
+
function normalizeErrorLine(line) {
|
|
413
|
+
return line.replace(/0x[0-9a-f]+/giu, "0x#").replace(/\/[^\s:,)]+/gu, "/#").replace(/\d+/gu, "#").trim().toLowerCase();
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Collapses repeats so one failure repeated two hundred times costs one slot.
|
|
417
|
+
* Counting continues past the entry cap so the tallies stay honest.
|
|
418
|
+
*/
|
|
419
|
+
function collectErrorLines(lines, maxEntries = getErrorMaxEntries()) {
|
|
420
|
+
const scan = new ErrorScan(maxEntries);
|
|
421
|
+
for (const line of lines) scan.push(line);
|
|
422
|
+
return scan.entries();
|
|
423
|
+
}
|
|
424
|
+
function commonAffixLengths(values) {
|
|
425
|
+
const shortest = values.reduce((min, value) => Math.min(min, value.length), Infinity);
|
|
426
|
+
let prefix = 0;
|
|
427
|
+
while (prefix < shortest && values.every((value) => value[prefix] === values[0][prefix])) prefix += 1;
|
|
428
|
+
let suffix = 0;
|
|
429
|
+
while (suffix < shortest - prefix && values.every((value) => value[value.length - 1 - suffix] === values[0][values[0].length - 1 - suffix])) suffix += 1;
|
|
430
|
+
return {
|
|
431
|
+
prefix,
|
|
432
|
+
suffix
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** Back off to a whitespace boundary so the brackets hold whole tokens. */
|
|
436
|
+
function retreatToBoundary(value, length) {
|
|
437
|
+
const boundary = value.lastIndexOf(" ", Math.max(0, length - 1));
|
|
438
|
+
return boundary >= 0 ? boundary + 1 : 0;
|
|
439
|
+
}
|
|
440
|
+
function advanceToBoundary(value, length) {
|
|
441
|
+
const cut = value.length - length;
|
|
442
|
+
const boundary = value.indexOf(" ", cut);
|
|
443
|
+
return boundary >= 0 ? value.length - boundary : 0;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* One line per distinct failure would be ideal, but repeats of one failure
|
|
447
|
+
* across many files are the common case. Sharing the fixed text and joining
|
|
448
|
+
* only the parts that differ keeps every file name without paying for the
|
|
449
|
+
* message N times.
|
|
450
|
+
*/
|
|
451
|
+
function renderGroup(group) {
|
|
452
|
+
const [first] = group.variants;
|
|
453
|
+
if (first === void 0) return "";
|
|
454
|
+
if (group.variants.length === 1) return group.count > 1 ? `${first} (\u00d7${formatCount(group.count)})` : first;
|
|
455
|
+
const shown = group.variants.slice(0, getErrorMaxVariantsJoined());
|
|
456
|
+
const { prefix, suffix } = commonAffixLengths(shown);
|
|
457
|
+
const head = first.slice(0, retreatToBoundary(first, prefix));
|
|
458
|
+
const tailLength = advanceToBoundary(first, suffix);
|
|
459
|
+
const tail = tailLength > 0 ? first.slice(first.length - tailLength) : "";
|
|
460
|
+
if (head.length + tail.length < 8) return shown.join("\n");
|
|
461
|
+
const middles = shown.map((value) => value.slice(head.length, value.length - tail.length));
|
|
462
|
+
const overflow = group.variants.length - shown.length;
|
|
463
|
+
return `${head}[${overflow > 0 ? `${middles.join("|")}|+${formatCount(overflow)}` : middles.join("|")}]${tail}`;
|
|
464
|
+
}
|
|
465
|
+
function renderSalvaged(errors, maxBytes) {
|
|
466
|
+
const rendered = [];
|
|
467
|
+
let used = 0;
|
|
468
|
+
for (const group of errors) {
|
|
469
|
+
const text = renderGroup(group);
|
|
470
|
+
if (text.length === 0) continue;
|
|
471
|
+
const cost = Buffer.byteLength(text, "utf8") + 1;
|
|
472
|
+
if (used + cost > maxBytes) break;
|
|
473
|
+
rendered.push(text);
|
|
474
|
+
used += cost;
|
|
475
|
+
}
|
|
476
|
+
return rendered;
|
|
477
|
+
}
|
|
478
|
+
function elisionMarker(elidedLines, salvagedCount = 0) {
|
|
479
|
+
const unit = elidedLines === 1 ? "line" : "lines";
|
|
480
|
+
const errors = salvagedCount > 0 ? `, ${formatCount(salvagedCount)} shown below` : "";
|
|
481
|
+
return `\u2026 [${formatCount(elidedLines)} ${unit} elided${errors}] \u2026`;
|
|
482
|
+
}
|
|
483
|
+
function splitTextLines(text) {
|
|
484
|
+
const trailingNewline = text.endsWith("\n");
|
|
485
|
+
const lines = text.split("\n");
|
|
486
|
+
if (trailingNewline) lines.pop();
|
|
487
|
+
return {
|
|
488
|
+
lines,
|
|
489
|
+
trailingNewline
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** Whole lines only: a fragment of a line reads as data that was never there. */
|
|
493
|
+
function takeSide(lines, maxCount, maxBytes, fromEnd, maxTokens = Number.POSITIVE_INFINITY) {
|
|
494
|
+
if (maxBytes <= 0 || maxCount <= 0) return {
|
|
495
|
+
text: "",
|
|
496
|
+
count: 0
|
|
497
|
+
};
|
|
498
|
+
const ordered = fromEnd ? [...lines].reverse() : [...lines];
|
|
499
|
+
const taken = [];
|
|
500
|
+
let used = 0;
|
|
501
|
+
let tokens = 0;
|
|
502
|
+
for (const line of ordered) {
|
|
503
|
+
if (taken.length >= maxCount) break;
|
|
504
|
+
const cost = Buffer.byteLength(line, "utf8") + (taken.length > 0 ? 1 : 0);
|
|
505
|
+
const lineTokens = estimateTokens(line) + (taken.length > 0 ? 1 : 0);
|
|
506
|
+
if (used + cost > maxBytes || tokens + lineTokens > maxTokens) break;
|
|
507
|
+
taken.push(line);
|
|
508
|
+
used += cost;
|
|
509
|
+
tokens += lineTokens;
|
|
510
|
+
}
|
|
511
|
+
if (taken.length === 0) return {
|
|
512
|
+
text: "",
|
|
513
|
+
count: 0
|
|
514
|
+
};
|
|
515
|
+
const restored = fromEnd ? taken.reverse() : taken;
|
|
516
|
+
return {
|
|
517
|
+
text: restored.join("\n"),
|
|
518
|
+
count: restored.length
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Used only when no whole line fits either side, which means the source has no
|
|
523
|
+
* usable line boundary. A clamped fragment beats returning nothing at all.
|
|
524
|
+
*/
|
|
525
|
+
function clampedFragment(text, limitBytes) {
|
|
526
|
+
const clamped = utf8Tail$1(Buffer.from(text, "utf8"), limitBytes);
|
|
527
|
+
const newline = clamped.indexOf("\n");
|
|
528
|
+
const whole = newline >= 0 ? clamped.slice(newline + 1) : clamped;
|
|
529
|
+
return {
|
|
530
|
+
text: whole,
|
|
531
|
+
count: whole.length > 0 ? 1 : 0
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/** Charged against the budget so the composed excerpt still honours the ceiling. */
|
|
535
|
+
function markerReservation(totalLines) {
|
|
536
|
+
return Buffer.byteLength(elisionMarker(totalLines), "utf8") + 2;
|
|
537
|
+
}
|
|
538
|
+
function assembleSides(head, tail, elidedLines, trailingNewline, limitBytes, salvaged = []) {
|
|
539
|
+
const opening = elidedLines > 0 ? [elisionMarker(elidedLines, salvaged.length)] : [];
|
|
540
|
+
const closing = elidedLines > 0 && salvaged.length > 0 ? ["…"] : [];
|
|
541
|
+
const body = [
|
|
542
|
+
head.text,
|
|
543
|
+
...opening,
|
|
544
|
+
...salvaged,
|
|
545
|
+
...closing,
|
|
546
|
+
tail.text
|
|
547
|
+
].filter((part) => part.length > 0).join("\n");
|
|
548
|
+
const withNewline = trailingNewline && body.length > 0 ? `${body}\n` : body;
|
|
549
|
+
const encoded = Buffer.from(withNewline, "utf8");
|
|
550
|
+
return {
|
|
551
|
+
text: encoded.byteLength > limitBytes ? utf8Tail$1(encoded, limitBytes) : withNewline,
|
|
552
|
+
lines: head.count + tail.count,
|
|
553
|
+
truncated: true,
|
|
554
|
+
elidedLines: Math.max(0, elidedLines)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function budgetFor(maxLines, maxBytes, totalLines, maxTokens) {
|
|
558
|
+
const limitLines = Math.max(1, Math.floor(maxLines));
|
|
559
|
+
const usable = Math.max(0, maxBytes - markerReservation(totalLines));
|
|
560
|
+
const tokens = Math.max(1, Math.floor(maxTokens) - estimateTokens(elisionMarker(totalLines)));
|
|
561
|
+
return {
|
|
562
|
+
limitLines,
|
|
563
|
+
headLines: Math.max(1, Math.floor(limitLines * getHeadRatio())),
|
|
564
|
+
headBytes: Math.max(0, Math.floor(usable * getHeadRatio())),
|
|
565
|
+
headTokens: Math.max(1, Math.floor(tokens * getHeadRatio())),
|
|
566
|
+
usable,
|
|
567
|
+
tokens
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Bounds text to the budget, keeping a leading excerpt as well as the tail.
|
|
572
|
+
*
|
|
573
|
+
* Failures announce themselves at both ends: a command that could not start
|
|
574
|
+
* says so on line one, and a command that died says so on the last. Keeping
|
|
575
|
+
* only the tail loses the first kind entirely.
|
|
576
|
+
*/
|
|
577
|
+
function boundExcerpt(text, maxLines = getResultMaxLines(), maxBytes = getResultMaxBytes(), maxTokens = getResultMaxTokens()) {
|
|
578
|
+
if (text.length === 0) return {
|
|
579
|
+
text: "",
|
|
580
|
+
lines: 0,
|
|
581
|
+
truncated: false,
|
|
582
|
+
elidedLines: 0
|
|
583
|
+
};
|
|
584
|
+
const { lines, trailingNewline } = splitTextLines(text);
|
|
585
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
586
|
+
const limitBytes = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : totalBytes;
|
|
587
|
+
const limitLines = Math.max(1, Math.floor(maxLines));
|
|
588
|
+
if (lines.length <= limitLines && totalBytes <= limitBytes && estimateTokens(text) <= maxTokens) return {
|
|
589
|
+
text,
|
|
590
|
+
lines: lines.length,
|
|
591
|
+
truncated: false,
|
|
592
|
+
elidedLines: 0
|
|
593
|
+
};
|
|
594
|
+
const budget = budgetFor(maxLines, limitBytes, lines.length, maxTokens);
|
|
595
|
+
const head = takeSide(lines, budget.headLines, budget.headBytes, false, budget.headTokens);
|
|
596
|
+
const rest = lines.slice(head.count);
|
|
597
|
+
const errorBytes = rest.some(isErrorLine) ? Math.floor(budget.usable * getErrorBudgetRatio()) : 0;
|
|
598
|
+
const tailBytes = budget.usable - Buffer.byteLength(head.text, "utf8") - errorBytes;
|
|
599
|
+
const tailTokens = budget.tokens - estimateTokens(head.text);
|
|
600
|
+
const tail = takeSide(rest, Math.max(1, budget.limitLines - head.count), tailBytes, true, tailTokens);
|
|
601
|
+
if (head.count === 0 && tail.count === 0) {
|
|
602
|
+
const fragment = clampedFragment(text, limitBytes);
|
|
603
|
+
return {
|
|
604
|
+
text: fragment.text,
|
|
605
|
+
lines: fragment.count,
|
|
606
|
+
truncated: true,
|
|
607
|
+
elidedLines: lines.length - fragment.count
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const middle = lines.slice(head.count, lines.length - tail.count);
|
|
611
|
+
const salvaged = errorBytes > 0 ? renderSalvaged(collectErrorLines(middle), errorBytes) : [];
|
|
612
|
+
return assembleSides(head, tail, lines.length - head.count - tail.count, trailingNewline, limitBytes, salvaged);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Same budget as `boundExcerpt`, for callers that streamed the two ends
|
|
616
|
+
* separately and never held the middle. `totalLines` counts the whole source,
|
|
617
|
+
* so the marker states what was dropped rather than what was captured.
|
|
618
|
+
*/
|
|
619
|
+
function composeExcerpt(headSource, tailSource, totalLines, maxLines = getResultMaxLines(), maxBytes = getResultMaxBytes(), errors = [], maxTokens = getResultMaxTokens()) {
|
|
620
|
+
const limitBytes = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : Number.MAX_SAFE_INTEGER;
|
|
621
|
+
const budget = budgetFor(maxLines, limitBytes, totalLines, maxTokens);
|
|
622
|
+
const headLines = splitTextLines(headSource).lines;
|
|
623
|
+
const { lines: tailLines, trailingNewline } = splitTextLines(tailSource);
|
|
624
|
+
const head = takeSide(headLines.slice(0, Math.max(0, headLines.length - 1)), budget.headLines, budget.headBytes, false, budget.headTokens);
|
|
625
|
+
const errorBytes = errors.length > 0 ? Math.floor(budget.usable * getErrorBudgetRatio()) : 0;
|
|
626
|
+
const tailBytes = budget.usable - Buffer.byteLength(head.text, "utf8") - errorBytes;
|
|
627
|
+
const tailTokens = budget.tokens - estimateTokens(head.text);
|
|
628
|
+
const tail = takeSide(tailLines.slice(1), Math.max(1, budget.limitLines - head.count), tailBytes, true, tailTokens);
|
|
629
|
+
if (head.count === 0 && tail.count === 0) {
|
|
630
|
+
const fragment = clampedFragment(tailSource, limitBytes);
|
|
631
|
+
return {
|
|
632
|
+
text: fragment.text,
|
|
633
|
+
lines: fragment.count,
|
|
634
|
+
truncated: true,
|
|
635
|
+
elidedLines: Math.max(0, totalLines - fragment.count)
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const salvaged = errorBytes > 0 ? renderSalvaged(errors, errorBytes) : [];
|
|
639
|
+
return assembleSides(head, tail, Math.max(0, totalLines - head.count - tail.count), trailingNewline, limitBytes, salvaged);
|
|
640
|
+
}
|
|
641
|
+
/** Bounds the complete model-facing result after status and recovery text are attached. */
|
|
642
|
+
function boundResultText(text, maxBytes = getResultMaxBytes()) {
|
|
643
|
+
return boundExcerpt(text, getResultMaxLines(), maxBytes).text;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Keeps both ends of an oversized result and points at the file holding the rest.
|
|
647
|
+
*
|
|
648
|
+
* The notice states the file's real size and line count so the model does not
|
|
649
|
+
* guess at offsets when it goes on to read the file, which is the failure this
|
|
650
|
+
* whole policy exists to prevent.
|
|
651
|
+
*/
|
|
652
|
+
function utf8Tail$1(encoded, maxBytes) {
|
|
653
|
+
let start = Math.max(0, encoded.byteLength - maxBytes);
|
|
654
|
+
while (start < encoded.byteLength && (encoded[start] & 192) === 128) start += 1;
|
|
655
|
+
return encoded.subarray(start).toString("utf8");
|
|
656
|
+
}
|
|
657
|
+
const PROMOTION_REASONS = {
|
|
658
|
+
requested: "Started in the background",
|
|
659
|
+
threshold: "Still running after the background threshold",
|
|
660
|
+
interactive: "Started interactively"
|
|
661
|
+
};
|
|
662
|
+
function completionFailed(result) {
|
|
663
|
+
return result.timedOut === true || result.signal !== null || result.exitCode !== null && result.exitCode !== 0;
|
|
664
|
+
}
|
|
665
|
+
function completionStatus(result) {
|
|
666
|
+
if (result.timedOut === true) return "Timed out: exceeded the requested timeout.";
|
|
667
|
+
if (result.signal !== null) return `Signal: ${result.signal}`;
|
|
668
|
+
if (result.exitCode === null) return "Exit status unavailable.";
|
|
669
|
+
if (result.exitCode !== 0) return `Exit: ${result.exitCode}`;
|
|
670
|
+
}
|
|
671
|
+
const emptyLogSummary = () => ({
|
|
672
|
+
tail: "",
|
|
673
|
+
bytes: 0,
|
|
674
|
+
lines: 0,
|
|
675
|
+
tailLines: 0
|
|
676
|
+
});
|
|
677
|
+
function formatRunResult(result, budget = {}, summarizeLog = emptyLogSummary) {
|
|
678
|
+
if (result.kind === "failed") throw new Error([`Could not start runner "${result.name}": ${result.error}`, "Next: correct the reported launch or supervision problem. Retry only after changing the command or environment."].join("\n"));
|
|
679
|
+
if (result.kind === "promoted") return textResult([
|
|
680
|
+
`${PROMOTION_REASONS[result.reason]}: runner "${result.name}" (${result.id}).`,
|
|
681
|
+
`Streaming log: ${result.logPath}`,
|
|
682
|
+
`Inspect: doom-runner logs ${result.id}`
|
|
683
|
+
].join("\n"), {
|
|
684
|
+
id: result.id,
|
|
685
|
+
runner: result.name,
|
|
686
|
+
pid: result.pid,
|
|
687
|
+
logPath: result.logPath,
|
|
688
|
+
promoted: true,
|
|
689
|
+
reason: result.reason
|
|
690
|
+
});
|
|
691
|
+
const succeeded = !completionFailed(result);
|
|
692
|
+
const maxLines = budget.maxLines ?? getResultMaxLines();
|
|
693
|
+
const maxBytes = budget.maxBytes ?? (succeeded ? getSuccessResultMaxBytes() : getResultMaxBytes());
|
|
694
|
+
const maxTokens = budget.maxTokens ?? (succeeded ? getSuccessResultMaxTokens() : getResultMaxTokens());
|
|
695
|
+
const log = summarizeLog(result.logPath, maxLines, maxBytes, maxTokens);
|
|
696
|
+
const useCapturedOutput = !result.rtkOutput && log.tail.length === 0 && result.output.length > 0;
|
|
697
|
+
let tail;
|
|
698
|
+
let tailLines;
|
|
699
|
+
let outputLines;
|
|
700
|
+
let outputBytes;
|
|
701
|
+
let truncated;
|
|
702
|
+
if (result.rtkOutput) {
|
|
703
|
+
const bounded = Buffer.byteLength(result.rtkOutput.output, "utf8") < result.rtkOutput.bytes ? composeExcerpt(result.rtkOutput.head, result.rtkOutput.output, result.rtkOutput.lines, maxLines, maxBytes, [], maxTokens) : boundExcerpt(result.rtkOutput.output, maxLines, maxBytes, maxTokens);
|
|
704
|
+
tail = bounded.text;
|
|
705
|
+
tailLines = bounded.lines;
|
|
706
|
+
outputLines = result.rtkOutput.lines;
|
|
707
|
+
outputBytes = result.rtkOutput.bytes;
|
|
708
|
+
truncated = outputLines > tailLines || outputBytes > Buffer.byteLength(tail, "utf8");
|
|
709
|
+
} else {
|
|
710
|
+
tail = useCapturedOutput ? result.output : log.tail;
|
|
711
|
+
tailLines = useCapturedOutput ? countLines(tail) : log.tailLines;
|
|
712
|
+
outputLines = Math.max(log.lines, tailLines);
|
|
713
|
+
outputBytes = useCapturedOutput ? Buffer.byteLength(tail, "utf8") : log.bytes;
|
|
714
|
+
truncated = !useCapturedOutput && (outputLines > tailLines || outputBytes > Buffer.byteLength(tail, "utf8"));
|
|
715
|
+
}
|
|
716
|
+
const plainTail = stripAnsi(tail).replace(/\r?\n$/u, "");
|
|
717
|
+
const failed = completionFailed(result);
|
|
718
|
+
const status = completionStatus(result);
|
|
719
|
+
const textLines = [];
|
|
720
|
+
if (plainTail.length === 0) textLines.push(status === void 0 ? "Completed with no output." : "No output.");
|
|
721
|
+
else if (truncated) {
|
|
722
|
+
const label = result.rtkOutput ? `RTK ${result.rtkOutput.filter} excerpt` : "Log excerpt";
|
|
723
|
+
textLines.push(`${label} (${tailLines.toLocaleString("en-US")} of ${outputLines.toLocaleString("en-US")} lines):\n${plainTail}`);
|
|
724
|
+
} else textLines.push(plainTail);
|
|
725
|
+
if (status !== void 0) textLines.push(status);
|
|
726
|
+
if (result.rtkWarning) textLines.push(result.rtkWarning);
|
|
727
|
+
if (truncated) {
|
|
728
|
+
const label = result.rtkOutput ? "Complete raw log" : "Full log";
|
|
729
|
+
textLines.push(`${label}: ${result.logPath} (${formatSize(log.bytes)}, ${log.lines.toLocaleString("en-US")} lines); inspect with doom-runner logs ${result.id}`);
|
|
730
|
+
} else if (failed && plainTail.length === 0) textLines.push(`Log: ${result.logPath}`, "Next: run one read-only diagnostic; retry only after correcting the cause.");
|
|
731
|
+
const text = boundResultText(textLines.join("\n"), maxBytes);
|
|
732
|
+
const details = {
|
|
733
|
+
id: result.id,
|
|
734
|
+
runner: result.name,
|
|
735
|
+
exitCode: result.exitCode,
|
|
736
|
+
logPath: result.logPath,
|
|
737
|
+
backend: result.backend,
|
|
738
|
+
fileSize: log.bytes,
|
|
739
|
+
lines: useCapturedOutput ? tailLines : log.lines,
|
|
740
|
+
tail,
|
|
741
|
+
tailLines,
|
|
742
|
+
...result.rtkOutput ? {
|
|
743
|
+
rtkFilter: result.rtkOutput.filter,
|
|
744
|
+
rtkOutputBytes: result.rtkOutput.bytes,
|
|
745
|
+
rtkOutputLines: result.rtkOutput.lines
|
|
746
|
+
} : {},
|
|
747
|
+
...result.rtkWarning ? { rtkWarning: result.rtkWarning } : {},
|
|
748
|
+
...result.timedOut ? { timedOut: true } : {}
|
|
749
|
+
};
|
|
750
|
+
if (failed) throw new Error(text);
|
|
751
|
+
return textResult(text, details);
|
|
752
|
+
}
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region src/services/headless/index.ts
|
|
755
|
+
function createHeadlessBashTool(bashRunService, summarizeLog) {
|
|
756
|
+
return {
|
|
757
|
+
name: BASH_TOOL_NAME,
|
|
758
|
+
label: BASH_TOOL_LABEL,
|
|
759
|
+
description: "Execute one Bash command in the current working directory. Foreground commands return bounded output; commands that outlive the threshold return a supervised runner id and streaming log path.",
|
|
760
|
+
promptSnippet: "Execute shell commands with bounded foreground output and supervised background runners",
|
|
761
|
+
promptGuidelines: [
|
|
762
|
+
`A command still running after ${Math.round(getBackgroundThresholdMs() / MS_PER_SECOND)} seconds remains active as a background runner with an id and streaming log path.`,
|
|
763
|
+
"Pass background: true for commands that should remain active, such as dev servers, watchers, and tails.",
|
|
764
|
+
"Stop background runners when they are no longer needed."
|
|
765
|
+
],
|
|
766
|
+
parameters: BashParamsSchema,
|
|
767
|
+
executionMode: "serial",
|
|
768
|
+
execute: async (_toolCallId, params, signal, onUpdate, context) => {
|
|
769
|
+
const { command, timeout, background, interactive, name } = params;
|
|
770
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
771
|
+
if (onUpdate) onUpdate(textResult(`Starting ${interactive === true ? "interactive runner" : background === true ? "background runner" : "command"}...`));
|
|
772
|
+
let onOutput;
|
|
773
|
+
if (background !== true && interactive !== true && onUpdate) onOutput = (output) => onUpdate(textResult(output));
|
|
774
|
+
const result = await bashRunService.run({
|
|
775
|
+
command,
|
|
776
|
+
timeoutMs: timeout === void 0 ? void 0 : timeout * MS_PER_SECOND,
|
|
777
|
+
background,
|
|
778
|
+
interactive,
|
|
779
|
+
name,
|
|
780
|
+
...onOutput ? { onOutput } : {},
|
|
781
|
+
...signal ? { signal } : {},
|
|
782
|
+
cwd: context.cwd,
|
|
783
|
+
sessionId: context.sessionId
|
|
784
|
+
});
|
|
785
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
786
|
+
return formatRunResult(result, parseResultPragma(command), summarizeLog);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/constants/logReader.ts
|
|
792
|
+
const READ_CHUNK_BYTES = 65536;
|
|
793
|
+
//#endregion
|
|
794
|
+
//#region src/services/logReader/index.ts
|
|
795
|
+
var LogReader = class {
|
|
796
|
+
read(logPath, query = {}) {
|
|
797
|
+
let handle;
|
|
798
|
+
try {
|
|
799
|
+
handle = node_fs.default.openSync(logPath, "r");
|
|
800
|
+
return {
|
|
801
|
+
...scanLog(handle, query),
|
|
802
|
+
fileSize: node_fs.default.fstatSync(handle).size,
|
|
803
|
+
path: logPath,
|
|
804
|
+
exists: true
|
|
805
|
+
};
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (error.code !== "ENOENT") throw error;
|
|
808
|
+
return {
|
|
809
|
+
text: "",
|
|
810
|
+
lineCount: 0,
|
|
811
|
+
totalLines: 0,
|
|
812
|
+
fileSize: 0,
|
|
813
|
+
completeBytes: 0,
|
|
814
|
+
path: logPath,
|
|
815
|
+
exists: false
|
|
816
|
+
};
|
|
817
|
+
} finally {
|
|
818
|
+
if (handle !== void 0) node_fs.default.closeSync(handle);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
function scanLog(handle, query) {
|
|
823
|
+
const limit = Math.max(0, Math.floor(query.lines ?? 200));
|
|
824
|
+
const needle = query.grep ? query.ignoreCase ? query.grep.toLowerCase() : query.grep : void 0;
|
|
825
|
+
const contextLines = Math.max(0, Math.floor(query.contextLines ?? 0));
|
|
826
|
+
const decoder = new node_string_decoder.StringDecoder("utf8");
|
|
827
|
+
const buffer = Buffer.alloc(READ_CHUNK_BYTES);
|
|
828
|
+
const before = [];
|
|
829
|
+
const output = [];
|
|
830
|
+
let pending = "";
|
|
831
|
+
let totalLines = 0;
|
|
832
|
+
let afterThrough = -1;
|
|
833
|
+
let lastEmitted = -1;
|
|
834
|
+
let lastByte;
|
|
835
|
+
let bytesSeen = 0;
|
|
836
|
+
let completeBytes = 0;
|
|
837
|
+
const retain = (line) => {
|
|
838
|
+
if (limit === 0) return;
|
|
839
|
+
output.push(line);
|
|
840
|
+
if (output.length > limit) output.shift();
|
|
841
|
+
};
|
|
842
|
+
const emit = (line) => {
|
|
843
|
+
if (line.index <= lastEmitted) return;
|
|
844
|
+
lastEmitted = line.index;
|
|
845
|
+
retain(line);
|
|
846
|
+
};
|
|
847
|
+
const consume = (text) => {
|
|
848
|
+
const line = {
|
|
849
|
+
index: totalLines,
|
|
850
|
+
text
|
|
851
|
+
};
|
|
852
|
+
totalLines += 1;
|
|
853
|
+
if (!needle) {
|
|
854
|
+
retain(line);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if ((query.ignoreCase ? text.toLowerCase() : text).includes(needle)) {
|
|
858
|
+
for (const previous of before) emit(previous);
|
|
859
|
+
emit(line);
|
|
860
|
+
afterThrough = Math.max(afterThrough, line.index + contextLines);
|
|
861
|
+
} else if (line.index <= afterThrough) emit(line);
|
|
862
|
+
before.push(line);
|
|
863
|
+
if (before.length > contextLines) before.shift();
|
|
864
|
+
};
|
|
865
|
+
for (;;) {
|
|
866
|
+
const bytesRead = node_fs.default.readSync(handle, buffer, 0, buffer.byteLength, null);
|
|
867
|
+
if (bytesRead === 0) break;
|
|
868
|
+
lastByte = buffer[bytesRead - 1];
|
|
869
|
+
const lastNewline = buffer.subarray(0, bytesRead).lastIndexOf(10);
|
|
870
|
+
if (lastNewline >= 0) completeBytes = bytesSeen + lastNewline + 1;
|
|
871
|
+
bytesSeen += bytesRead;
|
|
872
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
873
|
+
let newline = pending.indexOf("\n");
|
|
874
|
+
while (newline >= 0) {
|
|
875
|
+
consume(pending.slice(0, newline));
|
|
876
|
+
pending = pending.slice(newline + 1);
|
|
877
|
+
newline = pending.indexOf("\n");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
pending += decoder.end();
|
|
881
|
+
if (pending.length > 0) consume(pending);
|
|
882
|
+
const trailingNewline = lastByte === 10;
|
|
883
|
+
const scanned = {
|
|
884
|
+
text: `${output.map((line) => line.text).join("\n")}${!needle && trailingNewline && output.length > 0 ? "\n" : ""}`,
|
|
885
|
+
lineCount: output.length,
|
|
886
|
+
totalLines,
|
|
887
|
+
completeBytes
|
|
888
|
+
};
|
|
889
|
+
if (!needle) return scanned;
|
|
890
|
+
return {
|
|
891
|
+
...scanned,
|
|
892
|
+
lineNumbers: output.map((line) => line.index + 1)
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/** Reads a bounded head and tail plus exact metadata after the writer has flushed. */
|
|
896
|
+
function summarizeLog(path, maxLines = getResultMaxLines(), maxBytes = getResultMaxBytes(), maxTokens = getResultMaxTokens()) {
|
|
897
|
+
let handle;
|
|
898
|
+
try {
|
|
899
|
+
const size = node_fs.default.statSync(path).size;
|
|
900
|
+
handle = node_fs.default.openSync(path, "r");
|
|
901
|
+
const readBuffer = Buffer.alloc(READ_CHUNK_BYTES);
|
|
902
|
+
const endLimit = Math.max(1, maxBytes + 1);
|
|
903
|
+
let headBuffer = Buffer.alloc(0);
|
|
904
|
+
let tailBuffer = Buffer.alloc(0);
|
|
905
|
+
let newlineCount = 0;
|
|
906
|
+
let lastByte;
|
|
907
|
+
const decoder = new node_string_decoder.StringDecoder("utf8");
|
|
908
|
+
const scanned = createErrorScanner();
|
|
909
|
+
let pending = "";
|
|
910
|
+
for (;;) {
|
|
911
|
+
const bytesRead = node_fs.default.readSync(handle, readBuffer, 0, readBuffer.byteLength, null);
|
|
912
|
+
if (bytesRead === 0) break;
|
|
913
|
+
const chunk = readBuffer.subarray(0, bytesRead);
|
|
914
|
+
for (const byte of chunk) if (byte === 10) newlineCount += 1;
|
|
915
|
+
lastByte = chunk.at(-1);
|
|
916
|
+
if (headBuffer.byteLength < endLimit) headBuffer = Buffer.concat([headBuffer, chunk.subarray(0, endLimit - headBuffer.byteLength)]);
|
|
917
|
+
tailBuffer = appendBufferTail(tailBuffer, chunk, endLimit);
|
|
918
|
+
pending += decoder.write(chunk);
|
|
919
|
+
let newline = pending.indexOf("\n");
|
|
920
|
+
while (newline >= 0) {
|
|
921
|
+
scanned.push(pending.slice(0, newline));
|
|
922
|
+
pending = pending.slice(newline + 1);
|
|
923
|
+
newline = pending.indexOf("\n");
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
pending += decoder.end();
|
|
927
|
+
if (pending.length > 0) scanned.push(pending);
|
|
928
|
+
const lines = newlineCount + (size > 0 && lastByte !== 10 ? 1 : 0);
|
|
929
|
+
const excerpt = size <= tailBuffer.byteLength ? boundExcerpt(utf8Tail(tailBuffer, tailBuffer.byteLength), maxLines, maxBytes, maxTokens) : composeExcerpt(headBuffer.toString("utf8"), utf8Tail(tailBuffer, tailBuffer.byteLength), lines, maxLines, maxBytes, scanned.entries(), maxTokens);
|
|
930
|
+
return {
|
|
931
|
+
tail: excerpt.text,
|
|
932
|
+
bytes: size,
|
|
933
|
+
lines,
|
|
934
|
+
tailLines: excerpt.lines
|
|
935
|
+
};
|
|
936
|
+
} catch {
|
|
937
|
+
return {
|
|
938
|
+
tail: "",
|
|
939
|
+
bytes: 0,
|
|
940
|
+
lines: 0,
|
|
941
|
+
tailLines: 0
|
|
942
|
+
};
|
|
943
|
+
} finally {
|
|
944
|
+
if (handle !== void 0) node_fs.default.closeSync(handle);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function utf8Tail(encoded, maxBytes) {
|
|
948
|
+
let start = Math.max(0, encoded.byteLength - maxBytes);
|
|
949
|
+
while (start < encoded.byteLength && (encoded[start] & 192) === 128) start += 1;
|
|
950
|
+
return encoded.subarray(start).toString("utf8");
|
|
951
|
+
}
|
|
952
|
+
function appendBufferTail(current, chunk, limit) {
|
|
953
|
+
if (chunk.byteLength >= limit) return Buffer.from(chunk.subarray(chunk.byteLength - limit));
|
|
954
|
+
const combined = Buffer.concat([current, chunk]);
|
|
955
|
+
return combined.byteLength > limit ? Buffer.from(combined.subarray(combined.byteLength - limit)) : combined;
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/constants/bashRunService.ts
|
|
959
|
+
const COMPLETED = "completed";
|
|
960
|
+
const FAILED = "failed";
|
|
961
|
+
const LAUNCHER_ERROR = "launcher_error";
|
|
962
|
+
const SIGNALED = "signaled";
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region src/types/rtkProcessor.ts
|
|
965
|
+
/** Raw-output fallback notices. Shared so the processor and its caller cannot drift. */
|
|
966
|
+
const RTK_UNAVAILABLE_WARNING = "Warning: RTK is unavailable; showing raw output.";
|
|
967
|
+
const RTK_FAILED_WARNING = "Warning: RTK processing failed; showing raw output.";
|
|
968
|
+
const RTK_TIMEOUT_WARNING = "Warning: RTK processing timed out; showing raw output.";
|
|
969
|
+
const RTK_OVERSIZED_WARNING = "Warning: RTK input exceeds 10 MiB; showing raw output.";
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/services/bashRunService/index.ts
|
|
972
|
+
/** The command outlived the background threshold and should be promoted. */
|
|
973
|
+
const TIMED_OUT = Symbol("threshold-reached");
|
|
974
|
+
/** The command outlived the caller's explicit timeout and should be stopped. */
|
|
975
|
+
const DEADLINE = Symbol("deadline-reached");
|
|
976
|
+
/** The caller cancelled the command and its owned runner should be stopped. */
|
|
977
|
+
const ABORTED = Symbol("aborted");
|
|
978
|
+
function runnerId() {
|
|
979
|
+
return `${Date.now().toString(36)}-${(0, node_crypto.randomBytes)(3).toString("base64url")}`;
|
|
980
|
+
}
|
|
981
|
+
var BashRunService = class {
|
|
982
|
+
launcher;
|
|
983
|
+
rmuxBackend;
|
|
984
|
+
namer;
|
|
985
|
+
registry;
|
|
986
|
+
clock;
|
|
987
|
+
rtkProcessor;
|
|
988
|
+
constructor(launcher, rmuxBackend, namer, registry, clock, rtkProcessor) {
|
|
989
|
+
this.launcher = launcher;
|
|
990
|
+
this.rmuxBackend = rmuxBackend;
|
|
991
|
+
this.namer = namer;
|
|
992
|
+
this.registry = registry;
|
|
993
|
+
this.clock = clock;
|
|
994
|
+
this.rtkProcessor = rtkProcessor;
|
|
995
|
+
}
|
|
996
|
+
async run(request) {
|
|
997
|
+
const id = runnerId();
|
|
998
|
+
const name = await this.namer.allocate(request.command, request.sessionId, request.name);
|
|
999
|
+
const cwd = request.cwd ?? process.cwd();
|
|
1000
|
+
const interactive = request.interactive === true;
|
|
1001
|
+
if (request.signal?.aborted) return {
|
|
1002
|
+
kind: FAILED,
|
|
1003
|
+
id,
|
|
1004
|
+
name,
|
|
1005
|
+
error: "Operation aborted"
|
|
1006
|
+
};
|
|
1007
|
+
let handle;
|
|
1008
|
+
try {
|
|
1009
|
+
const rmuxHandle = await this.rmuxBackend.launch({
|
|
1010
|
+
id,
|
|
1011
|
+
name,
|
|
1012
|
+
command: request.command,
|
|
1013
|
+
cwd,
|
|
1014
|
+
sessionId: request.sessionId,
|
|
1015
|
+
interactive
|
|
1016
|
+
});
|
|
1017
|
+
if (interactive && !rmuxHandle) return {
|
|
1018
|
+
kind: FAILED,
|
|
1019
|
+
id,
|
|
1020
|
+
name,
|
|
1021
|
+
error: "RMUX is required for interactive commands but is unavailable"
|
|
1022
|
+
};
|
|
1023
|
+
handle = rmuxHandle ?? this.launcher.launch({
|
|
1024
|
+
id,
|
|
1025
|
+
name,
|
|
1026
|
+
command: request.command,
|
|
1027
|
+
cwd,
|
|
1028
|
+
sessionId: request.sessionId
|
|
1029
|
+
});
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
return {
|
|
1032
|
+
kind: FAILED,
|
|
1033
|
+
id,
|
|
1034
|
+
name,
|
|
1035
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
if (handle.pid === void 0) return {
|
|
1039
|
+
kind: FAILED,
|
|
1040
|
+
id,
|
|
1041
|
+
name,
|
|
1042
|
+
error: "The command did not start, so it cannot be supervised"
|
|
1043
|
+
};
|
|
1044
|
+
if (request.signal?.aborted) {
|
|
1045
|
+
await handle.stop();
|
|
1046
|
+
return {
|
|
1047
|
+
kind: FAILED,
|
|
1048
|
+
id,
|
|
1049
|
+
name,
|
|
1050
|
+
error: "Operation aborted"
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
await this.registry.register({
|
|
1055
|
+
id,
|
|
1056
|
+
name,
|
|
1057
|
+
pid: handle.pid,
|
|
1058
|
+
command: request.command,
|
|
1059
|
+
cwd,
|
|
1060
|
+
logPath: handle.logPath,
|
|
1061
|
+
interactive,
|
|
1062
|
+
sessionId: request.sessionId,
|
|
1063
|
+
backend: handle.backend,
|
|
1064
|
+
...handle.backendTarget ? { backendTarget: handle.backendTarget } : {}
|
|
1065
|
+
});
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
await handle.stop();
|
|
1068
|
+
return {
|
|
1069
|
+
kind: FAILED,
|
|
1070
|
+
id,
|
|
1071
|
+
name,
|
|
1072
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
if (request.background === true || interactive) return this.promote(handle, request.background === true ? "requested" : "interactive", request.signal);
|
|
1076
|
+
const outputUpdates = request.onOutput ? this.followOutput(handle, request.onOutput) : void 0;
|
|
1077
|
+
try {
|
|
1078
|
+
const outcome = await this.race(handle, request.timeoutMs, request.signal);
|
|
1079
|
+
outputUpdates?.flush();
|
|
1080
|
+
if (outcome === TIMED_OUT) return this.promote(handle, "threshold", request.signal);
|
|
1081
|
+
if (outcome === ABORTED) {
|
|
1082
|
+
await handle.stop();
|
|
1083
|
+
outputUpdates?.flush();
|
|
1084
|
+
await this.registry.complete(id, {
|
|
1085
|
+
reason: "stopped",
|
|
1086
|
+
code: null,
|
|
1087
|
+
signal: "SIGTERM"
|
|
1088
|
+
});
|
|
1089
|
+
return {
|
|
1090
|
+
kind: COMPLETED,
|
|
1091
|
+
id,
|
|
1092
|
+
name,
|
|
1093
|
+
output: handle.output(),
|
|
1094
|
+
exitCode: null,
|
|
1095
|
+
signal: "SIGTERM",
|
|
1096
|
+
logPath: handle.logPath,
|
|
1097
|
+
backend: handle.backend,
|
|
1098
|
+
aborted: true
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
if (outcome === DEADLINE) {
|
|
1102
|
+
await handle.stop();
|
|
1103
|
+
outputUpdates?.flush();
|
|
1104
|
+
await this.registry.complete(id, {
|
|
1105
|
+
reason: "timed_out",
|
|
1106
|
+
code: null,
|
|
1107
|
+
signal: "SIGTERM"
|
|
1108
|
+
});
|
|
1109
|
+
return {
|
|
1110
|
+
kind: COMPLETED,
|
|
1111
|
+
id,
|
|
1112
|
+
name,
|
|
1113
|
+
output: handle.output(),
|
|
1114
|
+
exitCode: null,
|
|
1115
|
+
signal: "SIGTERM",
|
|
1116
|
+
logPath: handle.logPath,
|
|
1117
|
+
backend: handle.backend,
|
|
1118
|
+
timedOut: true
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
if (outcome instanceof Error) {
|
|
1122
|
+
await this.registry.complete(id, {
|
|
1123
|
+
reason: LAUNCHER_ERROR,
|
|
1124
|
+
code: null,
|
|
1125
|
+
signal: null
|
|
1126
|
+
});
|
|
1127
|
+
return {
|
|
1128
|
+
kind: FAILED,
|
|
1129
|
+
id,
|
|
1130
|
+
name,
|
|
1131
|
+
error: outcome.message
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
await this.registry.complete(id, {
|
|
1135
|
+
reason: outcome.signal ? SIGNALED : outcome.code === 0 ? COMPLETED : FAILED,
|
|
1136
|
+
code: outcome.code,
|
|
1137
|
+
signal: outcome.signal
|
|
1138
|
+
});
|
|
1139
|
+
return {
|
|
1140
|
+
kind: COMPLETED,
|
|
1141
|
+
id,
|
|
1142
|
+
name,
|
|
1143
|
+
output: handle.output(),
|
|
1144
|
+
exitCode: outcome.code,
|
|
1145
|
+
signal: outcome.signal,
|
|
1146
|
+
logPath: handle.logPath,
|
|
1147
|
+
backend: handle.backend,
|
|
1148
|
+
...await this.processLog(request.command, handle.logPath)
|
|
1149
|
+
};
|
|
1150
|
+
} finally {
|
|
1151
|
+
outputUpdates?.dispose();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
async processLog(command, logPath) {
|
|
1155
|
+
try {
|
|
1156
|
+
const result = await this.rtkProcessor.process({
|
|
1157
|
+
command,
|
|
1158
|
+
logPath
|
|
1159
|
+
});
|
|
1160
|
+
if (result.kind === "processed") return { rtkOutput: result.result };
|
|
1161
|
+
if (result.kind === "fallback") return { rtkWarning: result.warning };
|
|
1162
|
+
return {};
|
|
1163
|
+
} catch {
|
|
1164
|
+
return { rtkWarning: RTK_FAILED_WARNING };
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
followOutput(handle, onOutput) {
|
|
1168
|
+
let active = true;
|
|
1169
|
+
let previous = "";
|
|
1170
|
+
let cancelPoll;
|
|
1171
|
+
const flush = () => {
|
|
1172
|
+
if (!active) return;
|
|
1173
|
+
const output = handle.output();
|
|
1174
|
+
if (output === previous) return;
|
|
1175
|
+
previous = output;
|
|
1176
|
+
onOutput(output);
|
|
1177
|
+
};
|
|
1178
|
+
const poll = () => {
|
|
1179
|
+
if (!active) return;
|
|
1180
|
+
flush();
|
|
1181
|
+
if (active) cancelPoll = this.clock.after(100, poll);
|
|
1182
|
+
};
|
|
1183
|
+
cancelPoll = this.clock.after(100, poll);
|
|
1184
|
+
return {
|
|
1185
|
+
flush,
|
|
1186
|
+
dispose: () => {
|
|
1187
|
+
active = false;
|
|
1188
|
+
cancelPoll?.();
|
|
1189
|
+
cancelPoll = void 0;
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Waits for the command, giving up at the background threshold or at the
|
|
1195
|
+
* caller's timeout, whichever comes first.
|
|
1196
|
+
*
|
|
1197
|
+
* A rejected completion is returned rather than thrown so the caller can
|
|
1198
|
+
* report a start failure as a tool result instead of an exception.
|
|
1199
|
+
*/
|
|
1200
|
+
async race(handle, timeoutMs, signal) {
|
|
1201
|
+
const cancels = [];
|
|
1202
|
+
const racers = [handle.completion().catch((error) => error)];
|
|
1203
|
+
racers.push(new Promise((resolve) => {
|
|
1204
|
+
cancels.push(this.clock.after(getBackgroundThresholdMs(), () => resolve(TIMED_OUT)));
|
|
1205
|
+
}));
|
|
1206
|
+
if (timeoutMs !== void 0 && timeoutMs > 0) racers.push(new Promise((resolve) => {
|
|
1207
|
+
cancels.push(this.clock.after(timeoutMs, () => resolve(DEADLINE)));
|
|
1208
|
+
}));
|
|
1209
|
+
if (signal) racers.push(new Promise((resolve) => {
|
|
1210
|
+
if (signal.aborted) {
|
|
1211
|
+
resolve(ABORTED);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const abort = () => resolve(ABORTED);
|
|
1215
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1216
|
+
cancels.push(() => signal.removeEventListener("abort", abort));
|
|
1217
|
+
}));
|
|
1218
|
+
try {
|
|
1219
|
+
return await Promise.race(racers);
|
|
1220
|
+
} finally {
|
|
1221
|
+
for (const cancel of cancels) cancel();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
async promote(handle, reason, signal) {
|
|
1225
|
+
if (handle.pid === void 0) return {
|
|
1226
|
+
kind: FAILED,
|
|
1227
|
+
id: handle.id,
|
|
1228
|
+
name: handle.name,
|
|
1229
|
+
error: "The command did not start"
|
|
1230
|
+
};
|
|
1231
|
+
await this.registry.markPromoted(handle.id);
|
|
1232
|
+
handle.detach();
|
|
1233
|
+
let abortStop;
|
|
1234
|
+
const abort = () => {
|
|
1235
|
+
abortStop = handle.stop().then(async () => {
|
|
1236
|
+
await this.registry.complete(handle.id, {
|
|
1237
|
+
reason: "stopped",
|
|
1238
|
+
code: null,
|
|
1239
|
+
signal: "SIGTERM"
|
|
1240
|
+
});
|
|
1241
|
+
return true;
|
|
1242
|
+
}).catch((error) => {
|
|
1243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1244
|
+
process.emitWarning(`Failed to stop aborted runner ${handle.id}: ${message}`);
|
|
1245
|
+
return false;
|
|
1246
|
+
});
|
|
1247
|
+
};
|
|
1248
|
+
if (signal) {
|
|
1249
|
+
if (signal.aborted) abort();
|
|
1250
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
1251
|
+
}
|
|
1252
|
+
handle.completion().then(async (outcome) => {
|
|
1253
|
+
if (await abortStop) return;
|
|
1254
|
+
await this.registry.complete(handle.id, {
|
|
1255
|
+
reason: outcome.signal ? SIGNALED : outcome.code === 0 ? COMPLETED : FAILED,
|
|
1256
|
+
code: outcome.code,
|
|
1257
|
+
signal: outcome.signal
|
|
1258
|
+
});
|
|
1259
|
+
}, async () => {
|
|
1260
|
+
if (await abortStop) return;
|
|
1261
|
+
await this.registry.complete(handle.id, {
|
|
1262
|
+
reason: LAUNCHER_ERROR,
|
|
1263
|
+
code: null,
|
|
1264
|
+
signal: null
|
|
1265
|
+
});
|
|
1266
|
+
}).catch((error) => {
|
|
1267
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1268
|
+
process.emitWarning(`Failed to complete runner ${handle.id}: ${message}`);
|
|
1269
|
+
}).finally(() => signal?.removeEventListener("abort", abort));
|
|
1270
|
+
return {
|
|
1271
|
+
kind: "promoted",
|
|
1272
|
+
id: handle.id,
|
|
1273
|
+
name: handle.name,
|
|
1274
|
+
pid: handle.pid,
|
|
1275
|
+
logPath: handle.logPath,
|
|
1276
|
+
backend: handle.backend,
|
|
1277
|
+
reason
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
//#endregion
|
|
1282
|
+
//#region src/services/clock/index.ts
|
|
1283
|
+
/** Real wall clock and timers. */
|
|
1284
|
+
var SystemClock = class {
|
|
1285
|
+
now() {
|
|
1286
|
+
return Date.now();
|
|
1287
|
+
}
|
|
1288
|
+
after(ms, handler) {
|
|
1289
|
+
const timer = setTimeout(handler, ms);
|
|
1290
|
+
timer.unref?.();
|
|
1291
|
+
return () => clearTimeout(timer);
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
//#endregion
|
|
1295
|
+
//#region src/constants/launcher.ts
|
|
1296
|
+
const NON_INTERACTIVE_ENV$2 = {
|
|
1297
|
+
FORCE_COLOR: "1",
|
|
1298
|
+
CI: "1"
|
|
1299
|
+
};
|
|
1300
|
+
const TERM_GRACE_MS$1 = 2e3;
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/constants/session.ts
|
|
1303
|
+
const PI_SESSION_ID_ENV = "PI_SESSION_ID";
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/services/runnerSupervisor/index.ts
|
|
1306
|
+
/**
|
|
1307
|
+
* Settings that make a terminal safe for a caller with no keyboard.
|
|
1308
|
+
*
|
|
1309
|
+
* Commands run inside an rmux pane, so their stdout is a real tty. Git and
|
|
1310
|
+
* friends take that as permission to open a pager, and `less` then waits at
|
|
1311
|
+
* `(END)` for a keypress nobody is there to send, so the run hangs until its
|
|
1312
|
+
* timeout. The same tty invites credential prompts, which hang the same way.
|
|
1313
|
+
* A conventional piped stdout disables both automatically; a pane has to be
|
|
1314
|
+
* told. A command that genuinely wants a pager can still ask for one, as in
|
|
1315
|
+
* `git -c core.pager=less log`.
|
|
1316
|
+
*/
|
|
1317
|
+
const NO_TERMINAL_INPUT_ENV = {
|
|
1318
|
+
GIT_PAGER: "cat",
|
|
1319
|
+
PAGER: "cat",
|
|
1320
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1321
|
+
};
|
|
1322
|
+
const SPEC_MODE = 384;
|
|
1323
|
+
const NOT_FOUND_ERROR_CODE$2 = "ENOENT";
|
|
1324
|
+
function supervisorPaths(directory, id) {
|
|
1325
|
+
return {
|
|
1326
|
+
spec: node_path.default.join(directory, `${id}.command.json`),
|
|
1327
|
+
gate: node_path.default.join(directory, `${id}.gate`),
|
|
1328
|
+
exit: node_path.default.join(directory, `${id}.exit.json`),
|
|
1329
|
+
logDone: node_path.default.join(directory, `${id}.log.done`)
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
function writeCommandSpec(target, spec) {
|
|
1333
|
+
node_fs.default.writeFileSync(target, `${JSON.stringify(spec)}\n`, { mode: SPEC_MODE });
|
|
1334
|
+
}
|
|
1335
|
+
function readExitMetadata(target) {
|
|
1336
|
+
try {
|
|
1337
|
+
const value = JSON.parse(node_fs.default.readFileSync(target, "utf8"));
|
|
1338
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
1339
|
+
const metadata = value;
|
|
1340
|
+
return {
|
|
1341
|
+
code: typeof metadata.code === "number" ? metadata.code : null,
|
|
1342
|
+
signal: typeof metadata.signal === "string" ? metadata.signal : null
|
|
1343
|
+
};
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
if (error.code !== NOT_FOUND_ERROR_CODE$2) process.emitWarning(`Could not read runner exit metadata ${target}: ${String(error)}`);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
function cleanupSupervisorFiles(paths) {
|
|
1350
|
+
for (const target of Object.values(paths)) try {
|
|
1351
|
+
node_fs.default.unlinkSync(target);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
if (error.code !== NOT_FOUND_ERROR_CODE$2) process.emitWarning(String(error));
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Resolves an executable entry point by walking up to the `bin` directory that
|
|
1358
|
+
* ships beside this module. Source and published layouts nest services at
|
|
1359
|
+
* different depths. Source modules select built subprocess entries so Node can resolve their imports. A hardcoded depth stays silent
|
|
1360
|
+
* until the shell tries to exec it.
|
|
1361
|
+
*/
|
|
1362
|
+
function runtimeEntry(name, moduleUrl = require("url").pathToFileURL(__filename).href) {
|
|
1363
|
+
const modulePath = (0, node_url.fileURLToPath)(moduleUrl);
|
|
1364
|
+
const source = node_path.default.extname(modulePath) === ".ts";
|
|
1365
|
+
const extension = "mjs";
|
|
1366
|
+
let directory = node_path.default.dirname(modulePath);
|
|
1367
|
+
while (true) {
|
|
1368
|
+
const entry = source ? node_path.default.join(directory, "dist", "bin", `${name}.${extension}`) : node_path.default.join(directory, "bin", `${name}.${extension}`);
|
|
1369
|
+
if (node_fs.default.existsSync(entry)) return node_fs.default.realpathSync(entry);
|
|
1370
|
+
const parent = node_path.default.dirname(directory);
|
|
1371
|
+
if (parent === directory) break;
|
|
1372
|
+
directory = parent;
|
|
1373
|
+
}
|
|
1374
|
+
throw new Error(`Cannot find bin/${name}.${extension} by walking up from ${modulePath}.`);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Replaces the shell rather than nesting under it, so the pid handed back is the
|
|
1378
|
+
* supervisor's and a group signal reaches it directly.
|
|
1379
|
+
*/
|
|
1380
|
+
function supervisorCommand(paths) {
|
|
1381
|
+
return `exec ${shellJoin([
|
|
1382
|
+
process.execPath,
|
|
1383
|
+
runtimeEntry("runnerHost"),
|
|
1384
|
+
paths.spec,
|
|
1385
|
+
paths.gate,
|
|
1386
|
+
paths.exit
|
|
1387
|
+
])}`;
|
|
1388
|
+
}
|
|
1389
|
+
function shellJoin(values) {
|
|
1390
|
+
return values.map((value) => `'${value.replaceAll("'", `'"'"'`)}'`).join(" ");
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* One watcher per directory, shared by every waiter inside it.
|
|
1394
|
+
*
|
|
1395
|
+
* A session's runners share a runs directory, and `DOOMPI_LOG_DIR` makes every
|
|
1396
|
+
* session using it share one. A watcher per waited file would then put a watcher
|
|
1397
|
+
* per in-flight runner on the same directory, each woken by every other runner's
|
|
1398
|
+
* sidecar writes, so the watchers are pooled and reference counted instead.
|
|
1399
|
+
*/
|
|
1400
|
+
const directoryWatches = /* @__PURE__ */ new Map();
|
|
1401
|
+
function acquireDirectoryWatch(directory) {
|
|
1402
|
+
const existing = directoryWatches.get(directory);
|
|
1403
|
+
if (existing) {
|
|
1404
|
+
existing.references += 1;
|
|
1405
|
+
return existing;
|
|
1406
|
+
}
|
|
1407
|
+
const entry = {
|
|
1408
|
+
watcher: void 0,
|
|
1409
|
+
waiters: /* @__PURE__ */ new Map(),
|
|
1410
|
+
references: 1
|
|
1411
|
+
};
|
|
1412
|
+
try {
|
|
1413
|
+
entry.watcher = node_fs.default.watch(directory, (_event, changed) => {
|
|
1414
|
+
const named = changed === null ? void 0 : String(changed);
|
|
1415
|
+
const targets = named === void 0 ? [...entry.waiters.values()] : [entry.waiters.get(named)];
|
|
1416
|
+
for (const listeners of targets) {
|
|
1417
|
+
if (!listeners) continue;
|
|
1418
|
+
for (const listener of listeners) listener();
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
entry.watcher.on("error", () => void 0);
|
|
1422
|
+
} catch {
|
|
1423
|
+
entry.watcher = void 0;
|
|
1424
|
+
}
|
|
1425
|
+
directoryWatches.set(directory, entry);
|
|
1426
|
+
return entry;
|
|
1427
|
+
}
|
|
1428
|
+
function releaseDirectoryWatch(directory, entry) {
|
|
1429
|
+
entry.references -= 1;
|
|
1430
|
+
if (entry.references > 0) return;
|
|
1431
|
+
entry.watcher?.close();
|
|
1432
|
+
entry.watcher = void 0;
|
|
1433
|
+
entry.waiters.clear();
|
|
1434
|
+
directoryWatches.delete(directory);
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Reports a supervisor sidecar landing on disk without polling for it.
|
|
1438
|
+
*
|
|
1439
|
+
* The sidecars are written once and never rewritten, so the directory's own
|
|
1440
|
+
* create event answers the only question a waiter has. Polling for them costs a
|
|
1441
|
+
* full poll interval on every run that finishes inside one, and the runs that
|
|
1442
|
+
* finish fastest are the ones that pay that tax most often.
|
|
1443
|
+
*
|
|
1444
|
+
* The watch is best effort: a platform or a directory that cannot deliver events
|
|
1445
|
+
* leaves every slice to time out, which is exactly the poll the caller already
|
|
1446
|
+
* runs as its fallback.
|
|
1447
|
+
*/
|
|
1448
|
+
function watchForFile(filePath) {
|
|
1449
|
+
const directory = node_path.default.dirname(filePath);
|
|
1450
|
+
const name = node_path.default.basename(filePath);
|
|
1451
|
+
const entry = acquireDirectoryWatch(directory);
|
|
1452
|
+
let released = false;
|
|
1453
|
+
let found = false;
|
|
1454
|
+
let notify;
|
|
1455
|
+
const settle = () => {
|
|
1456
|
+
if (found || !node_fs.default.existsSync(filePath)) return;
|
|
1457
|
+
found = true;
|
|
1458
|
+
notify?.();
|
|
1459
|
+
};
|
|
1460
|
+
const listeners = entry.waiters.get(name) ?? /* @__PURE__ */ new Set();
|
|
1461
|
+
listeners.add(settle);
|
|
1462
|
+
entry.waiters.set(name, listeners);
|
|
1463
|
+
settle();
|
|
1464
|
+
return {
|
|
1465
|
+
async appeared(withinMs) {
|
|
1466
|
+
if (found) return true;
|
|
1467
|
+
return await new Promise((resolve) => {
|
|
1468
|
+
const timer = setTimeout(() => {
|
|
1469
|
+
notify = void 0;
|
|
1470
|
+
settle();
|
|
1471
|
+
resolve(found);
|
|
1472
|
+
}, withinMs);
|
|
1473
|
+
notify = () => {
|
|
1474
|
+
clearTimeout(timer);
|
|
1475
|
+
notify = void 0;
|
|
1476
|
+
resolve(true);
|
|
1477
|
+
};
|
|
1478
|
+
});
|
|
1479
|
+
},
|
|
1480
|
+
close() {
|
|
1481
|
+
if (released) return;
|
|
1482
|
+
released = true;
|
|
1483
|
+
notify = void 0;
|
|
1484
|
+
listeners.delete(settle);
|
|
1485
|
+
if (listeners.size === 0) entry.waiters.delete(name);
|
|
1486
|
+
releaseDirectoryWatch(directory, entry);
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
//#endregion
|
|
1491
|
+
//#region src/services/launcher/index.ts
|
|
1492
|
+
/**
|
|
1493
|
+
* Prompts belong on a screen, not in a log file, so `CI` stays set.
|
|
1494
|
+
*
|
|
1495
|
+
* Colour is kept: the log is replayed into the transcript, and the scrub keeps
|
|
1496
|
+
* SGR while still removing the cursor moves that made raw output unreadable.
|
|
1497
|
+
*/
|
|
1498
|
+
/** In-memory output is a multiple of the result ceiling; the log holds the rest. */
|
|
1499
|
+
var Launcher = class {
|
|
1500
|
+
spawner;
|
|
1501
|
+
processControl;
|
|
1502
|
+
logFile;
|
|
1503
|
+
clock;
|
|
1504
|
+
paths;
|
|
1505
|
+
constructor(spawner, processControl, logFile, clock, paths) {
|
|
1506
|
+
this.spawner = spawner;
|
|
1507
|
+
this.processControl = processControl;
|
|
1508
|
+
this.logFile = logFile;
|
|
1509
|
+
this.clock = clock;
|
|
1510
|
+
this.paths = paths;
|
|
1511
|
+
}
|
|
1512
|
+
launch(request) {
|
|
1513
|
+
const writer = this.logFile.open(request.id);
|
|
1514
|
+
const memoryLimit = getResultMaxBytes() * 2;
|
|
1515
|
+
let buffer = "";
|
|
1516
|
+
let buffering = true;
|
|
1517
|
+
let settled = false;
|
|
1518
|
+
let resolveCompletion = () => void 0;
|
|
1519
|
+
let rejectCompletion = () => void 0;
|
|
1520
|
+
const completion = new Promise((resolve, reject) => {
|
|
1521
|
+
resolveCompletion = resolve;
|
|
1522
|
+
rejectCompletion = reject;
|
|
1523
|
+
});
|
|
1524
|
+
this.paths.ensureDirectories(request.sessionId);
|
|
1525
|
+
const supervisor = supervisorPaths(this.paths.stateDirectory(request.sessionId), request.id);
|
|
1526
|
+
const env = {
|
|
1527
|
+
...process.env,
|
|
1528
|
+
...NON_INTERACTIVE_ENV$2,
|
|
1529
|
+
...NO_TERMINAL_INPUT_ENV,
|
|
1530
|
+
[PI_SESSION_ID_ENV]: request.sessionId
|
|
1531
|
+
};
|
|
1532
|
+
cleanupSupervisorFiles(supervisor);
|
|
1533
|
+
writeCommandSpec(supervisor.spec, {
|
|
1534
|
+
command: request.command,
|
|
1535
|
+
cwd: request.cwd,
|
|
1536
|
+
env: defined(env)
|
|
1537
|
+
});
|
|
1538
|
+
node_fs.default.writeFileSync(supervisor.gate, "", { mode: 384 });
|
|
1539
|
+
const child = this.spawner.spawn({
|
|
1540
|
+
command: supervisorCommand(supervisor),
|
|
1541
|
+
cwd: request.cwd,
|
|
1542
|
+
env,
|
|
1543
|
+
detached: true
|
|
1544
|
+
});
|
|
1545
|
+
const failLaunch = (error) => {
|
|
1546
|
+
if (settled) return;
|
|
1547
|
+
settled = true;
|
|
1548
|
+
writer.close();
|
|
1549
|
+
cleanupSupervisorFiles(supervisor);
|
|
1550
|
+
try {
|
|
1551
|
+
child.kill("SIGTERM");
|
|
1552
|
+
} catch (killError) {
|
|
1553
|
+
process.emitWarning(`Could not stop runner after log failure: ${String(killError)}`);
|
|
1554
|
+
}
|
|
1555
|
+
rejectCompletion(error instanceof Error ? error : new Error(String(error)));
|
|
1556
|
+
};
|
|
1557
|
+
child.onOutput((chunk) => {
|
|
1558
|
+
if (settled) return;
|
|
1559
|
+
try {
|
|
1560
|
+
writer.append(chunk);
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
failLaunch(error);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (!buffering) return;
|
|
1566
|
+
buffer += chunk;
|
|
1567
|
+
if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);
|
|
1568
|
+
});
|
|
1569
|
+
child.onError(failLaunch);
|
|
1570
|
+
child.onExit((result) => {
|
|
1571
|
+
if (settled) return;
|
|
1572
|
+
settled = true;
|
|
1573
|
+
writer.close();
|
|
1574
|
+
const outcome = readExitMetadata(supervisor.exit) ?? result;
|
|
1575
|
+
cleanupSupervisorFiles(supervisor);
|
|
1576
|
+
resolveCompletion(outcome);
|
|
1577
|
+
});
|
|
1578
|
+
child.unref();
|
|
1579
|
+
const stop = async () => child.pid === void 0 ? false : this.stop(child.pid);
|
|
1580
|
+
return {
|
|
1581
|
+
id: request.id,
|
|
1582
|
+
name: request.name,
|
|
1583
|
+
pid: child.pid,
|
|
1584
|
+
logPath: writer.path,
|
|
1585
|
+
backend: "native",
|
|
1586
|
+
output: () => buffer,
|
|
1587
|
+
completion: () => completion,
|
|
1588
|
+
detach: () => {
|
|
1589
|
+
buffering = false;
|
|
1590
|
+
buffer = "";
|
|
1591
|
+
},
|
|
1592
|
+
stop
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
async stop(pid) {
|
|
1596
|
+
if (!this.processControl.isAlive(pid)) return false;
|
|
1597
|
+
this.processControl.signalGroup(pid, "SIGTERM");
|
|
1598
|
+
const deadline = this.clock.now() + TERM_GRACE_MS$1;
|
|
1599
|
+
while (this.clock.now() < deadline) {
|
|
1600
|
+
if (!this.processControl.isAlive(pid)) return true;
|
|
1601
|
+
await this.sleep(100);
|
|
1602
|
+
}
|
|
1603
|
+
this.processControl.signalGroup(pid, "SIGKILL");
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
sleep(ms) {
|
|
1607
|
+
return new Promise((resolve) => {
|
|
1608
|
+
this.clock.after(ms, resolve);
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
/** The spec is JSON, which has no way to carry an unset variable. */
|
|
1613
|
+
function defined(env) {
|
|
1614
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0));
|
|
1615
|
+
}
|
|
1616
|
+
//#endregion
|
|
1617
|
+
//#region src/constants/lifeline.ts
|
|
1618
|
+
const SOCKET_NAME = "lifeline.sock";
|
|
1619
|
+
//#endregion
|
|
1620
|
+
//#region src/services/lifeline/client.ts
|
|
1621
|
+
/** Environment variable carrying the lifeline socket path to every runner supervisor. */
|
|
1622
|
+
const LIFELINE_ENV = "DOOM_RUNNER_LIFELINE";
|
|
1623
|
+
//#endregion
|
|
1624
|
+
//#region src/services/lifeline/index.ts
|
|
1625
|
+
/** Unix socket paths are capped at 104 bytes, so a long agent directory falls back to the temp dir. */
|
|
1626
|
+
var NodeLifeline = class {
|
|
1627
|
+
paths;
|
|
1628
|
+
server;
|
|
1629
|
+
target;
|
|
1630
|
+
/** Closing a server stops it accepting; only the connections carry the death signal. */
|
|
1631
|
+
connections = /* @__PURE__ */ new Set();
|
|
1632
|
+
constructor(paths) {
|
|
1633
|
+
this.paths = paths;
|
|
1634
|
+
}
|
|
1635
|
+
async arm(sessionId) {
|
|
1636
|
+
if (this.target) return this.target;
|
|
1637
|
+
try {
|
|
1638
|
+
this.paths.ensureDirectories(sessionId);
|
|
1639
|
+
const target = this.socketPath(sessionId);
|
|
1640
|
+
node_fs.default.rmSync(target, { force: true });
|
|
1641
|
+
const server = node_net.default.createServer((connection) => {
|
|
1642
|
+
connection.unref();
|
|
1643
|
+
connection.resume();
|
|
1644
|
+
this.connections.add(connection);
|
|
1645
|
+
connection.on("close", () => this.connections.delete(connection));
|
|
1646
|
+
});
|
|
1647
|
+
await listen(server, target);
|
|
1648
|
+
node_fs.default.chmodSync(target, 384);
|
|
1649
|
+
server.unref();
|
|
1650
|
+
this.server = server;
|
|
1651
|
+
this.target = target;
|
|
1652
|
+
process.env[LIFELINE_ENV] = target;
|
|
1653
|
+
return target;
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
process.emitWarning(`Could not arm the doom-runner lifeline: ${String(error)}`);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
path() {
|
|
1660
|
+
return this.target;
|
|
1661
|
+
}
|
|
1662
|
+
dispose() {
|
|
1663
|
+
for (const connection of this.connections) connection.destroy();
|
|
1664
|
+
this.connections.clear();
|
|
1665
|
+
if (this.server) {
|
|
1666
|
+
try {
|
|
1667
|
+
this.server.close();
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
process.emitWarning(`Could not close the doom-runner lifeline: ${String(error)}`);
|
|
1670
|
+
}
|
|
1671
|
+
this.server = void 0;
|
|
1672
|
+
}
|
|
1673
|
+
if (this.target) {
|
|
1674
|
+
try {
|
|
1675
|
+
node_fs.default.rmSync(this.target, { force: true });
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
process.emitWarning(`Could not remove the doom-runner lifeline: ${String(error)}`);
|
|
1678
|
+
}
|
|
1679
|
+
this.target = void 0;
|
|
1680
|
+
}
|
|
1681
|
+
if (process.env["DOOM_RUNNER_LIFELINE"]) delete process.env[LIFELINE_ENV];
|
|
1682
|
+
}
|
|
1683
|
+
/** Keeps the socket beside the session it belongs to unless that path is too long to bind. */
|
|
1684
|
+
socketPath(sessionId) {
|
|
1685
|
+
const preferred = node_path.default.join(this.paths.stateDirectory(sessionId), SOCKET_NAME);
|
|
1686
|
+
if (Buffer.byteLength(preferred) <= 100) return preferred;
|
|
1687
|
+
const hash = (0, node_crypto.createHash)("sha256").update(preferred).digest("hex").slice(0, 12);
|
|
1688
|
+
return node_path.default.join(node_os.default.tmpdir(), `doom-runner-${hash}.sock`);
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
/** Binding must complete before the first runner launches, so arming waits for it. */
|
|
1692
|
+
function listen(server, target) {
|
|
1693
|
+
return new Promise((resolve, reject) => {
|
|
1694
|
+
server.once("error", reject);
|
|
1695
|
+
server.listen(target, () => {
|
|
1696
|
+
server.removeListener("error", reject);
|
|
1697
|
+
server.on("error", (error) => process.emitWarning(`doom-runner lifeline failed: ${error.message}`));
|
|
1698
|
+
resolve();
|
|
1699
|
+
});
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
//#endregion
|
|
1703
|
+
//#region src/services/logFile/index.ts
|
|
1704
|
+
var LogFile = class {
|
|
1705
|
+
paths;
|
|
1706
|
+
constructor(paths) {
|
|
1707
|
+
this.paths = paths;
|
|
1708
|
+
}
|
|
1709
|
+
open(id) {
|
|
1710
|
+
this.paths.ensureDirectories();
|
|
1711
|
+
const logPath = this.paths.logPathFor(id);
|
|
1712
|
+
const rotatedPath = this.paths.rotatedLogPathFor(id);
|
|
1713
|
+
const maxBytes = getLogMaxBytes();
|
|
1714
|
+
let handle = node_fs.default.openSync(logPath, "w");
|
|
1715
|
+
let written = 0;
|
|
1716
|
+
let closed = false;
|
|
1717
|
+
/**
|
|
1718
|
+
* A run that keeps producing output for hours would otherwise grow one file
|
|
1719
|
+
* without limit. Rotating keeps the newest window at the advertised path
|
|
1720
|
+
* and the window before it alongside, so on-disk cost is bounded at roughly
|
|
1721
|
+
* twice the ceiling, the path a result advertises stays valid, and the line
|
|
1722
|
+
* counts a reader reports still describe the file it actually read.
|
|
1723
|
+
*/
|
|
1724
|
+
const rotate = () => {
|
|
1725
|
+
node_fs.default.closeSync(handle);
|
|
1726
|
+
try {
|
|
1727
|
+
node_fs.default.renameSync(logPath, rotatedPath);
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
process.emitWarning(`Could not rotate runner log ${logPath}: ${String(error)}`);
|
|
1730
|
+
}
|
|
1731
|
+
handle = node_fs.default.openSync(logPath, "w");
|
|
1732
|
+
written = 0;
|
|
1733
|
+
};
|
|
1734
|
+
return {
|
|
1735
|
+
path: logPath,
|
|
1736
|
+
append(text) {
|
|
1737
|
+
const chunk = Buffer.from(text, "utf8");
|
|
1738
|
+
if (written > 0 && written + chunk.byteLength > maxBytes) rotate();
|
|
1739
|
+
node_fs.default.writeSync(handle, chunk);
|
|
1740
|
+
written += chunk.byteLength;
|
|
1741
|
+
},
|
|
1742
|
+
size() {
|
|
1743
|
+
return written;
|
|
1744
|
+
},
|
|
1745
|
+
close() {
|
|
1746
|
+
if (closed) return;
|
|
1747
|
+
closed = true;
|
|
1748
|
+
try {
|
|
1749
|
+
node_fs.default.closeSync(handle);
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
process.emitWarning(`Could not close runner log ${logPath}: ${String(error)}`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
//#endregion
|
|
1758
|
+
//#region src/services/processControl/index.ts
|
|
1759
|
+
/** Wraps `process.kill`, which doubles as the liveness probe on POSIX. */
|
|
1760
|
+
var NodeProcessControl = class {
|
|
1761
|
+
isAlive(pid) {
|
|
1762
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1763
|
+
try {
|
|
1764
|
+
process.kill(pid, 0);
|
|
1765
|
+
return true;
|
|
1766
|
+
} catch {
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
signalGroup(pid, signal) {
|
|
1771
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1772
|
+
try {
|
|
1773
|
+
process.kill(-pid, signal);
|
|
1774
|
+
return true;
|
|
1775
|
+
} catch {
|
|
1776
|
+
return false;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
//#endregion
|
|
1781
|
+
//#region src/constants/tmuxBackend.ts
|
|
1782
|
+
const LOG_DRAIN_TIMEOUT_MS$1 = 2e3;
|
|
1783
|
+
const PANE_STATE_FORMAT$1 = "#{pane_dead}:#{pane_dead_status}:#{session_name}";
|
|
1784
|
+
const SESSION_PREFIX$1 = "doom-tmux-";
|
|
1785
|
+
const OWNED_TARGET_PATTERN$1 = /^doom-tmux-[A-Za-z0-9_-]+$/;
|
|
1786
|
+
const SOCKET_PREFIX = "doom-tmux-";
|
|
1787
|
+
const NON_INTERACTIVE_ENV$1 = {
|
|
1788
|
+
NO_COLOR: "1",
|
|
1789
|
+
CI: "1"
|
|
1790
|
+
};
|
|
1791
|
+
//#endregion
|
|
1792
|
+
//#region src/services/ptyBackendChain/index.ts
|
|
1793
|
+
/**
|
|
1794
|
+
* Presents the available PTY backends as one, preferring RMUX.
|
|
1795
|
+
*
|
|
1796
|
+
* A launch takes the first backend that accepts it, so a host without a
|
|
1797
|
+
* compatible RMUX binary still gets panes rather than falling through to the
|
|
1798
|
+
* plain subprocess launcher. Every later call routes on the target's prefix
|
|
1799
|
+
* instead of asking each backend in turn: a backend cannot tell "this session
|
|
1800
|
+
* is gone" from "this session was never mine", and guessing would let one
|
|
1801
|
+
* report success for the other's run.
|
|
1802
|
+
*/
|
|
1803
|
+
var PtyBackendChain = class {
|
|
1804
|
+
rmux;
|
|
1805
|
+
tmux;
|
|
1806
|
+
constructor(rmux, tmux) {
|
|
1807
|
+
this.rmux = rmux;
|
|
1808
|
+
this.tmux = tmux;
|
|
1809
|
+
}
|
|
1810
|
+
async launch(request) {
|
|
1811
|
+
return await this.rmux.launch(request) ?? await this.tmux.launch(request);
|
|
1812
|
+
}
|
|
1813
|
+
watch(id, target, sessionId) {
|
|
1814
|
+
return this.owner(target).watch(id, target, sessionId);
|
|
1815
|
+
}
|
|
1816
|
+
stop(target, expectedPid) {
|
|
1817
|
+
return this.owner(target).stop(target, expectedPid);
|
|
1818
|
+
}
|
|
1819
|
+
input(target, text) {
|
|
1820
|
+
return this.owner(target).input(target, text);
|
|
1821
|
+
}
|
|
1822
|
+
capture(target) {
|
|
1823
|
+
return this.owner(target).capture(target);
|
|
1824
|
+
}
|
|
1825
|
+
/** Both backends persist terminal evidence at the same path, so either answers. */
|
|
1826
|
+
readOutcome(id, sessionId) {
|
|
1827
|
+
return this.rmux.readOutcome(id, sessionId) ?? this.tmux.readOutcome(id, sessionId);
|
|
1828
|
+
}
|
|
1829
|
+
get(name) {
|
|
1830
|
+
return this.rmux.get(name) ?? this.tmux.get(name);
|
|
1831
|
+
}
|
|
1832
|
+
owner(target) {
|
|
1833
|
+
return target.startsWith("doom-tmux-") ? this.tmux : this.rmux;
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
//#endregion
|
|
1837
|
+
//#region src/constants/ptyHost.ts
|
|
1838
|
+
const SCROLLBACK = 2e3;
|
|
1839
|
+
const TERM_GRACE_MS = 2e3;
|
|
1840
|
+
//#endregion
|
|
1841
|
+
//#region src/services/ptyHost/index.ts
|
|
1842
|
+
/** Enough history for the overlay to scroll without holding a whole build. */
|
|
1843
|
+
const { Terminal: HeadlessTerminal } = _xterm_headless.default;
|
|
1844
|
+
var PtyHost = class {
|
|
1845
|
+
spawner;
|
|
1846
|
+
logFile;
|
|
1847
|
+
processControl;
|
|
1848
|
+
clock;
|
|
1849
|
+
runs = /* @__PURE__ */ new Map();
|
|
1850
|
+
constructor(spawner, logFile, processControl, clock) {
|
|
1851
|
+
this.spawner = spawner;
|
|
1852
|
+
this.logFile = logFile;
|
|
1853
|
+
this.processControl = processControl;
|
|
1854
|
+
this.clock = clock;
|
|
1855
|
+
}
|
|
1856
|
+
async launch(request) {
|
|
1857
|
+
const cols = request.cols ?? 120;
|
|
1858
|
+
const rows = request.rows ?? 30;
|
|
1859
|
+
const writer = this.logFile.open(request.id);
|
|
1860
|
+
const terminal = new HeadlessTerminal({
|
|
1861
|
+
cols,
|
|
1862
|
+
rows,
|
|
1863
|
+
scrollback: SCROLLBACK,
|
|
1864
|
+
allowProposedApi: true
|
|
1865
|
+
});
|
|
1866
|
+
const memoryLimit = getResultMaxBytes() * 2;
|
|
1867
|
+
const child = await this.spawner.spawn({
|
|
1868
|
+
command: request.command,
|
|
1869
|
+
cwd: request.cwd,
|
|
1870
|
+
env: {
|
|
1871
|
+
...process.env,
|
|
1872
|
+
...NO_TERMINAL_INPUT_ENV,
|
|
1873
|
+
[PI_SESSION_ID_ENV]: request.sessionId
|
|
1874
|
+
},
|
|
1875
|
+
cols,
|
|
1876
|
+
rows
|
|
1877
|
+
});
|
|
1878
|
+
let buffer = "";
|
|
1879
|
+
let buffering = true;
|
|
1880
|
+
let settled = false;
|
|
1881
|
+
let resolveCompletion = () => void 0;
|
|
1882
|
+
const completion = new Promise((resolve) => {
|
|
1883
|
+
resolveCompletion = resolve;
|
|
1884
|
+
});
|
|
1885
|
+
const dataHandlers = /* @__PURE__ */ new Set();
|
|
1886
|
+
child.onData((data) => {
|
|
1887
|
+
terminal.write(data);
|
|
1888
|
+
const scrubbed = scrubTerminalOutput(data);
|
|
1889
|
+
writer.append(scrubbed);
|
|
1890
|
+
if (buffering) {
|
|
1891
|
+
buffer += scrubbed;
|
|
1892
|
+
if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);
|
|
1893
|
+
}
|
|
1894
|
+
for (const handler of dataHandlers) handler(data);
|
|
1895
|
+
});
|
|
1896
|
+
child.onExit((result) => {
|
|
1897
|
+
if (settled) return;
|
|
1898
|
+
settled = true;
|
|
1899
|
+
this.runs.delete(request.name);
|
|
1900
|
+
writer.close();
|
|
1901
|
+
resolveCompletion({
|
|
1902
|
+
code: result.exitCode,
|
|
1903
|
+
signal: null
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
const run = {
|
|
1907
|
+
id: request.id,
|
|
1908
|
+
name: request.name,
|
|
1909
|
+
pid: child.pid,
|
|
1910
|
+
logPath: writer.path,
|
|
1911
|
+
backend: "native",
|
|
1912
|
+
output: () => buffer,
|
|
1913
|
+
completion: () => completion,
|
|
1914
|
+
detach: () => {
|
|
1915
|
+
buffering = false;
|
|
1916
|
+
buffer = "";
|
|
1917
|
+
},
|
|
1918
|
+
stop: () => this.stopChild(child, request.name),
|
|
1919
|
+
write: (text) => child.write(text),
|
|
1920
|
+
screen: () => renderScreen(terminal),
|
|
1921
|
+
onData: (handler) => {
|
|
1922
|
+
dataHandlers.add(handler);
|
|
1923
|
+
return () => dataHandlers.delete(handler);
|
|
1924
|
+
},
|
|
1925
|
+
resize: (nextCols, nextRows) => {
|
|
1926
|
+
child.resize(nextCols, nextRows);
|
|
1927
|
+
terminal.resize(nextCols, nextRows);
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
this.runs.set(request.name, run);
|
|
1931
|
+
return run;
|
|
1932
|
+
}
|
|
1933
|
+
get(name) {
|
|
1934
|
+
return this.runs.get(name);
|
|
1935
|
+
}
|
|
1936
|
+
write(name, text) {
|
|
1937
|
+
const run = this.runs.get(name);
|
|
1938
|
+
if (!run) return false;
|
|
1939
|
+
run.write(text.endsWith("\n") ? text : `${text}
|
|
1940
|
+
`);
|
|
1941
|
+
return true;
|
|
1942
|
+
}
|
|
1943
|
+
list() {
|
|
1944
|
+
return [...this.runs.values()];
|
|
1945
|
+
}
|
|
1946
|
+
async disposeAll() {
|
|
1947
|
+
const runs = [...this.runs.values()];
|
|
1948
|
+
this.runs.clear();
|
|
1949
|
+
for (const run of runs) await run.stop();
|
|
1950
|
+
}
|
|
1951
|
+
async stopChild(child, name) {
|
|
1952
|
+
this.runs.delete(name);
|
|
1953
|
+
if (!this.processControl.isAlive(child.pid)) return false;
|
|
1954
|
+
child.kill("SIGTERM");
|
|
1955
|
+
const deadline = this.clock.now() + TERM_GRACE_MS;
|
|
1956
|
+
while (this.clock.now() < deadline) {
|
|
1957
|
+
if (!this.processControl.isAlive(child.pid)) return true;
|
|
1958
|
+
await new Promise((resolve) => {
|
|
1959
|
+
this.clock.after(100, resolve);
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
child.kill("SIGKILL");
|
|
1963
|
+
return true;
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
/** The visible rows, trimmed of trailing blanks, as plain text. */
|
|
1967
|
+
function renderScreen(terminal) {
|
|
1968
|
+
const buffer = terminal.buffer.active;
|
|
1969
|
+
const lines = [];
|
|
1970
|
+
for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row += 1) lines.push(buffer.getLine(row)?.translateToString(true) ?? "");
|
|
1971
|
+
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
1972
|
+
return lines.join("\n");
|
|
1973
|
+
}
|
|
1974
|
+
//#endregion
|
|
1975
|
+
//#region src/services/ptySpawner/index.ts
|
|
1976
|
+
const RMUX_REQUIRED_ERROR = "The node-pty fallback has been removed. Interactive commands require a supported RMUX runtime.";
|
|
1977
|
+
/**
|
|
1978
|
+
* Compatibility stub for the published NodePtySpawner subpath.
|
|
1979
|
+
*
|
|
1980
|
+
* Doom Runner no longer starts interactive commands through node-pty. The class
|
|
1981
|
+
* remains exportable so existing imports fail at launch with an actionable error.
|
|
1982
|
+
*/
|
|
1983
|
+
var NodePtySpawner = class {
|
|
1984
|
+
async spawn(_request) {
|
|
1985
|
+
throw new Error(RMUX_REQUIRED_ERROR);
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
//#endregion
|
|
1989
|
+
//#region src/constants/rmuxBackend.ts
|
|
1990
|
+
const RMUX_BINARY_ENV = "DOOMPI_RMUX_BINARY";
|
|
1991
|
+
const LOG_DRAIN_TIMEOUT_MS = 2e3;
|
|
1992
|
+
const PANE_STATE_FORMAT = "#{pane_dead}:#{pane_dead_status}:#{session_name}";
|
|
1993
|
+
const PANE_PID_FORMAT = "#{pane_pid}";
|
|
1994
|
+
const STOP_CLOSE_TIMEOUT_MS = 1e3;
|
|
1995
|
+
const SESSION_PREFIX = "doom-runner-";
|
|
1996
|
+
const OWNED_TARGET_PATTERN = /^doom-runner-[A-Za-z0-9_-]+$/;
|
|
1997
|
+
const NON_INTERACTIVE_ENV = {
|
|
1998
|
+
NO_COLOR: "1",
|
|
1999
|
+
CI: "1"
|
|
2000
|
+
};
|
|
2001
|
+
//#endregion
|
|
2002
|
+
//#region src/services/rmuxBackend/index.ts
|
|
2003
|
+
/** Pane liveness, its exit status, and whether the session still exists, in one read. */
|
|
2004
|
+
const PACKAGE_BY_PLATFORM$1 = {
|
|
2005
|
+
"darwin-arm64": "@agimon-ai/doompi-runner-rmux-darwin-arm64",
|
|
2006
|
+
"darwin-x64": "@agimon-ai/doompi-runner-rmux-darwin-x64",
|
|
2007
|
+
"linux-arm64": "@agimon-ai/doompi-runner-rmux-linux-arm64",
|
|
2008
|
+
"linux-x64": "@agimon-ai/doompi-runner-rmux-linux-x64"
|
|
2009
|
+
};
|
|
2010
|
+
var RmuxBackend = class {
|
|
2011
|
+
paths;
|
|
2012
|
+
clientPromise;
|
|
2013
|
+
runs = /* @__PURE__ */ new Map();
|
|
2014
|
+
completions = /* @__PURE__ */ new Map();
|
|
2015
|
+
constructor(paths) {
|
|
2016
|
+
this.paths = paths;
|
|
2017
|
+
}
|
|
2018
|
+
async launch(request) {
|
|
2019
|
+
const rmux = await this.client();
|
|
2020
|
+
if (!rmux) return void 0;
|
|
2021
|
+
this.paths.ensureDirectories();
|
|
2022
|
+
const target = `${SESSION_PREFIX}${request.id}`;
|
|
2023
|
+
const logPath = this.paths.logPathFor(request.id);
|
|
2024
|
+
const aux = this.auxPaths(request.id, request.sessionId);
|
|
2025
|
+
let created = false;
|
|
2026
|
+
let released = false;
|
|
2027
|
+
try {
|
|
2028
|
+
this.prepareFiles(request, logPath, aux);
|
|
2029
|
+
const opened = await rmux.cmd("new-session", "-d", "-P", "-F", PANE_PID_FORMAT, "-s", target, "-c", request.cwd, process.execPath, runtimeEntry("runnerHost"), aux.spec, aux.gate, aux.exit, { check: true });
|
|
2030
|
+
created = true;
|
|
2031
|
+
await rmux.cmd("set-option", "-t", target, "remain-on-exit", "on", { check: true });
|
|
2032
|
+
await rmux.cmd("pipe-pane", "-t", target, this.logPipeCommand(logPath, aux.logDone, request), { check: true });
|
|
2033
|
+
const pane = rmux.session(target).pane(0, 0);
|
|
2034
|
+
const pid = reportedPid(String(opened.stdout ?? "")) ?? await panePid$1(rmux, target);
|
|
2035
|
+
node_fs.default.writeFileSync(aux.gate, "", { mode: 384 });
|
|
2036
|
+
released = true;
|
|
2037
|
+
let buffering = true;
|
|
2038
|
+
const completion = this.monitor(rmux, pane, target, aux);
|
|
2039
|
+
this.trackCompletion(target, completion);
|
|
2040
|
+
const stop = () => this.stop(target, pid);
|
|
2041
|
+
const handle = {
|
|
2042
|
+
id: request.id,
|
|
2043
|
+
name: request.name,
|
|
2044
|
+
pid,
|
|
2045
|
+
logPath,
|
|
2046
|
+
backend: "rmux",
|
|
2047
|
+
backendTarget: target,
|
|
2048
|
+
output: () => buffering ? readTail$1(logPath, getResultMaxBytes() * 2) : "",
|
|
2049
|
+
completion: () => completion,
|
|
2050
|
+
detach: () => {
|
|
2051
|
+
buffering = false;
|
|
2052
|
+
},
|
|
2053
|
+
stop
|
|
2054
|
+
};
|
|
2055
|
+
if (request.interactive) this.runs.set(request.name, this.interactiveRun(handle, pane, completion));
|
|
2056
|
+
completion.finally(() => this.runs.delete(request.name)).catch((error) => process.emitWarning(`RMUX completion failed for ${target}: ${errorMessage$1(error)}`));
|
|
2057
|
+
return handle;
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
if (released) throw error;
|
|
2060
|
+
if (created) await rmux.session(target).kill().catch((cleanupError) => process.emitWarning(`Could not clean up failed RMUX launch ${target}: ${errorMessage$1(cleanupError)}`));
|
|
2061
|
+
cleanupSupervisorFiles(aux);
|
|
2062
|
+
process.emitWarning(request.interactive ? `RMUX launch unavailable for interactive runner: ${errorMessage$1(error)}` : `RMUX launch unavailable, using supervised subprocess: ${errorMessage$1(error)}`);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
async stop(target, expectedPid) {
|
|
2067
|
+
if (!OWNED_TARGET_PATTERN.test(target) || !Number.isInteger(expectedPid) || expectedPid <= 0) return false;
|
|
2068
|
+
const rmux = await this.client();
|
|
2069
|
+
if (!rmux) return false;
|
|
2070
|
+
try {
|
|
2071
|
+
if (await sessionMissing(rmux, target)) return true;
|
|
2072
|
+
const pid = await panePid$1(rmux, target);
|
|
2073
|
+
if (pid !== expectedPid) return false;
|
|
2074
|
+
const signalled = signalPane$1(pid, target);
|
|
2075
|
+
if (signalled === "gone") return true;
|
|
2076
|
+
if (signalled === "failed") return false;
|
|
2077
|
+
if (await waitForPaneStop$1(rmux, target, 3e3)) return true;
|
|
2078
|
+
if (await sessionMissing(rmux, target)) return true;
|
|
2079
|
+
if (await panePid$1(rmux, target) !== expectedPid) return false;
|
|
2080
|
+
return closeOwnedSession(rmux, target);
|
|
2081
|
+
} catch (error) {
|
|
2082
|
+
process.emitWarning(`Could not stop RMUX target ${target}: ${errorMessage$1(error)}`);
|
|
2083
|
+
return false;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
async watch(id, target, sessionId) {
|
|
2087
|
+
const rmux = await this.client();
|
|
2088
|
+
if (!rmux) return void 0;
|
|
2089
|
+
const pane = rmux.session(target).pane(0, 0);
|
|
2090
|
+
try {
|
|
2091
|
+
await rmux.displayMessage("#{pane_id}", { target });
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
process.emitWarning(`Could not watch RMUX target ${target}: ${errorMessage$1(error)}`);
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
const completion = this.monitor(rmux, pane, target, this.auxPaths(id, sessionId));
|
|
2097
|
+
this.trackCompletion(target, completion);
|
|
2098
|
+
return completion;
|
|
2099
|
+
}
|
|
2100
|
+
async input(target, text) {
|
|
2101
|
+
const rmux = await this.client();
|
|
2102
|
+
if (!rmux) return false;
|
|
2103
|
+
return await rmux.session(target).pane(0, 0).sendText(text).catch((error) => {
|
|
2104
|
+
process.emitWarning(`Could not send input to RMUX target ${target}: ${errorMessage$1(error)}`);
|
|
2105
|
+
}) !== void 0;
|
|
2106
|
+
}
|
|
2107
|
+
async capture(target) {
|
|
2108
|
+
const rmux = await this.client();
|
|
2109
|
+
if (!rmux) return void 0;
|
|
2110
|
+
return rmux.session(target).pane(0, 0).captureText().catch((error) => {
|
|
2111
|
+
process.emitWarning(`Could not capture RMUX target ${target}: ${errorMessage$1(error)}`);
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
get(name) {
|
|
2115
|
+
return this.runs.get(name);
|
|
2116
|
+
}
|
|
2117
|
+
readOutcome(id, sessionId) {
|
|
2118
|
+
return readExitMetadata(this.auxPaths(id, sessionId).exit);
|
|
2119
|
+
}
|
|
2120
|
+
trackCompletion(target, completion) {
|
|
2121
|
+
this.completions.set(target, completion);
|
|
2122
|
+
completion.finally(() => this.completions.delete(target)).catch((error) => process.emitWarning(`RMUX monitor failed for ${target}: ${errorMessage$1(error)}`));
|
|
2123
|
+
}
|
|
2124
|
+
client() {
|
|
2125
|
+
this.clientPromise ??= this.resolveClient();
|
|
2126
|
+
return this.clientPromise;
|
|
2127
|
+
}
|
|
2128
|
+
async resolveClient() {
|
|
2129
|
+
const binaries = [bundledBinary$1(), "rmux"].filter((value) => value !== void 0);
|
|
2130
|
+
for (const binary of binaries) try {
|
|
2131
|
+
if (binary !== "rmux") node_fs.default.chmodSync(binary, 493);
|
|
2132
|
+
const client = new _rmux_sdk.RMUX({
|
|
2133
|
+
binary,
|
|
2134
|
+
socketName: socketName$1(this.paths.repositoryPath())
|
|
2135
|
+
});
|
|
2136
|
+
await client.capabilities();
|
|
2137
|
+
return client;
|
|
2138
|
+
} catch (error) {
|
|
2139
|
+
process.emitWarning(`RMUX candidate ${binary} is unavailable: ${errorMessage$1(error)}`);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
prepareFiles(request, logPath, aux) {
|
|
2143
|
+
cleanupSupervisorFiles(aux);
|
|
2144
|
+
node_fs.default.writeFileSync(logPath, "", { mode: 384 });
|
|
2145
|
+
const env = environment$1(request.sessionId, request.interactive);
|
|
2146
|
+
const spec = {
|
|
2147
|
+
command: request.command,
|
|
2148
|
+
cwd: request.cwd,
|
|
2149
|
+
env
|
|
2150
|
+
};
|
|
2151
|
+
writeCommandSpec(aux.spec, spec);
|
|
2152
|
+
}
|
|
2153
|
+
auxPaths(id, sessionId) {
|
|
2154
|
+
return supervisorPaths(this.paths.stateDirectory(sessionId), id);
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* The sink the pane pipes into.
|
|
2158
|
+
*
|
|
2159
|
+
* An interactive run also asks for a raw copy: the scrubbed log it writes
|
|
2160
|
+
* beside it has had cursor movement taken out, which is what a terminal
|
|
2161
|
+
* needs most. Only interactive runs pay for it, because only they can be
|
|
2162
|
+
* attached to.
|
|
2163
|
+
*/
|
|
2164
|
+
logPipeCommand(logPath, donePath, request) {
|
|
2165
|
+
const id = node_path.default.basename(logPath, ".log");
|
|
2166
|
+
return shellJoin([
|
|
2167
|
+
process.execPath,
|
|
2168
|
+
runtimeEntry("logSink"),
|
|
2169
|
+
logPath,
|
|
2170
|
+
this.paths.rotatedLogPathFor(id),
|
|
2171
|
+
String(getLogMaxBytes()),
|
|
2172
|
+
donePath,
|
|
2173
|
+
...request.interactive ? [this.paths.rawLogPathFor(id)] : []
|
|
2174
|
+
]);
|
|
2175
|
+
}
|
|
2176
|
+
async monitor(rmux, pane, target, aux) {
|
|
2177
|
+
try {
|
|
2178
|
+
const paneCode = await waitForRunEnd$1(rmux, target, aux.exit);
|
|
2179
|
+
await waitForLogDrain$1(rmux, target, aux.logDone);
|
|
2180
|
+
return readExitMetadata(aux.exit) ?? {
|
|
2181
|
+
code: paneCode,
|
|
2182
|
+
signal: null
|
|
2183
|
+
};
|
|
2184
|
+
} finally {
|
|
2185
|
+
let timer;
|
|
2186
|
+
const cleanup = async () => {
|
|
2187
|
+
if (!await sessionMissing(rmux, target)) await pane.server.session(target).kill();
|
|
2188
|
+
};
|
|
2189
|
+
try {
|
|
2190
|
+
await Promise.race([cleanup().catch((error) => process.emitWarning(`Could not clean up completed RMUX target ${target}: ${errorMessage$1(error)}`)), new Promise((resolve) => {
|
|
2191
|
+
timer = setTimeout(() => {
|
|
2192
|
+
process.emitWarning(`Timed out cleaning up RMUX target ${target} after ${STOP_CLOSE_TIMEOUT_MS}ms`);
|
|
2193
|
+
resolve();
|
|
2194
|
+
}, STOP_CLOSE_TIMEOUT_MS);
|
|
2195
|
+
})]);
|
|
2196
|
+
} finally {
|
|
2197
|
+
if (timer) clearTimeout(timer);
|
|
2198
|
+
cleanupSupervisorFiles(aux);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
interactiveRun(handle, pane, completion) {
|
|
2203
|
+
let screen = "";
|
|
2204
|
+
let active = true;
|
|
2205
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
2206
|
+
const refresh = async () => {
|
|
2207
|
+
while (active) {
|
|
2208
|
+
const next = await pane.captureText().catch((error) => {
|
|
2209
|
+
process.emitWarning(`Could not capture RMUX screen for ${handle.name}: ${errorMessage$1(error)}`);
|
|
2210
|
+
return screen;
|
|
2211
|
+
});
|
|
2212
|
+
if (next !== screen) {
|
|
2213
|
+
screen = next;
|
|
2214
|
+
for (const handler of handlers) handler(next);
|
|
2215
|
+
}
|
|
2216
|
+
await delay$1(100);
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
refresh();
|
|
2220
|
+
completion.finally(() => {
|
|
2221
|
+
active = false;
|
|
2222
|
+
}).catch((error) => process.emitWarning(`RMUX interactive completion failed for ${handle.name}: ${errorMessage$1(error)}`));
|
|
2223
|
+
return {
|
|
2224
|
+
...handle,
|
|
2225
|
+
write: (text) => {
|
|
2226
|
+
pane.sendText(text).catch((error) => process.emitWarning(errorMessage$1(error)));
|
|
2227
|
+
},
|
|
2228
|
+
screen: () => screen,
|
|
2229
|
+
onData: (handler) => {
|
|
2230
|
+
handlers.add(handler);
|
|
2231
|
+
return () => handlers.delete(handler);
|
|
2232
|
+
},
|
|
2233
|
+
resize: (cols, rows) => {
|
|
2234
|
+
pane.resize({
|
|
2235
|
+
width: cols,
|
|
2236
|
+
height: rows
|
|
2237
|
+
}).catch((error) => process.emitWarning(errorMessage$1(error)));
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
function rmuxPackageForTarget(platform, architecture) {
|
|
2243
|
+
const target = `${platform}-${architecture}`;
|
|
2244
|
+
const packageName = PACKAGE_BY_PLATFORM$1[target];
|
|
2245
|
+
if (!packageName) process.emitWarning(`Bundled RMUX binary is unavailable for unsupported target ${target}`);
|
|
2246
|
+
return packageName;
|
|
2247
|
+
}
|
|
2248
|
+
function resolveBundledRmuxBinary(moduleUrl = require("url").pathToFileURL(__filename).href) {
|
|
2249
|
+
const configured = process.env[RMUX_BINARY_ENV];
|
|
2250
|
+
if (configured !== void 0 && configured !== "") return configured;
|
|
2251
|
+
const packageName = rmuxPackageForTarget(process.platform, process.arch);
|
|
2252
|
+
if (!packageName) return void 0;
|
|
2253
|
+
try {
|
|
2254
|
+
const manifest = (0, node_module.createRequire)(moduleUrl).resolve(`${packageName}/package.json`);
|
|
2255
|
+
return node_path.default.join(node_path.default.dirname(manifest), "vendor", "bin", "rmux");
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
process.emitWarning(`Bundled RMUX binary is unavailable for ${packageName}: ${errorMessage$1(error)}`);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
function bundledBinary$1() {
|
|
2262
|
+
return resolveBundledRmuxBinary();
|
|
2263
|
+
}
|
|
2264
|
+
function socketName$1(repositoryPath) {
|
|
2265
|
+
const hash = (0, node_crypto.createHash)("sha256").update(repositoryPath).digest("hex").slice(0, 12);
|
|
2266
|
+
return `${SESSION_PREFIX}${hash}`;
|
|
2267
|
+
}
|
|
2268
|
+
function environment$1(sessionId, interactive) {
|
|
2269
|
+
const values = {
|
|
2270
|
+
...process.env,
|
|
2271
|
+
...interactive ? {} : NON_INTERACTIVE_ENV,
|
|
2272
|
+
...NO_TERMINAL_INPUT_ENV,
|
|
2273
|
+
PI_SESSION_ID: sessionId
|
|
2274
|
+
};
|
|
2275
|
+
if (!interactive) delete values["FORCE_COLOR"];
|
|
2276
|
+
return Object.fromEntries(Object.entries(values).filter((entry) => entry[1] !== void 0));
|
|
2277
|
+
}
|
|
2278
|
+
async function panePid$1(rmux, target) {
|
|
2279
|
+
const response = await rmux.displayMessage(PANE_PID_FORMAT, { target });
|
|
2280
|
+
const pid = reportedPid(typeof response["message"] === "string" ? response["message"] : "");
|
|
2281
|
+
if (pid === void 0) throw new Error(`RMUX did not report a pane pid for ${target}`);
|
|
2282
|
+
return pid;
|
|
2283
|
+
}
|
|
2284
|
+
/** A pane pid as rmux prints it, or nothing when the output holds no usable pid. */
|
|
2285
|
+
function reportedPid(output) {
|
|
2286
|
+
const pid = Number(output.trim());
|
|
2287
|
+
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Waits for the run to end, preferring the supervisor's own record of it.
|
|
2291
|
+
*
|
|
2292
|
+
* The sidecar is written before the command's pane can end, and it outlives the
|
|
2293
|
+
* server that the pane's exit takes down with it. Watching for it means a run
|
|
2294
|
+
* that finishes inside a poll interval is observed when it finishes rather than
|
|
2295
|
+
* on the next tick. Asking the server stays the fallback for a supervisor that
|
|
2296
|
+
* died without recording anything.
|
|
2297
|
+
*/
|
|
2298
|
+
async function waitForRunEnd$1(rmux, target, exitPath) {
|
|
2299
|
+
const landed = watchForFile(exitPath);
|
|
2300
|
+
let active = true;
|
|
2301
|
+
const readEvidence = async () => {
|
|
2302
|
+
while (active) if (await landed.appeared(100)) return null;
|
|
2303
|
+
return null;
|
|
2304
|
+
};
|
|
2305
|
+
const probePane = async () => {
|
|
2306
|
+
while (active) {
|
|
2307
|
+
await delay$1(100);
|
|
2308
|
+
if (!active) return null;
|
|
2309
|
+
const state = await readPaneExit$1(rmux, target);
|
|
2310
|
+
if (state.exited) return state.code;
|
|
2311
|
+
}
|
|
2312
|
+
return null;
|
|
2313
|
+
};
|
|
2314
|
+
try {
|
|
2315
|
+
return await Promise.race([readEvidence(), probePane()]);
|
|
2316
|
+
} finally {
|
|
2317
|
+
active = false;
|
|
2318
|
+
landed.close();
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Gives the log sink a moment to mark the log complete.
|
|
2323
|
+
*
|
|
2324
|
+
* Normal completion retains the session until the sink records its final
|
|
2325
|
+
* writes. A lost session cannot guarantee a later marker, so it ends the wait.
|
|
2326
|
+
*/
|
|
2327
|
+
async function waitForLogDrain$1(rmux, target, donePath) {
|
|
2328
|
+
const deadline = Date.now() + LOG_DRAIN_TIMEOUT_MS;
|
|
2329
|
+
const landed = watchForFile(donePath);
|
|
2330
|
+
let active = true;
|
|
2331
|
+
const readEvidence = async () => {
|
|
2332
|
+
while (active && Date.now() < deadline) if (await landed.appeared(25)) return;
|
|
2333
|
+
};
|
|
2334
|
+
const probeSession = async () => {
|
|
2335
|
+
while (active && Date.now() < deadline) {
|
|
2336
|
+
await delay$1(25);
|
|
2337
|
+
if (!active) return;
|
|
2338
|
+
if (await sessionMissing(rmux, target)) return;
|
|
2339
|
+
}
|
|
2340
|
+
};
|
|
2341
|
+
try {
|
|
2342
|
+
await Promise.race([readEvidence(), probeSession()]);
|
|
2343
|
+
} finally {
|
|
2344
|
+
active = false;
|
|
2345
|
+
landed.close();
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
async function closeOwnedSession(rmux, target) {
|
|
2349
|
+
return new Promise((resolve) => {
|
|
2350
|
+
let settled = false;
|
|
2351
|
+
const finish = (result) => {
|
|
2352
|
+
if (settled) return;
|
|
2353
|
+
settled = true;
|
|
2354
|
+
clearTimeout(timer);
|
|
2355
|
+
resolve(result);
|
|
2356
|
+
};
|
|
2357
|
+
const timer = setTimeout(() => {
|
|
2358
|
+
process.emitWarning(`Timed out closing RMUX target ${target} after ${STOP_CLOSE_TIMEOUT_MS}ms`);
|
|
2359
|
+
finish(false);
|
|
2360
|
+
}, STOP_CLOSE_TIMEOUT_MS);
|
|
2361
|
+
rmux.session(target).kill().then(() => finish(true), (error) => {
|
|
2362
|
+
process.emitWarning(`Could not close RMUX target ${target}: ${errorMessage$1(error)}`);
|
|
2363
|
+
finish(false);
|
|
2364
|
+
});
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
async function waitForPaneStop$1(rmux, target, timeoutMs) {
|
|
2368
|
+
const deadline = Date.now() + timeoutMs;
|
|
2369
|
+
while (Date.now() < deadline) {
|
|
2370
|
+
try {
|
|
2371
|
+
if ((await readPaneExit$1(rmux, target)).exited) return true;
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
process.emitWarning(`Could not verify RMUX target ${target} stopped: ${errorMessage$1(error)}`);
|
|
2374
|
+
return false;
|
|
2375
|
+
}
|
|
2376
|
+
await delay$1(100);
|
|
2377
|
+
}
|
|
2378
|
+
return false;
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* One formatted read answers both questions this poll asks, so watching a run
|
|
2382
|
+
* costs the same single rmux call it always did.
|
|
2383
|
+
*/
|
|
2384
|
+
async function readPaneExit$1(rmux, target) {
|
|
2385
|
+
let message;
|
|
2386
|
+
try {
|
|
2387
|
+
const response = await rmux.displayMessage(PANE_STATE_FORMAT, { target });
|
|
2388
|
+
message = typeof response["message"] === "string" ? response["message"] : "";
|
|
2389
|
+
} catch {
|
|
2390
|
+
return {
|
|
2391
|
+
exited: true,
|
|
2392
|
+
code: null
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
const [dead, status, ...rest] = message.trim().split(":");
|
|
2396
|
+
if (rest.join(":") === "") return {
|
|
2397
|
+
exited: true,
|
|
2398
|
+
code: null
|
|
2399
|
+
};
|
|
2400
|
+
if (dead === "1" || dead === "true") {
|
|
2401
|
+
const code = Number(status);
|
|
2402
|
+
return {
|
|
2403
|
+
exited: true,
|
|
2404
|
+
code: Number.isInteger(code) ? code : null
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
return {
|
|
2408
|
+
exited: false,
|
|
2409
|
+
code: null
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
/** A session that cannot be found, on a server that may itself be gone. */
|
|
2413
|
+
async function sessionMissing(rmux, target) {
|
|
2414
|
+
try {
|
|
2415
|
+
return (await rmux.cmd("has-session", "-t", target)).returnCode !== 0;
|
|
2416
|
+
} catch {
|
|
2417
|
+
return true;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* A pane that was already gone and a signal that could not be delivered are
|
|
2422
|
+
* opposite answers to "is it stopped", so they are reported apart.
|
|
2423
|
+
*/
|
|
2424
|
+
function signalPane$1(pid, target) {
|
|
2425
|
+
try {
|
|
2426
|
+
process.kill(pid, "SIGTERM");
|
|
2427
|
+
return "signalled";
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
if (error.code === "ESRCH") return "gone";
|
|
2430
|
+
process.emitWarning(`Could not signal RMUX target ${target}: ${errorMessage$1(error)}`);
|
|
2431
|
+
return "failed";
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
function readTail$1(target, maxBytes) {
|
|
2435
|
+
let handle;
|
|
2436
|
+
try {
|
|
2437
|
+
const size = node_fs.default.statSync(target).size;
|
|
2438
|
+
const bytes = Math.min(size, maxBytes);
|
|
2439
|
+
const buffer = Buffer.alloc(bytes);
|
|
2440
|
+
handle = node_fs.default.openSync(target, "r");
|
|
2441
|
+
node_fs.default.readSync(handle, buffer, 0, bytes, size - bytes);
|
|
2442
|
+
return buffer.toString("utf8");
|
|
2443
|
+
} catch (error) {
|
|
2444
|
+
if (error.code !== "ENOENT") process.emitWarning(`Could not read RMUX log ${target}: ${errorMessage$1(error)}`);
|
|
2445
|
+
return "";
|
|
2446
|
+
} finally {
|
|
2447
|
+
if (handle !== void 0) node_fs.default.closeSync(handle);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
function delay$1(ms) {
|
|
2451
|
+
return new Promise((resolve) => {
|
|
2452
|
+
setTimeout(resolve, ms).unref?.();
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
function errorMessage$1(error) {
|
|
2456
|
+
return error instanceof Error ? error.message : String(error);
|
|
2457
|
+
}
|
|
2458
|
+
const DEFAULT_RTK_TIMEOUT_MS = 1e4;
|
|
2459
|
+
const RTK_KILL_WAIT_MS = 1e3;
|
|
2460
|
+
const SIMPLE_TOKEN_PATTERN = /^[A-Za-z0-9_./:@%+=,-]+$/u;
|
|
2461
|
+
const SHELL_CONTROL_PATTERN = /[\r\n;&|<>`$\\'"(){}*?!]/u;
|
|
2462
|
+
const RTK_BINARY_ENV = "DOOMPI_RTK_BINARY";
|
|
2463
|
+
//#endregion
|
|
2464
|
+
//#region src/services/rtkProcessor/index.ts
|
|
2465
|
+
const GIT_DIFF_INCOMPATIBLE_OPTIONS = [
|
|
2466
|
+
"-s",
|
|
2467
|
+
"-z",
|
|
2468
|
+
"--raw",
|
|
2469
|
+
"--numstat",
|
|
2470
|
+
"--shortstat",
|
|
2471
|
+
"--stat",
|
|
2472
|
+
"--dirstat",
|
|
2473
|
+
"--dirstat-by-file",
|
|
2474
|
+
"--summary",
|
|
2475
|
+
"--compact-summary",
|
|
2476
|
+
"--name-only",
|
|
2477
|
+
"--name-status",
|
|
2478
|
+
"--check",
|
|
2479
|
+
"--binary",
|
|
2480
|
+
"--word-diff",
|
|
2481
|
+
"--word-diff-regex",
|
|
2482
|
+
"--color",
|
|
2483
|
+
"--color-words",
|
|
2484
|
+
"--color-moved",
|
|
2485
|
+
"--color-moved-ws",
|
|
2486
|
+
"--line-prefix",
|
|
2487
|
+
"--no-patch",
|
|
2488
|
+
"--patch-with-raw",
|
|
2489
|
+
"--patch-with-stat",
|
|
2490
|
+
"--ext-diff",
|
|
2491
|
+
"--textconv",
|
|
2492
|
+
"--quiet",
|
|
2493
|
+
"--submodule"
|
|
2494
|
+
];
|
|
2495
|
+
const STRUCTURED_SEARCH_INCOMPATIBLE_OPTIONS = [
|
|
2496
|
+
"--json",
|
|
2497
|
+
"--null",
|
|
2498
|
+
"--null-data",
|
|
2499
|
+
"--color",
|
|
2500
|
+
"--colour",
|
|
2501
|
+
"--after-context",
|
|
2502
|
+
"--before-context",
|
|
2503
|
+
"--context",
|
|
2504
|
+
"--no-filename",
|
|
2505
|
+
"--no-line-number",
|
|
2506
|
+
"--heading"
|
|
2507
|
+
];
|
|
2508
|
+
const PACKAGE_BY_PLATFORM = {
|
|
2509
|
+
"darwin-arm64": "@agimon-ai/doompi-runner-rtk-darwin-arm64",
|
|
2510
|
+
"darwin-x64": "@agimon-ai/doompi-runner-rtk-darwin-x64",
|
|
2511
|
+
"linux-arm64": "@agimon-ai/doompi-runner-rtk-linux-arm64",
|
|
2512
|
+
"linux-x64": "@agimon-ai/doompi-runner-rtk-linux-x64"
|
|
2513
|
+
};
|
|
2514
|
+
var RtkProcessor = class {
|
|
2515
|
+
resolveBinary;
|
|
2516
|
+
timeoutMs;
|
|
2517
|
+
/**
|
|
2518
|
+
* Latched once a binary proves unrunnable on this host.
|
|
2519
|
+
*
|
|
2520
|
+
* A glibc build installed on a musl host satisfies os and cpu, so it
|
|
2521
|
+
* resolves and then fails to exec. Without this the same doomed spawn is
|
|
2522
|
+
* paid on every command, and each one is reported as a processing failure
|
|
2523
|
+
* rather than the absence it actually is.
|
|
2524
|
+
*/
|
|
2525
|
+
unrunnable = false;
|
|
2526
|
+
constructor(resolveBinary = bundledBinary, timeoutMs = DEFAULT_RTK_TIMEOUT_MS) {
|
|
2527
|
+
this.resolveBinary = resolveBinary;
|
|
2528
|
+
this.timeoutMs = timeoutMs;
|
|
2529
|
+
}
|
|
2530
|
+
async process(request) {
|
|
2531
|
+
const filter = classifyRtkFilter(request.command);
|
|
2532
|
+
if (!filter) return { kind: "skipped" };
|
|
2533
|
+
let inputBytes;
|
|
2534
|
+
try {
|
|
2535
|
+
inputBytes = node_fs.default.statSync(request.logPath).size;
|
|
2536
|
+
} catch {
|
|
2537
|
+
return {
|
|
2538
|
+
kind: "fallback",
|
|
2539
|
+
warning: RTK_FAILED_WARNING
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
if (inputBytes > 10485760) return {
|
|
2543
|
+
kind: "fallback",
|
|
2544
|
+
warning: RTK_OVERSIZED_WARNING
|
|
2545
|
+
};
|
|
2546
|
+
if (this.unrunnable) return {
|
|
2547
|
+
kind: "fallback",
|
|
2548
|
+
warning: RTK_UNAVAILABLE_WARNING
|
|
2549
|
+
};
|
|
2550
|
+
let binary;
|
|
2551
|
+
try {
|
|
2552
|
+
binary = this.resolveBinary();
|
|
2553
|
+
if (!binary) return {
|
|
2554
|
+
kind: "fallback",
|
|
2555
|
+
warning: RTK_UNAVAILABLE_WARNING
|
|
2556
|
+
};
|
|
2557
|
+
if ((node_fs.default.statSync(binary).mode & 73) === 0) node_fs.default.chmodSync(binary, 493);
|
|
2558
|
+
} catch {
|
|
2559
|
+
return {
|
|
2560
|
+
kind: "fallback",
|
|
2561
|
+
warning: RTK_UNAVAILABLE_WARNING
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
return this.pipe(binary, filter, request.logPath, inputBytes);
|
|
2565
|
+
}
|
|
2566
|
+
pipe(binary, filter, logPath, inputBytes) {
|
|
2567
|
+
return new Promise((resolve) => {
|
|
2568
|
+
let child;
|
|
2569
|
+
try {
|
|
2570
|
+
child = (0, node_child_process.spawn)(binary, [
|
|
2571
|
+
"pipe",
|
|
2572
|
+
"-f",
|
|
2573
|
+
filter
|
|
2574
|
+
], { stdio: "pipe" });
|
|
2575
|
+
} catch {
|
|
2576
|
+
this.unrunnable = true;
|
|
2577
|
+
resolve({
|
|
2578
|
+
kind: "fallback",
|
|
2579
|
+
warning: RTK_UNAVAILABLE_WARNING
|
|
2580
|
+
});
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
const input = node_fs.default.createReadStream(logPath);
|
|
2584
|
+
const outputLimit = getResultMaxBytes() * 2;
|
|
2585
|
+
let head = Buffer.alloc(0);
|
|
2586
|
+
let output = Buffer.alloc(0);
|
|
2587
|
+
let outputBytes = 0;
|
|
2588
|
+
let newlineCount = 0;
|
|
2589
|
+
let lastByte;
|
|
2590
|
+
let settled = false;
|
|
2591
|
+
let fallbackWarning;
|
|
2592
|
+
let killWaitTimer;
|
|
2593
|
+
const finish = (result) => {
|
|
2594
|
+
if (settled) return;
|
|
2595
|
+
settled = true;
|
|
2596
|
+
clearTimeout(timer);
|
|
2597
|
+
if (killWaitTimer) clearTimeout(killWaitTimer);
|
|
2598
|
+
input.destroy();
|
|
2599
|
+
resolve(result);
|
|
2600
|
+
};
|
|
2601
|
+
const stop = (warning) => {
|
|
2602
|
+
if (settled) return;
|
|
2603
|
+
fallbackWarning = warning;
|
|
2604
|
+
let killed = false;
|
|
2605
|
+
try {
|
|
2606
|
+
killed = child.kill("SIGKILL");
|
|
2607
|
+
} catch {
|
|
2608
|
+
finish({
|
|
2609
|
+
kind: "fallback",
|
|
2610
|
+
warning
|
|
2611
|
+
});
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
if (!killed) {
|
|
2615
|
+
finish({
|
|
2616
|
+
kind: "fallback",
|
|
2617
|
+
warning
|
|
2618
|
+
});
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
killWaitTimer ??= setTimeout(() => finish({
|
|
2622
|
+
kind: "fallback",
|
|
2623
|
+
warning
|
|
2624
|
+
}), RTK_KILL_WAIT_MS);
|
|
2625
|
+
};
|
|
2626
|
+
const timer = setTimeout(() => stop(RTK_TIMEOUT_WARNING), this.timeoutMs);
|
|
2627
|
+
child.stdout.on("data", (chunk) => {
|
|
2628
|
+
outputBytes += chunk.byteLength;
|
|
2629
|
+
for (const byte of chunk) if (byte === 10) newlineCount += 1;
|
|
2630
|
+
lastByte = chunk.at(-1);
|
|
2631
|
+
if (head.byteLength < outputLimit) head = Buffer.concat([head, chunk.subarray(0, outputLimit - head.byteLength)]);
|
|
2632
|
+
output = appendTail(output, chunk, outputLimit);
|
|
2633
|
+
});
|
|
2634
|
+
child.stderr.resume();
|
|
2635
|
+
child.once("error", (error) => {
|
|
2636
|
+
if (error.code === "ENOENT" || error.code === "EACCES" || error.code === "ENOEXEC") {
|
|
2637
|
+
this.unrunnable = true;
|
|
2638
|
+
stop(RTK_UNAVAILABLE_WARNING);
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
stop(RTK_FAILED_WARNING);
|
|
2642
|
+
});
|
|
2643
|
+
child.stdin.once("error", () => stop(RTK_FAILED_WARNING));
|
|
2644
|
+
input.once("error", () => stop(RTK_FAILED_WARNING));
|
|
2645
|
+
child.once("close", (code) => {
|
|
2646
|
+
if (fallbackWarning) {
|
|
2647
|
+
finish({
|
|
2648
|
+
kind: "fallback",
|
|
2649
|
+
warning: fallbackWarning
|
|
2650
|
+
});
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const processedOutput = decodeUtf8Tail(output);
|
|
2654
|
+
if (code !== 0 || inputBytes > 0 && processedOutput.trim().length === 0) {
|
|
2655
|
+
finish({
|
|
2656
|
+
kind: "fallback",
|
|
2657
|
+
warning: RTK_FAILED_WARNING
|
|
2658
|
+
});
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
const lines = newlineCount + (outputBytes > 0 && lastByte !== 10 ? 1 : 0);
|
|
2662
|
+
finish({
|
|
2663
|
+
kind: "processed",
|
|
2664
|
+
result: {
|
|
2665
|
+
filter,
|
|
2666
|
+
head: outputBytes > output.byteLength ? head.toString("utf8") : "",
|
|
2667
|
+
output: processedOutput,
|
|
2668
|
+
bytes: outputBytes,
|
|
2669
|
+
lines
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
});
|
|
2673
|
+
input.pipe(child.stdin);
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
/**
|
|
2678
|
+
* Classifies only whitespace-delimited single commands. Shell syntax, expansion,
|
|
2679
|
+
* quoting, and globbing are rejected instead of being partially interpreted.
|
|
2680
|
+
*/
|
|
2681
|
+
function classifyRtkFilter(command) {
|
|
2682
|
+
if (SHELL_CONTROL_PATTERN.test(command)) return void 0;
|
|
2683
|
+
const trimmed = command.trim();
|
|
2684
|
+
if (!trimmed) return void 0;
|
|
2685
|
+
const tokens = trimmed.split(/[ \t]+/u);
|
|
2686
|
+
if (tokens.some((token) => !SIMPLE_TOKEN_PATTERN.test(token))) return void 0;
|
|
2687
|
+
if (tokens[0] === "cargo" && tokens[1] === "test") {
|
|
2688
|
+
if (tokens.includes("--") || tokens.some((token) => token.startsWith("--message-format") || token === "--json")) return;
|
|
2689
|
+
return "cargo-test";
|
|
2690
|
+
}
|
|
2691
|
+
if (tokens[0] === "pytest") {
|
|
2692
|
+
if (tokens.some((token) => token.startsWith("--json-report"))) return void 0;
|
|
2693
|
+
return "pytest";
|
|
2694
|
+
}
|
|
2695
|
+
if (tokens[0] === "git" && tokens[1] === "diff") {
|
|
2696
|
+
if (tokens.some((token) => GIT_DIFF_INCOMPATIBLE_OPTIONS.some((option) => token === option || token.startsWith(`${option}=`)))) return;
|
|
2697
|
+
return "git-diff";
|
|
2698
|
+
}
|
|
2699
|
+
if (tokens[0] === "grep") {
|
|
2700
|
+
if (tokens.length < 4 || incompatibleStructuredSearch(tokens)) return void 0;
|
|
2701
|
+
return hasShortOrLongOption(tokens, "n", "--line-number") && hasShortOrLongOption(tokens, "H", "--with-filename") ? "grep" : void 0;
|
|
2702
|
+
}
|
|
2703
|
+
if (tokens[0] === "rg") {
|
|
2704
|
+
if (tokens.length < 4 || incompatibleStructuredSearch(tokens) || !tokens.includes("--no-heading")) return void 0;
|
|
2705
|
+
return hasShortOrLongOption(tokens, "n", "--line-number") && hasShortOrLongOption(tokens, "H", "--with-filename") ? "grep" : void 0;
|
|
2706
|
+
}
|
|
2707
|
+
if (tokens[0] === "go" && tokens[1] === "test") {
|
|
2708
|
+
let jsonOutput = false;
|
|
2709
|
+
for (const token of tokens.slice(2)) {
|
|
2710
|
+
if (token === "-json" || token === "-json=true") jsonOutput = true;
|
|
2711
|
+
if (token === "-json=false") jsonOutput = false;
|
|
2712
|
+
}
|
|
2713
|
+
return jsonOutput ? "go-test" : void 0;
|
|
2714
|
+
}
|
|
2715
|
+
if (tokens[0] === "ruff" && tokens[1] === "check") return tokens.some((token, index) => token === "--output-format=json" || token === "--output-format" && tokens[index + 1] === "json") ? "ruff-check" : void 0;
|
|
2716
|
+
}
|
|
2717
|
+
function rtkPackageForTarget(platform, architecture) {
|
|
2718
|
+
const target = `${platform}-${architecture}`;
|
|
2719
|
+
const packageName = PACKAGE_BY_PLATFORM[target];
|
|
2720
|
+
if (!packageName) process.emitWarning(`Bundled RTK binary is unavailable for unsupported target ${target}`);
|
|
2721
|
+
return packageName;
|
|
2722
|
+
}
|
|
2723
|
+
function bundledBinary() {
|
|
2724
|
+
const configured = process.env[RTK_BINARY_ENV];
|
|
2725
|
+
if (configured !== void 0 && configured !== "") return configured;
|
|
2726
|
+
const packageName = rtkPackageForTarget(process.platform, process.arch);
|
|
2727
|
+
if (!packageName) return void 0;
|
|
2728
|
+
try {
|
|
2729
|
+
const manifest = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href).resolve(`${packageName}/package.json`);
|
|
2730
|
+
return node_path.default.join(node_path.default.dirname(manifest), "vendor", "bin", "rtk");
|
|
2731
|
+
} catch {
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
function appendTail(current, chunk, limit) {
|
|
2736
|
+
if (chunk.byteLength >= limit) return Buffer.from(chunk.subarray(chunk.byteLength - limit));
|
|
2737
|
+
const combined = Buffer.concat([current, chunk]);
|
|
2738
|
+
return combined.byteLength > limit ? Buffer.from(combined.subarray(combined.byteLength - limit)) : combined;
|
|
2739
|
+
}
|
|
2740
|
+
function decodeUtf8Tail(output) {
|
|
2741
|
+
let start = 0;
|
|
2742
|
+
while (start < output.byteLength && (output[start] & 192) === 128) start += 1;
|
|
2743
|
+
return output.subarray(start).toString("utf8");
|
|
2744
|
+
}
|
|
2745
|
+
function hasShortOrLongOption(tokens, short, long) {
|
|
2746
|
+
return tokens.some((token) => token === long || token.startsWith("-") && !token.startsWith("--") && token.slice(1).includes(short));
|
|
2747
|
+
}
|
|
2748
|
+
function incompatibleStructuredSearch(tokens) {
|
|
2749
|
+
return tokens.some((token) => token.startsWith("-") && !token.startsWith("--") && /[0ABCILNchzZ]/u.test(token.slice(1)) || STRUCTURED_SEARCH_INCOMPATIBLE_OPTIONS.some((option) => token === option || token.startsWith(`${option}=`)));
|
|
2750
|
+
}
|
|
2751
|
+
//#endregion
|
|
2752
|
+
//#region src/constants/runnerNamer.ts
|
|
2753
|
+
const MAX_ATTEMPTS = 1e3;
|
|
2754
|
+
const FALLBACK_SLUG = "runner";
|
|
2755
|
+
//#endregion
|
|
2756
|
+
//#region src/services/runnerNamer/index.ts
|
|
2757
|
+
/** Shell noise that says nothing about what the command actually runs. */
|
|
2758
|
+
const IGNORED_LEADING_WORDS = /* @__PURE__ */ new Set([
|
|
2759
|
+
"sudo",
|
|
2760
|
+
"env",
|
|
2761
|
+
"time",
|
|
2762
|
+
"nohup",
|
|
2763
|
+
"exec"
|
|
2764
|
+
]);
|
|
2765
|
+
var RunnerNamer = class {
|
|
2766
|
+
registry;
|
|
2767
|
+
constructor(registry) {
|
|
2768
|
+
this.registry = registry;
|
|
2769
|
+
}
|
|
2770
|
+
async allocate(command, sessionId, requested) {
|
|
2771
|
+
const base = slugify(requested ?? deriveSlug(command));
|
|
2772
|
+
const taken = new Set((await this.registry.listBySession(sessionId)).map((record) => record.name));
|
|
2773
|
+
if (!taken.has(base)) return base;
|
|
2774
|
+
for (let suffix = 2; suffix < MAX_ATTEMPTS; suffix += 1) {
|
|
2775
|
+
const candidate = `${base}-${suffix}`;
|
|
2776
|
+
if (!taken.has(candidate)) return candidate;
|
|
2777
|
+
}
|
|
2778
|
+
throw new Error(`Could not find a free runner name based on "${base}"`);
|
|
2779
|
+
}
|
|
2780
|
+
};
|
|
2781
|
+
/**
|
|
2782
|
+
* Names come from the first few meaningful words of the command, which is what
|
|
2783
|
+
* a reader recognises in a runner list: `nx-dev-start-api`, not `runner-3`.
|
|
2784
|
+
*/
|
|
2785
|
+
function deriveSlug(command) {
|
|
2786
|
+
return command.split(/\s+/).filter((word) => word.length > 0 && !word.startsWith("-") && !word.includes("=")).filter((word) => !IGNORED_LEADING_WORDS.has(word)).slice(0, 4).join("-");
|
|
2787
|
+
}
|
|
2788
|
+
function slugify(value) {
|
|
2789
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "");
|
|
2790
|
+
return slug.length > 0 ? slug : FALLBACK_SLUG;
|
|
2791
|
+
}
|
|
2792
|
+
//#endregion
|
|
2793
|
+
//#region src/constants/runnerPaths.ts
|
|
2794
|
+
const STORE_DIR_NAME = "doom-runner";
|
|
2795
|
+
const AGENT_DIR_NAME = "agent";
|
|
2796
|
+
const LOG_DIR_NAME = "logs";
|
|
2797
|
+
const STATE_DIR_NAME = "runs";
|
|
2798
|
+
const LOG_EXTENSION = ".log";
|
|
2799
|
+
const STATE_EXTENSION = ".json";
|
|
2800
|
+
const RAW_SUFFIX = ".raw";
|
|
2801
|
+
const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
|
2802
|
+
const FILE_ENCODING = "utf8";
|
|
2803
|
+
//#endregion
|
|
2804
|
+
//#region src/services/session/index.ts
|
|
2805
|
+
/** Session identity inherited by commands launched from a Pi session. */
|
|
2806
|
+
/** Returns a usable inherited session id, failing closed on blank values. */
|
|
2807
|
+
function inheritedSessionId(env = process.env) {
|
|
2808
|
+
const value = env[PI_SESSION_ID_ENV]?.trim();
|
|
2809
|
+
return value ? value : void 0;
|
|
2810
|
+
}
|
|
2811
|
+
//#endregion
|
|
2812
|
+
//#region src/services/runnerPaths/index.ts
|
|
2813
|
+
/** Unscrubbed pane bytes, kept beside the log so an attached terminal can replay them. */
|
|
2814
|
+
/**
|
|
2815
|
+
* How long an entirely empty session directory is left alone before it counts
|
|
2816
|
+
* as an orphan. A session that has started but not yet run anything owns such a
|
|
2817
|
+
* directory for a moment, and reclaiming it out from under that session would
|
|
2818
|
+
* be churn for no gain.
|
|
2819
|
+
*/
|
|
2820
|
+
function git(args, cwd) {
|
|
2821
|
+
try {
|
|
2822
|
+
const trimmed = (0, node_child_process.execFileSync)("git", args, {
|
|
2823
|
+
cwd,
|
|
2824
|
+
encoding: FILE_ENCODING,
|
|
2825
|
+
stdio: [
|
|
2826
|
+
"ignore",
|
|
2827
|
+
"pipe",
|
|
2828
|
+
"ignore"
|
|
2829
|
+
]
|
|
2830
|
+
}).trim();
|
|
2831
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
2832
|
+
} catch {
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
function realPath(target) {
|
|
2837
|
+
try {
|
|
2838
|
+
return node_fs.default.realpathSync(target);
|
|
2839
|
+
} catch {
|
|
2840
|
+
return target;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
function resolveAgentDirectory(env) {
|
|
2844
|
+
const configured = env[PI_CODING_AGENT_DIR_ENV]?.trim();
|
|
2845
|
+
if (configured === "~") return node_os.default.homedir();
|
|
2846
|
+
if (configured?.startsWith("~/")) return node_path.default.join(node_os.default.homedir(), configured.slice(2));
|
|
2847
|
+
return configured ? node_path.default.resolve(configured) : node_path.default.join(node_os.default.homedir(), ".pi", AGENT_DIR_NAME);
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Newest mtime anywhere one level inside the session, falling back to the
|
|
2851
|
+
* directory itself. A session still being written to therefore always looks
|
|
2852
|
+
* recent, whatever state its individual records are in.
|
|
2853
|
+
*/
|
|
2854
|
+
function newestEntryTime(directory) {
|
|
2855
|
+
let newest = node_fs.default.statSync(directory).mtimeMs;
|
|
2856
|
+
for (const entry of node_fs.default.readdirSync(directory, { withFileTypes: true })) {
|
|
2857
|
+
const target = node_path.default.join(directory, entry.name);
|
|
2858
|
+
newest = Math.max(newest, node_fs.default.statSync(target).mtimeMs);
|
|
2859
|
+
if (!entry.isDirectory()) continue;
|
|
2860
|
+
for (const child of node_fs.default.readdirSync(target)) newest = Math.max(newest, node_fs.default.statSync(node_path.default.join(target, child)).mtimeMs);
|
|
2861
|
+
}
|
|
2862
|
+
return newest;
|
|
2863
|
+
}
|
|
2864
|
+
async function newestEntryTimeAsync(directory) {
|
|
2865
|
+
let newest = (await node_fs.default.promises.stat(directory)).mtimeMs;
|
|
2866
|
+
for (const entry of await node_fs.default.promises.readdir(directory, { withFileTypes: true })) {
|
|
2867
|
+
const target = node_path.default.join(directory, entry.name);
|
|
2868
|
+
newest = Math.max(newest, (await node_fs.default.promises.stat(target)).mtimeMs);
|
|
2869
|
+
if (!entry.isDirectory()) continue;
|
|
2870
|
+
for (const child of await node_fs.default.promises.readdir(target)) newest = Math.max(newest, (await node_fs.default.promises.stat(node_path.default.join(target, child))).mtimeMs);
|
|
2871
|
+
}
|
|
2872
|
+
return newest;
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* Whether any run in this session names a pi process that is still running.
|
|
2876
|
+
*
|
|
2877
|
+
* One live record is enough: records are only ever written by their owner, so a
|
|
2878
|
+
* live host pid means the session is still in use however old its files look.
|
|
2879
|
+
*/
|
|
2880
|
+
function ownerAlive(stateDirectory) {
|
|
2881
|
+
let entries;
|
|
2882
|
+
try {
|
|
2883
|
+
entries = node_fs.default.readdirSync(stateDirectory);
|
|
2884
|
+
} catch {
|
|
2885
|
+
return false;
|
|
2886
|
+
}
|
|
2887
|
+
for (const entry of entries) {
|
|
2888
|
+
if (!entry.endsWith(".json")) continue;
|
|
2889
|
+
try {
|
|
2890
|
+
const hostPid = JSON.parse(node_fs.default.readFileSync(node_path.default.join(stateDirectory, entry), FILE_ENCODING)).hostPid;
|
|
2891
|
+
if (typeof hostPid !== "number") continue;
|
|
2892
|
+
process.kill(hostPid, 0);
|
|
2893
|
+
return true;
|
|
2894
|
+
} catch {}
|
|
2895
|
+
}
|
|
2896
|
+
return false;
|
|
2897
|
+
}
|
|
2898
|
+
async function ownerAliveAsync(stateDirectory) {
|
|
2899
|
+
let entries;
|
|
2900
|
+
try {
|
|
2901
|
+
entries = await node_fs.default.promises.readdir(stateDirectory);
|
|
2902
|
+
} catch {
|
|
2903
|
+
return false;
|
|
2904
|
+
}
|
|
2905
|
+
for (const entry of entries) {
|
|
2906
|
+
if (!entry.endsWith(".json")) continue;
|
|
2907
|
+
try {
|
|
2908
|
+
const hostPid = JSON.parse(await node_fs.default.promises.readFile(node_path.default.join(stateDirectory, entry), FILE_ENCODING)).hostPid;
|
|
2909
|
+
if (typeof hostPid !== "number") continue;
|
|
2910
|
+
process.kill(hostPid, 0);
|
|
2911
|
+
return true;
|
|
2912
|
+
} catch {}
|
|
2913
|
+
}
|
|
2914
|
+
return false;
|
|
2915
|
+
}
|
|
2916
|
+
function completedAt(value) {
|
|
2917
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
2918
|
+
const record = value;
|
|
2919
|
+
if (record.state !== "completed" || typeof record.exit?.finishedAt !== "string") return void 0;
|
|
2920
|
+
const timestamp = Date.parse(record.exit.finishedAt);
|
|
2921
|
+
return Number.isFinite(timestamp) ? timestamp : void 0;
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* Completed records a sweep may delete: everything past the TTL, and everything
|
|
2925
|
+
* beyond the newest `keep`.
|
|
2926
|
+
*
|
|
2927
|
+
* The count bound is what keeps a live session from growing without limit. Its
|
|
2928
|
+
* records only reach a multi-day TTL long after the session that is still
|
|
2929
|
+
* writing them would have filled its directory, so age alone bounds nothing
|
|
2930
|
+
* while the session lasts.
|
|
2931
|
+
*/
|
|
2932
|
+
function expiredCompletedRecords(records, ttlMs, now, keep) {
|
|
2933
|
+
return [...records].sort((left, right) => right.finishedAt - left.finishedAt).filter((record, index) => index >= keep || now - record.finishedAt > ttlMs);
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Whether the directory holds no files at all, only empty subdirectories.
|
|
2937
|
+
*
|
|
2938
|
+
* A session that ran something leaves metadata behind, and a live session keeps
|
|
2939
|
+
* its lifeline socket in the state directory, so an entirely empty tree names
|
|
2940
|
+
* no owner and no history.
|
|
2941
|
+
*/
|
|
2942
|
+
function isEmptySession(directory) {
|
|
2943
|
+
for (const entry of node_fs.default.readdirSync(directory, { withFileTypes: true })) {
|
|
2944
|
+
if (!entry.isDirectory()) return false;
|
|
2945
|
+
if (node_fs.default.readdirSync(node_path.default.join(directory, entry.name)).length > 0) return false;
|
|
2946
|
+
}
|
|
2947
|
+
return true;
|
|
2948
|
+
}
|
|
2949
|
+
async function isEmptySessionAsync(directory) {
|
|
2950
|
+
for (const entry of await node_fs.default.promises.readdir(directory, { withFileTypes: true })) {
|
|
2951
|
+
if (!entry.isDirectory()) return false;
|
|
2952
|
+
if ((await node_fs.default.promises.readdir(node_path.default.join(directory, entry.name))).length > 0) return false;
|
|
2953
|
+
}
|
|
2954
|
+
return true;
|
|
2955
|
+
}
|
|
2956
|
+
/**
|
|
2957
|
+
* The root every session's runner state lives under, before any log-directory
|
|
2958
|
+
* override: `<agent dir>/doom-runner/<session id>/{runs,logs}`. A reader in
|
|
2959
|
+
* another process, such as the cockpit hub, resolves the same root from its
|
|
2960
|
+
* own environment.
|
|
2961
|
+
*/
|
|
2962
|
+
function resolveRunnerStoreDirectory(env) {
|
|
2963
|
+
return node_path.default.join(resolveAgentDirectory(env), STORE_DIR_NAME);
|
|
2964
|
+
}
|
|
2965
|
+
/** Session-scoped runner storage under the Pi agent directory. */
|
|
2966
|
+
var RunnerPaths = class {
|
|
2967
|
+
adoptedSessionId;
|
|
2968
|
+
repository;
|
|
2969
|
+
repositoryPath() {
|
|
2970
|
+
this.repository ??= realPath(git(["rev-parse", "--show-toplevel"], process.cwd()) ?? process.cwd());
|
|
2971
|
+
return this.repository;
|
|
2972
|
+
}
|
|
2973
|
+
setSessionId(sessionId) {
|
|
2974
|
+
const trimmed = sessionId.trim();
|
|
2975
|
+
if (!trimmed) throw new Error("doom-runner session id cannot be blank");
|
|
2976
|
+
this.adoptedSessionId = trimmed;
|
|
2977
|
+
}
|
|
2978
|
+
logDirectory(sessionId) {
|
|
2979
|
+
const override = process.env[LOG_DIR_ENV]?.trim();
|
|
2980
|
+
if (override) return node_path.default.resolve(override);
|
|
2981
|
+
return node_path.default.join(this.sessionDirectory(sessionId), LOG_DIR_NAME);
|
|
2982
|
+
}
|
|
2983
|
+
stateDirectory(sessionId) {
|
|
2984
|
+
const override = process.env[LOG_DIR_ENV]?.trim();
|
|
2985
|
+
if (override) return node_path.default.join(node_path.default.resolve(override), "..", STATE_DIR_NAME);
|
|
2986
|
+
return node_path.default.join(this.sessionDirectory(sessionId), STATE_DIR_NAME);
|
|
2987
|
+
}
|
|
2988
|
+
logPathFor(id, sessionId) {
|
|
2989
|
+
return node_path.default.join(this.logDirectory(sessionId), `${id}${LOG_EXTENSION}`);
|
|
2990
|
+
}
|
|
2991
|
+
rotatedLogPathFor(id, sessionId) {
|
|
2992
|
+
return `${this.logPathFor(id, sessionId)}.1`;
|
|
2993
|
+
}
|
|
2994
|
+
rawLogPathFor(id, sessionId) {
|
|
2995
|
+
return `${this.logPathFor(id, sessionId)}${RAW_SUFFIX}`;
|
|
2996
|
+
}
|
|
2997
|
+
statePathFor(id, sessionId) {
|
|
2998
|
+
return node_path.default.join(this.stateDirectory(sessionId), `${id}${STATE_EXTENSION}`);
|
|
2999
|
+
}
|
|
3000
|
+
ensureDirectories(sessionId) {
|
|
3001
|
+
node_fs.default.mkdirSync(this.logDirectory(sessionId), { recursive: true });
|
|
3002
|
+
node_fs.default.mkdirSync(this.stateDirectory(sessionId), { recursive: true });
|
|
3003
|
+
}
|
|
3004
|
+
sweepHistory(ttlMs, now = Date.now()) {
|
|
3005
|
+
const result = {
|
|
3006
|
+
removed: [],
|
|
3007
|
+
errors: []
|
|
3008
|
+
};
|
|
3009
|
+
if (process.env["DOOM_RUNNER_LOG_DIR"]?.trim()) {
|
|
3010
|
+
this.sweepStateDirectory(this.stateDirectory(), this.sessionId(), ttlMs, now, result);
|
|
3011
|
+
return result;
|
|
3012
|
+
}
|
|
3013
|
+
const root = this.storeDirectory();
|
|
3014
|
+
let sessions;
|
|
3015
|
+
try {
|
|
3016
|
+
sessions = node_fs.default.readdirSync(root);
|
|
3017
|
+
} catch (error) {
|
|
3018
|
+
if (error.code === "ENOENT") return result;
|
|
3019
|
+
result.errors.push(`Could not read runner history directory ${root}: ${String(error)}`);
|
|
3020
|
+
return result;
|
|
3021
|
+
}
|
|
3022
|
+
const active = this.currentSessionId();
|
|
3023
|
+
for (const sessionId of sessions) {
|
|
3024
|
+
if (sessionId === active) {
|
|
3025
|
+
this.sweepActiveSession(sessionId, ttlMs, now, result);
|
|
3026
|
+
continue;
|
|
3027
|
+
}
|
|
3028
|
+
this.sweepSessionDirectory(node_path.default.join(root, sessionId), ttlMs, now, result);
|
|
3029
|
+
}
|
|
3030
|
+
return result;
|
|
3031
|
+
}
|
|
3032
|
+
async sweepHistoryAsync(ttlMs, now = Date.now()) {
|
|
3033
|
+
const result = {
|
|
3034
|
+
removed: [],
|
|
3035
|
+
errors: []
|
|
3036
|
+
};
|
|
3037
|
+
if (process.env["DOOM_RUNNER_LOG_DIR"]?.trim()) {
|
|
3038
|
+
await this.sweepStateDirectoryAsync(this.stateDirectory(), this.sessionId(), ttlMs, now, result);
|
|
3039
|
+
return result;
|
|
3040
|
+
}
|
|
3041
|
+
const root = this.storeDirectory();
|
|
3042
|
+
let sessions;
|
|
3043
|
+
try {
|
|
3044
|
+
sessions = await node_fs.default.promises.readdir(root);
|
|
3045
|
+
} catch (error) {
|
|
3046
|
+
if (error.code === "ENOENT") return result;
|
|
3047
|
+
result.errors.push(`Could not read runner history directory ${root}: ${String(error)}`);
|
|
3048
|
+
return result;
|
|
3049
|
+
}
|
|
3050
|
+
const active = this.currentSessionId();
|
|
3051
|
+
for (const sessionId of sessions) {
|
|
3052
|
+
if (sessionId === active) {
|
|
3053
|
+
await this.sweepActiveSessionAsync(sessionId, ttlMs, now, result);
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
await this.sweepSessionDirectoryAsync(node_path.default.join(root, sessionId), ttlMs, now, result);
|
|
3057
|
+
}
|
|
3058
|
+
return result;
|
|
3059
|
+
}
|
|
3060
|
+
legacyDirectory() {
|
|
3061
|
+
const commonDirectory = git([
|
|
3062
|
+
"rev-parse",
|
|
3063
|
+
"--path-format=absolute",
|
|
3064
|
+
"--git-common-dir"
|
|
3065
|
+
], process.cwd());
|
|
3066
|
+
return commonDirectory ? node_path.default.join(commonDirectory, STORE_DIR_NAME) : void 0;
|
|
3067
|
+
}
|
|
3068
|
+
removeLegacyStore() {
|
|
3069
|
+
const target = this.legacyDirectory();
|
|
3070
|
+
if (!target || node_path.default.basename(target) !== "doom-runner" || !node_fs.default.existsSync(target)) return void 0;
|
|
3071
|
+
node_fs.default.rmSync(target, {
|
|
3072
|
+
recursive: true,
|
|
3073
|
+
force: true
|
|
3074
|
+
});
|
|
3075
|
+
return target;
|
|
3076
|
+
}
|
|
3077
|
+
/** The live session, if one is known, so a sweep never deletes underneath itself. */
|
|
3078
|
+
currentSessionId() {
|
|
3079
|
+
try {
|
|
3080
|
+
return this.sessionId();
|
|
3081
|
+
} catch {
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Retention inside the session that is still running.
|
|
3087
|
+
*
|
|
3088
|
+
* The whole-directory sweep cannot be pointed at the live session: it would
|
|
3089
|
+
* take the lifeline socket, the supervisor sidecars and the running runners'
|
|
3090
|
+
* own metadata with it. Per-record retention is safe there because it only
|
|
3091
|
+
* ever removes a record whose state is `completed` together with the two log
|
|
3092
|
+
* files that record names, and never the directory itself. A running record
|
|
3093
|
+
* has no `exit.finishedAt`, so it is not a candidate at any age.
|
|
3094
|
+
*/
|
|
3095
|
+
sweepActiveSession(sessionId, ttlMs, now, result) {
|
|
3096
|
+
this.sweepStateDirectory(this.stateDirectory(sessionId), sessionId, ttlMs, now, result);
|
|
3097
|
+
}
|
|
3098
|
+
async sweepActiveSessionAsync(sessionId, ttlMs, now, result) {
|
|
3099
|
+
await this.sweepStateDirectoryAsync(this.stateDirectory(sessionId), sessionId, ttlMs, now, result);
|
|
3100
|
+
}
|
|
3101
|
+
/** Removes a session's whole directory once its newest entry is past the TTL. */
|
|
3102
|
+
sweepSessionDirectory(directory, ttlMs, now, result) {
|
|
3103
|
+
try {
|
|
3104
|
+
const newest = newestEntryTime(directory);
|
|
3105
|
+
if (now - newest > 3e5 && isEmptySession(directory)) {
|
|
3106
|
+
node_fs.default.rmSync(directory, {
|
|
3107
|
+
recursive: true,
|
|
3108
|
+
force: true
|
|
3109
|
+
});
|
|
3110
|
+
result.removed.push(directory);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
if (now - newest <= ttlMs) return;
|
|
3114
|
+
if (ownerAlive(node_path.default.join(directory, "runs"))) return;
|
|
3115
|
+
node_fs.default.rmSync(directory, {
|
|
3116
|
+
recursive: true,
|
|
3117
|
+
force: true
|
|
3118
|
+
});
|
|
3119
|
+
result.removed.push(directory);
|
|
3120
|
+
} catch (error) {
|
|
3121
|
+
if (error.code !== "ENOENT") result.errors.push(`Could not sweep runner session ${directory}: ${String(error)}`);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
async sweepSessionDirectoryAsync(directory, ttlMs, now, result) {
|
|
3125
|
+
try {
|
|
3126
|
+
const newest = await newestEntryTimeAsync(directory);
|
|
3127
|
+
if (now - newest > 3e5 && await isEmptySessionAsync(directory)) {
|
|
3128
|
+
await node_fs.default.promises.rm(directory, {
|
|
3129
|
+
recursive: true,
|
|
3130
|
+
force: true
|
|
3131
|
+
});
|
|
3132
|
+
result.removed.push(directory);
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
if (now - newest <= ttlMs) return;
|
|
3136
|
+
if (await ownerAliveAsync(node_path.default.join(directory, "runs"))) return;
|
|
3137
|
+
await node_fs.default.promises.rm(directory, {
|
|
3138
|
+
recursive: true,
|
|
3139
|
+
force: true
|
|
3140
|
+
});
|
|
3141
|
+
result.removed.push(directory);
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
if (error.code !== "ENOENT") result.errors.push(`Could not sweep runner session ${directory}: ${String(error)}`);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
sessionId(explicit) {
|
|
3147
|
+
const sessionId = explicit?.trim() || this.adoptedSessionId || inheritedSessionId();
|
|
3148
|
+
if (!sessionId) throw new Error("PI_SESSION_ID is required for doom-runner storage");
|
|
3149
|
+
return sessionId;
|
|
3150
|
+
}
|
|
3151
|
+
storeDirectory() {
|
|
3152
|
+
const override = process.env[LOG_DIR_ENV]?.trim();
|
|
3153
|
+
if (override) return node_path.default.dirname(node_path.default.resolve(override));
|
|
3154
|
+
return resolveRunnerStoreDirectory(process.env);
|
|
3155
|
+
}
|
|
3156
|
+
sessionDirectory(sessionId) {
|
|
3157
|
+
return node_path.default.join(this.storeDirectory(), this.sessionId(sessionId));
|
|
3158
|
+
}
|
|
3159
|
+
sweepStateDirectory(directory, sessionId, ttlMs, now, result) {
|
|
3160
|
+
let entries;
|
|
3161
|
+
try {
|
|
3162
|
+
entries = node_fs.default.readdirSync(directory);
|
|
3163
|
+
} catch (error) {
|
|
3164
|
+
if (error.code !== "ENOENT") result.errors.push(`Could not read runner state directory ${directory}: ${String(error)}`);
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
const completed = [];
|
|
3168
|
+
for (const entry of entries) {
|
|
3169
|
+
if (!entry.endsWith(".json")) continue;
|
|
3170
|
+
const statePath = node_path.default.join(directory, entry);
|
|
3171
|
+
try {
|
|
3172
|
+
const finishedAt = completedAt(JSON.parse(node_fs.default.readFileSync(statePath, FILE_ENCODING)));
|
|
3173
|
+
if (finishedAt === void 0) continue;
|
|
3174
|
+
completed.push({
|
|
3175
|
+
id: entry.slice(0, -5),
|
|
3176
|
+
statePath,
|
|
3177
|
+
finishedAt
|
|
3178
|
+
});
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
result.errors.push(`Could not sweep runner metadata ${statePath}: ${String(error)}`);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
for (const record of expiredCompletedRecords(completed, ttlMs, now, getMaxCompletedRunners())) this.removeHistoryFiles(sessionId, record.id, record.statePath, result);
|
|
3184
|
+
}
|
|
3185
|
+
async sweepStateDirectoryAsync(directory, sessionId, ttlMs, now, result) {
|
|
3186
|
+
let entries;
|
|
3187
|
+
try {
|
|
3188
|
+
entries = await node_fs.default.promises.readdir(directory);
|
|
3189
|
+
} catch (error) {
|
|
3190
|
+
if (error.code !== "ENOENT") result.errors.push(`Could not read runner state directory ${directory}: ${String(error)}`);
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
const completed = [];
|
|
3194
|
+
for (const entry of entries) {
|
|
3195
|
+
if (!entry.endsWith(".json")) continue;
|
|
3196
|
+
const statePath = node_path.default.join(directory, entry);
|
|
3197
|
+
try {
|
|
3198
|
+
const finishedAt = completedAt(JSON.parse(await node_fs.default.promises.readFile(statePath, FILE_ENCODING)));
|
|
3199
|
+
if (finishedAt === void 0) continue;
|
|
3200
|
+
completed.push({
|
|
3201
|
+
id: entry.slice(0, -5),
|
|
3202
|
+
statePath,
|
|
3203
|
+
finishedAt
|
|
3204
|
+
});
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
result.errors.push(`Could not sweep runner metadata ${statePath}: ${String(error)}`);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
for (const record of expiredCompletedRecords(completed, ttlMs, now, getMaxCompletedRunners())) await this.removeHistoryFilesAsync(sessionId, record.id, record.statePath, result);
|
|
3210
|
+
}
|
|
3211
|
+
removeHistoryFiles(sessionId, id, statePath, result) {
|
|
3212
|
+
const candidates = [
|
|
3213
|
+
statePath,
|
|
3214
|
+
this.logPathFor(id, sessionId),
|
|
3215
|
+
this.rotatedLogPathFor(id, sessionId),
|
|
3216
|
+
this.rawLogPathFor(id, sessionId)
|
|
3217
|
+
];
|
|
3218
|
+
for (const candidate of candidates) try {
|
|
3219
|
+
node_fs.default.unlinkSync(candidate);
|
|
3220
|
+
result.removed.push(candidate);
|
|
3221
|
+
} catch (error) {
|
|
3222
|
+
if (error.code !== "ENOENT") result.errors.push(`Could not remove runner history ${candidate}: ${String(error)}`);
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
async removeHistoryFilesAsync(sessionId, id, statePath, result) {
|
|
3226
|
+
const candidates = [
|
|
3227
|
+
statePath,
|
|
3228
|
+
this.logPathFor(id, sessionId),
|
|
3229
|
+
this.rotatedLogPathFor(id, sessionId)
|
|
3230
|
+
];
|
|
3231
|
+
for (const candidate of candidates) try {
|
|
3232
|
+
await node_fs.default.promises.unlink(candidate);
|
|
3233
|
+
result.removed.push(candidate);
|
|
3234
|
+
} catch (error) {
|
|
3235
|
+
if (error.code !== "ENOENT") result.errors.push(`Could not remove runner history ${candidate}: ${String(error)}`);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
//#endregion
|
|
3240
|
+
//#region src/constants/runnerRegistry.ts
|
|
3241
|
+
const SERVICE_TYPE = "tool";
|
|
3242
|
+
const SERVICE_PREFIX = "doom-runner:";
|
|
3243
|
+
const RUNNER_TAG = "doom-runner";
|
|
3244
|
+
const REGISTRY_PATH_ENV = "PROCESS_REGISTRY_PATH";
|
|
3245
|
+
//#endregion
|
|
3246
|
+
//#region src/services/runnerRecord/index.ts
|
|
3247
|
+
/** The disk record's shape guard, shared by the registry and every reader of its files. */
|
|
3248
|
+
function isRunnerRecord(value) {
|
|
3249
|
+
if (typeof value !== "object" || value === null) return false;
|
|
3250
|
+
const record = value;
|
|
3251
|
+
return typeof record.id === "string" && typeof record.name === "string" && typeof record.pid === "number" && typeof record.command === "string" && typeof record.cwd === "string" && typeof record.logPath === "string" && typeof record.sessionId === "string" && typeof record.startedAt === "string" && (record.state === "running" || record.state === "completed") && typeof record.promoted === "boolean" && (record.backend === "rmux" || record.backend === "tmux" || record.backend === "native");
|
|
3252
|
+
}
|
|
3253
|
+
//#endregion
|
|
3254
|
+
//#region src/services/runnerRegistry/index.ts
|
|
3255
|
+
/** Opens the SQLite-backed registry the rest of the monorepo already shares. */
|
|
3256
|
+
function createDefaultProcessRegistry() {
|
|
3257
|
+
return (0, _agimon_ai_foundation_process_registry.createProcessRegistryService)(process.env[REGISTRY_PATH_ENV]);
|
|
3258
|
+
}
|
|
3259
|
+
function sessionTag(sessionId) {
|
|
3260
|
+
return `session:${sessionId}`;
|
|
3261
|
+
}
|
|
3262
|
+
function readString(record, key) {
|
|
3263
|
+
const value = record.metadata?.[key];
|
|
3264
|
+
return typeof value === "string" ? value : "";
|
|
3265
|
+
}
|
|
3266
|
+
function readNumber(record, key) {
|
|
3267
|
+
const value = record.metadata?.[key];
|
|
3268
|
+
return typeof value === "number" ? value : 0;
|
|
3269
|
+
}
|
|
3270
|
+
const runCommand = (0, node_util.promisify)(node_child_process.execFile);
|
|
3271
|
+
/**
|
|
3272
|
+
* The process start time the registry stores to prove a pid is still the
|
|
3273
|
+
* process it registered.
|
|
3274
|
+
*
|
|
3275
|
+
* The registry resolves it itself when the caller omits it, and it does so with
|
|
3276
|
+
* `execFileSync`, which stops this process for the length of a `ps` on every
|
|
3277
|
+
* runner that starts. Reading it here costs the same subprocess and blocks
|
|
3278
|
+
* nothing: the command is already running by the time a runner is registered.
|
|
3279
|
+
*
|
|
3280
|
+
* The value has to match what the registry would have read, so the command and
|
|
3281
|
+
* the trimming are deliberately identical to its own.
|
|
3282
|
+
*/
|
|
3283
|
+
async function processStartedAt(pid) {
|
|
3284
|
+
if (process.platform === "win32") return void 0;
|
|
3285
|
+
try {
|
|
3286
|
+
const { stdout } = await runCommand("ps", [
|
|
3287
|
+
"-o",
|
|
3288
|
+
"lstart=",
|
|
3289
|
+
"-p",
|
|
3290
|
+
String(pid)
|
|
3291
|
+
], { encoding: "utf8" });
|
|
3292
|
+
return stdout.trim() || void 0;
|
|
3293
|
+
} catch {
|
|
3294
|
+
return;
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
const listenersBySession = /* @__PURE__ */ new Map();
|
|
3298
|
+
var RunnerRegistry = class {
|
|
3299
|
+
paths;
|
|
3300
|
+
processControl;
|
|
3301
|
+
registry;
|
|
3302
|
+
environment;
|
|
3303
|
+
subscriptions = /* @__PURE__ */ new Set();
|
|
3304
|
+
constructor(paths, processControl, registry, environment) {
|
|
3305
|
+
this.paths = paths;
|
|
3306
|
+
this.processControl = processControl;
|
|
3307
|
+
this.registry = registry;
|
|
3308
|
+
this.environment = environment;
|
|
3309
|
+
}
|
|
3310
|
+
async register(input) {
|
|
3311
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3312
|
+
const rootSessionId = (0, _agimon_ai_doompi_core_child_process.resolveRootSessionId)(input.sessionId, this.environment);
|
|
3313
|
+
const identity = await processStartedAt(input.pid);
|
|
3314
|
+
const response = await this.registry.registerProcess({
|
|
3315
|
+
repositoryPath: this.repositoryPath(),
|
|
3316
|
+
serviceName: `${SERVICE_PREFIX}${input.id}`,
|
|
3317
|
+
serviceType: SERVICE_TYPE,
|
|
3318
|
+
pid: input.pid,
|
|
3319
|
+
command: input.command,
|
|
3320
|
+
...identity === void 0 ? {} : { processStartedAt: identity },
|
|
3321
|
+
tags: (0, _agimon_ai_foundation_process_registry.resolveProcessTags)([
|
|
3322
|
+
RUNNER_TAG,
|
|
3323
|
+
`runner:${input.id}`,
|
|
3324
|
+
sessionTag(input.sessionId)
|
|
3325
|
+
]),
|
|
3326
|
+
metadata: {
|
|
3327
|
+
id: input.id,
|
|
3328
|
+
name: input.name,
|
|
3329
|
+
command: input.command,
|
|
3330
|
+
cwd: input.cwd,
|
|
3331
|
+
logPath: input.logPath,
|
|
3332
|
+
interactive: input.interactive,
|
|
3333
|
+
sessionId: input.sessionId,
|
|
3334
|
+
rootSessionId,
|
|
3335
|
+
backend: input.backend,
|
|
3336
|
+
...input.backendTarget ? { backendTarget: input.backendTarget } : {},
|
|
3337
|
+
startedAt,
|
|
3338
|
+
hostPid: process.pid
|
|
3339
|
+
},
|
|
3340
|
+
force: true
|
|
3341
|
+
});
|
|
3342
|
+
if (!response.success) throw new Error(response.error ?? `Failed to register runner ${input.name}`);
|
|
3343
|
+
const record = {
|
|
3344
|
+
id: input.id,
|
|
3345
|
+
name: input.name,
|
|
3346
|
+
pid: input.pid,
|
|
3347
|
+
command: input.command,
|
|
3348
|
+
cwd: input.cwd,
|
|
3349
|
+
logPath: input.logPath,
|
|
3350
|
+
interactive: input.interactive,
|
|
3351
|
+
sessionId: input.sessionId,
|
|
3352
|
+
rootSessionId,
|
|
3353
|
+
startedAt,
|
|
3354
|
+
state: "running",
|
|
3355
|
+
promoted: false,
|
|
3356
|
+
backend: input.backend,
|
|
3357
|
+
...input.backendTarget ? { backendTarget: input.backendTarget } : {},
|
|
3358
|
+
hostPid: process.pid
|
|
3359
|
+
};
|
|
3360
|
+
this.writeRecord(record);
|
|
3361
|
+
this.notify(record.sessionId);
|
|
3362
|
+
return record;
|
|
3363
|
+
}
|
|
3364
|
+
async list() {
|
|
3365
|
+
return (await this.registry.listProcesses({
|
|
3366
|
+
repositoryPath: this.repositoryPath(),
|
|
3367
|
+
serviceType: SERVICE_TYPE,
|
|
3368
|
+
tags: (0, _agimon_ai_foundation_process_registry.resolveProcessTags)([RUNNER_TAG])
|
|
3369
|
+
})).map(toRunnerRecord);
|
|
3370
|
+
}
|
|
3371
|
+
async listAcrossRepositories() {
|
|
3372
|
+
return (await this.registry.listProcesses({
|
|
3373
|
+
serviceType: SERVICE_TYPE,
|
|
3374
|
+
tags: (0, _agimon_ai_foundation_process_registry.resolveProcessTags)([RUNNER_TAG])
|
|
3375
|
+
})).map(toRunnerRecord);
|
|
3376
|
+
}
|
|
3377
|
+
async listBySession(sessionId) {
|
|
3378
|
+
return (await this.list()).filter((record) => record.sessionId === sessionId);
|
|
3379
|
+
}
|
|
3380
|
+
async listByRootSession(rootSessionId) {
|
|
3381
|
+
return (await this.list()).filter((record) => (record.rootSessionId ?? record.sessionId) === rootSessionId);
|
|
3382
|
+
}
|
|
3383
|
+
async listAll(sessionId) {
|
|
3384
|
+
this.paths.ensureDirectories(sessionId);
|
|
3385
|
+
const records = [];
|
|
3386
|
+
const directory = this.paths.stateDirectory(sessionId);
|
|
3387
|
+
for (const entry of node_fs.default.readdirSync(directory)) {
|
|
3388
|
+
if (!isPrimaryMetadataEntry(entry)) continue;
|
|
3389
|
+
try {
|
|
3390
|
+
const parsed = JSON.parse(node_fs.default.readFileSync(`${directory}/${entry}`, "utf8"));
|
|
3391
|
+
if (isRunnerRecord(parsed)) records.push(parsed);
|
|
3392
|
+
} catch (error) {
|
|
3393
|
+
process.emitWarning(`Could not read runner metadata ${entry}: ${String(error)}`);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
|
3397
|
+
}
|
|
3398
|
+
async get(id, sessionId) {
|
|
3399
|
+
const target = this.paths.statePathFor(id, sessionId);
|
|
3400
|
+
try {
|
|
3401
|
+
const parsed = JSON.parse(node_fs.default.readFileSync(target, "utf8"));
|
|
3402
|
+
if (!isRunnerRecord(parsed) || parsed.id !== id) {
|
|
3403
|
+
process.emitWarning(`Could not read runner metadata ${node_path.default.basename(target)}: invalid runner record`);
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
return parsed;
|
|
3407
|
+
} catch (error) {
|
|
3408
|
+
if (error.code === "ENOENT") return void 0;
|
|
3409
|
+
process.emitWarning(`Could not read runner metadata ${node_path.default.basename(target)}: ${String(error)}`);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
async markPromoted(id) {
|
|
3414
|
+
const record = await this.get(id);
|
|
3415
|
+
if (!record) return void 0;
|
|
3416
|
+
const promoted = {
|
|
3417
|
+
...record,
|
|
3418
|
+
promoted: true
|
|
3419
|
+
};
|
|
3420
|
+
this.writeRecord(promoted);
|
|
3421
|
+
this.notify(promoted.sessionId);
|
|
3422
|
+
return promoted;
|
|
3423
|
+
}
|
|
3424
|
+
async complete(id, outcome, sessionId) {
|
|
3425
|
+
const persisted = await this.get(id, sessionId);
|
|
3426
|
+
if (persisted?.state === "completed") {
|
|
3427
|
+
await this.release(id, persisted.sessionId);
|
|
3428
|
+
return persisted;
|
|
3429
|
+
}
|
|
3430
|
+
const active = await this.list();
|
|
3431
|
+
const record = persisted ?? active.find((candidate) => candidate.id === id && (sessionId === void 0 || candidate.sessionId === sessionId));
|
|
3432
|
+
if (!record) {
|
|
3433
|
+
if (!(sessionId !== void 0 && active.some((candidate) => candidate.id === id && candidate.sessionId !== sessionId))) await this.release(id, sessionId);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
const completed = {
|
|
3437
|
+
...record,
|
|
3438
|
+
state: "completed",
|
|
3439
|
+
exit: {
|
|
3440
|
+
...outcome,
|
|
3441
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
this.writeRecord(completed);
|
|
3445
|
+
await this.release(id, completed.sessionId);
|
|
3446
|
+
return completed;
|
|
3447
|
+
}
|
|
3448
|
+
async release(id, sessionId) {
|
|
3449
|
+
const ownedSessionId = sessionId ?? (await this.list()).find((record) => record.id === id)?.sessionId ?? (await this.get(id))?.sessionId;
|
|
3450
|
+
const response = await this.registry.releaseProcess({
|
|
3451
|
+
repositoryPath: this.repositoryPath(),
|
|
3452
|
+
serviceName: `${SERVICE_PREFIX}${id}`,
|
|
3453
|
+
serviceType: SERVICE_TYPE,
|
|
3454
|
+
kill: false,
|
|
3455
|
+
releasePort: false,
|
|
3456
|
+
force: true
|
|
3457
|
+
});
|
|
3458
|
+
if (!response.success && !response.error?.includes("No matching process entry")) throw new Error(response.error ?? `Failed to release runner ${id}`);
|
|
3459
|
+
if (ownedSessionId !== void 0) this.notify(ownedSessionId);
|
|
3460
|
+
}
|
|
3461
|
+
async pruneDead() {
|
|
3462
|
+
const dead = (await this.list()).filter((record) => !this.processControl.isAlive(record.pid));
|
|
3463
|
+
for (const record of dead) await this.complete(record.id, {
|
|
3464
|
+
reason: "backend_lost",
|
|
3465
|
+
code: null,
|
|
3466
|
+
signal: null
|
|
3467
|
+
}, record.sessionId);
|
|
3468
|
+
return dead.map((record) => record.id);
|
|
3469
|
+
}
|
|
3470
|
+
subscribe(listener, sessionId) {
|
|
3471
|
+
const subscribed = () => listener();
|
|
3472
|
+
const registration = {
|
|
3473
|
+
key: this.subscriptionKey(sessionId),
|
|
3474
|
+
listener: subscribed
|
|
3475
|
+
};
|
|
3476
|
+
const listeners = listenersBySession.get(registration.key) ?? /* @__PURE__ */ new Set();
|
|
3477
|
+
listeners.add(subscribed);
|
|
3478
|
+
listenersBySession.set(registration.key, listeners);
|
|
3479
|
+
this.subscriptions.add(registration);
|
|
3480
|
+
return () => this.removeSubscription(registration);
|
|
3481
|
+
}
|
|
3482
|
+
close() {
|
|
3483
|
+
for (const registration of this.subscriptions) this.removeSubscription(registration);
|
|
3484
|
+
this.registry.close();
|
|
3485
|
+
}
|
|
3486
|
+
notify(sessionId) {
|
|
3487
|
+
for (const listener of listenersBySession.get(this.subscriptionKey(sessionId)) ?? []) try {
|
|
3488
|
+
listener();
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
process.emitWarning(`Runner registry listener failed: ${String(error)}`);
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
removeSubscription(registration) {
|
|
3494
|
+
const listeners = listenersBySession.get(registration.key);
|
|
3495
|
+
listeners?.delete(registration.listener);
|
|
3496
|
+
if (listeners?.size === 0) listenersBySession.delete(registration.key);
|
|
3497
|
+
this.subscriptions.delete(registration);
|
|
3498
|
+
}
|
|
3499
|
+
subscriptionKey(sessionId) {
|
|
3500
|
+
return JSON.stringify([node_path.default.resolve(this.paths.stateDirectory(sessionId)), sessionId]);
|
|
3501
|
+
}
|
|
3502
|
+
repositoryPath() {
|
|
3503
|
+
return (0, _agimon_ai_foundation_process_registry.normalizeRepositoryPath)(this.paths.repositoryPath());
|
|
3504
|
+
}
|
|
3505
|
+
writeRecord(record) {
|
|
3506
|
+
this.paths.ensureDirectories(record.sessionId);
|
|
3507
|
+
const target = this.paths.statePathFor(record.id, record.sessionId);
|
|
3508
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
3509
|
+
node_fs.default.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
3510
|
+
node_fs.default.renameSync(temporary, target);
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
/** Every field is read back from metadata this class wrote on registration. */
|
|
3514
|
+
function toRunnerRecord(record) {
|
|
3515
|
+
return {
|
|
3516
|
+
id: readString(record, "id"),
|
|
3517
|
+
name: readString(record, "name"),
|
|
3518
|
+
pid: record.pid,
|
|
3519
|
+
command: readString(record, "command"),
|
|
3520
|
+
cwd: readString(record, "cwd"),
|
|
3521
|
+
logPath: readString(record, "logPath"),
|
|
3522
|
+
interactive: record.metadata?.interactive === true,
|
|
3523
|
+
sessionId: readString(record, "sessionId"),
|
|
3524
|
+
...readString(record, "rootSessionId") ? { rootSessionId: readString(record, "rootSessionId") } : {},
|
|
3525
|
+
startedAt: readString(record, "startedAt"),
|
|
3526
|
+
state: "running",
|
|
3527
|
+
promoted: false,
|
|
3528
|
+
backend: readBackend(record.metadata?.backend),
|
|
3529
|
+
...readString(record, "backendTarget") ? { backendTarget: readString(record, "backendTarget") } : {},
|
|
3530
|
+
hostPid: readNumber(record, "hostPid")
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function readBackend(value) {
|
|
3534
|
+
return value === "rmux" || value === "tmux" ? value : "native";
|
|
3535
|
+
}
|
|
3536
|
+
function isPrimaryMetadataEntry(entry) {
|
|
3537
|
+
return entry.endsWith(".json") && !entry.endsWith(".command.json") && !entry.endsWith(".exit.json");
|
|
3538
|
+
}
|
|
3539
|
+
//#endregion
|
|
3540
|
+
//#region src/constants/spawner.ts
|
|
3541
|
+
const SHELL = "/bin/bash";
|
|
3542
|
+
//#endregion
|
|
3543
|
+
//#region src/services/spawner/index.ts
|
|
3544
|
+
/** Wraps `node:child_process.spawn`. */
|
|
3545
|
+
var NodeSpawner = class {
|
|
3546
|
+
spawn(request) {
|
|
3547
|
+
const child = (0, node_child_process.spawn)(request.command, {
|
|
3548
|
+
cwd: request.cwd,
|
|
3549
|
+
env: request.env,
|
|
3550
|
+
detached: request.detached,
|
|
3551
|
+
shell: SHELL,
|
|
3552
|
+
stdio: [
|
|
3553
|
+
"ignore",
|
|
3554
|
+
"pipe",
|
|
3555
|
+
"pipe"
|
|
3556
|
+
]
|
|
3557
|
+
});
|
|
3558
|
+
child.stdout?.setEncoding("utf8");
|
|
3559
|
+
child.stderr?.setEncoding("utf8");
|
|
3560
|
+
return {
|
|
3561
|
+
pid: child.pid,
|
|
3562
|
+
onOutput(handler) {
|
|
3563
|
+
child.stdout?.on("data", (chunk) => handler(chunk, "stdout"));
|
|
3564
|
+
child.stderr?.on("data", (chunk) => handler(chunk, "stderr"));
|
|
3565
|
+
},
|
|
3566
|
+
onExit(handler) {
|
|
3567
|
+
let finished = false;
|
|
3568
|
+
let drainTimer;
|
|
3569
|
+
const finish = (result) => {
|
|
3570
|
+
if (finished) return;
|
|
3571
|
+
finished = true;
|
|
3572
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
3573
|
+
handler(result);
|
|
3574
|
+
};
|
|
3575
|
+
child.once("exit", (code, signal) => {
|
|
3576
|
+
drainTimer = setTimeout(() => finish({
|
|
3577
|
+
code,
|
|
3578
|
+
signal
|
|
3579
|
+
}), 250);
|
|
3580
|
+
});
|
|
3581
|
+
child.once("close", (code, signal) => finish({
|
|
3582
|
+
code,
|
|
3583
|
+
signal
|
|
3584
|
+
}));
|
|
3585
|
+
},
|
|
3586
|
+
onError(handler) {
|
|
3587
|
+
child.on("error", handler);
|
|
3588
|
+
},
|
|
3589
|
+
kill(signal) {
|
|
3590
|
+
child.kill(signal);
|
|
3591
|
+
},
|
|
3592
|
+
unref() {
|
|
3593
|
+
child.unref();
|
|
3594
|
+
}
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
};
|
|
3598
|
+
//#endregion
|
|
3599
|
+
//#region src/services/tmuxBackend/TmuxClient.ts
|
|
3600
|
+
const TMUX_BINARY = "tmux";
|
|
3601
|
+
const SOCKET_OPTION = "-L";
|
|
3602
|
+
/** Long enough for a loaded server, short enough that a hung tmux is not fatal. */
|
|
3603
|
+
const COMMAND_TIMEOUT_MS = 1e4;
|
|
3604
|
+
const MAX_OUTPUT_BYTES = 4194304;
|
|
3605
|
+
var TmuxClient = class {
|
|
3606
|
+
socket;
|
|
3607
|
+
binary;
|
|
3608
|
+
constructor(socket, binary = TMUX_BINARY) {
|
|
3609
|
+
this.socket = socket;
|
|
3610
|
+
this.binary = binary;
|
|
3611
|
+
}
|
|
3612
|
+
run(args) {
|
|
3613
|
+
return new Promise((resolve, reject) => {
|
|
3614
|
+
(0, node_child_process.execFile)(this.binary, [
|
|
3615
|
+
SOCKET_OPTION,
|
|
3616
|
+
this.socket,
|
|
3617
|
+
...args
|
|
3618
|
+
], {
|
|
3619
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
3620
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
3621
|
+
encoding: "utf8"
|
|
3622
|
+
}, (error, stdout, stderr) => {
|
|
3623
|
+
const code = error?.code;
|
|
3624
|
+
if (error && typeof code !== "number") {
|
|
3625
|
+
reject(error);
|
|
3626
|
+
return;
|
|
3627
|
+
}
|
|
3628
|
+
resolve({
|
|
3629
|
+
returnCode: typeof code === "number" ? code : 0,
|
|
3630
|
+
stdout,
|
|
3631
|
+
stderr
|
|
3632
|
+
});
|
|
3633
|
+
});
|
|
3634
|
+
});
|
|
3635
|
+
}
|
|
3636
|
+
async format(target, format) {
|
|
3637
|
+
const result = await this.run([
|
|
3638
|
+
"display-message",
|
|
3639
|
+
"-p",
|
|
3640
|
+
"-t",
|
|
3641
|
+
target,
|
|
3642
|
+
"-F",
|
|
3643
|
+
format
|
|
3644
|
+
]).catch(() => void 0);
|
|
3645
|
+
if (!result || result.returnCode !== 0) return void 0;
|
|
3646
|
+
return result.stdout.replace(/\n$/, "");
|
|
3647
|
+
}
|
|
3648
|
+
async sessionMissing(target) {
|
|
3649
|
+
const result = await this.run([
|
|
3650
|
+
"has-session",
|
|
3651
|
+
"-t",
|
|
3652
|
+
target
|
|
3653
|
+
]).catch(() => void 0);
|
|
3654
|
+
return !result || result.returnCode !== 0;
|
|
3655
|
+
}
|
|
3656
|
+
};
|
|
3657
|
+
//#endregion
|
|
3658
|
+
//#region src/services/tmuxBackend/index.ts
|
|
3659
|
+
/** Pane liveness, its exit status, and whether the session still exists, in one read. */
|
|
3660
|
+
/** Distinct from the RMUX prefix so a target names the backend that owns it. */
|
|
3661
|
+
/**
|
|
3662
|
+
* Supervises runs in tmux panes, for hosts with no compatible RMUX binary.
|
|
3663
|
+
*
|
|
3664
|
+
* The pane runs the same runnerHost supervisor as the RMUX backend, so exit
|
|
3665
|
+
* metadata, log rotation, and lifeline handling are shared rather than
|
|
3666
|
+
* reimplemented: only the multiplexer differs. Kept beside the RMUX backend
|
|
3667
|
+
* rather than folded into it because that path has no unit coverage to
|
|
3668
|
+
* refactor against.
|
|
3669
|
+
*/
|
|
3670
|
+
var TmuxBackend = class {
|
|
3671
|
+
paths;
|
|
3672
|
+
createClient;
|
|
3673
|
+
clientPromise;
|
|
3674
|
+
runs = /* @__PURE__ */ new Map();
|
|
3675
|
+
completions = /* @__PURE__ */ new Map();
|
|
3676
|
+
constructor(paths, createClient = (socket) => new TmuxClient(socket)) {
|
|
3677
|
+
this.paths = paths;
|
|
3678
|
+
this.createClient = createClient;
|
|
3679
|
+
}
|
|
3680
|
+
async launch(request) {
|
|
3681
|
+
const tmux = await this.client();
|
|
3682
|
+
if (!tmux) return void 0;
|
|
3683
|
+
this.paths.ensureDirectories();
|
|
3684
|
+
const target = `${SESSION_PREFIX$1}${request.id}`;
|
|
3685
|
+
const logPath = this.paths.logPathFor(request.id);
|
|
3686
|
+
const aux = this.auxPaths(request.id, request.sessionId);
|
|
3687
|
+
let created = false;
|
|
3688
|
+
let released = false;
|
|
3689
|
+
try {
|
|
3690
|
+
this.prepareFiles(request, logPath, aux);
|
|
3691
|
+
const supervisor = shellJoin([
|
|
3692
|
+
process.execPath,
|
|
3693
|
+
runtimeEntry("runnerHost"),
|
|
3694
|
+
aux.spec,
|
|
3695
|
+
aux.gate,
|
|
3696
|
+
aux.exit
|
|
3697
|
+
]);
|
|
3698
|
+
await this.checked(tmux, [
|
|
3699
|
+
"new-session",
|
|
3700
|
+
"-d",
|
|
3701
|
+
"-s",
|
|
3702
|
+
target,
|
|
3703
|
+
"-c",
|
|
3704
|
+
request.cwd,
|
|
3705
|
+
supervisor
|
|
3706
|
+
]);
|
|
3707
|
+
created = true;
|
|
3708
|
+
await this.checked(tmux, [
|
|
3709
|
+
"set-option",
|
|
3710
|
+
"-t",
|
|
3711
|
+
target,
|
|
3712
|
+
"remain-on-exit",
|
|
3713
|
+
"on"
|
|
3714
|
+
]);
|
|
3715
|
+
await this.checked(tmux, [
|
|
3716
|
+
"pipe-pane",
|
|
3717
|
+
"-t",
|
|
3718
|
+
target,
|
|
3719
|
+
this.logPipeCommand(logPath, aux.logDone, request)
|
|
3720
|
+
]);
|
|
3721
|
+
const pid = await panePid(tmux, target);
|
|
3722
|
+
node_fs.default.writeFileSync(aux.gate, "", { mode: 384 });
|
|
3723
|
+
released = true;
|
|
3724
|
+
let buffering = true;
|
|
3725
|
+
const completion = this.monitor(tmux, target, aux);
|
|
3726
|
+
this.trackCompletion(target, completion);
|
|
3727
|
+
const handle = {
|
|
3728
|
+
id: request.id,
|
|
3729
|
+
name: request.name,
|
|
3730
|
+
pid,
|
|
3731
|
+
logPath,
|
|
3732
|
+
backend: "tmux",
|
|
3733
|
+
backendTarget: target,
|
|
3734
|
+
output: () => buffering ? readTail(logPath, getResultMaxBytes() * 2) : "",
|
|
3735
|
+
completion: () => completion,
|
|
3736
|
+
detach: () => {
|
|
3737
|
+
buffering = false;
|
|
3738
|
+
},
|
|
3739
|
+
stop: () => this.stop(target, pid)
|
|
3740
|
+
};
|
|
3741
|
+
if (request.interactive) this.runs.set(request.name, this.interactiveRun(handle, tmux, target, completion));
|
|
3742
|
+
completion.finally(() => this.runs.delete(request.name)).catch((error) => process.emitWarning(`tmux completion failed for ${target}: ${errorMessage(error)}`));
|
|
3743
|
+
return handle;
|
|
3744
|
+
} catch (error) {
|
|
3745
|
+
if (released) throw error;
|
|
3746
|
+
if (created) await killSession(tmux, target);
|
|
3747
|
+
cleanupSupervisorFiles(aux);
|
|
3748
|
+
process.emitWarning(request.interactive ? `tmux launch unavailable for interactive runner: ${errorMessage(error)}` : `tmux launch unavailable, using supervised subprocess: ${errorMessage(error)}`);
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
async stop(target, expectedPid) {
|
|
3753
|
+
if (!OWNED_TARGET_PATTERN$1.test(target) || !Number.isInteger(expectedPid) || expectedPid <= 0) return false;
|
|
3754
|
+
const tmux = await this.client();
|
|
3755
|
+
if (!tmux) return false;
|
|
3756
|
+
try {
|
|
3757
|
+
if (await tmux.sessionMissing(target)) return true;
|
|
3758
|
+
const pid = await panePid(tmux, target);
|
|
3759
|
+
if (pid !== expectedPid) return false;
|
|
3760
|
+
const signalled = signalPane(pid, target);
|
|
3761
|
+
if (signalled === "gone") return true;
|
|
3762
|
+
if (signalled === "failed") return false;
|
|
3763
|
+
if (await waitForPaneStop(tmux, target, 3e3)) return true;
|
|
3764
|
+
if (await tmux.sessionMissing(target)) return true;
|
|
3765
|
+
if (await panePid(tmux, target).catch(() => void 0) !== expectedPid) return false;
|
|
3766
|
+
return killSession(tmux, target);
|
|
3767
|
+
} catch (error) {
|
|
3768
|
+
process.emitWarning(`Could not stop tmux target ${target}: ${errorMessage(error)}`);
|
|
3769
|
+
return false;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
async watch(id, target, sessionId) {
|
|
3773
|
+
const tmux = await this.client();
|
|
3774
|
+
if (!tmux) return void 0;
|
|
3775
|
+
if (await tmux.sessionMissing(target)) return void 0;
|
|
3776
|
+
const completion = this.monitor(tmux, target, this.auxPaths(id, sessionId));
|
|
3777
|
+
this.trackCompletion(target, completion);
|
|
3778
|
+
return completion;
|
|
3779
|
+
}
|
|
3780
|
+
async input(target, text) {
|
|
3781
|
+
const tmux = await this.client();
|
|
3782
|
+
if (!tmux) return false;
|
|
3783
|
+
return (await tmux.run([
|
|
3784
|
+
"send-keys",
|
|
3785
|
+
"-t",
|
|
3786
|
+
target,
|
|
3787
|
+
"-l",
|
|
3788
|
+
text
|
|
3789
|
+
]).catch((error) => {
|
|
3790
|
+
process.emitWarning(`Could not send input to tmux target ${target}: ${errorMessage(error)}`);
|
|
3791
|
+
}))?.returnCode === 0;
|
|
3792
|
+
}
|
|
3793
|
+
async capture(target) {
|
|
3794
|
+
const tmux = await this.client();
|
|
3795
|
+
if (!tmux) return void 0;
|
|
3796
|
+
const result = await tmux.run([
|
|
3797
|
+
"capture-pane",
|
|
3798
|
+
"-p",
|
|
3799
|
+
"-t",
|
|
3800
|
+
target
|
|
3801
|
+
]).catch((error) => {
|
|
3802
|
+
process.emitWarning(`Could not capture tmux target ${target}: ${errorMessage(error)}`);
|
|
3803
|
+
});
|
|
3804
|
+
return result?.returnCode === 0 ? result.stdout : void 0;
|
|
3805
|
+
}
|
|
3806
|
+
get(name) {
|
|
3807
|
+
return this.runs.get(name);
|
|
3808
|
+
}
|
|
3809
|
+
readOutcome(id, sessionId) {
|
|
3810
|
+
return readExitMetadata(this.auxPaths(id, sessionId).exit);
|
|
3811
|
+
}
|
|
3812
|
+
trackCompletion(target, completion) {
|
|
3813
|
+
this.completions.set(target, completion);
|
|
3814
|
+
completion.finally(() => this.completions.delete(target)).catch((error) => process.emitWarning(`tmux monitor failed for ${target}: ${errorMessage(error)}`));
|
|
3815
|
+
}
|
|
3816
|
+
client() {
|
|
3817
|
+
this.clientPromise ??= this.resolveClient();
|
|
3818
|
+
return this.clientPromise;
|
|
3819
|
+
}
|
|
3820
|
+
async resolveClient() {
|
|
3821
|
+
const client = this.createClient(socketName(this.paths.repositoryPath()));
|
|
3822
|
+
try {
|
|
3823
|
+
const version = await client.run(["-V"]);
|
|
3824
|
+
if (version.returnCode !== 0) throw new Error(version.stderr.trim() || "tmux reported no version");
|
|
3825
|
+
return client;
|
|
3826
|
+
} catch (error) {
|
|
3827
|
+
process.emitWarning(`tmux is unavailable: ${errorMessage(error)}`);
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
async checked(tmux, args) {
|
|
3832
|
+
const result = await tmux.run(args);
|
|
3833
|
+
if (result.returnCode !== 0) throw new Error(`tmux ${args[0] ?? ""} failed: ${result.stderr.trim() || `exit ${result.returnCode}`}`);
|
|
3834
|
+
}
|
|
3835
|
+
prepareFiles(request, logPath, aux) {
|
|
3836
|
+
cleanupSupervisorFiles(aux);
|
|
3837
|
+
node_fs.default.writeFileSync(logPath, "", { mode: 384 });
|
|
3838
|
+
const env = environment(request.sessionId, request.interactive);
|
|
3839
|
+
const spec = {
|
|
3840
|
+
command: request.command,
|
|
3841
|
+
cwd: request.cwd,
|
|
3842
|
+
env
|
|
3843
|
+
};
|
|
3844
|
+
writeCommandSpec(aux.spec, spec);
|
|
3845
|
+
}
|
|
3846
|
+
auxPaths(id, sessionId) {
|
|
3847
|
+
return supervisorPaths(this.paths.stateDirectory(sessionId), id);
|
|
3848
|
+
}
|
|
3849
|
+
/**
|
|
3850
|
+
* The sink the pane pipes into.
|
|
3851
|
+
*
|
|
3852
|
+
* An interactive run also asks for a raw copy: the scrubbed log it writes
|
|
3853
|
+
* beside it has had cursor movement taken out, which is what a terminal
|
|
3854
|
+
* needs most. Only interactive runs pay for it, because only they can be
|
|
3855
|
+
* attached to.
|
|
3856
|
+
*/
|
|
3857
|
+
logPipeCommand(logPath, donePath, request) {
|
|
3858
|
+
const id = node_path.default.basename(logPath, ".log");
|
|
3859
|
+
return shellJoin([
|
|
3860
|
+
process.execPath,
|
|
3861
|
+
runtimeEntry("logSink"),
|
|
3862
|
+
logPath,
|
|
3863
|
+
this.paths.rotatedLogPathFor(id),
|
|
3864
|
+
String(getLogMaxBytes()),
|
|
3865
|
+
donePath,
|
|
3866
|
+
...request.interactive ? [this.paths.rawLogPathFor(id)] : []
|
|
3867
|
+
]);
|
|
3868
|
+
}
|
|
3869
|
+
async monitor(tmux, target, aux) {
|
|
3870
|
+
try {
|
|
3871
|
+
const paneCode = await waitForRunEnd(tmux, target, aux.exit);
|
|
3872
|
+
await waitForLogDrain(tmux, target, aux.logDone);
|
|
3873
|
+
return readExitMetadata(aux.exit) ?? {
|
|
3874
|
+
code: paneCode,
|
|
3875
|
+
signal: null
|
|
3876
|
+
};
|
|
3877
|
+
} finally {
|
|
3878
|
+
if (!await tmux.sessionMissing(target).catch(() => false)) await killSession(tmux, target);
|
|
3879
|
+
cleanupSupervisorFiles(aux);
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
interactiveRun(handle, tmux, target, completion) {
|
|
3883
|
+
let screen = "";
|
|
3884
|
+
let active = true;
|
|
3885
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
3886
|
+
const refresh = async () => {
|
|
3887
|
+
while (active) {
|
|
3888
|
+
const captured = await tmux.run([
|
|
3889
|
+
"capture-pane",
|
|
3890
|
+
"-p",
|
|
3891
|
+
"-t",
|
|
3892
|
+
target
|
|
3893
|
+
]).catch((error) => {
|
|
3894
|
+
process.emitWarning(`Could not capture tmux screen for ${handle.name}: ${errorMessage(error)}`);
|
|
3895
|
+
});
|
|
3896
|
+
const next = captured?.returnCode === 0 ? captured.stdout : screen;
|
|
3897
|
+
if (next !== screen) {
|
|
3898
|
+
screen = next;
|
|
3899
|
+
for (const handler of handlers) handler(next);
|
|
3900
|
+
}
|
|
3901
|
+
await delay(100);
|
|
3902
|
+
}
|
|
3903
|
+
};
|
|
3904
|
+
refresh();
|
|
3905
|
+
completion.finally(() => {
|
|
3906
|
+
active = false;
|
|
3907
|
+
}).catch((error) => process.emitWarning(`tmux interactive completion failed for ${handle.name}: ${errorMessage(error)}`));
|
|
3908
|
+
return {
|
|
3909
|
+
...handle,
|
|
3910
|
+
write: (text) => {
|
|
3911
|
+
tmux.run([
|
|
3912
|
+
"send-keys",
|
|
3913
|
+
"-t",
|
|
3914
|
+
target,
|
|
3915
|
+
"-l",
|
|
3916
|
+
text
|
|
3917
|
+
]).catch((error) => process.emitWarning(errorMessage(error)));
|
|
3918
|
+
},
|
|
3919
|
+
screen: () => screen,
|
|
3920
|
+
onData: (handler) => {
|
|
3921
|
+
handlers.add(handler);
|
|
3922
|
+
return () => handlers.delete(handler);
|
|
3923
|
+
},
|
|
3924
|
+
resize: (cols, rows) => {
|
|
3925
|
+
tmux.run([
|
|
3926
|
+
"resize-window",
|
|
3927
|
+
"-t",
|
|
3928
|
+
target,
|
|
3929
|
+
"-x",
|
|
3930
|
+
String(cols),
|
|
3931
|
+
"-y",
|
|
3932
|
+
String(rows)
|
|
3933
|
+
]).catch((error) => process.emitWarning(errorMessage(error)));
|
|
3934
|
+
}
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
3937
|
+
};
|
|
3938
|
+
function socketName(repositoryPath) {
|
|
3939
|
+
const hash = (0, node_crypto.createHash)("sha256").update(repositoryPath).digest("hex").slice(0, 12);
|
|
3940
|
+
return `${SOCKET_PREFIX}${hash}`;
|
|
3941
|
+
}
|
|
3942
|
+
function environment(sessionId, interactive) {
|
|
3943
|
+
const values = {
|
|
3944
|
+
...process.env,
|
|
3945
|
+
...interactive ? {} : NON_INTERACTIVE_ENV$1,
|
|
3946
|
+
...NO_TERMINAL_INPUT_ENV,
|
|
3947
|
+
PI_SESSION_ID: sessionId
|
|
3948
|
+
};
|
|
3949
|
+
if (!interactive) delete values["FORCE_COLOR"];
|
|
3950
|
+
return Object.fromEntries(Object.entries(values).filter((entry) => entry[1] !== void 0));
|
|
3951
|
+
}
|
|
3952
|
+
async function killSession(tmux, target) {
|
|
3953
|
+
return (await tmux.run([
|
|
3954
|
+
"kill-session",
|
|
3955
|
+
"-t",
|
|
3956
|
+
target
|
|
3957
|
+
]).catch((error) => {
|
|
3958
|
+
process.emitWarning(`Could not close tmux target ${target}: ${errorMessage(error)}`);
|
|
3959
|
+
}))?.returnCode === 0;
|
|
3960
|
+
}
|
|
3961
|
+
async function panePid(tmux, target) {
|
|
3962
|
+
const message = await tmux.format(target, "#{pane_pid}");
|
|
3963
|
+
const pid = Number(message);
|
|
3964
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error(`tmux did not report a pane pid for ${target}`);
|
|
3965
|
+
return pid;
|
|
3966
|
+
}
|
|
3967
|
+
/**
|
|
3968
|
+
* Waits for the run to end, preferring the supervisor's own record of it.
|
|
3969
|
+
*
|
|
3970
|
+
* The sidecar is written before the pane can end and outlives the server the
|
|
3971
|
+
* pane's exit takes down, so asking tmux is the fallback for a supervisor that
|
|
3972
|
+
* died without recording anything.
|
|
3973
|
+
*/
|
|
3974
|
+
async function waitForRunEnd(tmux, target, exitPath) {
|
|
3975
|
+
for (;;) {
|
|
3976
|
+
if (node_fs.default.existsSync(exitPath)) return null;
|
|
3977
|
+
const state = await readPaneExit(tmux, target);
|
|
3978
|
+
if (state.exited) return state.code;
|
|
3979
|
+
await delay(100);
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
async function waitForLogDrain(tmux, target, donePath) {
|
|
3983
|
+
const deadline = Date.now() + LOG_DRAIN_TIMEOUT_MS$1;
|
|
3984
|
+
while (Date.now() < deadline) {
|
|
3985
|
+
if (node_fs.default.existsSync(donePath)) return;
|
|
3986
|
+
if (await tmux.sessionMissing(target)) return;
|
|
3987
|
+
await delay(25);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
async function waitForPaneStop(tmux, target, timeoutMs) {
|
|
3991
|
+
const deadline = Date.now() + timeoutMs;
|
|
3992
|
+
while (Date.now() < deadline) {
|
|
3993
|
+
if ((await readPaneExit(tmux, target)).exited) return true;
|
|
3994
|
+
await delay(100);
|
|
3995
|
+
}
|
|
3996
|
+
return false;
|
|
3997
|
+
}
|
|
3998
|
+
/** One formatted read answers both questions this poll asks. */
|
|
3999
|
+
async function readPaneExit(tmux, target) {
|
|
4000
|
+
const message = await tmux.format(target, PANE_STATE_FORMAT$1);
|
|
4001
|
+
if (message === void 0) return {
|
|
4002
|
+
exited: true,
|
|
4003
|
+
code: null
|
|
4004
|
+
};
|
|
4005
|
+
const [dead, status, ...rest] = message.trim().split(":");
|
|
4006
|
+
if (rest.join(":") === "") return {
|
|
4007
|
+
exited: true,
|
|
4008
|
+
code: null
|
|
4009
|
+
};
|
|
4010
|
+
if (dead === "1" || dead === "true") {
|
|
4011
|
+
const code = Number(status);
|
|
4012
|
+
return {
|
|
4013
|
+
exited: true,
|
|
4014
|
+
code: Number.isInteger(code) ? code : null
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
return {
|
|
4018
|
+
exited: false,
|
|
4019
|
+
code: null
|
|
4020
|
+
};
|
|
4021
|
+
}
|
|
4022
|
+
function signalPane(pid, target) {
|
|
4023
|
+
try {
|
|
4024
|
+
process.kill(pid, "SIGTERM");
|
|
4025
|
+
return "signalled";
|
|
4026
|
+
} catch (error) {
|
|
4027
|
+
if (error.code === "ESRCH") return "gone";
|
|
4028
|
+
process.emitWarning(`Could not signal tmux target ${target}: ${errorMessage(error)}`);
|
|
4029
|
+
return "failed";
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
function readTail(target, maxBytes) {
|
|
4033
|
+
let handle;
|
|
4034
|
+
try {
|
|
4035
|
+
const size = node_fs.default.statSync(target).size;
|
|
4036
|
+
const bytes = Math.min(size, maxBytes);
|
|
4037
|
+
const buffer = Buffer.alloc(bytes);
|
|
4038
|
+
handle = node_fs.default.openSync(target, "r");
|
|
4039
|
+
node_fs.default.readSync(handle, buffer, 0, bytes, size - bytes);
|
|
4040
|
+
return buffer.toString("utf8");
|
|
4041
|
+
} catch (error) {
|
|
4042
|
+
if (error.code !== "ENOENT") process.emitWarning(`Could not read tmux log ${target}: ${errorMessage(error)}`);
|
|
4043
|
+
return "";
|
|
4044
|
+
} finally {
|
|
4045
|
+
if (handle !== void 0) node_fs.default.closeSync(handle);
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
function delay(ms) {
|
|
4049
|
+
return new Promise((resolve) => {
|
|
4050
|
+
setTimeout(resolve, ms).unref?.();
|
|
4051
|
+
});
|
|
4052
|
+
}
|
|
4053
|
+
function errorMessage(error) {
|
|
4054
|
+
return error instanceof Error ? error.message : String(error);
|
|
4055
|
+
}
|
|
4056
|
+
//#endregion
|
|
4057
|
+
//#region src/services/runnerDependencies/index.ts
|
|
4058
|
+
/** Build once on first access, then hand back the same instance. */
|
|
4059
|
+
function memoize(build) {
|
|
4060
|
+
let value;
|
|
4061
|
+
let built = false;
|
|
4062
|
+
return () => {
|
|
4063
|
+
if (!built) {
|
|
4064
|
+
value = build();
|
|
4065
|
+
built = true;
|
|
4066
|
+
}
|
|
4067
|
+
return value;
|
|
4068
|
+
};
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* Builds the extension's service graph.
|
|
4072
|
+
*
|
|
4073
|
+
* A factory rather than a module-level singleton: the extension owns one graph
|
|
4074
|
+
* per session, and tests get a fresh one they can substitute ports on through
|
|
4075
|
+
* `overrides` without leaking state between cases.
|
|
4076
|
+
*
|
|
4077
|
+
* Every slot resolves lazily and memoizes, which keeps the previous container's
|
|
4078
|
+
* behaviour: nothing is constructed until it is asked for, so opening the SQLite
|
|
4079
|
+
* process registry stays deferred until something actually needs a runner.
|
|
4080
|
+
*/
|
|
4081
|
+
function createRunnerDependencies(overrides) {
|
|
4082
|
+
const environment = overrides.environment;
|
|
4083
|
+
const clock = memoize(() => overrides.clock ?? new SystemClock());
|
|
4084
|
+
const spawner = memoize(() => overrides.spawner ?? new NodeSpawner());
|
|
4085
|
+
const processControl = memoize(() => overrides.processControl ?? new NodeProcessControl());
|
|
4086
|
+
const ptySpawner = memoize(() => overrides.ptySpawner ?? new NodePtySpawner());
|
|
4087
|
+
const paths = memoize(() => overrides.paths ?? new RunnerPaths());
|
|
4088
|
+
const lifeline = memoize(() => overrides.lifeline ?? new NodeLifeline(paths()));
|
|
4089
|
+
const processRegistry = memoize(() => overrides.processRegistry ?? createDefaultProcessRegistry());
|
|
4090
|
+
const runnerRegistry = memoize(() => overrides.runnerRegistry ?? new RunnerRegistry(paths(), processControl(), processRegistry(), environment));
|
|
4091
|
+
const namer = memoize(() => overrides.namer ?? new RunnerNamer(runnerRegistry()));
|
|
4092
|
+
const logFile = memoize(() => overrides.logFile ?? new LogFile(paths()));
|
|
4093
|
+
const logReader = memoize(() => overrides.logReader ?? new LogReader());
|
|
4094
|
+
const launcher = memoize(() => overrides.launcher ?? new Launcher(spawner(), processControl(), logFile(), clock(), paths()));
|
|
4095
|
+
const rmuxBackend = memoize(() => overrides.rmuxBackend ?? new PtyBackendChain(new RmuxBackend(paths()), new TmuxBackend(paths())));
|
|
4096
|
+
const rtkProcessor = memoize(() => overrides.rtkProcessor ?? new RtkProcessor());
|
|
4097
|
+
const ptyHost = memoize(() => overrides.ptyHost ?? new PtyHost(ptySpawner(), logFile(), processControl(), clock()));
|
|
4098
|
+
const bashRunService = memoize(() => overrides.bashRunService ?? new BashRunService(launcher(), rmuxBackend(), namer(), runnerRegistry(), clock(), rtkProcessor()));
|
|
4099
|
+
return {
|
|
4100
|
+
environment,
|
|
4101
|
+
get clock() {
|
|
4102
|
+
return clock();
|
|
4103
|
+
},
|
|
4104
|
+
get spawner() {
|
|
4105
|
+
return spawner();
|
|
4106
|
+
},
|
|
4107
|
+
get processControl() {
|
|
4108
|
+
return processControl();
|
|
4109
|
+
},
|
|
4110
|
+
get ptySpawner() {
|
|
4111
|
+
return ptySpawner();
|
|
4112
|
+
},
|
|
4113
|
+
get paths() {
|
|
4114
|
+
return paths();
|
|
4115
|
+
},
|
|
4116
|
+
get lifeline() {
|
|
4117
|
+
return lifeline();
|
|
4118
|
+
},
|
|
4119
|
+
get processRegistry() {
|
|
4120
|
+
return processRegistry();
|
|
4121
|
+
},
|
|
4122
|
+
get runnerRegistry() {
|
|
4123
|
+
return runnerRegistry();
|
|
4124
|
+
},
|
|
4125
|
+
get namer() {
|
|
4126
|
+
return namer();
|
|
4127
|
+
},
|
|
4128
|
+
get logFile() {
|
|
4129
|
+
return logFile();
|
|
4130
|
+
},
|
|
4131
|
+
get logReader() {
|
|
4132
|
+
return logReader();
|
|
4133
|
+
},
|
|
4134
|
+
get launcher() {
|
|
4135
|
+
return launcher();
|
|
4136
|
+
},
|
|
4137
|
+
get rmuxBackend() {
|
|
4138
|
+
return rmuxBackend();
|
|
4139
|
+
},
|
|
4140
|
+
get rtkProcessor() {
|
|
4141
|
+
return rtkProcessor();
|
|
4142
|
+
},
|
|
4143
|
+
get ptyHost() {
|
|
4144
|
+
return ptyHost();
|
|
4145
|
+
},
|
|
4146
|
+
get bashRunService() {
|
|
4147
|
+
return bashRunService();
|
|
4148
|
+
}
|
|
4149
|
+
};
|
|
4150
|
+
}
|
|
4151
|
+
//#endregion
|
|
4152
|
+
//#region src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts
|
|
4153
|
+
var bash_mcp_default = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpTool)(() => {
|
|
4154
|
+
const dependencies = createRunnerDependencies({ environment: Object.freeze({ ...process.env }) });
|
|
4155
|
+
return createHeadlessBashTool({ run: (request) => dependencies.bashRunService.run(request) }, summarizeLog);
|
|
4156
|
+
});
|
|
4157
|
+
//#endregion
|
|
4158
|
+
//#region generated/mcp.ts
|
|
4159
|
+
const at = (value, context) => typeof value === "function" ? value(context) : value;
|
|
4160
|
+
const via = (identity, value) => ({
|
|
4161
|
+
...identity,
|
|
4162
|
+
...value
|
|
4163
|
+
});
|
|
4164
|
+
const mcp = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpPlugin)({
|
|
4165
|
+
name: "@agimon-ai/doompi-runner",
|
|
4166
|
+
session: (context) => ({
|
|
4167
|
+
get skills() {
|
|
4168
|
+
return [at(doompi_use_runner_mcp_default, context)];
|
|
4169
|
+
},
|
|
4170
|
+
get tools() {
|
|
4171
|
+
return [via({ name: "bash" }, at(bash_mcp_default, context))];
|
|
4172
|
+
}
|
|
4173
|
+
})
|
|
4174
|
+
});
|
|
4175
|
+
//#endregion
|
|
4176
|
+
exports.default = mcp;
|
|
4177
|
+
exports.mcp = mcp;
|
|
4178
|
+
|
|
4179
|
+
//# sourceMappingURL=mcp.cjs.map
|