@keyou007/agent-bot 0.1.15-alpha.3 → 0.1.15-alpha.5
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/CHANGELOG.md +11 -0
- package/README.md +4 -1
- package/README.zh.md +5 -2
- package/dist/cli/VerificationOutput.js +1 -6
- package/dist/cli/VerificationOutput.js.map +1 -1
- package/dist/cli.js +7 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/CommandRouter.js +7 -0
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +76 -9
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FinalResponseBranding.js +1 -1
- package/dist/feishu/FinalResponseBranding.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +161 -83
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/shell/ShellCommandJobManager.js +264 -0
- package/dist/shell/ShellCommandJobManager.js.map +1 -0
- package/dist/shell/shellCommandRunner.js +157 -0
- package/dist/shell/shellCommandRunner.js.map +1 -0
- package/dist/utils/executeShellCommand.js +1 -1
- package/dist/utils/executeShellCommand.js.map +1 -1
- package/docs/technical-reference.md +5 -3
- package/docs/technical-reference.zh.md +5 -3
- package/npm-shrinkwrap.json +2 -19
- package/package.json +1 -3
- package/src/cli/VerificationOutput.ts +1 -12
- package/src/cli.ts +6 -22
- package/src/commands/CommandRouter.ts +6 -0
- package/src/commands/commandTypes.ts +1 -0
- package/src/feishu/CardRenderer.ts +91 -17
- package/src/feishu/FinalResponseBranding.ts +2 -1
- package/src/proxy/ProxySessionController.ts +196 -89
- package/src/shell/ShellCommandJobManager.ts +341 -0
- package/src/shell/shellCommandRunner.ts +163 -0
- package/src/utils/executeShellCommand.ts +1 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const JOB_SPEC_FILE = "job.json";
|
|
8
|
+
const JOB_STATUS_FILE = "status.json";
|
|
9
|
+
const JOB_PRESENTATION_FILE = "presentation.json";
|
|
10
|
+
const CANCEL_REQUEST_FILE = "cancel.requested";
|
|
11
|
+
const OUTPUT_FILE = "output.log";
|
|
12
|
+
const LEGACY_STDOUT_FILE = "stdout.log";
|
|
13
|
+
const LEGACY_STDERR_FILE = "stderr.log";
|
|
14
|
+
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
export type ShellCommandJobStatus =
|
|
17
|
+
| "starting"
|
|
18
|
+
| "running"
|
|
19
|
+
| "cancelling"
|
|
20
|
+
| "completed"
|
|
21
|
+
| "failed"
|
|
22
|
+
| "cancelled";
|
|
23
|
+
|
|
24
|
+
export interface ShellCommandJobSpec {
|
|
25
|
+
version: 1;
|
|
26
|
+
id: string;
|
|
27
|
+
contextKey: string;
|
|
28
|
+
sourceMessageId?: string;
|
|
29
|
+
command: string;
|
|
30
|
+
cwd: string;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
cardMessageId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ShellCommandJobState {
|
|
36
|
+
status: Exclude<ShellCommandJobStatus, "cancelling">;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
updatedAt: number;
|
|
39
|
+
runnerPid?: number;
|
|
40
|
+
childPid?: number;
|
|
41
|
+
completedAt?: number;
|
|
42
|
+
exitCode?: number | null;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ShellCommandJobSnapshot extends ShellCommandJobSpec, Omit<ShellCommandJobState, "status"> {
|
|
47
|
+
output: string;
|
|
48
|
+
outputTruncated: boolean;
|
|
49
|
+
status: ShellCommandJobStatus;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ShellCommandJobManagerLike {
|
|
53
|
+
createJob(input: Pick<ShellCommandJobSpec, "contextKey" | "sourceMessageId" | "command" | "cwd">): Promise<ShellCommandJobSnapshot>;
|
|
54
|
+
bindCard(jobId: string, cardMessageId: string): Promise<ShellCommandJobSnapshot>;
|
|
55
|
+
startJob(jobId: string): Promise<void>;
|
|
56
|
+
failJob(jobId: string, error: string): Promise<void>;
|
|
57
|
+
readJob(jobId: string): Promise<ShellCommandJobSnapshot>;
|
|
58
|
+
listRecoverableJobs(): Promise<ShellCommandJobSnapshot[]>;
|
|
59
|
+
requestCancellation(jobId: string): Promise<boolean>;
|
|
60
|
+
markPresented(jobId: string): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ShellCommandJobManagerOptions {
|
|
64
|
+
runnerEntry?: string;
|
|
65
|
+
spawnRunner?: (entry: string, jobDirectory: string) => Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ShellCommandJobManager implements ShellCommandJobManagerLike {
|
|
69
|
+
private readonly runnerEntry: string;
|
|
70
|
+
private readonly spawnRunner: (entry: string, jobDirectory: string) => Promise<void>;
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
private readonly jobsRoot: string,
|
|
74
|
+
options: ShellCommandJobManagerOptions = {},
|
|
75
|
+
) {
|
|
76
|
+
this.runnerEntry = options.runnerEntry ?? resolveRunnerEntry();
|
|
77
|
+
this.spawnRunner = options.spawnRunner ?? launchDetachedRunner;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async createJob(
|
|
81
|
+
input: Pick<ShellCommandJobSpec, "contextKey" | "sourceMessageId" | "command" | "cwd">,
|
|
82
|
+
): Promise<ShellCommandJobSnapshot> {
|
|
83
|
+
const id = randomUUID();
|
|
84
|
+
const createdAt = Date.now();
|
|
85
|
+
const jobDirectory = this.jobDirectory(id);
|
|
86
|
+
const spec: ShellCommandJobSpec = { version: 1, id, createdAt, ...input };
|
|
87
|
+
const state: ShellCommandJobState = {
|
|
88
|
+
status: "starting",
|
|
89
|
+
startedAt: createdAt,
|
|
90
|
+
updatedAt: createdAt,
|
|
91
|
+
};
|
|
92
|
+
await fs.mkdir(jobDirectory, { recursive: true });
|
|
93
|
+
await Promise.all([
|
|
94
|
+
writeJsonAtomic(path.join(jobDirectory, JOB_SPEC_FILE), spec),
|
|
95
|
+
writeJsonAtomic(path.join(jobDirectory, JOB_STATUS_FILE), state),
|
|
96
|
+
fs.writeFile(path.join(jobDirectory, OUTPUT_FILE), "", { flag: "wx" }),
|
|
97
|
+
]);
|
|
98
|
+
return { ...spec, ...state, output: "", outputTruncated: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async bindCard(jobId: string, cardMessageId: string): Promise<ShellCommandJobSnapshot> {
|
|
102
|
+
const jobDirectory = this.jobDirectory(jobId);
|
|
103
|
+
const spec = await readJson<ShellCommandJobSpec>(path.join(jobDirectory, JOB_SPEC_FILE));
|
|
104
|
+
await writeJsonAtomic(path.join(jobDirectory, JOB_SPEC_FILE), { ...spec, cardMessageId });
|
|
105
|
+
return this.readJob(jobId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async startJob(jobId: string): Promise<void> {
|
|
109
|
+
const jobDirectory = this.jobDirectory(jobId);
|
|
110
|
+
await this.spawnRunner(this.runnerEntry, jobDirectory);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async failJob(jobId: string, error: string): Promise<void> {
|
|
114
|
+
const jobDirectory = this.jobDirectory(jobId);
|
|
115
|
+
const state = await readJson<ShellCommandJobState>(path.join(jobDirectory, JOB_STATUS_FILE));
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
await writeJsonAtomic(path.join(jobDirectory, JOB_STATUS_FILE), {
|
|
118
|
+
...state,
|
|
119
|
+
status: "failed",
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
completedAt: now,
|
|
122
|
+
exitCode: null,
|
|
123
|
+
error,
|
|
124
|
+
} satisfies ShellCommandJobState);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async readJob(jobId: string): Promise<ShellCommandJobSnapshot> {
|
|
128
|
+
const jobDirectory = this.jobDirectory(jobId);
|
|
129
|
+
const [spec, savedState, cancellationRequested, output] = await Promise.all([
|
|
130
|
+
readJson<ShellCommandJobSpec>(path.join(jobDirectory, JOB_SPEC_FILE)),
|
|
131
|
+
readJson<ShellCommandJobState>(path.join(jobDirectory, JOB_STATUS_FILE)),
|
|
132
|
+
fileExists(path.join(jobDirectory, CANCEL_REQUEST_FILE)),
|
|
133
|
+
readJobOutput(jobDirectory),
|
|
134
|
+
]);
|
|
135
|
+
const state = await reconcileExitedRunner(jobDirectory, savedState);
|
|
136
|
+
const status = cancellationRequested && (state.status === "starting" || state.status === "running")
|
|
137
|
+
? "cancelling"
|
|
138
|
+
: state.status;
|
|
139
|
+
return { ...spec, ...state, ...output, status };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async listRecoverableJobs(): Promise<ShellCommandJobSnapshot[]> {
|
|
143
|
+
const entries = await fs.readdir(this.jobsRoot, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
|
144
|
+
if (error.code === "ENOENT") return [];
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
const jobs: ShellCommandJobSnapshot[] = [];
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (!entry.isDirectory()) continue;
|
|
150
|
+
try {
|
|
151
|
+
const job = await this.readJob(entry.name);
|
|
152
|
+
const presented = await fileExists(path.join(this.jobDirectory(job.id), JOB_PRESENTATION_FILE));
|
|
153
|
+
if (!presented || isActiveShellCommandJob(job.status)) jobs.push(job);
|
|
154
|
+
} catch {
|
|
155
|
+
// A partially written or unrelated directory is not a recoverable command job.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return jobs.sort((left, right) => left.createdAt - right.createdAt);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async requestCancellation(jobId: string): Promise<boolean> {
|
|
162
|
+
const job = await this.readJob(jobId);
|
|
163
|
+
if (!isActiveShellCommandJob(job.status)) return false;
|
|
164
|
+
await fs.writeFile(path.join(this.jobDirectory(jobId), CANCEL_REQUEST_FILE), `${Date.now()}\n`);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async markPresented(jobId: string): Promise<void> {
|
|
169
|
+
await writeJsonAtomic(
|
|
170
|
+
path.join(this.jobDirectory(jobId), JOB_PRESENTATION_FILE),
|
|
171
|
+
{ presentedAt: Date.now() },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private jobDirectory(jobId: string): string {
|
|
176
|
+
if (!/^[0-9a-f-]{36}$/iu.test(jobId)) throw new Error("Invalid shell command job ID.");
|
|
177
|
+
return path.join(this.jobsRoot, jobId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isActiveShellCommandJob(status: ShellCommandJobStatus): boolean {
|
|
182
|
+
return status === "starting" || status === "running" || status === "cancelling";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveRunnerEntry(): string {
|
|
186
|
+
const builtEntry = fileURLToPath(new URL("./shellCommandRunner.js", import.meta.url));
|
|
187
|
+
const sourceEntry = fileURLToPath(new URL("./shellCommandRunner.ts", import.meta.url));
|
|
188
|
+
return fileURLToPath(import.meta.url).endsWith(".ts") ? sourceEntry : builtEntry;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function launchDetachedRunner(entry: string, jobDirectory: string): Promise<void> {
|
|
192
|
+
const runnerArgs = entry.endsWith(".ts")
|
|
193
|
+
? [...process.execArgv, entry, jobDirectory]
|
|
194
|
+
: [entry, jobDirectory];
|
|
195
|
+
const child = spawn(process.execPath, runnerArgs, {
|
|
196
|
+
cwd: jobDirectory,
|
|
197
|
+
detached: true,
|
|
198
|
+
env: process.env,
|
|
199
|
+
stdio: "ignore",
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
});
|
|
202
|
+
await waitForSpawn(child);
|
|
203
|
+
child.once("exit", (exitCode, signal) => {
|
|
204
|
+
void recordUnexpectedRunnerExit(jobDirectory, exitCode, signal);
|
|
205
|
+
});
|
|
206
|
+
child.unref();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function recordUnexpectedRunnerExit(
|
|
210
|
+
jobDirectory: string,
|
|
211
|
+
exitCode: number | null,
|
|
212
|
+
signal: NodeJS.Signals | null,
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
try {
|
|
215
|
+
const statusPath = path.join(jobDirectory, JOB_STATUS_FILE);
|
|
216
|
+
const state = await readJson<ShellCommandJobState>(statusPath);
|
|
217
|
+
if (state.status !== "starting" && state.status !== "running") return;
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
await writeJsonAtomic(statusPath, {
|
|
220
|
+
...state,
|
|
221
|
+
status: "failed",
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
completedAt: now,
|
|
224
|
+
exitCode,
|
|
225
|
+
error: `The background shell command runner exited unexpectedly${signal ? ` with ${signal}` : ""}.`,
|
|
226
|
+
} satisfies ShellCommandJobState);
|
|
227
|
+
} catch {
|
|
228
|
+
// Startup recovery will retry a job whose runner exited before writing status.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function waitForSpawn(child: ChildProcess): Promise<void> {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
child.once("spawn", resolve);
|
|
235
|
+
child.once("error", reject);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function readJobOutput(jobDirectory: string): Promise<{
|
|
240
|
+
output: string;
|
|
241
|
+
outputTruncated: boolean;
|
|
242
|
+
}> {
|
|
243
|
+
const outputPath = path.join(jobDirectory, OUTPUT_FILE);
|
|
244
|
+
if (await fileExists(outputPath)) {
|
|
245
|
+
const output = await readBoundedFile(outputPath, MAX_CAPTURED_OUTPUT_BYTES);
|
|
246
|
+
return { output: output.value, outputTruncated: output.truncated };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const stdoutPath = path.join(jobDirectory, LEGACY_STDOUT_FILE);
|
|
250
|
+
const stderrPath = path.join(jobDirectory, LEGACY_STDERR_FILE);
|
|
251
|
+
const [stdoutSize, stderrSize] = await Promise.all([fileSize(stdoutPath), fileSize(stderrPath)]);
|
|
252
|
+
const totalSize = stdoutSize + stderrSize;
|
|
253
|
+
const stdoutLimit = totalSize <= MAX_CAPTURED_OUTPUT_BYTES
|
|
254
|
+
? stdoutSize
|
|
255
|
+
: stderrSize === 0
|
|
256
|
+
? MAX_CAPTURED_OUTPUT_BYTES
|
|
257
|
+
: Math.max(1_024, Math.floor(MAX_CAPTURED_OUTPUT_BYTES * (stdoutSize / totalSize)));
|
|
258
|
+
const stderrLimit = totalSize <= MAX_CAPTURED_OUTPUT_BYTES
|
|
259
|
+
? stderrSize
|
|
260
|
+
: Math.max(1_024, MAX_CAPTURED_OUTPUT_BYTES - stdoutLimit);
|
|
261
|
+
const [stdout, stderr] = await Promise.all([
|
|
262
|
+
readBoundedFile(stdoutPath, stdoutLimit),
|
|
263
|
+
readBoundedFile(stderrPath, stderrLimit),
|
|
264
|
+
]);
|
|
265
|
+
return {
|
|
266
|
+
output: `${stdout.value}${stderr.value}`,
|
|
267
|
+
outputTruncated: stdout.truncated || stderr.truncated,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function reconcileExitedRunner(
|
|
272
|
+
jobDirectory: string,
|
|
273
|
+
state: ShellCommandJobState,
|
|
274
|
+
): Promise<ShellCommandJobState> {
|
|
275
|
+
if (state.status !== "running" || state.runnerPid === undefined || processIsAlive(state.runnerPid)) return state;
|
|
276
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
277
|
+
const latest = await readJson<ShellCommandJobState>(path.join(jobDirectory, JOB_STATUS_FILE));
|
|
278
|
+
if (latest.status !== "running" || latest.runnerPid !== state.runnerPid || processIsAlive(latest.runnerPid)) {
|
|
279
|
+
return latest;
|
|
280
|
+
}
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const failed: ShellCommandJobState = {
|
|
283
|
+
...latest,
|
|
284
|
+
status: "failed",
|
|
285
|
+
updatedAt: now,
|
|
286
|
+
completedAt: now,
|
|
287
|
+
exitCode: null,
|
|
288
|
+
error: "The background shell command runner exited unexpectedly.",
|
|
289
|
+
};
|
|
290
|
+
await writeJsonAtomic(path.join(jobDirectory, JOB_STATUS_FILE), failed);
|
|
291
|
+
return failed;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function processIsAlive(pid: number): boolean {
|
|
295
|
+
try {
|
|
296
|
+
process.kill(pid, 0);
|
|
297
|
+
return true;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function readBoundedFile(filePath: string, maxBytes: number): Promise<{ value: string; truncated: boolean }> {
|
|
304
|
+
const size = await fileSize(filePath);
|
|
305
|
+
if (size === 0 || maxBytes <= 0) return { value: "", truncated: size > 0 };
|
|
306
|
+
if (size <= maxBytes) return { value: await fs.readFile(filePath, "utf8"), truncated: false };
|
|
307
|
+
const marker = Buffer.from("\n... output truncated ...\n", "utf8");
|
|
308
|
+
const headBytes = Math.max(0, Math.ceil(maxBytes / 3) - marker.length);
|
|
309
|
+
const tailBytes = Math.max(0, maxBytes - headBytes - marker.length);
|
|
310
|
+
const handle = await fs.open(filePath, "r");
|
|
311
|
+
try {
|
|
312
|
+
const head = Buffer.alloc(headBytes);
|
|
313
|
+
const tail = Buffer.alloc(tailBytes);
|
|
314
|
+
if (headBytes > 0) await handle.read(head, 0, headBytes, 0);
|
|
315
|
+
if (tailBytes > 0) await handle.read(tail, 0, tailBytes, size - tailBytes);
|
|
316
|
+
return { value: Buffer.concat([head, marker, tail]).toString("utf8"), truncated: true };
|
|
317
|
+
} finally {
|
|
318
|
+
await handle.close();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function fileSize(filePath: string): Promise<number> {
|
|
323
|
+
return fs.stat(filePath).then((stat) => stat.size).catch((error: NodeJS.ErrnoException) => {
|
|
324
|
+
if (error.code === "ENOENT") return 0;
|
|
325
|
+
throw error;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
330
|
+
return fs.access(filePath).then(() => true).catch(() => false);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function readJson<T>(filePath: string): Promise<T> {
|
|
334
|
+
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
338
|
+
const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
339
|
+
await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
340
|
+
await fs.rename(temporary, filePath);
|
|
341
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { prepareShellCommand } from "../utils/executeShellCommand.js";
|
|
5
|
+
import type { ShellCommandJobSpec, ShellCommandJobState } from "./ShellCommandJobManager.js";
|
|
6
|
+
|
|
7
|
+
const MAX_LOG_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const RETAINED_LOG_HEAD_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const RETAINED_LOG_TAIL_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
const LOG_TRUNCATION_MARKER = Buffer.from("\n... earlier output compacted ...\n", "utf8");
|
|
11
|
+
const CANCEL_POLL_INTERVAL_MS = 500;
|
|
12
|
+
const FORCE_KILL_DELAY_MS = 3_000;
|
|
13
|
+
|
|
14
|
+
class RotatingLogWriter {
|
|
15
|
+
private descriptor: number;
|
|
16
|
+
private size: number;
|
|
17
|
+
|
|
18
|
+
constructor(private readonly filePath: string) {
|
|
19
|
+
this.descriptor = fs.openSync(filePath, "a");
|
|
20
|
+
this.size = fs.fstatSync(this.descriptor).size;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
append(chunk: Buffer): void {
|
|
24
|
+
if (chunk.length === 0) return;
|
|
25
|
+
fs.writeSync(this.descriptor, chunk);
|
|
26
|
+
this.size += chunk.length;
|
|
27
|
+
if (this.size > MAX_LOG_BYTES) this.compact();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
close(): void {
|
|
31
|
+
fs.closeSync(this.descriptor);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private compact(): void {
|
|
35
|
+
fs.closeSync(this.descriptor);
|
|
36
|
+
const current = fs.readFileSync(this.filePath);
|
|
37
|
+
const head = current.subarray(0, Math.min(RETAINED_LOG_HEAD_BYTES, current.length));
|
|
38
|
+
const tailStart = Math.max(head.length, current.length - RETAINED_LOG_TAIL_BYTES);
|
|
39
|
+
const tail = current.subarray(tailStart);
|
|
40
|
+
fs.writeFileSync(this.filePath, Buffer.concat([head, LOG_TRUNCATION_MARKER, tail]));
|
|
41
|
+
this.descriptor = fs.openSync(this.filePath, "a");
|
|
42
|
+
this.size = fs.fstatSync(this.descriptor).size;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const jobDirectory = path.resolve(process.argv[2] ?? "");
|
|
47
|
+
if (!process.argv[2]) throw new Error("A shell command job directory is required.");
|
|
48
|
+
|
|
49
|
+
const spec = readJson<ShellCommandJobSpec>(path.join(jobDirectory, "job.json"));
|
|
50
|
+
const statusPath = path.join(jobDirectory, "status.json");
|
|
51
|
+
const cancelPath = path.join(jobDirectory, "cancel.requested");
|
|
52
|
+
const output = new RotatingLogWriter(path.join(jobDirectory, "output.log"));
|
|
53
|
+
const prepared = prepareShellCommand(spec.command, process.platform);
|
|
54
|
+
const child = spawn(prepared.command, prepared.args, {
|
|
55
|
+
cwd: spec.cwd,
|
|
56
|
+
detached: process.platform !== "win32",
|
|
57
|
+
env: process.env,
|
|
58
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let cancellationRequested = false;
|
|
63
|
+
let settled = false;
|
|
64
|
+
let forceKillTimer: NodeJS.Timeout | undefined;
|
|
65
|
+
writeStatus({
|
|
66
|
+
status: "running",
|
|
67
|
+
startedAt: spec.createdAt,
|
|
68
|
+
updatedAt: Date.now(),
|
|
69
|
+
runnerPid: process.pid,
|
|
70
|
+
...(child.pid ? { childPid: child.pid } : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
child.stdout.on("data", (chunk: Buffer) => output.append(chunk));
|
|
74
|
+
child.stderr.on("data", (chunk: Buffer) => output.append(chunk));
|
|
75
|
+
child.once("error", (error) => finish("failed", null, error.message));
|
|
76
|
+
child.once("close", (exitCode) => {
|
|
77
|
+
finish(cancellationRequested ? "cancelled" : exitCode === 0 ? "completed" : "failed", exitCode);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const cancelPoll = setInterval(() => {
|
|
81
|
+
if (settled || cancellationRequested || !fs.existsSync(cancelPath)) return;
|
|
82
|
+
cancellationRequested = true;
|
|
83
|
+
void terminateProcessTree(child.pid);
|
|
84
|
+
forceKillTimer = setTimeout(() => forceKillProcessTree(child.pid), FORCE_KILL_DELAY_MS);
|
|
85
|
+
forceKillTimer.unref?.();
|
|
86
|
+
}, CANCEL_POLL_INTERVAL_MS);
|
|
87
|
+
cancelPoll.unref?.();
|
|
88
|
+
|
|
89
|
+
process.on("SIGTERM", () => {
|
|
90
|
+
if (settled || cancellationRequested) return;
|
|
91
|
+
cancellationRequested = true;
|
|
92
|
+
void terminateProcessTree(child.pid);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function finish(
|
|
96
|
+
status: "completed" | "failed" | "cancelled",
|
|
97
|
+
exitCode: number | null,
|
|
98
|
+
error?: string,
|
|
99
|
+
): void {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearInterval(cancelPoll);
|
|
103
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
104
|
+
output.close();
|
|
105
|
+
writeStatus({
|
|
106
|
+
status,
|
|
107
|
+
startedAt: spec.createdAt,
|
|
108
|
+
updatedAt: Date.now(),
|
|
109
|
+
runnerPid: process.pid,
|
|
110
|
+
...(child.pid ? { childPid: child.pid } : {}),
|
|
111
|
+
completedAt: Date.now(),
|
|
112
|
+
exitCode,
|
|
113
|
+
...(error ? { error } : {}),
|
|
114
|
+
});
|
|
115
|
+
process.exitCode = status === "completed" ? 0 : 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function terminateProcessTree(pid: number | undefined): Promise<void> {
|
|
119
|
+
if (!pid) return;
|
|
120
|
+
if (process.platform === "win32") {
|
|
121
|
+
await new Promise<void>((resolve) => {
|
|
122
|
+
const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
|
|
123
|
+
stdio: "ignore",
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
});
|
|
126
|
+
killer.once("close", () => resolve());
|
|
127
|
+
killer.once("error", () => resolve());
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
process.kill(-pid, "SIGTERM");
|
|
133
|
+
} catch {
|
|
134
|
+
try {
|
|
135
|
+
process.kill(pid, "SIGTERM");
|
|
136
|
+
} catch {
|
|
137
|
+
// The command may already have exited.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function forceKillProcessTree(pid: number | undefined): void {
|
|
143
|
+
if (!pid || process.platform === "win32") return;
|
|
144
|
+
try {
|
|
145
|
+
process.kill(-pid, "SIGKILL");
|
|
146
|
+
} catch {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, "SIGKILL");
|
|
149
|
+
} catch {
|
|
150
|
+
// The command may already have exited.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function writeStatus(status: ShellCommandJobState): void {
|
|
156
|
+
const temporary = `${statusPath}.${process.pid}.tmp`;
|
|
157
|
+
fs.writeFileSync(temporary, `${JSON.stringify(status, null, 2)}\n`);
|
|
158
|
+
fs.renameSync(temporary, statusPath);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readJson<T>(filePath: string): T {
|
|
162
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
|
|
163
|
+
}
|
|
@@ -71,7 +71,7 @@ export function executeShellCommand(
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function prepareShellCommand(command: string, platform: NodeJS.Platform): { command: string; args: string[] } {
|
|
74
|
+
export function prepareShellCommand(command: string, platform: NodeJS.Platform): { command: string; args: string[] } {
|
|
75
75
|
if (platform !== "win32") return { command: "/bin/sh", args: ["-lc", command] };
|
|
76
76
|
const script = `$ProgressPreference = 'SilentlyContinue'; [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); ${command}`;
|
|
77
77
|
const encoded = Buffer.from(script, "utf16le").toString("base64");
|