mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
Bring governed connection reviews into task history and composer approvals. Share resolution with Connections, add scoped remembered permissions, and resume agents through durable outcome receipts. Keep cards compact, collapse raw results, isolate untrusted provider output, bound continuation payloads, and reconcile missed live events. Add Storybook coverage, browser journeys, and service regression tests. Verification: all PR CI gates passed, Greptile 5/5, security scans passed, five connection-review browser journeys passed, and real native Codex approval/continuation was verified against the local MCP fixture. Co-Authored-By: Paperclip <noreply@paperclip.ing>
75 lines
2.2 KiB
TypeScript
75 lines
2.2 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
|
|
const controlPath = process.env.PAPERCLIP_REVIEW_RESTART_FILE;
|
|
if (!controlPath) throw new Error("PAPERCLIP_REVIEW_RESTART_FILE is required");
|
|
let child: ChildProcess;
|
|
let stopping = false;
|
|
let restarting = false;
|
|
function launch(first: boolean) {
|
|
const launched = spawn(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
"./cli/node_modules/tsx/dist/loader.mjs",
|
|
"cli/src/index.ts",
|
|
...(first ? ["onboard", "--yes", "--run"] : ["run"]),
|
|
],
|
|
{ stdio: "inherit", detached: true, env: process.env },
|
|
);
|
|
child = launched;
|
|
launched.once("exit", (code) => {
|
|
// An old child's exit notification may arrive after its replacement starts.
|
|
if (child === launched && !stopping && !restarting) process.exit(code ?? 1);
|
|
});
|
|
}
|
|
async function stopChild() {
|
|
const pid = child.pid;
|
|
if (!pid) return;
|
|
// tsx may exit before its server child. Track the owned process group, not
|
|
// only the launcher, so restarts and Playwright teardown cannot leak servers.
|
|
try {
|
|
process.kill(-pid, "SIGTERM");
|
|
} catch {
|
|
return;
|
|
}
|
|
const deadline = Date.now() + 2_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
process.kill(-pid, 0);
|
|
} catch {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
try {
|
|
process.kill(-pid, "SIGKILL");
|
|
} catch {}
|
|
}
|
|
for (const signal of ["SIGTERM", "SIGINT"] as const)
|
|
process.on(signal, () => {
|
|
stopping = true;
|
|
clearInterval(timer);
|
|
void stopChild().finally(() => process.exit(0));
|
|
});
|
|
// Also clean up after an unexpected supervisor exit; the server is detached
|
|
// only so the restart test can stop its entire owned process group.
|
|
process.on("exit", () => {
|
|
if (!child?.pid) return;
|
|
try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
});
|
|
launch(true);
|
|
const timer = setInterval(async () => {
|
|
if (stopping || restarting) return;
|
|
const command = await readFile(controlPath, "utf8").catch(() => "");
|
|
if (!command.startsWith("restart:")) return;
|
|
restarting = true;
|
|
try {
|
|
await stopChild();
|
|
launch(false);
|
|
await writeFile(controlPath, command.replace("restart:", "started:"));
|
|
} finally {
|
|
restarting = false;
|
|
}
|
|
}, 250);
|