@indigoai-us/hq-cli 5.345.168 → 5.345.169

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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Process start times, used to tell the owner of a pid from a later process
3
+ * that reused it (feedback cd56a501).
4
+ */
5
+ import { spawnSync } from "node:child_process";
6
+ /**
7
+ * A process's start time, or null if it cannot be read: `ps -o lstart=` on
8
+ * POSIX, the process StartTime from PowerShell on Windows.
9
+ */
10
+ export function processStartTime(pid) {
11
+ if (!Number.isInteger(pid) || pid <= 0)
12
+ return null;
13
+ try {
14
+ const result = process.platform === "win32"
15
+ ? spawnSync("powershell.exe", [
16
+ "-NoProfile",
17
+ "-NonInteractive",
18
+ "-Command",
19
+ `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`,
20
+ ], { encoding: "utf8", timeout: 10_000, windowsHide: true })
21
+ : spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
22
+ const text = (result.stdout ?? "").trim();
23
+ return result.status === 0 && text ? text : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** Real process-table identity for the refresh spawn lock. */
30
+ export const DEFAULT_OWNER_IDENTITY = {
31
+ selfStart: () => processStartTime(process.pid),
32
+ startOf: (pid) => processStartTime(pid),
33
+ };
34
+ //# sourceMappingURL=process-identity.js.map
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Supervised `hq doctor --fix` journal pulls (feedback cd56a501).
3
+ *
4
+ * A pull records `lastSync` only when its whole pass finishes. `--fix` used to
5
+ * kill its pull at 90s, so a large vault never refreshed its journal and the
6
+ * client-health hook reported it every day.
7
+ *
8
+ * Every `--fix` pull now runs under a small supervisor process. The
9
+ * supervisor writes a heartbeat while the pull runs, the pull's pid, and an
10
+ * exit file with the pull's real status when it ends. It stops the pull at
11
+ * {@link REFRESH_BACKGROUND_MAX_MS}. If the pull is still running at the
12
+ * foreground deadline, `--fix` leaves the supervisor running.
13
+ *
14
+ * One pull per HQ root. A run claims the root before it spawns
15
+ * ({@link claimRefreshRun}): holding an O_EXCL spawn lock, it checks that no
16
+ * other pull occupies the root and creates the pull's record exclusively. A
17
+ * second `--fix` started at the same moment either cannot get the lock or
18
+ * sees that record, and does not spawn.
19
+ *
20
+ * State lives in one file per company under
21
+ * `workspace/doctor/refresh-in-progress/`, and each run's output, heartbeat,
22
+ * pid and exit files are named by a per-run id.
23
+ *
24
+ * The next `--fix` harvests finished runs first: it reads the exit file and
25
+ * the log tail, reports the real outcome, and removes the record. A pull whose
26
+ * supervisor died is still alive while its process group is, until the cap.
27
+ * Past the cap the next run removes it as capped, and kills it only when the
28
+ * caller's identity check proves the pid is still that pull.
29
+ */
30
+ /** The supervisor stops a background pull after this long. */
31
+ export declare const REFRESH_BACKGROUND_MAX_MS: number;
32
+ /** The supervisor refreshes its heartbeat this often. */
33
+ export declare const REFRESH_HEARTBEAT_INTERVAL_MS = 10000;
34
+ /** A heartbeat older than this means the supervisor is gone. */
35
+ export declare const REFRESH_HEARTBEAT_STALE_MS = 60000;
36
+ /** After SIGTERM, the supervisor waits this long before SIGKILL of the pull's process group. */
37
+ export declare const REFRESH_KILL_GRACE_MS = 5000;
38
+ /**
39
+ * A record older than this is over the cap whatever its heartbeat says. The
40
+ * supervisor stops the pull at {@link REFRESH_BACKGROUND_MAX_MS} and kills it
41
+ * {@link REFRESH_KILL_GRACE_MS} later; this adds a heartbeat interval of slack.
42
+ */
43
+ export declare const REFRESH_RECORD_MAX_AGE_MS: number;
44
+ /**
45
+ * Reads a process's start time, to tell the owner of a pid from a later
46
+ * process that reused it. Supplied by the caller (it needs a process table
47
+ * probe). `null` means the start time could not be read.
48
+ */
49
+ export interface RefreshOwnerIdentity {
50
+ /** This process's start time. */
51
+ selfStart: () => string | null;
52
+ /** Start time of `pid`, or null if unreadable. */
53
+ startOf: (pid: number) => string | null;
54
+ }
55
+ /** Files for one supervised pull. */
56
+ export interface RefreshRunPaths {
57
+ id: string;
58
+ log: string;
59
+ exit: string;
60
+ heartbeat: string;
61
+ /** The supervisor writes the pull's pid (its process group on POSIX) here. */
62
+ pid: string;
63
+ /** The supervisor writes the pull's process start time (`ps -o lstart=`) here, on POSIX. */
64
+ start: string;
65
+ /** Create this file to ask the supervisor to stop the pull. */
66
+ cancel: string;
67
+ }
68
+ /** Exit file the supervisor writes when the pull ends. */
69
+ export interface RefreshRunExit {
70
+ status: number | null;
71
+ signal: string | null;
72
+ /** True when the supervisor stopped the pull because another operation waited on the lock. */
73
+ preempted?: boolean;
74
+ /** True when the pull was stopped at the background time cap. */
75
+ capped?: boolean;
76
+ }
77
+ export declare function safeRefreshSlug(slug: string): string;
78
+ /** Fresh per-run file names under workspace/doctor/refresh-runs/. */
79
+ export declare function newRefreshRunPaths(hqRoot: string, slug: string): RefreshRunPaths;
80
+ /** Write (or replace) the record for a pull of `slug`. Tests and recovery only; claims use {@link claimRefreshRun}. */
81
+ export declare function recordRefreshHandoff(hqRoot: string, slug: string, run: RefreshRunPaths, nowMs: number): void;
82
+ /**
83
+ * An unreadable record younger than this may still be mid-write (the
84
+ * exclusive-create fallback), so harvest does not delete it yet.
85
+ */
86
+ export declare const UNREADABLE_RECORD_SETTLE_MS = 10000;
87
+ /** Remove the record for `slug` if it still names `run`. */
88
+ export declare function clearRefreshRecord(hqRoot: string, slug: string, run: RefreshRunPaths): void;
89
+ /** Read a supervisor exit file, or null if the run has not ended. */
90
+ export declare function readRefreshRunExit(run: RefreshRunPaths): RefreshRunExit | null;
91
+ /** Last 64 KiB of a run's output: enough to classify, bounded in memory. */
92
+ export declare function readRefreshRunLog(run: RefreshRunPaths): string;
93
+ /** What an identity-checked killer needs to know about an orphaned pull. */
94
+ export interface OrphanPull {
95
+ pid: number;
96
+ /** Start time the supervisor recorded for the pull, or null if none. */
97
+ recordedStart: string | null;
98
+ /** When the record was written (the pull started at or before this). */
99
+ recordStartedAtMs: number;
100
+ }
101
+ /**
102
+ * Kills an orphaned pull only if it can prove the pid is still that pull.
103
+ * Supplied by the caller (it needs a process table probe); without one,
104
+ * harvest never kills.
105
+ */
106
+ export type OrphanPullKiller = (orphan: OrphanPull, nowMs: number) => void;
107
+ /** An orphan is only ever killed within this long of its record. */
108
+ export declare const REFRESH_ORPHAN_KILL_WINDOW_MS: number;
109
+ /** Delete a run's files. */
110
+ export declare function removeRefreshRun(run: RefreshRunPaths): void;
111
+ /** A run that has ended (or died) and been removed. */
112
+ export interface HarvestedRefreshRun {
113
+ slug: string;
114
+ exit: RefreshRunExit | null;
115
+ output: string;
116
+ }
117
+ /**
118
+ * Remove every run that is no longer alive and return what each one ended
119
+ * with. `exit` is null when the supervisor died without writing one. A run
120
+ * over the cap is reported as capped, and an orphaned pull still running past
121
+ * the cap is killed. Live runs are left in place. Unreadable records are
122
+ * deleted.
123
+ */
124
+ export declare function harvestRefreshHandoffs(hqRoot: string, nowMs: number, killOrphan?: OrphanPullKiller): HarvestedRefreshRun[];
125
+ /** Start time of the live pull for `slug`, or null. */
126
+ export declare function refreshHandoffStartedAt(hqRoot: string, slug: string, nowMs: number): number | null;
127
+ /**
128
+ * True when any `--fix` pull for this HQ root still occupies it: running,
129
+ * starting, orphaned, or capped and still inside its stop grace window.
130
+ */
131
+ export declare function anyRefreshHandoffAlive(hqRoot: string, nowMs: number): boolean;
132
+ /**
133
+ * Take the per-root spawn lock: an O_EXCL file holding this process's pid,
134
+ * its start time and a random token.
135
+ *
136
+ * An existing lock is taken over only when identity proves its owner is gone:
137
+ * the pid is dead, or the pid is alive but its start time differs from the
138
+ * recorded one (the pid was reused). Age never counts. If the owner's start
139
+ * time cannot be verified the lock is left alone and this returns null.
140
+ */
141
+ /** Releases the spawn lock; `stillHeld` re-reads the lock file for this holder's token. */
142
+ export type SpawnLockRelease = (() => void) & {
143
+ stillHeld: () => boolean;
144
+ };
145
+ export declare function acquireRefreshSpawnLock(hqRoot: string, nowMs: number, identity?: RefreshOwnerIdentity): SpawnLockRelease | null;
146
+ /** A spawn lock still held after this long is reported by `hq doctor`. */
147
+ export declare const REFRESH_SPAWN_LOCK_WARN_MS: number;
148
+ /**
149
+ * The current spawn lock holder and how long it has held the lock, or null
150
+ * when no lock is held. Read-only: never removes or changes the lock.
151
+ */
152
+ export declare function inspectRefreshSpawnLock(hqRoot: string, nowMs: number, identity?: RefreshOwnerIdentity): {
153
+ pid: number;
154
+ heldMs: number | null;
155
+ ownerGone: boolean;
156
+ } | null;
157
+ /**
158
+ * Release a spawn lock whose owner is proven gone (pid dead, or start time
159
+ * changed). A live or unverifiable owner's lock is left alone. Uses the same
160
+ * atomic takeover as a claim, then releases at once.
161
+ */
162
+ export declare function releaseDeadRefreshSpawnLock(hqRoot: string, nowMs: number, identity?: RefreshOwnerIdentity): void;
163
+ /**
164
+ * Claim this HQ root for a pull of `slug`; true means spawn.
165
+ *
166
+ * - The spawn lock is required. If another live or unverifiable process holds
167
+ * it, nothing is written and the claim fails.
168
+ * - Under the lock: no other pull may occupy the root, and the record is
169
+ * created exclusively, never overwritten. An existing record for `slug`
170
+ * wins unless it no longer occupies the root (its pull exited, or its
171
+ * owner and pull are proven gone); then it is moved aside atomically and
172
+ * the create is retried once.
173
+ * - The claim holds only if, after writing, the record still carries this
174
+ * claim's token and this process still holds the lock.
175
+ */
176
+ export declare function claimRefreshRun(hqRoot: string, slug: string, run: RefreshRunPaths, nowMs: number, identity?: RefreshOwnerIdentity): boolean;
177
+ /** How often the supervisor looks for another operation waiting on the lock. */
178
+ export declare const REFRESH_PREEMPT_POLL_MS = 500;
179
+ /**
180
+ * Supervisor program, run as
181
+ * `node -e SUPERVISOR log exit heartbeat pidFile cancel locksDir maxMs cmd ...args`.
182
+ *
183
+ * It appends the pull's output to `log`, writes the pull's pid to `pidFile`,
184
+ * refreshes `heartbeat`, and writes `{status, signal, preempted, capped}` to
185
+ * `exit` when the pull ends.
186
+ *
187
+ * Stopping: at `maxMs`, when `cancel` exists, or when another live process
188
+ * publishes a waiter for the operation lock the pull holds
189
+ * (`<lock>.wait-<pid>-*`; `sync` holds the lock shared, so only exclusive
190
+ * callers such as rescue and repairs wait), the supervisor stops the pull.
191
+ * On POSIX it sends SIGTERM to the pull's process group, and SIGKILL to the
192
+ * group after {@link REFRESH_KILL_GRACE_MS} in every case, so a grandchild
193
+ * cannot outlive it. SIGTERM is the same stop as Ctrl-C on `hq sync`: the
194
+ * lock's signal handler releases the lock, downloads are staged and renamed
195
+ * into place, and the journal is written by atomic rename. On Windows the
196
+ * stop is `taskkill /T /F` on the tree, which is a forced kill, not a
197
+ * cooperative one; the same staged writes and atomic journal keep completed
198
+ * work.
199
+ */
200
+ export declare const REFRESH_SUPERVISOR_SOURCE = "\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst { spawn, spawnSync } = require(\"node:child_process\");\nconst [logPath, exitPath, hbPath, pidPath, cancelPath, locksDir, maxMs, cmd, ...args] = process.argv.slice(1);\nconst isWin = process.platform === \"win32\";\nconst beat = () => { try { fs.writeFileSync(hbPath, String(Date.now())); } catch {} };\nbeat();\nconst hb = setInterval(beat, 10000);\nlet fd;\ntry { fd = fs.openSync(logPath, \"a\", 0o600); } catch { fd = \"ignore\"; }\nlet finished = false;\nlet preempted = false;\nlet capped = false;\nlet stopAt = 0;\nlet watch;\n// Its own process group on POSIX, so a stop reaches every process the pull started.\nconst child = spawn(cmd, args, { stdio: [\"ignore\", fd, fd], windowsHide: true, detached: !isWin });\ntry { fs.writeFileSync(pidPath, String(child.pid)); } catch {}\nif (!isWin && child.pid) {\n try {\n const r = spawnSync(\"ps\", [\"-o\", \"lstart=\", \"-p\", String(child.pid)], { encoding: \"utf8\" });\n const started = (r.stdout || \"\").trim();\n if (started) fs.writeFileSync(path.join(path.dirname(pidPath), path.basename(pidPath, \".pid\") + \".start\"), started);\n } catch {}\n}\nconst killTree = (sig) => {\n if (isWin) {\n try { spawnSync(\"taskkill\", [\"/PID\", String(child.pid), \"/T\", \"/F\"], { stdio: \"ignore\", windowsHide: true }); } catch {}\n return;\n }\n try { process.kill(-child.pid, sig); } catch {}\n};\nconst stop = () => {\n if (stopAt) return;\n stopAt = Date.now();\n killTree(\"SIGTERM\");\n setTimeout(() => killTree(\"SIGKILL\"), 5000);\n};\nconst exitNow = (status) => process.exit(typeof status === \"number\" ? status : 1);\nconst done = (status, signal) => {\n if (finished) return;\n finished = true;\n clearInterval(hb);\n clearInterval(watch);\n clearTimeout(stopper);\n try {\n fs.writeFileSync(exitPath + \".tmp\", JSON.stringify({ status, signal, preempted, capped }));\n fs.renameSync(exitPath + \".tmp\", exitPath);\n } catch {}\n if (!stopAt) return exitNow(status);\n // A stopped pull's group is always SIGKILLed after the grace period, even\n // when the pull itself exited on SIGTERM, so no grandchild survives.\n const left = Math.max(0, stopAt + 5000 - Date.now());\n setTimeout(() => { killTree(\"SIGKILL\"); exitNow(status); }, left);\n};\nconst stopper = setTimeout(() => {\n capped = true;\n // At the cap the pull is no longer live: stop heartbeating so nothing reads it as in progress.\n clearInterval(hb);\n try { fs.writeFileSync(hbPath, \"capped:\" + Date.now()); } catch {}\n stop();\n}, Number(maxMs));\nconst alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return Boolean(e && e.code === \"EPERM\"); } };\nlet lockName = null;\nconst findLock = () => {\n let names;\n try { names = fs.readdirSync(locksDir); } catch { return null; }\n for (const name of names) {\n // A sync holds shared membership: <lock>.shared-<pid>-<hex>.\n const shared = /^(.+\\.lock)\\.shared-(\\d+)-/.exec(name);\n if (shared && Number(shared[2]) === child.pid) return shared[1];\n if (!name.endsWith(\".lock\")) continue;\n // An exclusive holder writes its record into the lock file itself.\n try {\n const info = JSON.parse(fs.readFileSync(path.join(locksDir, name), \"utf8\"));\n if (info && info.pid === child.pid) return name;\n } catch {}\n }\n return null;\n};\nconst someoneWaiting = () => {\n if (fs.existsSync(cancelPath)) return true;\n if (!lockName) lockName = findLock();\n if (!lockName) return false;\n let names;\n try { names = fs.readdirSync(locksDir); } catch { return false; }\n const prefix = lockName + \".wait-\";\n for (const name of names) {\n if (!name.startsWith(prefix)) continue;\n const waiter = Number(name.slice(prefix.length).split(\"-\")[0]);\n if (Number.isInteger(waiter) && waiter > 0 && waiter !== child.pid && waiter !== process.pid && alive(waiter)) return true;\n }\n return false;\n};\nwatch = setInterval(() => {\n if (finished || stopAt) return;\n if (someoneWaiting()) {\n preempted = true;\n stop();\n }\n}, 500);\nchild.on(\"error\", (error) => {\n try { fs.appendFileSync(logPath, String((error && error.code) || error) + \"\\n\"); } catch {}\n done(null, null);\n});\nchild.on(\"exit\", (status, signal) => done(status, signal));\n";
201
+ //# sourceMappingURL=refresh-sync-handoff.d.ts.map