@amenophis1er/foreman 0.1.14 → 0.1.16

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/src/services.ts CHANGED
@@ -16,11 +16,18 @@
16
16
  */
17
17
  import http from 'node:http';
18
18
  import net from 'node:net';
19
+ import { execFile } from 'node:child_process';
19
20
  import type { GuardVerdict } from './guard.js';
20
21
 
21
22
  export interface ExposedService {
22
23
  port: number;
23
24
  label: string;
25
+ /**
26
+ * The process listening on the port when the crew exposed it. Recorded so a
27
+ * stop later can prove it is still the same process — a port is a slot
28
+ * anyone can take, and Foreman must never kill something it did not start.
29
+ */
30
+ pid?: number;
24
31
  /** `/svc/<runId>/<port>/` — the path under the services origin, not Foreman's. */
25
32
  path: string;
26
33
  since: number;
@@ -53,14 +60,66 @@ export function portOpen(port: number, timeoutMs = 800): Promise<boolean> {
53
60
  });
54
61
  }
55
62
 
63
+ /**
64
+ * The pid of whatever is listening on a local port, via lsof. Null when
65
+ * nothing is, or when lsof is unavailable — in which case Foreman simply
66
+ * never offers to stop the service, which is the safe direction.
67
+ */
68
+ export async function listeningPid(port: number): Promise<number | null> {
69
+ return new Promise((resolve) => {
70
+ execFile('lsof', ['-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN'], { timeout: 3000 }, (err, stdout) => {
71
+ if (err) return resolve(null);
72
+ const pid = Number(String(stdout).split('\n')[0]?.trim());
73
+ resolve(Number.isInteger(pid) && pid > 0 ? pid : null);
74
+ });
75
+ });
76
+ }
77
+
78
+ /** Is this process still alive? Signal 0 asks without sending anything. */
79
+ function alive(pid: number): boolean {
80
+ try { process.kill(pid, 0); return true; } catch { return false; }
81
+ }
82
+
83
+ export type StopResult =
84
+ | { ok: true; how: 'term' | 'kill' }
85
+ | { ok: false; reason: string };
86
+
87
+ /**
88
+ * Stops a service the crew started, and only that one. The pid recorded when
89
+ * it was exposed must still be the process holding the port: if something
90
+ * else took the port since, or the recorded process is gone, this refuses
91
+ * rather than guessing. SIGTERM first, so a dev server gets to clean up.
92
+ */
93
+ export async function stopService(port: number, expectedPid: number | undefined, graceMs = 4000): Promise<StopResult> {
94
+ if (!expectedPid) return { ok: false, reason: 'Foreman did not record which process this was, so it will not kill anything.' };
95
+ const holder = await listeningPid(port);
96
+ if (holder === null) return { ok: false, reason: `nothing is listening on 127.0.0.1:${port} any more` };
97
+ if (holder !== expectedPid) {
98
+ return { ok: false, reason: `port ${port} is held by process ${holder} now, not the ${expectedPid} this run started — leaving it alone` };
99
+ }
100
+ try { process.kill(expectedPid, 'SIGTERM'); } catch (err) {
101
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
102
+ }
103
+ const deadline = Date.now() + graceMs;
104
+ while (Date.now() < deadline) {
105
+ await new Promise((r) => setTimeout(r, 150));
106
+ if (!alive(expectedPid)) return { ok: true, how: 'term' };
107
+ }
108
+ try { process.kill(expectedPid, 'SIGKILL'); } catch { /* already gone */ }
109
+ await new Promise((r) => setTimeout(r, 200));
110
+ return alive(expectedPid)
111
+ ? { ok: false, reason: `process ${expectedPid} did not stop` }
112
+ : { ok: true, how: 'kill' };
113
+ }
114
+
56
115
  export class ServiceRegistry {
57
116
  private byRun = new Map<string, ExposedService[]>();
58
117
 
59
- register(runId: string, port: number, label: string): ExposedService {
118
+ register(runId: string, port: number, label: string, pid?: number): ExposedService {
60
119
  const list = this.byRun.get(runId) ?? [];
61
120
  const existing = list.find((s) => s.port === port);
62
- if (existing) { existing.label = label || existing.label; return existing; }
63
- const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now() };
121
+ if (existing) { existing.label = label || existing.label; if (pid) existing.pid = pid; return existing; }
122
+ const svc: ExposedService = { port, label: label || `port ${port}`, path: servicePath(runId, port), since: Date.now(), pid };
64
123
  this.byRun.set(runId, [...list, svc]);
65
124
  return svc;
66
125
  }
package/src/types.ts CHANGED
@@ -315,7 +315,7 @@ export interface RunMeta {
315
315
  */
316
316
  allowedRoots?: string[];
317
317
  /** Dev servers the crew exposed through Foreman's proxy (see services.ts). */
318
- services?: Array<{ port: number; label: string; path: string; since: number }>;
318
+ services?: Array<{ port: number; label: string; path: string; since: number; pid?: number }>;
319
319
  /**
320
320
  * How long an approval card or director question may wait for the human
321
321
  * before it is resolved with its unattended default (deny / "decide
@@ -330,6 +330,11 @@ export interface RunMeta {
330
330
  * to offer the one action that helps — raise the budget and resume — and
331
331
  * a resume clears it. Absent when the run ended for any other reason.
332
332
  */
333
+ /**
334
+ * Percent of the cap at which the director is told to start winding down,
335
+ * frozen at dispatch like the cap itself. Absent means the default.
336
+ */
337
+ budgetWarnAt?: number;
333
338
  stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
334
339
  /** When the run's record was frozen (MISSION.md and deck copied beside it); absent on older runs. */
335
340
  snapshotAt?: number;