mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
<!-- Simplified Technical English (ASD-STE100). --> > **Stacked pull request.** This targets #11524. Merge #11524 first. Review only the second commit, `feat(runtime): managed Tailscale HTTPS lifecycle...`. ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip starts and supervises managed runtime services, so an agent's branch can be previewed while the agent works > - The previous pull request added the host broker, the shared contract, and the database columns, but no code used them > - A managed runtime can only be exposed over HTTPS if it holds a stable loopback port pair for the whole life of the service. The current control path cannot promise this: two controls can race the same execution workspace, a stranded control can stay `running` forever, and a start can adopt a port it does not own > - This pull request adds the HTTPS lifecycle and the control-path hardening that the lifecycle depends on > - The benefit is that a managed preview becomes reachable from another device, and a managed control now always reaches a terminal state ## Linked Issues or Issue Description No public GitHub issue exists. The change follows the feature request template. **Subsystem affected** Managed workspace runtime services, workspace operations, the execution workspace routes, and the workspace runtime UI. **Problem or motivation** A managed runtime service is reachable only on loopback, so a preview cannot be opened from a phone or a second computer. Exposing it safely needs an exclusively held port pair. Three existing gaps block that. Overlapping controls can race the same workspace. A control whose owner dies stays `running` and blocks the lane forever. Port allocation does not confirm that the process holding a port is the process Paperclip spawned. **Proposed solution** Add the exposure lifecycle on top of the broker from #11524: reserve before spawn, expose after readiness, validate the public URL, and remove on stop. In the same change, make managed controls mutually exclusive per workspace, give each control a durable issue-owned lease and a terminal state, and verify port ownership before use. **Alternatives considered** - Add HTTPS exposure without the control hardening. This was rejected because a raced or stranded control makes exposure point at the wrong process. - Guard the lane with an in-memory lock only. This was rejected because the lock does not survive a server restart, so the lane can be lost or double-claimed. - Trust the requested bind address. This was rejected because a checkout that predates managed HTTPS overwrites `PAPERCLIP_BIND` from its own `--bind` argument, and then binds the wildcard address. **Roadmap alignment** This completes the managed workspace runtime capability that already exists. It adds no new product surface beyond the HTTPS link. **Additional context** This is the second of three pull requests. The third adds central mediation of leased port pairs. ## What Changed Exposure lifecycle: - Add the server-side broker client and the exposure lifecycle manager. The manager reserves the mapping before spawn, exposes after backend readiness, validates the public URL, and removes the mapping on stop. - Default managed worktree runtimes to `tailscale_https`, read exposure intent from legacy `expose` blocks, and backfill runtimes that are still HTTP-only. - Verify listener ownership for the app port and its Vite HMR companion before the broker is asked to expose anything. An unrelated listener on either port fails the start closed. - Force the loopback bind through argv instead of environment hints. Leave a non-Paperclip service's `--bind` argument alone. - Probe loopback for readiness instead of the public URL, and give Vite HMR its own loopback-bound server in middleware mode. - Preserve operator-declared Serve mappings across the managed lifecycle, so cleanup never removes a mapping that Paperclip did not create. - Name which listener predicate denied an expose, so an operator can act on the message. Control-path hardening: - Make `start`, `stop`, `restart`, and job `run` mutually exclusive per execution workspace. An overlap gets `409 workspace_runtime_control_in_progress`, and authorization is still checked first. - Take a durable exclusivity lease on the execution workspace, owned by the controlling issue. A different issue gets `409 workspace_runtime_lease_conflict` before any operation is recorded. Board and operator actions bypass the lease. - Give every control a terminal state. Each control stamps its owning process and pid, heartbeats while it runs, and has a wall-clock ceiling. Recovery of a stranded control uses a compare-and-swap on `updated_at`, so a live owner is never stolen. - Bound readiness probes, verify allocated port ownership on POSIX and Windows, harden sibling port allocation, and reconcile desired runtimes on server startup. - Surface exposure state and bounded runtime errors in the workspace runtime UI. - Record the new behavior in `doc/DEVELOPING.md`. ## Verification Focused checks, all run on this branch: - `npx tsc --noEmit -p server/tsconfig.json` — 139 errors, exactly the count on `master`. All 139 come from the unbuilt `@paperclipai/plugin-sdk` package. - `pnpm --filter @paperclipai/ui typecheck` — clean. - Server suites, 177 tests pass across 9 files: `workspace-runtime.test.ts`, `workspace-runtime-leases.test.ts`, `workspace-runtime-control-recovery.test.ts`, `execution-workspace-runtime-control-conflict.test.ts`, `execution-workspace-runtime-lease-route.test.ts`, `workspace-operations-reconciliation.test.ts`, `workspace-runtime-start-terminality.test.ts`, `app-hmr-port.test.ts`, and `workspace-runtime-ready-comment.test.ts`. - Exposure unit suites, 77 tests pass: `src/services/runtime-exposure/` and `workspace-runtime-exposure-backfill.test.ts`. - UI: `WorkspaceRuntimeControls.test.tsx` and `WorkspaceServiceControlBar.test.tsx` — 34 tests pass. **One suite is red on the development host and is expected to be green in CI.** `server/src/services/workspace-runtime-exposure.test.ts` has 10 failures on the machine used to write this branch. The cause is host contamination, not the code. That machine already runs an HTTPS canary that holds ports 42000, 42001, 52000, and 52001 on a tailnet address. The suite allocates from the same range, so the new listener-ownership check correctly reports: ``` listener_ownership_mismatch — port 42000 is bound to 100.123.243.20, 127.0.0.1, fd7a:115c:a1e0:0:0:0:dd3a:f314 ... instead of loopback only ``` A CI runner has no listener on those ports, so the check sees loopback only and the suite passes. Please confirm this from the CI result on this pull request rather than from a local run on a host that already exposes a managed runtime. This is a real weakness of the current test fixture, and the third pull request in the series removes it by allocating the pair through a central mediator instead of a stubbed availability check. `workspace-runtime-https-live-exercise.test.ts` needs a live `tailscale` host and was not run locally. ## Risks - This is the behavior-bearing pull request of the three, so it carries the most risk. - Two new `409` responses appear on managed control routes. A caller that assumed a control always starts must handle a conflict. Board and operator actions are deliberately exempt, so an agent lease cannot lock an operator out. - Managed worktree runtimes now default to `tailscale_https`. If the host has no working broker, the start fails closed and reports the exposure failure instead of silently serving plain HTTP. This is intended, and it is the reason the failure message names the denying predicate. - Startup reconciliation touches persisted runtime rows. It is scoped to desired state and does not resurrect a service that never came up. - The lease has a 30-minute time to live and explicit release paths, so a crashed owner cannot hold a lane forever. - No migration runs in this pull request. The tables and columns land in #11524. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. ## Model Used Claude Opus 5 (`claude-opus-5`), 1M context window, extended thinking, with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass, with the one host-contaminated suite explained above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
709 lines
21 KiB
JavaScript
709 lines
21 KiB
JavaScript
#!/usr/bin/env -S node --import tsx
|
|
import { spawn } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { stdin, stdout } from "node:process";
|
|
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts";
|
|
import { collectWatchedSnapshot as collectDevServerWatchedSnapshot, diffSnapshots } from "./dev-runner-snapshot.mjs";
|
|
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
|
import { bootstrapDevRunnerWorktreeEnv, isWorktreeSeedPending } from "../server/src/dev-runner-worktree.ts";
|
|
import {
|
|
findAdoptableLocalService,
|
|
removeLocalServiceRegistryRecord,
|
|
touchLocalServiceRegistryRecord,
|
|
writeLocalServiceRegistryRecord,
|
|
} from "../server/src/services/local-service-supervisor.ts";
|
|
|
|
// Keep these values local so the dev runner can boot from the server package's
|
|
// tsx context without requiring workspace package resolution first.
|
|
const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
|
|
type BindMode = (typeof BIND_MODES)[number];
|
|
|
|
const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env);
|
|
if (worktreeEnvBootstrap.missingEnv) {
|
|
console.error(
|
|
`[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath)}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (isWorktreeSeedPending(repoRoot)) {
|
|
console.error(
|
|
"[paperclip] this worktree database is seed-pending. Run `pnpm paperclipai worktree ensure-seeded` before `pnpm dev`.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
|
const cliArgs = process.argv.slice(3);
|
|
const scanIntervalMs = 1500;
|
|
const autoRestartPollIntervalMs = 2500;
|
|
const gracefulShutdownTimeoutMs = 10_000;
|
|
const changedPathSampleLimit = 5;
|
|
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
|
|
const devServerRestartRequestFilePath = path.join(repoRoot, ".paperclip", "dev-server-restart-request.json");
|
|
const devServerStatusToken = mode === "dev" ? randomUUID() : null;
|
|
const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token";
|
|
|
|
const watchedDirectories = [
|
|
"cli",
|
|
"scripts",
|
|
"server",
|
|
"packages/adapter-utils",
|
|
"packages/adapters",
|
|
"packages/db",
|
|
"packages/skills-catalog",
|
|
"packages/plugins/sdk",
|
|
"packages/shared",
|
|
].map((relativePath) => path.join(repoRoot, relativePath));
|
|
|
|
const watchedFiles = [
|
|
".env",
|
|
"package.json",
|
|
"pnpm-workspace.yaml",
|
|
"tsconfig.base.json",
|
|
"tsconfig.json",
|
|
"vitest.config.ts",
|
|
].map((relativePath) => path.join(repoRoot, relativePath));
|
|
|
|
const ignoredDirectoryNames = new Set([
|
|
".git",
|
|
".turbo",
|
|
".vite",
|
|
"coverage",
|
|
"dist",
|
|
"node_modules",
|
|
"ui-dist",
|
|
]);
|
|
|
|
const ignoredRelativePaths = new Set([
|
|
".paperclip/dev-server-restart-request.json",
|
|
".paperclip/dev-server-status.json",
|
|
]);
|
|
|
|
const tailscaleAuthFlagNames = new Set([
|
|
"--tailscale-auth",
|
|
"--authenticated-private",
|
|
]);
|
|
|
|
let tailscaleAuth = false;
|
|
let bindMode: BindMode | null = null;
|
|
let bindHost: string | null = null;
|
|
const managedRuntimeExposure = process.env.PAPERCLIP_MANAGED_RUNTIME_EXPOSURE === "tailscale_https";
|
|
const forwardedArgs: string[] = [];
|
|
|
|
for (let index = 0; index < cliArgs.length; index += 1) {
|
|
const arg = cliArgs[index];
|
|
if (tailscaleAuthFlagNames.has(arg)) {
|
|
tailscaleAuth = true;
|
|
continue;
|
|
}
|
|
if (arg === "--bind") {
|
|
const value = cliArgs[index + 1];
|
|
if (!value || value.startsWith("--") || !BIND_MODES.includes(value as BindMode)) {
|
|
console.error(`[paperclip] invalid --bind value. Use one of: ${BIND_MODES.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
bindMode = value as BindMode;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--bind-host") {
|
|
const value = cliArgs[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
console.error("[paperclip] --bind-host requires a value");
|
|
process.exit(1);
|
|
}
|
|
bindHost = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
forwardedArgs.push(arg);
|
|
}
|
|
|
|
if (process.env.npm_config_tailscale_auth === "true") {
|
|
tailscaleAuth = true;
|
|
}
|
|
if (process.env.npm_config_authenticated_private === "true") {
|
|
tailscaleAuth = true;
|
|
}
|
|
if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.npm_config_bind as BindMode)) {
|
|
bindMode = process.env.npm_config_bind as BindMode;
|
|
}
|
|
if (!bindHost && process.env.npm_config_bind_host) {
|
|
bindHost = process.env.npm_config_bind_host;
|
|
}
|
|
if (managedRuntimeExposure) {
|
|
bindMode = "custom";
|
|
bindHost = "127.0.0.1";
|
|
}
|
|
if (bindMode === "custom" && !bindHost) {
|
|
console.error("[paperclip] --bind custom requires --bind-host <host>");
|
|
process.exit(1);
|
|
}
|
|
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
|
|
};
|
|
|
|
if (mode === "dev") {
|
|
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
|
|
env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? "";
|
|
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
|
}
|
|
|
|
if (mode === "watch") {
|
|
delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
|
|
env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
|
|
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
|
|
}
|
|
|
|
if (tailscaleAuth || bindMode) {
|
|
const effectiveBind = bindMode ?? "lan";
|
|
if (tailscaleAuth) {
|
|
console.log("[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan");
|
|
}
|
|
env.PAPERCLIP_BIND = effectiveBind;
|
|
if (bindHost) {
|
|
env.PAPERCLIP_BIND_HOST = bindHost;
|
|
} else {
|
|
delete env.PAPERCLIP_BIND_HOST;
|
|
}
|
|
if (effectiveBind === "loopback" && !tailscaleAuth) {
|
|
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
|
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
|
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
|
console.log("[paperclip] dev mode: local_trusted (bind=loopback)");
|
|
} else {
|
|
env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
|
env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
|
|
env.PAPERCLIP_AUTH_BASE_URL_MODE = managedRuntimeExposure ? "explicit" : "auto";
|
|
console.log(
|
|
`[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`,
|
|
);
|
|
}
|
|
} else {
|
|
delete env.PAPERCLIP_BIND;
|
|
delete env.PAPERCLIP_BIND_HOST;
|
|
delete env.PAPERCLIP_DEPLOYMENT_MODE;
|
|
delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
|
delete env.PAPERCLIP_AUTH_BASE_URL_MODE;
|
|
console.log("[paperclip] dev mode: local_trusted (default)");
|
|
}
|
|
|
|
const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100;
|
|
const devService = createDevServiceIdentity({
|
|
mode,
|
|
forwardedArgs,
|
|
networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"),
|
|
port: serverPort,
|
|
});
|
|
|
|
const existingRunner = await findAdoptableLocalService({
|
|
serviceKey: devService.serviceKey,
|
|
cwd: repoRoot,
|
|
envFingerprint: devService.envFingerprint,
|
|
port: serverPort,
|
|
});
|
|
if (existingRunner) {
|
|
console.log(
|
|
`[paperclip] ${devService.serviceName} already running (pid ${existingRunner.pid}${typeof existingRunner.metadata?.childPid === "number" ? `, child ${existingRunner.metadata.childPid}` : ""})`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
let previousSnapshot = collectWatchedSnapshot();
|
|
let dirtyPaths = new Set<string>();
|
|
let pendingMigrations: string[] = [];
|
|
let lastChangedAt: string | null = null;
|
|
let lastRestartAt: string | null = null;
|
|
let scanInFlight = false;
|
|
let restartInFlight = false;
|
|
let shuttingDown = false;
|
|
let childExitWasExpected = false;
|
|
let child: ReturnType<typeof spawn> | null = null;
|
|
let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null;
|
|
let scanTimer: ReturnType<typeof setInterval> | null = null;
|
|
let autoRestartTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function toError(error: unknown, context = "Dev runner command failed") {
|
|
if (error instanceof Error) return error;
|
|
if (error === undefined) return new Error(context);
|
|
if (typeof error === "string") return new Error(`${context}: ${error}`);
|
|
|
|
try {
|
|
return new Error(`${context}: ${JSON.stringify(error)}`);
|
|
} catch {
|
|
return new Error(`${context}: ${String(error)}`);
|
|
}
|
|
}
|
|
|
|
process.on("uncaughtException", async (error) => {
|
|
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
|
const err = toError(error, "Uncaught exception in dev runner");
|
|
process.stderr.write(`${err.stack ?? err.message}\n`);
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on("unhandledRejection", async (reason) => {
|
|
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
|
const err = toError(reason, "Unhandled promise rejection in dev runner");
|
|
process.stderr.write(`${err.stack ?? err.message}\n`);
|
|
process.exit(1);
|
|
});
|
|
|
|
function formatPendingMigrationSummary(migrations: string[]) {
|
|
if (migrations.length === 0) return "none";
|
|
return migrations.length > 3
|
|
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
|
: migrations.join(", ");
|
|
}
|
|
|
|
function exitForSignal(signal: NodeJS.Signals) {
|
|
if (signal === "SIGINT") {
|
|
process.exit(130);
|
|
}
|
|
if (signal === "SIGTERM") {
|
|
process.exit(143);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
function collectWatchedSnapshot() {
|
|
return collectDevServerWatchedSnapshot({
|
|
repoRoot,
|
|
watchedDirectories,
|
|
watchedFiles,
|
|
ignoredDirectoryNames,
|
|
ignoredRelativePaths,
|
|
}) as Map<string, string>;
|
|
}
|
|
|
|
function ensureDevStatusDirectory() {
|
|
mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true });
|
|
}
|
|
|
|
function writeDevServerStatus() {
|
|
if (mode !== "dev") return;
|
|
|
|
ensureDevStatusDirectory();
|
|
const changedPaths = [...dirtyPaths].sort();
|
|
writeFileSync(
|
|
devServerStatusFilePath,
|
|
`${JSON.stringify({
|
|
dirty: changedPaths.length > 0 || pendingMigrations.length > 0,
|
|
lastChangedAt,
|
|
changedPathCount: changedPaths.length,
|
|
changedPathsSample: changedPaths.slice(0, changedPathSampleLimit),
|
|
pendingMigrations,
|
|
lastRestartAt,
|
|
}, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function clearDevServerStatus() {
|
|
if (mode !== "dev") return;
|
|
rmSync(devServerStatusFilePath, { force: true });
|
|
rmSync(devServerRestartRequestFilePath, { force: true });
|
|
}
|
|
|
|
function consumeDevServerRestartRequest() {
|
|
if (mode !== "dev" || !existsSync(devServerRestartRequestFilePath)) return false;
|
|
rmSync(devServerRestartRequestFilePath, { force: true });
|
|
return true;
|
|
}
|
|
|
|
async function updateDevServiceRecord(extra?: Record<string, unknown>) {
|
|
await writeLocalServiceRegistryRecord({
|
|
version: 1,
|
|
serviceKey: devService.serviceKey,
|
|
profileKind: "paperclip-dev",
|
|
serviceName: devService.serviceName,
|
|
command: "dev-runner.ts",
|
|
cwd: repoRoot,
|
|
envFingerprint: devService.envFingerprint,
|
|
port: serverPort,
|
|
url: `http://127.0.0.1:${serverPort}`,
|
|
pid: process.pid,
|
|
processGroupId: null,
|
|
provider: "local_process",
|
|
runtimeServiceId: null,
|
|
reuseKey: null,
|
|
startedAt: lastRestartAt ?? new Date().toISOString(),
|
|
lastSeenAt: new Date().toISOString(),
|
|
metadata: {
|
|
repoRoot,
|
|
mode,
|
|
childPid: child?.pid ?? null,
|
|
url: `http://127.0.0.1:${serverPort}`,
|
|
...extra,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function runPnpm(args: string[], options: {
|
|
stdio?: "inherit" | ["ignore", "pipe", "pipe"];
|
|
env?: NodeJS.ProcessEnv;
|
|
cwd?: string;
|
|
} = {}) {
|
|
return await new Promise<{ code: number; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolve, reject) => {
|
|
const spawned = spawn(pnpmBin, args, {
|
|
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
env: options.env ?? process.env,
|
|
cwd: options.cwd,
|
|
shell: process.platform === "win32",
|
|
});
|
|
|
|
const stdoutBuffer = createCapturedOutputBuffer();
|
|
const stderrBuffer = createCapturedOutputBuffer();
|
|
|
|
if (spawned.stdout) {
|
|
spawned.stdout.on("data", (chunk) => {
|
|
stdoutBuffer.append(chunk);
|
|
});
|
|
}
|
|
if (spawned.stderr) {
|
|
spawned.stderr.on("data", (chunk) => {
|
|
stderrBuffer.append(chunk);
|
|
});
|
|
}
|
|
|
|
spawned.on("error", reject);
|
|
spawned.on("exit", (code, signal) => {
|
|
const stdout = stdoutBuffer.finish();
|
|
const stderr = stderrBuffer.finish();
|
|
resolve({
|
|
code: code ?? 0,
|
|
signal,
|
|
stdout: stdout.text,
|
|
stderr: stderr.text,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function getMigrationStatusPayload() {
|
|
const status = await runPnpm(
|
|
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
|
{ env },
|
|
);
|
|
if (status.code !== 0) {
|
|
process.stderr.write(
|
|
status.stderr ||
|
|
status.stdout ||
|
|
`[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`,
|
|
);
|
|
process.exit(status.code);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] };
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
status.stderr ||
|
|
status.stdout ||
|
|
"[paperclip] migration-status returned invalid JSON payload\n",
|
|
);
|
|
throw toError(error, "Unable to parse migration-status JSON output");
|
|
}
|
|
}
|
|
|
|
async function refreshPendingMigrations() {
|
|
const payload = await getMigrationStatusPayload();
|
|
pendingMigrations =
|
|
payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations)
|
|
? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
: [];
|
|
writeDevServerStatus();
|
|
return payload;
|
|
}
|
|
|
|
async function maybePreflightMigrations(options: { interactive?: boolean; autoApply?: boolean; exitOnDecline?: boolean } = {}) {
|
|
const interactive = options.interactive ?? mode === "watch";
|
|
const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
|
|
const exitOnDecline = options.exitOnDecline ?? mode === "watch";
|
|
|
|
const payload = await refreshPendingMigrations();
|
|
if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let shouldApply = autoApply;
|
|
|
|
if (!autoApply && interactive) {
|
|
if (!stdin.isTTY || !stdout.isTTY) {
|
|
shouldApply = true;
|
|
} else {
|
|
const prompt = createInterface({ input: stdin, output: stdout });
|
|
try {
|
|
const answer = (
|
|
await prompt.question(
|
|
`Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
|
|
)
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
shouldApply = answer === "y" || answer === "yes";
|
|
} finally {
|
|
prompt.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!shouldApply) {
|
|
if (exitOnDecline) {
|
|
process.stderr.write(
|
|
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). Refusing to start watch mode against a stale schema.\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const exit = await runPnpm(["db:migrate"], {
|
|
stdio: "inherit",
|
|
env,
|
|
cwd: repoRoot,
|
|
});
|
|
if (exit.signal) {
|
|
exitForSignal(exit.signal);
|
|
return;
|
|
}
|
|
if (exit.code !== 0) {
|
|
process.exit(exit.code);
|
|
}
|
|
|
|
await refreshPendingMigrations();
|
|
}
|
|
|
|
async function buildPluginSdk() {
|
|
console.log("[paperclip] building plugin sdk...");
|
|
const result = await runPnpm(
|
|
["--filter", "@paperclipai/plugin-sdk", "build"],
|
|
{ stdio: "inherit" },
|
|
);
|
|
if (result.signal) {
|
|
exitForSignal(result.signal);
|
|
return;
|
|
}
|
|
if (result.code !== 0) {
|
|
console.error("[paperclip] plugin sdk build failed");
|
|
process.exit(result.code);
|
|
}
|
|
}
|
|
|
|
async function markChildAsCurrent() {
|
|
previousSnapshot = collectWatchedSnapshot();
|
|
dirtyPaths = new Set();
|
|
lastChangedAt = null;
|
|
lastRestartAt = new Date().toISOString();
|
|
await refreshPendingMigrations();
|
|
await updateDevServiceRecord();
|
|
}
|
|
|
|
async function scanForBackendChanges() {
|
|
if (mode !== "dev" || scanInFlight || restartInFlight) return;
|
|
scanInFlight = true;
|
|
try {
|
|
const nextSnapshot = collectWatchedSnapshot();
|
|
const changed = diffSnapshots(previousSnapshot, nextSnapshot);
|
|
previousSnapshot = nextSnapshot;
|
|
if (changed.length === 0) return;
|
|
|
|
for (const relativePath of changed) {
|
|
dirtyPaths.add(relativePath);
|
|
}
|
|
lastChangedAt = new Date().toISOString();
|
|
await refreshPendingMigrations();
|
|
} finally {
|
|
scanInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function getDevHealthPayload() {
|
|
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, {
|
|
headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Health request failed (${response.status})`);
|
|
}
|
|
return await parseJsonResponseWithLimit(response);
|
|
}
|
|
|
|
async function waitForChildExit() {
|
|
if (!childExitPromise) {
|
|
return { code: 0, signal: null };
|
|
}
|
|
return await childExitPromise;
|
|
}
|
|
|
|
async function stopChildForRestart() {
|
|
if (!child) return { code: 0, signal: null };
|
|
childExitWasExpected = true;
|
|
child.kill("SIGTERM");
|
|
const killTimer = setTimeout(() => {
|
|
if (child) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, gracefulShutdownTimeoutMs);
|
|
try {
|
|
return await waitForChildExit();
|
|
} finally {
|
|
clearTimeout(killTimer);
|
|
}
|
|
}
|
|
|
|
async function startServerChild() {
|
|
await buildPluginSdk();
|
|
|
|
const serverScript = mode === "watch" ? "dev:watch" : "dev";
|
|
child = spawn(
|
|
pnpmBin,
|
|
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs],
|
|
{ stdio: "inherit", env, shell: process.platform === "win32" },
|
|
);
|
|
|
|
childExitPromise = new Promise((resolve, reject) => {
|
|
child?.on("error", reject);
|
|
child?.on("exit", (code, signal) => {
|
|
const expected = childExitWasExpected;
|
|
childExitWasExpected = false;
|
|
child = null;
|
|
childExitPromise = null;
|
|
void touchLocalServiceRegistryRecord(devService.serviceKey, {
|
|
metadata: {
|
|
repoRoot,
|
|
mode,
|
|
childPid: null,
|
|
url: `http://127.0.0.1:${serverPort}`,
|
|
},
|
|
});
|
|
resolve({ code: code ?? 0, signal });
|
|
|
|
if (restartInFlight || expected || shuttingDown) {
|
|
return;
|
|
}
|
|
if (signal) {
|
|
exitForSignal(signal);
|
|
return;
|
|
}
|
|
process.exit(code ?? 0);
|
|
});
|
|
});
|
|
|
|
await markChildAsCurrent();
|
|
}
|
|
|
|
async function maybeAutoRestartChild() {
|
|
if (mode !== "dev" || restartInFlight || !child) return;
|
|
const manualRestartRequested = consumeDevServerRestartRequest();
|
|
if (!manualRestartRequested && dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
|
|
|
|
restartInFlight = true;
|
|
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
|
|
try {
|
|
health = await getDevHealthPayload();
|
|
} catch {
|
|
restartInFlight = false;
|
|
return;
|
|
}
|
|
|
|
const devServer = health?.devServer;
|
|
if (!devServer?.enabled) {
|
|
restartInFlight = false;
|
|
return;
|
|
}
|
|
if (!manualRestartRequested && devServer.autoRestartEnabled !== true) {
|
|
restartInFlight = false;
|
|
return;
|
|
}
|
|
if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) {
|
|
restartInFlight = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await maybePreflightMigrations({
|
|
autoApply: true,
|
|
interactive: false,
|
|
exitOnDecline: false,
|
|
});
|
|
await stopChildForRestart();
|
|
await startServerChild();
|
|
} catch (error) {
|
|
const err = toError(error, "Auto-restart failed");
|
|
process.stderr.write(`${err.stack ?? err.message}\n`);
|
|
process.exit(1);
|
|
} finally {
|
|
restartInFlight = false;
|
|
}
|
|
}
|
|
|
|
function installDevIntervals() {
|
|
if (mode !== "dev") return;
|
|
|
|
scanTimer = setInterval(() => {
|
|
void scanForBackendChanges();
|
|
}, scanIntervalMs);
|
|
autoRestartTimer = setInterval(() => {
|
|
void maybeAutoRestartChild();
|
|
}, autoRestartPollIntervalMs);
|
|
}
|
|
|
|
function clearDevIntervals() {
|
|
if (scanTimer) {
|
|
clearInterval(scanTimer);
|
|
scanTimer = null;
|
|
}
|
|
if (autoRestartTimer) {
|
|
clearInterval(autoRestartTimer);
|
|
autoRestartTimer = null;
|
|
}
|
|
}
|
|
|
|
async function shutdown(signal: NodeJS.Signals) {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
clearDevIntervals();
|
|
clearDevServerStatus();
|
|
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
|
|
|
if (!child) {
|
|
exitForSignal(signal);
|
|
return;
|
|
}
|
|
|
|
childExitWasExpected = true;
|
|
child.kill(signal);
|
|
const exit = await waitForChildExit();
|
|
if (exit.signal) {
|
|
exitForSignal(exit.signal);
|
|
return;
|
|
}
|
|
process.exit(exit.code ?? 0);
|
|
}
|
|
|
|
process.on("SIGINT", () => {
|
|
void shutdown("SIGINT");
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
void shutdown("SIGTERM");
|
|
});
|
|
|
|
await maybePreflightMigrations();
|
|
await startServerChild();
|
|
installDevIntervals();
|
|
|
|
if (mode === "watch") {
|
|
const exit = await waitForChildExit();
|
|
await removeLocalServiceRegistryRecord(devService.serviceKey);
|
|
if (exit.signal) {
|
|
exitForSignal(exit.signal);
|
|
}
|
|
process.exit(exit.code ?? 0);
|
|
}
|