@indigoai-us/hq-cli 5.286.0 → 5.288.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 +57 -0
- package/assets/scaffold/core/scripts/clean-worktree-reconcile-handoff.sh +171 -0
- package/assets/scaffold/core/scripts/resume-thread-lock.sh +285 -0
- package/assets/scaffold/core/scripts/verify-story-deliverables.sh +148 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +362 -0
- package/dist/command-catalog.generated.d.ts +55 -0
- package/dist/command-catalog.generated.js +68 -0
- package/dist/commands/agent-enroll.js +10 -2
- package/dist/commands/agents.d.ts +1 -0
- package/dist/commands/agents.js +12 -4
- package/dist/commands/cloud-provision.js +99 -78
- package/dist/commands/core-native-handoff-flag.d.ts +4 -0
- package/dist/commands/core-native-handoff-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +4 -1
- package/dist/commands/core-ontology.js +26 -0
- package/dist/commands/core.js +45 -0
- package/dist/commands/lanes-resume.js +41 -2
- package/dist/commands/lanes-shared.d.ts +2 -0
- package/dist/commands/lanes.js +48 -29
- package/dist/commands/team-sync.js +71 -10
- package/dist/fast-lanes-list-route.js +2 -0
- package/dist/fast-lanes-list.js +1 -29
- package/dist/lanes-drain-hook.d.ts +12 -0
- package/dist/lanes-drain-hook.js +59 -55
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +17 -2
- package/dist/lib/checkpoint-ontology/plan.d.ts +0 -88
- package/dist/lib/checkpoint-ontology/plan.js +80 -107
- package/dist/lib/core-utils/sentry-report.js +5 -32
- package/dist/lib/core-utils/worktree-resume.d.ts +18 -0
- package/dist/lib/core-utils/worktree-resume.js +1182 -0
- package/dist/lib/lanes/dropbox.d.ts +1 -2
- package/dist/lib/lanes/dropbox.js +10 -111
- package/dist/lib/lanes/message-replay.d.ts +8 -0
- package/dist/lib/lanes/message-replay.js +119 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +21 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.js +329 -0
- package/dist/lib/plan-limit-nag.js +11 -0
- package/dist/main.js +17 -4
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +101 -1
- package/dist/utils/cognito-refresh-sentry.d.ts +3 -0
- package/dist/utils/cognito-refresh-sentry.js +49 -0
- package/dist/utils/cognito-session.js +55 -0
- package/package.json +1 -1
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* overlap.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
-
import { probeProviderCompactionsSince } from "./compaction.js";
|
|
12
11
|
import { tryReadLane } from "./store.js";
|
|
13
12
|
import { type LaneRecord, type LaneState, type QuestionRecord } from "./types.js";
|
|
14
13
|
export declare const DROPBOX_DIR = "dropbox";
|
|
@@ -59,7 +58,7 @@ export interface LaneDrainHookInput {
|
|
|
59
58
|
}
|
|
60
59
|
export interface RunDrainHookDeps {
|
|
61
60
|
readLane?: typeof tryReadLane;
|
|
62
|
-
probeCompactionsSince?: typeof probeProviderCompactionsSince;
|
|
61
|
+
probeCompactionsSince?: typeof import("./compaction.js").probeProviderCompactionsSince;
|
|
63
62
|
}
|
|
64
63
|
export declare function mintClaimId(): string;
|
|
65
64
|
export declare function assertSafeLaneId(laneId: string): void;
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
|
|
14
13
|
import { LanesError } from "./errors.js";
|
|
15
|
-
import { MESSAGE_COMPACTION_REPLAY_NOTE, MESSAGE_REPLAY_STATE_NAME, } from "./message-replay.js";
|
|
14
|
+
import { MESSAGE_COMPACTION_REPLAY_NOTE, MESSAGE_REPLAY_STATE_NAME, readMessageReplayState, updateMessageReplayTranscript, writeMessageReplayState, } from "./message-replay.js";
|
|
16
15
|
import { LANES_RUNS_REL, readProcStarttime } from "./spawn.js";
|
|
17
16
|
import { tryReadLane } from "./store.js";
|
|
18
17
|
export const DROPBOX_DIR = "dropbox";
|
|
@@ -430,83 +429,6 @@ export function hookAdditionalContextJson(messages, hookEventName = "PreToolUse"
|
|
|
430
429
|
function replayStatePath(hqRoot, laneId) {
|
|
431
430
|
return path.join(dropboxDir(hqRoot, laneId), MESSAGE_REPLAY_STATE_NAME);
|
|
432
431
|
}
|
|
433
|
-
function safeProviderTranscriptPath(transcriptPath, provider, env) {
|
|
434
|
-
if (provider === "grok")
|
|
435
|
-
return transcriptPath;
|
|
436
|
-
const home = provider === "claude" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
|
|
437
|
-
if (!home) {
|
|
438
|
-
throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
|
|
439
|
-
}
|
|
440
|
-
if (!path.isAbsolute(transcriptPath) || path.extname(transcriptPath) !== ".jsonl") {
|
|
441
|
-
throw new LanesError("message_replay_transcript_path_invalid", `The ${provider} hook transcript path must be an absolute JSONL path`);
|
|
442
|
-
}
|
|
443
|
-
const configuredHomePath = path.resolve(home);
|
|
444
|
-
const homePath = fs.realpathSync(home);
|
|
445
|
-
const candidate = path.resolve(transcriptPath);
|
|
446
|
-
const expectedRoot = provider === "claude" ? "projects" : "sessions";
|
|
447
|
-
const assertWithinProviderHome = (root, resolvedTranscript, message) => {
|
|
448
|
-
const relative = path.relative(root, resolvedTranscript);
|
|
449
|
-
if (relative === "" ||
|
|
450
|
-
relative === ".." ||
|
|
451
|
-
relative.startsWith(`..${path.sep}`) ||
|
|
452
|
-
path.isAbsolute(relative) ||
|
|
453
|
-
relative.split(path.sep)[0] !== expectedRoot) {
|
|
454
|
-
throw new LanesError("message_replay_transcript_outside_provider_home", message);
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
try {
|
|
458
|
-
const realTranscript = fs.realpathSync(candidate);
|
|
459
|
-
assertWithinProviderHome(homePath, realTranscript, `The ${provider} hook transcript resolves outside its ${expectedRoot} directory`);
|
|
460
|
-
return realTranscript;
|
|
461
|
-
}
|
|
462
|
-
catch (err) {
|
|
463
|
-
if (err.code === "ENOENT") {
|
|
464
|
-
assertWithinProviderHome(configuredHomePath, candidate, `The ${provider} hook transcript is outside its configured ${expectedRoot} directory`);
|
|
465
|
-
return candidate;
|
|
466
|
-
}
|
|
467
|
-
throw err;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
function readReplayState(filePath, turnId) {
|
|
471
|
-
const text = fs.readFileSync(filePath, "utf8");
|
|
472
|
-
let parsed;
|
|
473
|
-
try {
|
|
474
|
-
parsed = JSON.parse(text);
|
|
475
|
-
}
|
|
476
|
-
catch (err) {
|
|
477
|
-
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
478
|
-
}
|
|
479
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
480
|
-
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is not an object`);
|
|
481
|
-
}
|
|
482
|
-
const state = parsed;
|
|
483
|
-
if (state.schema_version !== 1 ||
|
|
484
|
-
state.turn_id !== turnId ||
|
|
485
|
-
(state.last_compaction_marker !== null &&
|
|
486
|
-
(!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
|
|
487
|
-
(state.transcript_offset !== undefined &&
|
|
488
|
-
(!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
|
|
489
|
-
(state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
|
|
490
|
-
(state.flag_enabled !== undefined && typeof state.flag_enabled !== "boolean")) {
|
|
491
|
-
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} does not match the current provider turn`);
|
|
492
|
-
}
|
|
493
|
-
// Older state files persisted the removed per-turn flag. Accept it, but
|
|
494
|
-
// remove it before the next write so the stale cache does not live on.
|
|
495
|
-
delete state.flag_enabled;
|
|
496
|
-
return state;
|
|
497
|
-
}
|
|
498
|
-
function writeReplayState(filePath, state) {
|
|
499
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
500
|
-
const tempPath = `${filePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
501
|
-
fs.writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
502
|
-
try {
|
|
503
|
-
fs.renameSync(tempPath, filePath);
|
|
504
|
-
}
|
|
505
|
-
catch (err) {
|
|
506
|
-
fs.rmSync(tempPath, { force: true });
|
|
507
|
-
throw err;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
432
|
function deliveredMessagesForTurn(hqRoot, laneId, turnId) {
|
|
511
433
|
const delivered = path.join(dropboxDir(hqRoot, laneId), DELIVERED_DIR);
|
|
512
434
|
let names;
|
|
@@ -522,7 +444,8 @@ function deliveredMessagesForTurn(hqRoot, laneId, turnId) {
|
|
|
522
444
|
return names.flatMap((name) => readClaimedFile(path.join(delivered, name), defaultDrainDeps()));
|
|
523
445
|
}
|
|
524
446
|
function reportReplayFailure(err) {
|
|
525
|
-
|
|
447
|
+
const code = err instanceof LanesError ? `${err.error}: ` : "";
|
|
448
|
+
return `hq-lanes-drain: message compaction replay failed: ${code}${err instanceof Error ? err.message : String(err)}\n`;
|
|
526
449
|
}
|
|
527
450
|
/**
|
|
528
451
|
* Hook protocol. HQ_LANE_ID absent or empty → exit 0, empty stdout, empty stderr.
|
|
@@ -564,7 +487,7 @@ export async function runDrainHook(env, input = {}, deps = {}) {
|
|
|
564
487
|
const statePath = replayStatePath(hqRoot, laneId);
|
|
565
488
|
let state;
|
|
566
489
|
try {
|
|
567
|
-
state =
|
|
490
|
+
state = readMessageReplayState(statePath, turnId);
|
|
568
491
|
}
|
|
569
492
|
catch (err) {
|
|
570
493
|
if (err.code !== "ENOENT")
|
|
@@ -576,38 +499,14 @@ export async function runDrainHook(env, input = {}, deps = {}) {
|
|
|
576
499
|
if (!lane) {
|
|
577
500
|
throw new LanesError("message_replay_lane_missing", `Lane ${laneId} could not be read for compaction replay`);
|
|
578
501
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (state.last_compaction_marker === null ||
|
|
585
|
-
state.transcript_path !== transcriptPath ||
|
|
586
|
-
state.transcript_offset === undefined) {
|
|
587
|
-
// Existing transcript history belongs to the turn's resume
|
|
588
|
-
// prompt. Start watching only records appended from this point.
|
|
589
|
-
state.last_compaction_marker = 0;
|
|
590
|
-
state.transcript_path = transcriptPath;
|
|
591
|
-
state.transcript_offset = providerTranscriptSize(transcriptPath);
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
const delta = (deps.probeCompactionsSince ?? probeProviderCompactionsSince)(transcriptPath, lane.provider, state.transcript_offset);
|
|
595
|
-
state.transcript_offset = delta.next_offset;
|
|
596
|
-
if (delta.replaced) {
|
|
597
|
-
// A replaced or truncated transcript establishes a new
|
|
598
|
-
// baseline; it is not evidence of a fresh compaction.
|
|
599
|
-
state.last_compaction_marker = 0;
|
|
600
|
-
}
|
|
601
|
-
else if (delta.count > 0) {
|
|
602
|
-
state.last_compaction_marker += delta.count;
|
|
603
|
-
replayed = deliveredMessagesForTurn(hqRoot, laneId, turnId);
|
|
604
|
-
if (replayed.length > 0)
|
|
605
|
-
replayNote = MESSAGE_COMPACTION_REPLAY_NOTE;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
502
|
+
const update = updateMessageReplayTranscript(state, input.transcript_path, lane.provider, env, deps.probeCompactionsSince);
|
|
503
|
+
if (update === "compacted") {
|
|
504
|
+
replayed = deliveredMessagesForTurn(hqRoot, laneId, turnId);
|
|
505
|
+
if (replayed.length > 0)
|
|
506
|
+
replayNote = MESSAGE_COMPACTION_REPLAY_NOTE;
|
|
608
507
|
}
|
|
609
508
|
}
|
|
610
|
-
|
|
509
|
+
writeMessageReplayState(statePath, state);
|
|
611
510
|
}
|
|
612
511
|
catch (err) {
|
|
613
512
|
// Replay is advisory hook context. Report failures but let the provider
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { probeProviderCompactionsSince } from "./compaction.js";
|
|
2
|
+
import type { LaneProvider } from "./types.js";
|
|
1
3
|
export declare const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
|
|
2
4
|
export declare const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
|
|
3
5
|
export interface MessageReplayState {
|
|
@@ -9,5 +11,11 @@ export interface MessageReplayState {
|
|
|
9
11
|
transcript_offset?: number;
|
|
10
12
|
/** Canonical transcript path for this provider turn. */
|
|
11
13
|
transcript_path?: string;
|
|
14
|
+
/** Provider needed to inspect appended transcript records without loading the lane store. */
|
|
15
|
+
provider?: LaneProvider;
|
|
12
16
|
}
|
|
17
|
+
export type ReplayTranscriptUpdate = "baseline" | "unchanged" | "replaced" | "compacted";
|
|
18
|
+
export declare function readMessageReplayState(filePath: string, turnId: string, onLegacyFlag?: () => void): MessageReplayState;
|
|
19
|
+
export declare function writeMessageReplayState(filePath: string, state: MessageReplayState): void;
|
|
20
|
+
export declare function updateMessageReplayTranscript(state: MessageReplayState, transcriptPath: string, provider: LaneProvider, env: NodeJS.ProcessEnv, probe?: typeof probeProviderCompactionsSince): ReplayTranscriptUpdate;
|
|
13
21
|
//# sourceMappingURL=message-replay.d.ts.map
|
|
@@ -1,3 +1,122 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
|
|
5
|
+
import { LanesError } from "./errors.js";
|
|
1
6
|
export const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
|
|
2
7
|
export const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
|
|
8
|
+
export function readMessageReplayState(filePath, turnId, onLegacyFlag) {
|
|
9
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(text);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
16
|
+
}
|
|
17
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
18
|
+
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is not an object`);
|
|
19
|
+
}
|
|
20
|
+
const state = parsed;
|
|
21
|
+
if (state.schema_version !== 1 ||
|
|
22
|
+
state.turn_id !== turnId ||
|
|
23
|
+
(state.last_compaction_marker !== null &&
|
|
24
|
+
(!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
|
|
25
|
+
(state.transcript_offset !== undefined &&
|
|
26
|
+
(!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
|
|
27
|
+
(state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
|
|
28
|
+
(state.provider !== undefined && !isLaneProvider(state.provider)) ||
|
|
29
|
+
(state.flag_enabled !== undefined && typeof state.flag_enabled !== "boolean")) {
|
|
30
|
+
throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} does not match the current provider turn`);
|
|
31
|
+
}
|
|
32
|
+
// Older state files persisted the removed per-turn flag. Accept it, but
|
|
33
|
+
// remove it before the next write so the stale cache does not live on.
|
|
34
|
+
if (state.flag_enabled !== undefined) {
|
|
35
|
+
delete state.flag_enabled;
|
|
36
|
+
onLegacyFlag?.();
|
|
37
|
+
}
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
function isLaneProvider(value) {
|
|
41
|
+
return value === "claude" || value === "codex" || value === "grok";
|
|
42
|
+
}
|
|
43
|
+
export function writeMessageReplayState(filePath, state) {
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
const tempPath = `${filePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
46
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
47
|
+
try {
|
|
48
|
+
fs.renameSync(tempPath, filePath);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
fs.rmSync(tempPath, { force: true });
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function safeProviderTranscriptPath(transcriptPath, provider, env) {
|
|
56
|
+
if (provider === "grok")
|
|
57
|
+
return transcriptPath;
|
|
58
|
+
const home = provider === "claude" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
|
|
59
|
+
if (!home) {
|
|
60
|
+
throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
|
|
61
|
+
}
|
|
62
|
+
if (!path.isAbsolute(transcriptPath) || path.extname(transcriptPath) !== ".jsonl") {
|
|
63
|
+
throw new LanesError("message_replay_transcript_path_invalid", `The ${provider} hook transcript path must be an absolute JSONL path`);
|
|
64
|
+
}
|
|
65
|
+
const configuredHomePath = path.resolve(home);
|
|
66
|
+
const homePath = fs.realpathSync(home);
|
|
67
|
+
const candidate = path.resolve(transcriptPath);
|
|
68
|
+
const expectedRoot = provider === "claude" ? "projects" : "sessions";
|
|
69
|
+
const assertWithinProviderHome = (root, resolvedTranscript, message) => {
|
|
70
|
+
const relative = path.relative(root, resolvedTranscript);
|
|
71
|
+
if (relative === "" ||
|
|
72
|
+
relative === ".." ||
|
|
73
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
74
|
+
path.isAbsolute(relative) ||
|
|
75
|
+
relative.split(path.sep)[0] !== expectedRoot) {
|
|
76
|
+
throw new LanesError("message_replay_transcript_outside_provider_home", message);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
const realTranscript = fs.realpathSync(candidate);
|
|
81
|
+
assertWithinProviderHome(homePath, realTranscript, `The ${provider} hook transcript resolves outside its ${expectedRoot} directory`);
|
|
82
|
+
return realTranscript;
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (err.code === "ENOENT") {
|
|
86
|
+
assertWithinProviderHome(configuredHomePath, candidate, `The ${provider} hook transcript is outside its configured ${expectedRoot} directory`);
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function updateMessageReplayTranscript(state, transcriptPath, provider, env, probe = probeProviderCompactionsSince) {
|
|
93
|
+
if (provider === "grok") {
|
|
94
|
+
state.provider = provider;
|
|
95
|
+
state.last_compaction_marker = 0;
|
|
96
|
+
return "unchanged";
|
|
97
|
+
}
|
|
98
|
+
const canonicalPath = safeProviderTranscriptPath(transcriptPath, provider, env);
|
|
99
|
+
const providerChanged = state.provider !== undefined && state.provider !== provider;
|
|
100
|
+
state.provider = provider;
|
|
101
|
+
if (providerChanged ||
|
|
102
|
+
state.last_compaction_marker === null ||
|
|
103
|
+
state.transcript_path !== canonicalPath ||
|
|
104
|
+
state.transcript_offset === undefined) {
|
|
105
|
+
state.last_compaction_marker = 0;
|
|
106
|
+
state.transcript_path = canonicalPath;
|
|
107
|
+
state.transcript_offset = providerTranscriptSize(canonicalPath);
|
|
108
|
+
return "baseline";
|
|
109
|
+
}
|
|
110
|
+
const delta = probe(canonicalPath, provider, state.transcript_offset);
|
|
111
|
+
state.transcript_offset = delta.next_offset;
|
|
112
|
+
if (delta.replaced) {
|
|
113
|
+
state.last_compaction_marker = 0;
|
|
114
|
+
return "replaced";
|
|
115
|
+
}
|
|
116
|
+
if (delta.count > 0) {
|
|
117
|
+
state.last_compaction_marker += delta.count;
|
|
118
|
+
return "compacted";
|
|
119
|
+
}
|
|
120
|
+
return "unchanged";
|
|
121
|
+
}
|
|
3
122
|
//# sourceMappingURL=message-replay.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export declare const OUTPOST_SNAPSHOT_REFRESH_LOCK_STALE_AFTER_MS: number;
|
|
3
|
+
export interface SnapshotRefreshChildRuntime {
|
|
4
|
+
pid: number;
|
|
5
|
+
once: (event: "exit", listener: () => void) => unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface StartSnapshotRefreshDeps {
|
|
8
|
+
spawn?: typeof spawn;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
pid?: number;
|
|
11
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
12
|
+
entryPath?: string;
|
|
13
|
+
beforeStaleLockClaim?: () => void;
|
|
14
|
+
writeError?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function outpostSnapshotRefreshLockPath(root: string, env?: NodeJS.ProcessEnv): string;
|
|
17
|
+
/** Start the detached remote snapshot refresh unless this HQ root is refreshing already. */
|
|
18
|
+
export declare function startOutpostLaneSnapshotRefresh(root: string, env?: NodeJS.ProcessEnv, deps?: StartSnapshotRefreshDeps): void;
|
|
19
|
+
/** Adopt the launcher lock in the detached child and remove it on normal exit. */
|
|
20
|
+
export declare function registerOutpostLaneSnapshotRefreshChild(env?: NodeJS.ProcessEnv, runtime?: SnapshotRefreshChildRuntime): boolean;
|
|
21
|
+
//# sourceMappingURL=outpost-snapshot-refresh.d.ts.map
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
const STATE_DIR_ENV = "HQ_LANES_SNAPSHOT_REFRESH_STATE_DIR";
|
|
7
|
+
const CHILD_LOCK_PATH_ENV = "HQ_INTERNAL_LANES_SNAPSHOT_REFRESH_LOCK_PATH";
|
|
8
|
+
const CHILD_LOCK_TOKEN_ENV = "HQ_INTERNAL_LANES_SNAPSHOT_REFRESH_LOCK_TOKEN";
|
|
9
|
+
// One Outpost summary pickup has a 20-second timeout. Thirty minutes leaves
|
|
10
|
+
// ample time for lists spanning many Outposts while bounding abandoned locks.
|
|
11
|
+
export const OUTPOST_SNAPSHOT_REFRESH_LOCK_STALE_AFTER_MS = 30 * 60 * 1_000;
|
|
12
|
+
function errorMessage(err) {
|
|
13
|
+
return err instanceof Error ? err.message : String(err);
|
|
14
|
+
}
|
|
15
|
+
function writeError(message) {
|
|
16
|
+
process.stderr.write(`${message}\n`);
|
|
17
|
+
}
|
|
18
|
+
export function outpostSnapshotRefreshLockPath(root, env = process.env) {
|
|
19
|
+
const stateBase = env[STATE_DIR_ENV]?.trim() || path.join(env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state"), "hq-cli");
|
|
20
|
+
if (!path.isAbsolute(stateBase))
|
|
21
|
+
throw new Error(`${STATE_DIR_ENV} must resolve to an absolute path`);
|
|
22
|
+
const canonicalRoot = fs.realpathSync(path.resolve(root));
|
|
23
|
+
const rootHash = createHash("sha256").update(canonicalRoot).digest("hex");
|
|
24
|
+
return path.join(stateBase, "lanes-snapshot-refresh", `${rootHash}.lock`);
|
|
25
|
+
}
|
|
26
|
+
function parseLock(filePath, content) {
|
|
27
|
+
let value;
|
|
28
|
+
try {
|
|
29
|
+
value = JSON.parse(content);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw new Error(`could not read lock ${filePath}: ${errorMessage(err)}`);
|
|
33
|
+
}
|
|
34
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
35
|
+
throw new Error(`invalid lock data in ${filePath}`);
|
|
36
|
+
}
|
|
37
|
+
const lock = value;
|
|
38
|
+
if (!Number.isSafeInteger(lock.pid) || (lock.pid ?? 0) <= 0 ||
|
|
39
|
+
!Number.isFinite(lock.startedAt) || (lock.startedAt ?? 0) < 0 ||
|
|
40
|
+
typeof lock.token !== "string" || lock.token.length === 0) {
|
|
41
|
+
throw new Error(`invalid lock fields in ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
return lock;
|
|
44
|
+
}
|
|
45
|
+
function readLock(filePath) {
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`could not read lock ${filePath}: ${errorMessage(err)}`);
|
|
52
|
+
}
|
|
53
|
+
return parseLock(filePath, content);
|
|
54
|
+
}
|
|
55
|
+
function observeLock(filePath) {
|
|
56
|
+
const descriptor = fs.openSync(filePath, "r");
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.fstatSync(descriptor);
|
|
59
|
+
const content = fs.readFileSync(descriptor, "utf8");
|
|
60
|
+
try {
|
|
61
|
+
return { lock: parseLock(filePath, content), content, stat };
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return { lock: null, content, stat, invalidReason: errorMessage(err) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
fs.closeSync(descriptor);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function processIsAlive(pid) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const code = err.code;
|
|
78
|
+
if (code === "ESRCH")
|
|
79
|
+
return false;
|
|
80
|
+
if (code === "EPERM")
|
|
81
|
+
return true;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function writeLockExclusive(filePath, lock) {
|
|
86
|
+
const descriptor = fs.openSync(filePath, "wx", 0o600);
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(lock)}\n`, "utf8");
|
|
89
|
+
fs.fsyncSync(descriptor);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(filePath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Keep the original write error as the actionable cause.
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
fs.closeSync(descriptor);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function writeLockReplacement(filePath, lock) {
|
|
105
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
106
|
+
const descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(lock)}\n`, "utf8");
|
|
109
|
+
fs.fsyncSync(descriptor);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
try {
|
|
113
|
+
fs.unlinkSync(temporaryPath);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Keep the original write error as the actionable cause.
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
fs.closeSync(descriptor);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync(temporaryPath, filePath);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
try {
|
|
128
|
+
fs.unlinkSync(temporaryPath);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Keep the rename error as the actionable cause.
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function releaseLock(filePath, token) {
|
|
137
|
+
try {
|
|
138
|
+
if (readLock(filePath).token === token)
|
|
139
|
+
fs.unlinkSync(filePath);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err.code !== "ENOENT") {
|
|
143
|
+
writeError(`lanes snapshot refresh could not release its lock: ${errorMessage(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function updateLockOwner(filePath, token, pid) {
|
|
148
|
+
const lock = readLock(filePath);
|
|
149
|
+
if (lock.token !== token)
|
|
150
|
+
throw new Error(`lock ownership changed at ${filePath}`);
|
|
151
|
+
writeLockReplacement(filePath, { ...lock, pid });
|
|
152
|
+
}
|
|
153
|
+
function sameFile(left, right) {
|
|
154
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Claim the exact stale inode before unlinking it. Claim paths are stable for
|
|
158
|
+
* one inode, so two contenders cannot both remove the same expired lock.
|
|
159
|
+
*/
|
|
160
|
+
function claimStaleLock(filePath, observed) {
|
|
161
|
+
const claimPath = `${filePath}.claim-${observed.stat.dev}-${observed.stat.ino}`;
|
|
162
|
+
try {
|
|
163
|
+
fs.linkSync(filePath, claimPath);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const code = err.code;
|
|
167
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
168
|
+
return false;
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
let claimedStaleLock = false;
|
|
172
|
+
let claimError;
|
|
173
|
+
try {
|
|
174
|
+
const claimStat = fs.statSync(claimPath);
|
|
175
|
+
const currentStat = fs.statSync(filePath);
|
|
176
|
+
if (sameFile(observed.stat, claimStat) && sameFile(observed.stat, currentStat)) {
|
|
177
|
+
const claimContent = fs.readFileSync(claimPath, "utf8");
|
|
178
|
+
const currentContent = fs.readFileSync(filePath, "utf8");
|
|
179
|
+
if (claimContent === observed.content && currentContent === observed.content) {
|
|
180
|
+
fs.unlinkSync(filePath);
|
|
181
|
+
claimedStaleLock = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (err.code !== "ENOENT")
|
|
187
|
+
claimError = err;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
fs.unlinkSync(claimPath);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (err.code !== "ENOENT")
|
|
194
|
+
claimError ??= err;
|
|
195
|
+
}
|
|
196
|
+
if (claimError)
|
|
197
|
+
throw claimError;
|
|
198
|
+
return claimedStaleLock;
|
|
199
|
+
}
|
|
200
|
+
function acquireLock(root, env, deps) {
|
|
201
|
+
const filePath = outpostSnapshotRefreshLockPath(root, env);
|
|
202
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
203
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
204
|
+
const lock = {
|
|
205
|
+
pid: deps.pid ?? process.pid,
|
|
206
|
+
startedAt: (deps.now ?? Date.now)(),
|
|
207
|
+
token: randomUUID(),
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
writeLockExclusive(filePath, lock);
|
|
211
|
+
return lock;
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (err.code !== "EEXIST")
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
let existing;
|
|
218
|
+
try {
|
|
219
|
+
existing = observeLock(filePath);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err.code === "ENOENT")
|
|
223
|
+
continue;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
const staleSince = existing.lock?.startedAt ?? existing.stat.mtimeMs;
|
|
227
|
+
const ageMs = (deps.now ?? Date.now)() - staleSince;
|
|
228
|
+
const oldEnough = ageMs >= OUTPOST_SNAPSHOT_REFRESH_LOCK_STALE_AFTER_MS;
|
|
229
|
+
if (!existing.lock) {
|
|
230
|
+
if (!oldEnough) {
|
|
231
|
+
throw new Error(`malformed lock ${filePath} is not old enough to recover: ${existing.invalidReason}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const alive = (deps.isProcessAlive ?? processIsAlive)(existing.lock.pid);
|
|
236
|
+
if (!oldEnough || alive)
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
deps.beforeStaleLockClaim?.();
|
|
240
|
+
if (!claimStaleLock(filePath, existing))
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
// Another caller may have replaced a stale lock during recovery. Fail closed
|
|
244
|
+
// and let the next list call retry rather than risking a second refresh.
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
/** Start the detached remote snapshot refresh unless this HQ root is refreshing already. */
|
|
248
|
+
export function startOutpostLaneSnapshotRefresh(root, env = process.env, deps = {}) {
|
|
249
|
+
const report = deps.writeError ?? writeError;
|
|
250
|
+
let entry;
|
|
251
|
+
try {
|
|
252
|
+
const currentEntry = deps.entryPath ?? process.argv[1];
|
|
253
|
+
if (!currentEntry)
|
|
254
|
+
throw new Error("process.argv[1] is empty");
|
|
255
|
+
entry = fs.realpathSync(currentEntry);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
report(`lanes snapshot refresh could not resolve the running CLI entry: ${errorMessage(err)}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let lock;
|
|
262
|
+
let filePath;
|
|
263
|
+
try {
|
|
264
|
+
filePath = outpostSnapshotRefreshLockPath(root, env);
|
|
265
|
+
lock = acquireLock(root, env, deps);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
report(`lanes snapshot refresh could not acquire its lock: ${errorMessage(err)}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!lock)
|
|
272
|
+
return;
|
|
273
|
+
let child;
|
|
274
|
+
try {
|
|
275
|
+
const childEnv = {
|
|
276
|
+
...env,
|
|
277
|
+
HQ_ROOT: root,
|
|
278
|
+
HQ_NO_UPDATE_CHECK: "1",
|
|
279
|
+
HQ_INTERNAL_LANES_REFRESH_REMOTE_SNAPSHOT: "1",
|
|
280
|
+
[CHILD_LOCK_PATH_ENV]: filePath,
|
|
281
|
+
[CHILD_LOCK_TOKEN_ENV]: lock.token,
|
|
282
|
+
};
|
|
283
|
+
const spawnChild = deps.spawn ?? spawn;
|
|
284
|
+
child = spawnChild(process.execPath, [...process.execArgv, entry, "lanes", "list", "--json"], {
|
|
285
|
+
cwd: root,
|
|
286
|
+
detached: true,
|
|
287
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
288
|
+
env: childEnv,
|
|
289
|
+
});
|
|
290
|
+
child.once("error", (err) => {
|
|
291
|
+
releaseLock(filePath, lock.token);
|
|
292
|
+
report(`lanes snapshot refresh could not start: ${errorMessage(err)}`);
|
|
293
|
+
});
|
|
294
|
+
if (child.pid)
|
|
295
|
+
updateLockOwner(filePath, lock.token, child.pid);
|
|
296
|
+
child.unref();
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
try {
|
|
300
|
+
child?.kill();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Spawn failure is already reported below; the lock is still released.
|
|
304
|
+
}
|
|
305
|
+
releaseLock(filePath, lock.token);
|
|
306
|
+
report(`lanes snapshot refresh could not start: ${errorMessage(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Adopt the launcher lock in the detached child and remove it on normal exit. */
|
|
310
|
+
export function registerOutpostLaneSnapshotRefreshChild(env = process.env, runtime = process) {
|
|
311
|
+
const filePath = env[CHILD_LOCK_PATH_ENV];
|
|
312
|
+
const token = env[CHILD_LOCK_TOKEN_ENV];
|
|
313
|
+
if (!filePath && !token)
|
|
314
|
+
return false;
|
|
315
|
+
if (!filePath || !token) {
|
|
316
|
+
writeError("lanes snapshot refresh child has incomplete lock metadata");
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
updateLockOwner(filePath, token, runtime.pid);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
writeError(`lanes snapshot refresh child could not register its lock: ${errorMessage(err)}`);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
runtime.once("exit", () => releaseLock(filePath, token));
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=outpost-snapshot-refresh.js.map
|