@indigoai-us/hq-cli 5.276.0 → 5.277.0
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/CHANGELOG.md +76 -0
- package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
- package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
- package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
- package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
- package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
- package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
- package/dist/command-catalog.generated.d.ts +239 -1
- package/dist/command-catalog.generated.js +302 -1
- package/dist/commands/core-delegate.d.ts +6 -0
- package/dist/commands/core-delegate.js +987 -0
- package/dist/commands/core.js +10 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +173 -1
- package/dist/commands/dm.d.ts +2 -0
- package/dist/commands/dm.js +11 -4
- package/dist/commands/lanes-accounts.js +44 -2
- package/dist/commands/lanes-message.js +69 -12
- package/dist/commands/lanes-resume.js +2 -0
- package/dist/commands/lanes-shared.d.ts +3 -0
- package/dist/commands/lanes-shared.js +12 -2
- package/dist/commands/lanes.d.ts +5 -1
- package/dist/commands/lanes.js +399 -47
- package/dist/commands/secrets.d.ts +16 -1
- package/dist/commands/secrets.js +77 -48
- package/dist/commands/sync-mode.d.ts +11 -0
- package/dist/commands/sync-mode.js +30 -0
- package/dist/fast-scaffold.d.ts +17 -0
- package/dist/fast-scaffold.js +91 -0
- package/dist/index.js +17 -1
- package/dist/lanes-drain-hook.js +154 -7
- package/dist/lib/agent-kit/run/inbox.js +3 -2
- package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
- package/dist/lib/checkpoint-ontology/apply.js +60 -3
- package/dist/lib/checkpoint-ontology/plan.d.ts +13 -0
- package/dist/lib/checkpoint-ontology/plan.js +13 -0
- package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/tree.js +34 -0
- package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
- package/dist/lib/core-utils/jq-json-serializer.js +10 -0
- package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
- package/dist/lib/core-utils/secret-patterns.js +33 -0
- package/dist/lib/daemon/catalog.d.ts +18 -2
- package/dist/lib/daemon/catalog.js +18 -2
- package/dist/lib/daemon/config.d.ts +6 -0
- package/dist/lib/daemon/config.js +15 -2
- package/dist/lib/daemon/requests.d.ts +4 -0
- package/dist/lib/daemon/requests.js +2 -0
- package/dist/lib/daemon/run.js +94 -8
- package/dist/lib/daemon/supervisor.d.ts +8 -0
- package/dist/lib/daemon/supervisor.js +101 -1
- package/dist/lib/daemon/sync-runner.d.ts +58 -0
- package/dist/lib/daemon/sync-runner.js +277 -0
- package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/claude-balance.d.ts +6 -1
- package/dist/lib/lanes/claude-balance.js +46 -8
- package/dist/lib/lanes/config.js +15 -0
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
- package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.d.ts +3 -0
- package/dist/lib/lanes/keep-alive.js +3 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
- package/dist/lib/lanes/outpost-dispatch.js +11 -2
- package/dist/lib/lanes/spawn.js +17 -10
- package/dist/lib/lanes/store.js +45 -1
- package/dist/lib/lanes/types.d.ts +30 -1
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.js +3 -2
- package/dist/secrets-env-fast-path.d.ts +5 -0
- package/dist/secrets-env-fast-path.js +16 -0
- package/dist/secrets-env-fast-route.d.ts +18 -0
- package/dist/secrets-env-fast-route.js +54 -0
- package/package.json +2 -2
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import type { UnitPlan } from "./catalog.js";
|
|
25
25
|
import type { MachineType } from "./machine-type.js";
|
|
26
26
|
import type { ControlRequest } from "./requests.js";
|
|
27
|
+
import { syncPassResult } from "./sync-runner.js";
|
|
27
28
|
export declare const BACKOFF_BASE_MS = 1000;
|
|
28
29
|
export declare const BACKOFF_MAX_MS = 60000;
|
|
29
30
|
export declare const HEALTHY_RUN_MS = 60000;
|
|
@@ -34,6 +35,9 @@ export declare const HANDOFF_STAGGER_MS = 15000;
|
|
|
34
35
|
export declare const HANDOFF_DRAIN_MAX_MS: number;
|
|
35
36
|
export interface SupervisedChild {
|
|
36
37
|
pid?: number;
|
|
38
|
+
stdout?: {
|
|
39
|
+
on(event: "data" | "end", handler: (chunk?: Buffer | string) => void): unknown;
|
|
40
|
+
};
|
|
37
41
|
on(event: "exit", handler: (code: number | null, signal: string | null) => void): unknown;
|
|
38
42
|
on(event: "error", handler: (err: Error) => void): unknown;
|
|
39
43
|
kill(signal?: NodeJS.Signals | number): boolean;
|
|
@@ -57,6 +61,8 @@ export interface UnitState {
|
|
|
57
61
|
lastRunAt?: number;
|
|
58
62
|
nextRunAt?: number;
|
|
59
63
|
lastResult?: "ok" | "failed";
|
|
64
|
+
lastHeartbeatAt?: string;
|
|
65
|
+
lastPassResult?: ReturnType<typeof syncPassResult>;
|
|
60
66
|
}
|
|
61
67
|
/** An app that runs the daemon as its own child process instead of an OS unit. */
|
|
62
68
|
export type HostApp = "desktop";
|
|
@@ -99,6 +105,8 @@ export interface SupervisorDeps {
|
|
|
99
105
|
clearTimeout?: (handle: unknown) => void;
|
|
100
106
|
reconcileMs?: number;
|
|
101
107
|
requestPollMs?: number;
|
|
108
|
+
/** Read hq-cloud's lease status after it exits with the lease-conflict code. */
|
|
109
|
+
syncOwnerBlockReason?: () => string | undefined;
|
|
102
110
|
/** The running CLI's version; part of the key of units that run `hq` commands. */
|
|
103
111
|
cliVersion?: string;
|
|
104
112
|
/** Services to take over from the previous daemon instead of starting them. */
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Processes, time, and state output are injected; `run.ts` wires the real ones.
|
|
23
23
|
*/
|
|
24
|
+
import { StringDecoder } from "node:string_decoder";
|
|
25
|
+
import { SYNC_RUNNER_LEASE_CONFLICT_EXIT, SYNC_RUNNER_LEASE_RETRY_MS, parseSyncRunnerProtocolLine, syncPassResult, } from "./sync-runner.js";
|
|
24
26
|
export const BACKOFF_BASE_MS = 1_000;
|
|
25
27
|
export const BACKOFF_MAX_MS = 60_000;
|
|
26
28
|
export const HEALTHY_RUN_MS = 60_000;
|
|
@@ -39,7 +41,15 @@ export function backoffMs(attempt) {
|
|
|
39
41
|
* `keepAcrossCliUpdates` running.
|
|
40
42
|
*/
|
|
41
43
|
export function planKey(plan, cliVersion) {
|
|
42
|
-
const fields = [
|
|
44
|
+
const fields = [
|
|
45
|
+
plan.kind,
|
|
46
|
+
plan.restart,
|
|
47
|
+
plan.intervalSec,
|
|
48
|
+
plan.initialDelaySec,
|
|
49
|
+
plan.heartbeatTimeoutMs,
|
|
50
|
+
plan.command,
|
|
51
|
+
plan.env,
|
|
52
|
+
];
|
|
43
53
|
if (cliVersion !== undefined && plan.command?.type === "hq" && !plan.keepAcrossCliUpdates)
|
|
44
54
|
fields.push(cliVersion);
|
|
45
55
|
return JSON.stringify(fields);
|
|
@@ -89,6 +99,11 @@ export function createSupervisor(deps) {
|
|
|
89
99
|
clearT(slot.timer);
|
|
90
100
|
slot.timer = undefined;
|
|
91
101
|
};
|
|
102
|
+
const clearHeartbeatTimer = (slot) => {
|
|
103
|
+
if (slot.heartbeatTimer !== undefined)
|
|
104
|
+
clearT(slot.heartbeatTimer);
|
|
105
|
+
slot.heartbeatTimer = undefined;
|
|
106
|
+
};
|
|
92
107
|
const setBlocked = (slot) => {
|
|
93
108
|
const block = slot.plan.blocked;
|
|
94
109
|
slot.state.status = block.state;
|
|
@@ -111,6 +126,7 @@ export function createSupervisor(deps) {
|
|
|
111
126
|
*/
|
|
112
127
|
const terminate = (slot, then, drain = false) => {
|
|
113
128
|
clearTimer(slot);
|
|
129
|
+
clearHeartbeatTimer(slot);
|
|
114
130
|
const child = slot.child;
|
|
115
131
|
if (!child) {
|
|
116
132
|
then?.();
|
|
@@ -163,6 +179,69 @@ export function createSupervisor(deps) {
|
|
|
163
179
|
const watch = (slot, child) => {
|
|
164
180
|
live.add(child);
|
|
165
181
|
let ended = false;
|
|
182
|
+
const timeoutMs = slot.plan.heartbeatTimeoutMs;
|
|
183
|
+
const decoder = timeoutMs ? new StringDecoder("utf8") : null;
|
|
184
|
+
let stdoutRemainder = "";
|
|
185
|
+
let lastHeartbeatPersistedAt = now() - 15_000;
|
|
186
|
+
const armHeartbeatWatchdog = () => {
|
|
187
|
+
if (!timeoutMs)
|
|
188
|
+
return;
|
|
189
|
+
clearHeartbeatTimer(slot);
|
|
190
|
+
slot.heartbeatTimer = setT(() => {
|
|
191
|
+
slot.heartbeatTimer = undefined;
|
|
192
|
+
if (stopped || slot.child !== child || slot.expectingExit)
|
|
193
|
+
return;
|
|
194
|
+
deps.log("warn", `${slot.plan.id} emitted no sync protocol heartbeat for ${timeoutMs}ms; restarting it`);
|
|
195
|
+
terminate(slot, () => scheduleRestart(slot));
|
|
196
|
+
emit();
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
};
|
|
199
|
+
const acceptProtocolLine = (line) => {
|
|
200
|
+
let event;
|
|
201
|
+
try {
|
|
202
|
+
event = parseSyncRunnerProtocolLine(line);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
deps.log("error", `${slot.plan.id} emitted invalid sync protocol output (${Buffer.byteLength(line)} bytes): ${errorMessage(err)}`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!event)
|
|
209
|
+
return;
|
|
210
|
+
const at = now();
|
|
211
|
+
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
212
|
+
const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
|
|
213
|
+
if (pass)
|
|
214
|
+
slot.state.lastPassResult = pass;
|
|
215
|
+
armHeartbeatWatchdog();
|
|
216
|
+
if (pass || at - lastHeartbeatPersistedAt >= 15_000) {
|
|
217
|
+
lastHeartbeatPersistedAt = at;
|
|
218
|
+
emit();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
if (decoder && child.stdout) {
|
|
222
|
+
child.stdout.on("data", (chunk) => {
|
|
223
|
+
if (chunk === undefined)
|
|
224
|
+
return;
|
|
225
|
+
stdoutRemainder += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
226
|
+
let newline = stdoutRemainder.indexOf("\n");
|
|
227
|
+
while (newline >= 0) {
|
|
228
|
+
acceptProtocolLine(stdoutRemainder.slice(0, newline).trim());
|
|
229
|
+
stdoutRemainder = stdoutRemainder.slice(newline + 1);
|
|
230
|
+
newline = stdoutRemainder.indexOf("\n");
|
|
231
|
+
}
|
|
232
|
+
// A malformed or unexpectedly large line is not a heartbeat. Bound
|
|
233
|
+
// retained data so a broken child cannot grow daemon memory forever.
|
|
234
|
+
if (Buffer.byteLength(stdoutRemainder) > 1024 * 1024)
|
|
235
|
+
stdoutRemainder = "";
|
|
236
|
+
});
|
|
237
|
+
child.stdout.on("end", () => {
|
|
238
|
+
stdoutRemainder += decoder.end();
|
|
239
|
+
if (stdoutRemainder)
|
|
240
|
+
acceptProtocolLine(stdoutRemainder.trim());
|
|
241
|
+
stdoutRemainder = "";
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
armHeartbeatWatchdog();
|
|
166
245
|
const onEnd = (code, signal) => {
|
|
167
246
|
if (ended)
|
|
168
247
|
return;
|
|
@@ -173,6 +252,7 @@ export function createSupervisor(deps) {
|
|
|
173
252
|
if (slot.child !== child)
|
|
174
253
|
return;
|
|
175
254
|
slot.child = undefined;
|
|
255
|
+
clearHeartbeatTimer(slot);
|
|
176
256
|
if (slot.killTimer !== undefined) {
|
|
177
257
|
clearT(slot.killTimer);
|
|
178
258
|
slot.killTimer = undefined;
|
|
@@ -200,6 +280,20 @@ export function createSupervisor(deps) {
|
|
|
200
280
|
emit();
|
|
201
281
|
return;
|
|
202
282
|
}
|
|
283
|
+
if (slot.plan.service === "sync" && code === SYNC_RUNNER_LEASE_CONFLICT_EXIT) {
|
|
284
|
+
const reason = deps.syncOwnerBlockReason?.() ?? "sync owned by another runner";
|
|
285
|
+
slot.state.status = "waiting";
|
|
286
|
+
slot.state.reason = reason;
|
|
287
|
+
slot.state.nextRunAt = now() + SYNC_RUNNER_LEASE_RETRY_MS;
|
|
288
|
+
deps.log("info", `${reason}; retrying in ${SYNC_RUNNER_LEASE_RETRY_MS}ms`);
|
|
289
|
+
slot.timer = setT(() => {
|
|
290
|
+
slot.timer = undefined;
|
|
291
|
+
startLong(slot);
|
|
292
|
+
emit();
|
|
293
|
+
}, SYNC_RUNNER_LEASE_RETRY_MS);
|
|
294
|
+
emit();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
203
297
|
deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
|
|
204
298
|
if (ranFor >= HEALTHY_RUN_MS)
|
|
205
299
|
slot.attempts = 0;
|
|
@@ -505,6 +599,11 @@ export function createSupervisor(deps) {
|
|
|
505
599
|
}
|
|
506
600
|
};
|
|
507
601
|
const handleRequest = (req) => {
|
|
602
|
+
if (req.action === "reconcile") {
|
|
603
|
+
deps.log("info", "reconciling daemon configuration (requested)");
|
|
604
|
+
reconcile();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
508
607
|
if (req.action === "restart-daemon") {
|
|
509
608
|
deps.log("info", "restarting the daemon (requested)");
|
|
510
609
|
if (deps.restartDaemon)
|
|
@@ -614,6 +713,7 @@ export function createSupervisor(deps) {
|
|
|
614
713
|
clearT(handoffTimer);
|
|
615
714
|
for (const slot of slots.values()) {
|
|
616
715
|
clearTimer(slot);
|
|
716
|
+
clearHeartbeatTimer(slot);
|
|
617
717
|
slot.child = undefined;
|
|
618
718
|
if (slot.killTimer !== undefined)
|
|
619
719
|
clearT(slot.killTimer);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Local status and resource controls shared by the daemon and its sync runner. */
|
|
2
|
+
export declare const SYNC_RUNNER_OWNER = "hq-daemon";
|
|
3
|
+
export declare const SYNC_RUNNER_LEASE_CONFLICT_EXIT = 20;
|
|
4
|
+
export declare const SYNC_RUNNER_LEASE_RETRY_MS = 15000;
|
|
5
|
+
export declare const DEFAULT_SYNC_HEARTBEAT_TIMEOUT_MS = 300000;
|
|
6
|
+
export declare const DEFAULT_SYNC_RUNNER_HEAP_MB = 3584;
|
|
7
|
+
export declare const SYNC_HEARTBEAT_TIMEOUT_ENV = "HQ_DAEMON_SYNC_HEARTBEAT_TIMEOUT_SECONDS";
|
|
8
|
+
export declare const SYNC_RUNNER_HEAP_ENV = "HQ_SYNC_RUNNER_MAX_OLD_SPACE_MB";
|
|
9
|
+
export interface WatchRunnerOwnerStatus {
|
|
10
|
+
owner: string;
|
|
11
|
+
pid: number;
|
|
12
|
+
version: string;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
heartbeatAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SyncRunnerProcess {
|
|
17
|
+
pid: number;
|
|
18
|
+
commandLine: string;
|
|
19
|
+
}
|
|
20
|
+
export type SyncRunnerProcessList = {
|
|
21
|
+
ok: true;
|
|
22
|
+
processes: SyncRunnerProcess[];
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
error: string;
|
|
26
|
+
};
|
|
27
|
+
export interface ProcessListCommandResult {
|
|
28
|
+
status: number | null;
|
|
29
|
+
stdout: string;
|
|
30
|
+
stderr: string;
|
|
31
|
+
error?: Error;
|
|
32
|
+
}
|
|
33
|
+
export type ProcessListRunner = (command: string, args: string[]) => ProcessListCommandResult;
|
|
34
|
+
export interface SyncPassResult {
|
|
35
|
+
status: "ok" | "partial" | "failed";
|
|
36
|
+
completedAt: string;
|
|
37
|
+
companiesAttempted?: number;
|
|
38
|
+
errors: number;
|
|
39
|
+
transient: number;
|
|
40
|
+
}
|
|
41
|
+
/** The status file path used by hq-cloud's watch-owner lease. */
|
|
42
|
+
export declare function watchRunnerOwnerStatusPath(hqRoot: string, home?: string, env?: NodeJS.ProcessEnv): string;
|
|
43
|
+
/** Read the watch-owner status JSON without taking or changing its lease. */
|
|
44
|
+
export declare function readWatchRunnerOwnerStatus(statusPath: string): WatchRunnerOwnerStatus | null;
|
|
45
|
+
/** Match a live process command line to the hq-cloud watch runner executable. */
|
|
46
|
+
export declare function isSyncRunnerCommandLine(commandLine: string): boolean;
|
|
47
|
+
/** Return a clear wait reason when an unleased runner already targets this HQ root. */
|
|
48
|
+
export declare function syncRunnerUnleasedBlockReason(hqRoot: string, processes: readonly SyncRunnerProcess[], ownerPid: number | null, platform?: NodeJS.Platform, daemonRunnerPids?: ReadonlySet<number>): string | undefined;
|
|
49
|
+
/** Read complete process command lines so root matching can include trailing arguments. */
|
|
50
|
+
export declare function listSyncRunnerProcesses(platform?: NodeJS.Platform, run?: ProcessListRunner): SyncRunnerProcessList;
|
|
51
|
+
/** The watchdog follows the runner's NDJSON protocol, the same liveness signal used by the desktop app. */
|
|
52
|
+
export declare function parseSyncRunnerProtocolLine(line: string): Record<string, unknown> | null;
|
|
53
|
+
/** Summarize the runner's all-complete event for daemon status. */
|
|
54
|
+
export declare function syncPassResult(event: Record<string, unknown>, completedAt?: string): SyncPassResult | null;
|
|
55
|
+
export declare function syncHeartbeatTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
56
|
+
/** Preserve an explicit NODE_OPTIONS heap setting; otherwise apply the desktop runner's default/override. */
|
|
57
|
+
export declare function syncRunnerNodeOptions(env: NodeJS.ProcessEnv): string | undefined;
|
|
58
|
+
//# sourceMappingURL=sync-runner.d.ts.map
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/** Local status and resource controls shared by the daemon and its sync runner. */
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
export const SYNC_RUNNER_OWNER = "hq-daemon";
|
|
8
|
+
export const SYNC_RUNNER_LEASE_CONFLICT_EXIT = 20;
|
|
9
|
+
export const SYNC_RUNNER_LEASE_RETRY_MS = 15_000;
|
|
10
|
+
export const DEFAULT_SYNC_HEARTBEAT_TIMEOUT_MS = 300_000;
|
|
11
|
+
export const DEFAULT_SYNC_RUNNER_HEAP_MB = 3_584;
|
|
12
|
+
export const SYNC_HEARTBEAT_TIMEOUT_ENV = "HQ_DAEMON_SYNC_HEARTBEAT_TIMEOUT_SECONDS";
|
|
13
|
+
export const SYNC_RUNNER_HEAP_ENV = "HQ_SYNC_RUNNER_MAX_OLD_SPACE_MB";
|
|
14
|
+
const SYNC_RUNNER_COMMAND = /(?:^|[\s/\\])(?:sync-runner\.js|hq-sync-runner(?:\.(?:cmd|exe))?)(?:[\s"']|$)/i;
|
|
15
|
+
function record(value) {
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
17
|
+
? value
|
|
18
|
+
: null;
|
|
19
|
+
}
|
|
20
|
+
/** The status file path used by hq-cloud's watch-owner lease. */
|
|
21
|
+
export function watchRunnerOwnerStatusPath(hqRoot, home = os.homedir(), env = process.env) {
|
|
22
|
+
let canonicalRoot = path.resolve(hqRoot);
|
|
23
|
+
try {
|
|
24
|
+
canonicalRoot = fs.realpathSync.native(canonicalRoot);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
// hq-cloud uses the resolved path until the HQ root can be canonicalized.
|
|
28
|
+
if (error?.code !== "ENOENT") {
|
|
29
|
+
throw new Error(`could not canonicalize HQ root ${canonicalRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const rootHash = crypto.createHash("sha1").update(canonicalRoot).digest("hex").slice(0, 16);
|
|
33
|
+
const stateDir = env.HQ_STATE_DIR?.trim() || path.join(home, ".hq");
|
|
34
|
+
return path.join(stateDir, "locks", `watch-owner-${rootHash}.json`);
|
|
35
|
+
}
|
|
36
|
+
/** Read the watch-owner status JSON without taking or changing its lease. */
|
|
37
|
+
export function readWatchRunnerOwnerStatus(statusPath) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error?.code === "ENOENT")
|
|
44
|
+
return null;
|
|
45
|
+
throw new Error(`could not read watch-runner owner status at ${statusPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
46
|
+
}
|
|
47
|
+
const value = record(parsed);
|
|
48
|
+
if (!value ||
|
|
49
|
+
typeof value.owner !== "string" ||
|
|
50
|
+
!Number.isInteger(value.pid) ||
|
|
51
|
+
typeof value.version !== "string" ||
|
|
52
|
+
typeof value.startedAt !== "string" ||
|
|
53
|
+
typeof value.heartbeatAt !== "string") {
|
|
54
|
+
throw new Error(`watch-runner owner status at ${statusPath} has an invalid shape`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/** Match a live process command line to the hq-cloud watch runner executable. */
|
|
59
|
+
export function isSyncRunnerCommandLine(commandLine) {
|
|
60
|
+
return SYNC_RUNNER_COMMAND.test(commandLine);
|
|
61
|
+
}
|
|
62
|
+
function commandLineArgs(commandLine) {
|
|
63
|
+
const args = [];
|
|
64
|
+
let current = "";
|
|
65
|
+
let quote = null;
|
|
66
|
+
let started = false;
|
|
67
|
+
for (let i = 0; i < commandLine.length; i++) {
|
|
68
|
+
const char = commandLine[i];
|
|
69
|
+
if (quote) {
|
|
70
|
+
if (char === quote) {
|
|
71
|
+
quote = null;
|
|
72
|
+
started = true;
|
|
73
|
+
}
|
|
74
|
+
else if (char === "\\" && commandLine[i + 1] === quote) {
|
|
75
|
+
current += commandLine[++i];
|
|
76
|
+
started = true;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
current += char;
|
|
80
|
+
started = true;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (char === "'" || char === '"') {
|
|
85
|
+
quote = char;
|
|
86
|
+
started = true;
|
|
87
|
+
}
|
|
88
|
+
else if (/\s/.test(char)) {
|
|
89
|
+
if (started) {
|
|
90
|
+
args.push(current);
|
|
91
|
+
current = "";
|
|
92
|
+
started = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
current += char;
|
|
97
|
+
started = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (started)
|
|
101
|
+
args.push(current);
|
|
102
|
+
return args;
|
|
103
|
+
}
|
|
104
|
+
function syncRunnerHqRoot(commandLine) {
|
|
105
|
+
const args = commandLineArgs(commandLine);
|
|
106
|
+
for (let i = 0; i < args.length; i++) {
|
|
107
|
+
const arg = args[i];
|
|
108
|
+
if (arg === "--hq-root")
|
|
109
|
+
return args[i + 1] || null;
|
|
110
|
+
if (arg.startsWith("--hq-root="))
|
|
111
|
+
return arg.slice("--hq-root=".length) || null;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function commandLineNamesRoot(commandLine, hqRoot, platform) {
|
|
116
|
+
const parsedRoot = syncRunnerHqRoot(commandLine);
|
|
117
|
+
if (parsedRoot !== null && canonicalRoot(parsedRoot, platform) === canonicalRoot(hqRoot, platform))
|
|
118
|
+
return true;
|
|
119
|
+
const pathApi = platform === "win32" ? path.win32 : path;
|
|
120
|
+
const variants = new Set([pathApi.resolve(hqRoot)]);
|
|
121
|
+
if (platform === process.platform) {
|
|
122
|
+
try {
|
|
123
|
+
variants.add(fs.realpathSync.native(pathApi.resolve(hqRoot)));
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error?.code !== "ENOENT") {
|
|
127
|
+
throw new Error(`could not canonicalize sync runner HQ root ${hqRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const compareFlags = platform === "win32" ? "i" : "";
|
|
132
|
+
return [...variants].some((root) => {
|
|
133
|
+
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
134
|
+
return new RegExp(`(?:^|\\s)--hq-root(?:=|\\s+)["']?${escaped}["']?(?=\\s|$)`, compareFlags).test(commandLine);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function canonicalRoot(root, platform) {
|
|
138
|
+
const pathApi = platform === "win32" ? path.win32 : path;
|
|
139
|
+
let resolved = pathApi.resolve(root);
|
|
140
|
+
if (platform === process.platform) {
|
|
141
|
+
try {
|
|
142
|
+
resolved = fs.realpathSync.native(resolved);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error?.code !== "ENOENT") {
|
|
146
|
+
throw new Error(`could not canonicalize sync runner HQ root ${resolved}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
151
|
+
}
|
|
152
|
+
/** Return a clear wait reason when an unleased runner already targets this HQ root. */
|
|
153
|
+
export function syncRunnerUnleasedBlockReason(hqRoot, processes, ownerPid, platform = process.platform, daemonRunnerPids = new Set()) {
|
|
154
|
+
const runner = processes.find((candidate) => {
|
|
155
|
+
if (daemonRunnerPids.has(candidate.pid) || candidate.pid === ownerPid)
|
|
156
|
+
return false;
|
|
157
|
+
if (!isSyncRunnerCommandLine(candidate.commandLine))
|
|
158
|
+
return false;
|
|
159
|
+
return commandLineNamesRoot(candidate.commandLine, hqRoot, platform);
|
|
160
|
+
});
|
|
161
|
+
if (!runner)
|
|
162
|
+
return undefined;
|
|
163
|
+
const lease = ownerPid === null ? "missing" : `belongs to pid ${ownerPid}`;
|
|
164
|
+
return `sync runner pid ${runner.pid} for ${hqRoot} is running without the watch-owner lease (status ${lease}); waiting for it to exit`;
|
|
165
|
+
}
|
|
166
|
+
/** Read complete process command lines so root matching can include trailing arguments. */
|
|
167
|
+
export function listSyncRunnerProcesses(platform = process.platform, run = (command, args) => {
|
|
168
|
+
const result = spawnSync(command, args, {
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
timeout: 15_000,
|
|
172
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
status: result.status,
|
|
176
|
+
stdout: result.stdout ?? "",
|
|
177
|
+
stderr: result.stderr ?? "",
|
|
178
|
+
...(result.error ? { error: result.error } : {}),
|
|
179
|
+
};
|
|
180
|
+
}) {
|
|
181
|
+
const command = platform === "win32" ? "powershell.exe" : "ps";
|
|
182
|
+
const args = platform === "win32"
|
|
183
|
+
? [
|
|
184
|
+
"-NoProfile",
|
|
185
|
+
"-NonInteractive",
|
|
186
|
+
"-Command",
|
|
187
|
+
"Get-CimInstance Win32_Process | Select-Object @{Name='pid';Expression={$_.ProcessId}},@{Name='commandLine';Expression={$_.CommandLine}} | ConvertTo-Json -Compress",
|
|
188
|
+
]
|
|
189
|
+
: ["-eww", "-o", "pid=", "-o", "args="];
|
|
190
|
+
const result = run(command, args);
|
|
191
|
+
if (result.error)
|
|
192
|
+
return { ok: false, error: `could not list process command lines: ${result.error.message}` };
|
|
193
|
+
if (result.status !== 0) {
|
|
194
|
+
return { ok: false, error: `could not list process command lines: ${(result.stderr ?? "").trim() || `exit ${result.status}`}` };
|
|
195
|
+
}
|
|
196
|
+
if (platform === "win32") {
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(result.stdout || "[]");
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
return { ok: false, error: `could not parse Windows process list: ${error instanceof Error ? error.message : String(error)}` };
|
|
203
|
+
}
|
|
204
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
processes: rows.flatMap((row) => {
|
|
208
|
+
if (!row || typeof row !== "object")
|
|
209
|
+
return [];
|
|
210
|
+
const value = row;
|
|
211
|
+
return Number.isInteger(value.pid) && typeof value.commandLine === "string"
|
|
212
|
+
? [{ pid: value.pid, commandLine: value.commandLine }]
|
|
213
|
+
: [];
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const processes = [];
|
|
218
|
+
for (const line of (result.stdout ?? "").split(/\r?\n/)) {
|
|
219
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
220
|
+
if (match)
|
|
221
|
+
processes.push({ pid: Number(match[1]), commandLine: match[2] });
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, processes };
|
|
224
|
+
}
|
|
225
|
+
/** The watchdog follows the runner's NDJSON protocol, the same liveness signal used by the desktop app. */
|
|
226
|
+
export function parseSyncRunnerProtocolLine(line) {
|
|
227
|
+
if (!line.trim())
|
|
228
|
+
return null;
|
|
229
|
+
const parsed = record(JSON.parse(line));
|
|
230
|
+
return parsed && typeof parsed.type === "string" && parsed.type.length > 0 ? parsed : null;
|
|
231
|
+
}
|
|
232
|
+
/** Summarize the runner's all-complete event for daemon status. */
|
|
233
|
+
export function syncPassResult(event, completedAt = new Date().toISOString()) {
|
|
234
|
+
if (event.type !== "all-complete")
|
|
235
|
+
return null;
|
|
236
|
+
const errors = Array.isArray(event.errors) ? event.errors.length : 0;
|
|
237
|
+
const transient = Array.isArray(event.transient) ? event.transient.length : 0;
|
|
238
|
+
const status = event.partial === true || transient > 0 ? "partial" : errors > 0 ? "failed" : "ok";
|
|
239
|
+
return {
|
|
240
|
+
status,
|
|
241
|
+
completedAt,
|
|
242
|
+
...(typeof event.companiesAttempted === "number" ? { companiesAttempted: event.companiesAttempted } : {}),
|
|
243
|
+
errors,
|
|
244
|
+
transient,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function syncHeartbeatTimeoutMs(env) {
|
|
248
|
+
const raw = env[SYNC_HEARTBEAT_TIMEOUT_ENV]?.trim();
|
|
249
|
+
if (!raw || !/^\d+$/.test(raw))
|
|
250
|
+
return DEFAULT_SYNC_HEARTBEAT_TIMEOUT_MS;
|
|
251
|
+
const seconds = Number(raw);
|
|
252
|
+
const maxSeconds = Math.floor(2_147_483_647 / 1_000);
|
|
253
|
+
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > maxSeconds)
|
|
254
|
+
return DEFAULT_SYNC_HEARTBEAT_TIMEOUT_MS;
|
|
255
|
+
return seconds * 1_000;
|
|
256
|
+
}
|
|
257
|
+
function hasHeapOption(options) {
|
|
258
|
+
const tokens = options.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
259
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
260
|
+
const token = tokens[i].replaceAll("--max_old_space_size", "--max-old-space-size");
|
|
261
|
+
if (token === "--max-old-space-size" || token.startsWith("--max-old-space-size="))
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
/** Preserve an explicit NODE_OPTIONS heap setting; otherwise apply the desktop runner's default/override. */
|
|
267
|
+
export function syncRunnerNodeOptions(env) {
|
|
268
|
+
const inherited = env.NODE_OPTIONS?.trim() ?? "";
|
|
269
|
+
if (hasHeapOption(inherited))
|
|
270
|
+
return inherited || undefined;
|
|
271
|
+
const rawOverride = env[SYNC_RUNNER_HEAP_ENV]?.trim();
|
|
272
|
+
const override = rawOverride && /^\d+$/.test(rawOverride) ? Number(rawOverride) : undefined;
|
|
273
|
+
const heapMb = override && Number.isSafeInteger(override) && override > 0 ? override : DEFAULT_SYNC_RUNNER_HEAP_MB;
|
|
274
|
+
const heapFlag = `--max-old-space-size=${heapMb}`;
|
|
275
|
+
return inherited ? `${inherited} ${heapFlag}` : heapFlag;
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=sync-runner.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RunDoctorOptions, type RunDoctorResult } from "../../../commands/doctor.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run doctor tests against synthetic state. The production registry normally
|
|
4
|
+
* reads local HQ work-context, sync, daemon, and session state; tests must not
|
|
5
|
+
* inspect the developer's HOME or request the live integrations inventory.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runDoctorForTest(options?: RunDoctorOptions): Promise<RunDoctorResult>;
|
|
8
|
+
//# sourceMappingURL=run-doctor.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { runDoctor as runDoctorCommand, } from "../../../commands/doctor.js";
|
|
5
|
+
/**
|
|
6
|
+
* Run doctor tests against synthetic state. The production registry normally
|
|
7
|
+
* reads local HQ work-context, sync, daemon, and session state; tests must not
|
|
8
|
+
* inspect the developer's HOME or request the live integrations inventory.
|
|
9
|
+
*/
|
|
10
|
+
export async function runDoctorForTest(options = {}) {
|
|
11
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "hq-doctor-test-home-"));
|
|
12
|
+
const homeRoot = path.parse(home).root;
|
|
13
|
+
const homeDrive = homeRoot.endsWith(path.sep) ? homeRoot.slice(0, -1) : homeRoot;
|
|
14
|
+
const homePath = home.slice(homeDrive.length);
|
|
15
|
+
const isolatedEnv = {
|
|
16
|
+
HOME: home,
|
|
17
|
+
USERPROFILE: home,
|
|
18
|
+
HOMEDRIVE: process.platform === "win32" ? homeDrive : undefined,
|
|
19
|
+
HOMEPATH: process.platform === "win32" ? homePath : undefined,
|
|
20
|
+
HQ_ROOT: undefined,
|
|
21
|
+
HQ_STATE_DIR: path.join(home, ".hq"),
|
|
22
|
+
HQ_MACHINE_TOKEN_STATE_DIR: path.join(home, ".hq", "work-mesh", "daemon"),
|
|
23
|
+
HQ_MACHINE_CREDS_FILE: undefined,
|
|
24
|
+
HQ_AGENT_DIR: path.join(home, ".hq-agent"),
|
|
25
|
+
HQ_BOTS_ROOT: path.join(home, ".hq", "bots"),
|
|
26
|
+
HQ_DAEMON_DIR: path.join(home, ".hq", "daemon"),
|
|
27
|
+
HQ_WORK_CONTEXT_ROOT: path.join(home, ".hq", "work-context"),
|
|
28
|
+
HQ_WORK_MESH_ROOT: path.join(home, ".hq", "work-mesh"),
|
|
29
|
+
HQ_RUNTIME_MARKER_FILE: path.join(home, "runtime.json"),
|
|
30
|
+
HQ_FLEET_CONFIG_FILE: path.join(home, ".hq-fleet", "config.yaml"),
|
|
31
|
+
};
|
|
32
|
+
const originalEnv = new Map(Object.keys(isolatedEnv).map((key) => [key, process.env[key]]));
|
|
33
|
+
try {
|
|
34
|
+
for (const [key, value] of Object.entries(isolatedEnv)) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
delete process.env[key];
|
|
37
|
+
else
|
|
38
|
+
process.env[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return await runDoctorCommand({ ...options, integrations: false });
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
for (const [key, value] of originalEnv) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
delete process.env[key];
|
|
46
|
+
else
|
|
47
|
+
process.env[key] = value;
|
|
48
|
+
}
|
|
49
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=run-doctor.js.map
|