111 lines
5.1 KiB
JavaScript
111 lines
5.1 KiB
JavaScript
// 本地守护进程:跑在用户电脑上,持有仓库、.env、工具链。
|
||
// 它不调模型、不存会话,只做一件事:领取工具调用 → 在仓库里执行 → 回传结果。
|
||
// 为了演示,每次执行后顺手拍一下 git 快照(status / diff),写进 trace。
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { spawn, execFileSync } = require('node:child_process');
|
||
const { tracer } = require('../shared');
|
||
|
||
const CLOUD = process.env.CLOUD_URL;
|
||
const DEVICE = process.env.DEVICE_ID;
|
||
const REPO = process.env.REPO_DIR;
|
||
const trace = tracer('laptop-daemon');
|
||
let jobSeq = Number(process.env.JOB_SEQ_START || 1);
|
||
const done = new Map(); // call_id -> 已执行结果(同一个调用被重发时不重复执行)
|
||
|
||
const git = (...a) => { try { return execFileSync('git', a, { cwd: REPO, encoding: 'utf8' }); } catch (e) { return e.stdout || ''; } };
|
||
function repoSnapshot() {
|
||
return { status: git('status', '--short').trim(), diff: git('diff').trim(), head: git('rev-parse', '--short', 'HEAD').trim() };
|
||
}
|
||
|
||
function sh(command, timeoutMs) {
|
||
return new Promise((ok) => {
|
||
const child = spawn('/bin/sh', ['-c', command], { cwd: REPO, env: process.env });
|
||
let out = '';
|
||
child.stdout.on('data', (c) => (out += c));
|
||
child.stderr.on('data', (c) => (out += c));
|
||
const timer = timeoutMs ? setTimeout(() => child.kill('SIGKILL'), timeoutMs) : null;
|
||
child.on('exit', (code) => { if (timer) clearTimeout(timer); ok({ code, out, pid: child.pid }); });
|
||
});
|
||
}
|
||
|
||
async function runTool(cmd) {
|
||
const { tool, input } = cmd;
|
||
const file = input.path ? path.join(REPO, input.path) : null;
|
||
if (tool === 'read_file') {
|
||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||
return { output: lines.map((l, i) => `${String(i + 1).padStart(3)}| ${l}`).join('\n') };
|
||
}
|
||
if (tool === 'edit_file') {
|
||
const src = fs.readFileSync(file, 'utf8');
|
||
const n = src.split(input.old_text).length - 1;
|
||
if (n !== 1) return { output: `错误:old_text 出现了 ${n} 次,需要恰好 1 次` };
|
||
fs.writeFileSync(file, src.replace(input.old_text, input.new_text));
|
||
return { output: `已修改 ${input.path}` };
|
||
}
|
||
if (tool === 'write_file') {
|
||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
fs.writeFileSync(file, input.content);
|
||
return { output: `已写入 ${input.path}(${input.content.length} 字节)` };
|
||
}
|
||
if (tool === 'run_command') {
|
||
const r = await sh(input.command, 15000);
|
||
trace('process_exit', { command: input.command, child_pid: r.pid, exit_code: r.code });
|
||
return { output: `退出码 ${r.code}\n${r.out.slice(-3000)}`, child_pid: r.pid };
|
||
}
|
||
if (tool === 'start_background') {
|
||
const jobId = `job-${jobSeq++}`;
|
||
const started = Date.now();
|
||
const child = spawn('/bin/sh', ['-c', input.command], { cwd: REPO, env: process.env });
|
||
let out = '';
|
||
child.stdout.on('data', (c) => (out += c));
|
||
child.stderr.on('data', (c) => (out += c));
|
||
trace('job_start', { job_id: jobId, command: input.command, child_pid: child.pid, session: cmd.session });
|
||
child.on('exit', async (code) => {
|
||
trace('job_exit', { job_id: jobId, child_pid: child.pid, exit_code: code, ms: Date.now() - started });
|
||
const tail = out.trim().split('\n').slice(-12).join('\n');
|
||
await postRetry(`/sessions/${cmd.session}/events`, { type: 'job_finished', job_id: jobId, command: input.command, exit_code: code, tail, ran_on: DEVICE, ran_pid: child.pid, by: `daemon:${process.pid}` });
|
||
});
|
||
return { output: `已在后台启动 ${jobId}(pid ${child.pid})`, child_pid: child.pid };
|
||
}
|
||
return { output: `未知工具 ${tool}` };
|
||
}
|
||
|
||
async function postRetry(p, b) {
|
||
for (let i = 0; i < 30; i++) {
|
||
try { return await (await fetch(CLOUD + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })).json(); }
|
||
catch { await new Promise((r) => setTimeout(r, 1000)); }
|
||
}
|
||
}
|
||
|
||
async function handle(cmd) {
|
||
const started = Date.now();
|
||
let result = done.get(cmd.call_id);
|
||
const replay = !!result;
|
||
if (!result) {
|
||
try { result = await runTool(cmd); } catch (e) { result = { output: `错误:${e.message}` }; }
|
||
done.set(cmd.call_id, result);
|
||
}
|
||
const repo_after = repoSnapshot();
|
||
trace('tool_exec', { call_id: cmd.call_id, tool: cmd.tool, input: cmd.input, cwd: REPO, ms: Date.now() - started, replay, output: result.output, child_pid: result.child_pid, repo_after });
|
||
await postRetry(`/devices/${DEVICE}/result`, { req_id: cmd.req_id, output: result.output, host: DEVICE, pid: process.pid, repo_after: { status: repo_after.status } });
|
||
}
|
||
|
||
let stopping = false;
|
||
async function loop() {
|
||
trace('daemon_start', { device: DEVICE, repo: REPO, repo_state: repoSnapshot() });
|
||
while (!stopping) {
|
||
let cmd = null;
|
||
try { cmd = await (await fetch(`${CLOUD}/devices/${DEVICE}/poll`)).json(); }
|
||
catch { await new Promise((r) => setTimeout(r, 1000)); continue; }
|
||
if (cmd) handle(cmd);
|
||
}
|
||
}
|
||
process.on('SIGTERM', async () => {
|
||
stopping = true;
|
||
trace('daemon_stop', { device: DEVICE, reason: 'laptop lid closed (SIGTERM)' });
|
||
try { await fetch(`${CLOUD}/devices/${DEVICE}/bye`, { method: 'POST' }); } catch {}
|
||
process.exit(0);
|
||
});
|
||
loop();
|