@indigoai-us/hq-cli 5.286.0 → 5.287.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 +19 -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 +52 -0
- package/dist/command-catalog.generated.js +64 -0
- 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.js +45 -0
- package/dist/commands/lanes.js +1 -28
- 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/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/outpost-snapshot-refresh.d.ts +21 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.js +329 -0
- package/package.json +1 -1
|
@@ -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
|