Files
AgentHub/scripts/clone-runner.js
T
nntrivi2001 a27589418c feat(clone): background clone jobs that survive a reload and a server restart
Both clone POSTs take background: true: every check answers synchronously,
then 202 {job}. git runs in a detached runner (scripts/clone-runner.js, its
own process group, stderr to a file — no SIGPIPE when the server restarts);
the server tails it for progress and runs the after-step (workspace / extra
folder) once when it exits. Jobs persist in <data dir>/runtime/clone-jobs.json
(0600, atomic, throttled progress writes); on start a live runner is adopted
(counted against the clone limit), an ended one finished, a queued one
requeued (interrupted if it carried a typed login), the rest interrupted.
GET /clone/jobs, DELETE /clone/jobs/:id, POST /clone/jobs/:id/cancel (queued:
dequeued; running: SIGTERM to the runner's group -> cancelled, no after-step)
and /retry (new job from the stored request, never a credential). The
held-open path is unchanged; worktree.gitCommand builds the runner's argv
and git-identity.cloneFailure the shared error classification.
2026-11-02 10:47:51 +07:00

78 lines
2.6 KiB
JavaScript

#!/usr/bin/env node
/**
* @file Detached runner for one background `git clone`
* (`server/lib/clone-jobs.js`). The dashboard restarts on every update, and a
* git whose stderr was a pipe into it died of SIGPIPE on its next progress
* line — so the clone runs here instead, in its own session/process group,
* and outlives the server (the unit is `KillMode=process`). It reads its
* spec (`{cmd, argv, cwd, timeoutMs}` — exactly the command `worktree.git()`
* would run, built by `gitCommand`; never a secret, which travels in this
* process's env as it would in git's) from the 0600 file it is given and
* deletes it, runs the command with stderr appended straight to
* `<job dir>/stderr.log` (a file, never a pipe: nothing can SIGPIPE it), and
* writes `<job dir>/exit.json` `{code, signal, timedOut, finishedAt}` when it
* ends. A cancel SIGTERMs this whole group: git dies (sudo relays it to a
* member's git) and this runner stays to record how it ended.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const specPath = process.argv[2];
const dir = path.dirname(specPath);
let spec;
try {
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
} finally {
fs.rmSync(specPath, { force: true });
}
function writeExit(fields) {
const file = path.join(dir, "exit.json");
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ ...fields, finishedAt: new Date().toISOString() }), {
mode: 0o600,
});
fs.renameSync(tmp, file);
}
// Stay to record git's end: a cancel signals the whole group, this included.
process.on("SIGTERM", () => {});
process.on("SIGHUP", () => {});
process.on("SIGINT", () => {});
const log = fs.openSync(path.join(dir, "stderr.log"), "a", 0o600);
let timedOut = false;
let child;
try {
child = spawn(spec.cmd, spec.argv, {
cwd: spec.cwd,
env: process.env,
stdio: ["ignore", "ignore", log],
windowsHide: true,
});
} catch (err) {
writeExit({ code: null, signal: null, timedOut: false, error: err.message });
process.exit(0);
}
const timer =
spec.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, spec.timeoutMs)
: null;
child.on("error", (err) => {
if (timer) clearTimeout(timer);
writeExit({ code: null, signal: null, timedOut, error: err.message });
process.exit(0);
});
child.on("exit", (code, signal) => {
if (timer) clearTimeout(timer);
writeExit({ code, signal, timedOut });
process.exit(0);
});