@indigoai-us/hq-cli 5.272.0 → 5.273.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 +29 -0
- package/dist/command-catalog.generated.d.ts +11 -422
- package/dist/command-catalog.generated.js +14 -547
- package/dist/commands/lanes-checks.d.ts +0 -1
- package/dist/commands/lanes-checks.js +217 -54
- package/dist/commands/lanes-hold.js +40 -2
- package/dist/commands/lanes-message.js +98 -14
- package/dist/commands/lanes-shared.d.ts +3 -2
- package/dist/commands/lanes.d.ts +2 -4
- package/dist/commands/lanes.js +3 -23
- package/dist/lib/doctor/checks/scaffold-cli-floor.d.ts +7 -0
- package/dist/lib/doctor/checks/scaffold-cli-floor.js +73 -0
- package/dist/lib/doctor/registry.js +2 -0
- package/dist/lib/lanes/hire-grant-dispatch.d.ts +6 -1
- package/dist/lib/lanes/hire-grant-dispatch.js +78 -3
- package/dist/lib/lanes/keep-alive.d.ts +3 -2
- package/dist/lib/lanes/keep-alive.js +2 -2
- package/dist/lib/lanes/merge-command.d.ts +1 -0
- package/dist/lib/lanes/merge-command.js +299 -33
- package/dist/lib/lanes/orphans.d.ts +7 -0
- package/dist/lib/lanes/orphans.js +39 -4
- package/dist/lib/lanes/tracker-store.d.ts +2 -18
- package/dist/lib/lanes/tracker-store.js +1 -172
- package/dist/lib/scaffold-cli-floor.d.ts +9 -0
- package/dist/lib/scaffold-cli-floor.js +50 -0
- package/package.json +1 -1
- package/dist/commands/lanes-import.d.ts +0 -14
- package/dist/commands/lanes-import.js +0 -1210
- package/dist/commands/lanes-tracker.d.ts +0 -18
- package/dist/commands/lanes-tracker.js +0 -452
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
1
2
|
import { LanesError } from "./errors.js";
|
|
3
|
+
const MAX_COMMAND_WRAPPER_DEPTH = 4;
|
|
2
4
|
function fail(error, message, details = {}) {
|
|
3
5
|
throw new LanesError(error, message, details);
|
|
4
6
|
}
|
|
@@ -45,11 +47,13 @@ function splitCommandChain(command) {
|
|
|
45
47
|
current += character;
|
|
46
48
|
continue;
|
|
47
49
|
}
|
|
48
|
-
if (character === ";" || character === "|") {
|
|
50
|
+
if (character === ";" || character === "|" || character === "\n" || character === "\r") {
|
|
49
51
|
segments.push(current.trim());
|
|
50
52
|
current = "";
|
|
51
53
|
if (character === "|" && command[index + 1] === "|")
|
|
52
54
|
index += 1;
|
|
55
|
+
if (character === "\r" && command[index + 1] === "\n")
|
|
56
|
+
index += 1;
|
|
53
57
|
continue;
|
|
54
58
|
}
|
|
55
59
|
if (character === "&" && command[index + 1] === "&") {
|
|
@@ -69,16 +73,26 @@ function splitCommandChain(command) {
|
|
|
69
73
|
function shellWords(segment) {
|
|
70
74
|
const words = [];
|
|
71
75
|
let current = "";
|
|
76
|
+
let wordStarted = false;
|
|
72
77
|
let quote = null;
|
|
73
78
|
let escaped = false;
|
|
79
|
+
const finishWord = () => {
|
|
80
|
+
if (!wordStarted)
|
|
81
|
+
return;
|
|
82
|
+
words.push(current);
|
|
83
|
+
current = "";
|
|
84
|
+
wordStarted = false;
|
|
85
|
+
};
|
|
74
86
|
for (const character of segment) {
|
|
75
87
|
if (escaped) {
|
|
76
88
|
current += character;
|
|
89
|
+
wordStarted = true;
|
|
77
90
|
escaped = false;
|
|
78
91
|
continue;
|
|
79
92
|
}
|
|
80
93
|
if (character === "\\" && quote !== "'") {
|
|
81
94
|
escaped = true;
|
|
95
|
+
wordStarted = true;
|
|
82
96
|
continue;
|
|
83
97
|
}
|
|
84
98
|
if (quote) {
|
|
@@ -86,24 +100,24 @@ function shellWords(segment) {
|
|
|
86
100
|
quote = null;
|
|
87
101
|
else
|
|
88
102
|
current += character;
|
|
103
|
+
wordStarted = true;
|
|
89
104
|
continue;
|
|
90
105
|
}
|
|
91
106
|
if (character === "'" || character === "\"") {
|
|
92
107
|
quote = character;
|
|
108
|
+
wordStarted = true;
|
|
93
109
|
continue;
|
|
94
110
|
}
|
|
95
111
|
if (/\s/.test(character)) {
|
|
96
|
-
|
|
97
|
-
words.push(current);
|
|
98
|
-
current = "";
|
|
112
|
+
finishWord();
|
|
99
113
|
continue;
|
|
100
114
|
}
|
|
101
115
|
current += character;
|
|
116
|
+
wordStarted = true;
|
|
102
117
|
}
|
|
103
|
-
if (escaped)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
words.push(current);
|
|
118
|
+
if (quote !== null || escaped)
|
|
119
|
+
return fail("repo_hold_command_invalid", "Command has an unclosed quote or escape");
|
|
120
|
+
finishWord();
|
|
107
121
|
return words;
|
|
108
122
|
}
|
|
109
123
|
function optionRepo(words) {
|
|
@@ -163,10 +177,8 @@ function skipEnvOptions(words, start) {
|
|
|
163
177
|
}
|
|
164
178
|
if (word === "--")
|
|
165
179
|
return index + 1;
|
|
166
|
-
if (word.startsWith("-"))
|
|
167
|
-
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
180
|
+
if (word.startsWith("-"))
|
|
181
|
+
return -1;
|
|
170
182
|
break;
|
|
171
183
|
}
|
|
172
184
|
return index;
|
|
@@ -191,32 +203,223 @@ function skipTimeoutOptions(words, start) {
|
|
|
191
203
|
index += 1;
|
|
192
204
|
continue;
|
|
193
205
|
}
|
|
194
|
-
if (word.startsWith("-"))
|
|
195
|
-
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
206
|
+
if (word.startsWith("-"))
|
|
207
|
+
return -1;
|
|
198
208
|
break;
|
|
199
209
|
}
|
|
200
|
-
|
|
210
|
+
const duration = words[index];
|
|
211
|
+
if (!duration || !/^(?:\d+(?:\.\d*)?|\.\d+)(?:s|m|h|d)?$/.test(duration))
|
|
212
|
+
return -1;
|
|
213
|
+
index += 1;
|
|
214
|
+
if (words[index] === "--")
|
|
215
|
+
index += 1;
|
|
216
|
+
return index < words.length ? index : -1;
|
|
201
217
|
}
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
const SHELL_SHORT_OPTIONS = new Set(["a", "b", "B", "C", "e", "E", "f", "h", "H", "i", "k", "l", "m", "n", "p", "P", "r", "s", "t", "T", "u", "v", "x"]);
|
|
219
|
+
const SHELL_LONG_OPTIONS = new Set([
|
|
220
|
+
"--interactive", "--login", "--noprofile", "--norc", "--posix", "--restricted",
|
|
221
|
+
"--verbose", "--noediting", "--debugger",
|
|
222
|
+
]);
|
|
223
|
+
const SHELL_LONG_VALUE_OPTIONS = new Set(["--rcfile", "--init-file", "--emulate"]);
|
|
224
|
+
function shellCommandWrapper(words) {
|
|
225
|
+
let index = 1;
|
|
204
226
|
while (index < words.length) {
|
|
205
|
-
|
|
227
|
+
const option = words[index] ?? "";
|
|
228
|
+
if (option === "--")
|
|
229
|
+
return null;
|
|
230
|
+
if (SHELL_LONG_VALUE_OPTIONS.has(option)) {
|
|
231
|
+
if (!words[index + 1])
|
|
232
|
+
return { kind: "invalid" };
|
|
233
|
+
index += 2;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if ([...SHELL_LONG_VALUE_OPTIONS].some((name) => option.startsWith(`${name}=`))) {
|
|
206
237
|
index += 1;
|
|
207
238
|
continue;
|
|
208
239
|
}
|
|
209
|
-
if (
|
|
210
|
-
index
|
|
240
|
+
if (SHELL_LONG_OPTIONS.has(option)) {
|
|
241
|
+
index += 1;
|
|
211
242
|
continue;
|
|
212
243
|
}
|
|
213
|
-
if (
|
|
214
|
-
|
|
244
|
+
if (option.startsWith("--"))
|
|
245
|
+
return { kind: "invalid" };
|
|
246
|
+
if ((option.startsWith("-") || option.startsWith("+")) && option.length > 1) {
|
|
247
|
+
const flags = option.slice(1);
|
|
248
|
+
let consumedOptionValue = false;
|
|
249
|
+
for (let flagIndex = 0; flagIndex < flags.length; flagIndex += 1) {
|
|
250
|
+
const flag = flags[flagIndex] ?? "";
|
|
251
|
+
if (flag === "c") {
|
|
252
|
+
const script = words[index + 1];
|
|
253
|
+
return script === undefined ? { kind: "invalid" } : { kind: "script", script };
|
|
254
|
+
}
|
|
255
|
+
if (flag === "o" || flag === "O") {
|
|
256
|
+
if (flagIndex + 1 < flags.length) {
|
|
257
|
+
index += 1;
|
|
258
|
+
consumedOptionValue = true;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
if (!words[index + 1])
|
|
262
|
+
return { kind: "invalid" };
|
|
263
|
+
index += 2;
|
|
264
|
+
consumedOptionValue = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (!SHELL_SHORT_OPTIONS.has(flag))
|
|
268
|
+
return { kind: "invalid" };
|
|
269
|
+
}
|
|
270
|
+
if (!consumedOptionValue)
|
|
271
|
+
index += 1;
|
|
215
272
|
continue;
|
|
216
273
|
}
|
|
217
|
-
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
function commandWrapper(words) {
|
|
279
|
+
const command = path.basename(words[0] ?? "").toLowerCase();
|
|
280
|
+
if (["bash", "sh", "zsh"].includes(command))
|
|
281
|
+
return shellCommandWrapper(words);
|
|
282
|
+
if (command === "sudo") {
|
|
283
|
+
let index = 1;
|
|
284
|
+
while (index < words.length) {
|
|
285
|
+
const option = words[index] ?? "";
|
|
286
|
+
if (option === "--") {
|
|
287
|
+
index += 1;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
if (option === "-u" || option === "--user") {
|
|
291
|
+
if (!words[index + 1])
|
|
292
|
+
return { kind: "invalid" };
|
|
293
|
+
index += 2;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (option.startsWith("--user=") || /^-u.+/.test(option)) {
|
|
297
|
+
index += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (["-A", "-b", "-E", "-H", "-K", "-k", "-n", "-P", "-S", "-V", "--askpass", "--background", "--preserve-env", "--set-home", "--remove-timestamp", "--reset-timestamp", "--non-interactive", "--preserve-groups", "--stdin", "--version"].includes(option)) {
|
|
301
|
+
index += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (option.startsWith("-"))
|
|
305
|
+
return { kind: "invalid" };
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
|
|
309
|
+
}
|
|
310
|
+
if (command === "command") {
|
|
311
|
+
let index = 1;
|
|
312
|
+
while (words[index] === "-p")
|
|
313
|
+
index += 1;
|
|
314
|
+
if (words[index] === "--")
|
|
315
|
+
index += 1;
|
|
316
|
+
if (words[index]?.startsWith("-"))
|
|
317
|
+
return { kind: "invalid" };
|
|
318
|
+
return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
|
|
218
319
|
}
|
|
219
|
-
|
|
320
|
+
if (command === "env") {
|
|
321
|
+
const index = skipEnvOptions(words, 0);
|
|
322
|
+
if (index < 0)
|
|
323
|
+
return { kind: "invalid" };
|
|
324
|
+
return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
|
|
325
|
+
}
|
|
326
|
+
if (command === "timeout") {
|
|
327
|
+
const index = skipTimeoutOptions(words, 0);
|
|
328
|
+
return index >= 0 ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
|
|
329
|
+
}
|
|
330
|
+
if (command === "nice") {
|
|
331
|
+
let index = 1;
|
|
332
|
+
while (index < words.length) {
|
|
333
|
+
const option = words[index] ?? "";
|
|
334
|
+
if (option === "--") {
|
|
335
|
+
index += 1;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
if (option === "-n" || option === "--adjustment") {
|
|
339
|
+
if (!/^-?\d+$/.test(words[index + 1] ?? ""))
|
|
340
|
+
return { kind: "invalid" };
|
|
341
|
+
index += 2;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (/^--adjustment=-?\d+$/.test(option) || /^-\d+$/.test(option)) {
|
|
345
|
+
index += 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (option.startsWith("-"))
|
|
349
|
+
return { kind: "invalid" };
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
|
|
353
|
+
}
|
|
354
|
+
if (command === "nohup") {
|
|
355
|
+
const index = words[1] === "--" ? 2 : 1;
|
|
356
|
+
return index < words.length ? { kind: "words", words: words.slice(index) } : { kind: "invalid" };
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
function containsMergeCandidate(command) {
|
|
361
|
+
// This lossy scan only decides whether malformed input resembles a merge;
|
|
362
|
+
// the parser never uses it to recover a command or target.
|
|
363
|
+
const words = command.replace(/["'`\\]/g, " ").match(/\S+/g) ?? [];
|
|
364
|
+
const valueOptions = new Set([
|
|
365
|
+
"-R", "--repo", "--hostname", "--config", "--cache", "--jq", "--template",
|
|
366
|
+
"--preview", "--http-unix-socket", "--git-protocol",
|
|
367
|
+
]);
|
|
368
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
369
|
+
if (words[index]?.toLowerCase() !== "gh")
|
|
370
|
+
continue;
|
|
371
|
+
let subcommandIndex = index + 1;
|
|
372
|
+
while (subcommandIndex < words.length) {
|
|
373
|
+
const word = words[subcommandIndex] ?? "";
|
|
374
|
+
if (word === "--")
|
|
375
|
+
break;
|
|
376
|
+
if (valueOptions.has(word)) {
|
|
377
|
+
if (subcommandIndex + 1 >= words.length)
|
|
378
|
+
break;
|
|
379
|
+
subcommandIndex += 2;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (word.startsWith("--repo=") || word.startsWith("--hostname=") || /^-R.+/.test(word)) {
|
|
383
|
+
subcommandIndex += 1;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (word.startsWith("-")) {
|
|
387
|
+
subcommandIndex += 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
const subcommand = words[subcommandIndex];
|
|
393
|
+
if (subcommand === "pr") {
|
|
394
|
+
let actionIndex = subcommandIndex + 1;
|
|
395
|
+
while (actionIndex < words.length) {
|
|
396
|
+
const word = words[actionIndex] ?? "";
|
|
397
|
+
if (word === "--")
|
|
398
|
+
break;
|
|
399
|
+
if (valueOptions.has(word)) {
|
|
400
|
+
if (actionIndex + 1 >= words.length)
|
|
401
|
+
break;
|
|
402
|
+
actionIndex += 2;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (word.startsWith("--repo=") || word.startsWith("--hostname=") || /^-R.+/.test(word)) {
|
|
406
|
+
actionIndex += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (word.startsWith("-")) {
|
|
410
|
+
actionIndex += 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
if (words[actionIndex] === "merge")
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
if (subcommand === "api" && words.slice(subcommandIndex + 1).some((word) => /\/merge\b/i.test(word) || /\bmergePullRequest\b/i.test(word))) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
220
423
|
}
|
|
221
424
|
function parsePullUrl(raw) {
|
|
222
425
|
if (!/^https?:\/\//i.test(raw))
|
|
@@ -330,8 +533,7 @@ function apiMergeTarget(words, apiIndex) {
|
|
|
330
533
|
}
|
|
331
534
|
return null;
|
|
332
535
|
}
|
|
333
|
-
function
|
|
334
|
-
const words = unwrapCommand(shellWords(segment));
|
|
536
|
+
function parseSegmentWords(words) {
|
|
335
537
|
if (words[0] === "git" && words.slice(1).includes("merge")) {
|
|
336
538
|
return {
|
|
337
539
|
kind: "git",
|
|
@@ -351,13 +553,77 @@ function parseSegment(segment) {
|
|
|
351
553
|
return null;
|
|
352
554
|
return apiMergeTarget(words, apiIndex);
|
|
353
555
|
}
|
|
556
|
+
function unparseableMergeTarget() {
|
|
557
|
+
return {
|
|
558
|
+
kind: "pr",
|
|
559
|
+
repo: null,
|
|
560
|
+
pr: null,
|
|
561
|
+
pinnedHead: null,
|
|
562
|
+
unresolvedReason: "merge command could not be parsed safely",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
function recordUnparseableMerge(state) {
|
|
566
|
+
state.unparseableMerge = true;
|
|
567
|
+
state.targets.push(unparseableMergeTarget());
|
|
568
|
+
}
|
|
569
|
+
function parseCommandWords(words, source, depth, state) {
|
|
570
|
+
let commandWords = words;
|
|
571
|
+
while (SHELL_ASSIGNMENT.test(commandWords[0] ?? ""))
|
|
572
|
+
commandWords = commandWords.slice(1);
|
|
573
|
+
if (commandWords.length === 0)
|
|
574
|
+
return;
|
|
575
|
+
const wrapper = commandWrapper(commandWords);
|
|
576
|
+
if (wrapper) {
|
|
577
|
+
if (wrapper.kind === "invalid" || depth >= MAX_COMMAND_WRAPPER_DEPTH) {
|
|
578
|
+
if (containsMergeCandidate(source))
|
|
579
|
+
recordUnparseableMerge(state);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (wrapper.kind === "script") {
|
|
583
|
+
parseCommandList(wrapper.script, depth + 1, state);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
parseCommandWords(wrapper.words, source, depth + 1, state);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const target = parseSegmentWords(commandWords);
|
|
590
|
+
if (target)
|
|
591
|
+
state.targets.push(target);
|
|
592
|
+
}
|
|
593
|
+
function parseCommandList(command, depth, state) {
|
|
594
|
+
let segments;
|
|
595
|
+
try {
|
|
596
|
+
segments = splitCommandChain(command);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
if (error instanceof LanesError && error.error === "repo_hold_command_invalid" && containsMergeCandidate(command)) {
|
|
600
|
+
recordUnparseableMerge(state);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
for (const segment of segments) {
|
|
606
|
+
let words;
|
|
607
|
+
try {
|
|
608
|
+
words = shellWords(segment);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error instanceof LanesError && error.error === "repo_hold_command_invalid" && containsMergeCandidate(segment)) {
|
|
612
|
+
recordUnparseableMerge(state);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
parseCommandWords(words, segment, depth, state);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
354
620
|
export function parseMergeCommands(command) {
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
.filter((target) => target !== null);
|
|
621
|
+
const state = { targets: [], unparseableMerge: false };
|
|
622
|
+
parseCommandList(command, 0, state);
|
|
358
623
|
return {
|
|
359
|
-
targets,
|
|
360
|
-
unresolved: targets.some((target) => target.unresolvedReason !== null),
|
|
624
|
+
targets: state.targets,
|
|
625
|
+
unresolved: state.targets.some((target) => target.unresolvedReason !== null),
|
|
626
|
+
unparseableMerge: state.unparseableMerge,
|
|
361
627
|
};
|
|
362
628
|
}
|
|
363
629
|
//# sourceMappingURL=merge-command.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type LaneRecord, type SeniorRef } from "./types.js";
|
|
2
2
|
export declare const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
|
|
3
|
+
export declare const DEFAULT_ORPHAN_USER_IDLE_HOURS = 72;
|
|
3
4
|
export interface OrphanLaneFinding {
|
|
4
5
|
lane_id: string;
|
|
5
6
|
senior: SeniorRef;
|
|
@@ -7,12 +8,18 @@ export interface OrphanLaneFinding {
|
|
|
7
8
|
age_minutes: number;
|
|
8
9
|
account: string | null;
|
|
9
10
|
host: LaneRecord["host"];
|
|
11
|
+
reason: "senior_ended" | "senior_missing" | "user_idle";
|
|
10
12
|
}
|
|
11
13
|
export declare function parseOrphanMinAge(value: string | undefined): number;
|
|
14
|
+
export declare function parseOrphanUserIdleHours(value: string | undefined): number;
|
|
12
15
|
export declare function findOrphanLanes(input: {
|
|
13
16
|
lanes: readonly LaneRecord[];
|
|
17
|
+
candidateLaneIds: ReadonlySet<string>;
|
|
14
18
|
now: Date;
|
|
15
19
|
minAgeMinutes: number;
|
|
20
|
+
userIdleHours: number;
|
|
16
21
|
sessionState: (sessionId: string, now: Date) => string | null;
|
|
22
|
+
lastActivityAt: (lane: LaneRecord) => number;
|
|
23
|
+
hasOpenStory: (lane: LaneRecord) => boolean;
|
|
17
24
|
}): OrphanLaneFinding[];
|
|
18
25
|
//# sourceMappingURL=orphans.d.ts.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LanesError } from "./errors.js";
|
|
2
2
|
import { isTerminalLaneState } from "./types.js";
|
|
3
3
|
export const DEFAULT_ORPHAN_MIN_AGE_MINUTES = 30;
|
|
4
|
+
export const DEFAULT_ORPHAN_USER_IDLE_HOURS = 72;
|
|
4
5
|
const LIVE_LANE_STATES = new Set(["running", "queued", "blocked", "awaiting_input"]);
|
|
5
6
|
export function parseOrphanMinAge(value) {
|
|
6
7
|
if (value === undefined)
|
|
@@ -14,6 +15,18 @@ export function parseOrphanMinAge(value) {
|
|
|
14
15
|
}
|
|
15
16
|
return minutes;
|
|
16
17
|
}
|
|
18
|
+
export function parseOrphanUserIdleHours(value) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return DEFAULT_ORPHAN_USER_IDLE_HOURS;
|
|
21
|
+
if (!/^(0|[1-9]\d*)$/.test(value)) {
|
|
22
|
+
throw new LanesError("orphan_user_idle_hours_invalid", "User-senior idle hours must be a non-negative whole number", { value });
|
|
23
|
+
}
|
|
24
|
+
const hours = Number(value);
|
|
25
|
+
if (!Number.isSafeInteger(hours) || hours > Math.floor(Number.MAX_SAFE_INTEGER / 3_600_000)) {
|
|
26
|
+
throw new LanesError("orphan_user_idle_hours_invalid", "User-senior idle hours are outside the supported range", { value });
|
|
27
|
+
}
|
|
28
|
+
return hours;
|
|
29
|
+
}
|
|
17
30
|
export function findOrphanLanes(input) {
|
|
18
31
|
const nowMs = input.now.getTime();
|
|
19
32
|
if (!Number.isFinite(nowMs)) {
|
|
@@ -21,12 +34,24 @@ export function findOrphanLanes(input) {
|
|
|
21
34
|
}
|
|
22
35
|
const byId = new Map(input.lanes.map((lane) => [lane.lane_id, lane]));
|
|
23
36
|
const minAgeMs = input.minAgeMinutes * 60_000;
|
|
37
|
+
const userIdleMs = input.userIdleHours * 3_600_000;
|
|
24
38
|
const findings = [];
|
|
25
39
|
for (const lane of input.lanes) {
|
|
40
|
+
if (!input.candidateLaneIds.has(lane.lane_id))
|
|
41
|
+
continue;
|
|
26
42
|
if (!LIVE_LANE_STATES.has(lane.state))
|
|
27
43
|
continue;
|
|
28
|
-
if (lane.keep_alive_standing === true
|
|
44
|
+
if (lane.keep_alive_standing === true)
|
|
29
45
|
continue;
|
|
46
|
+
let userLastActivity;
|
|
47
|
+
if (lane.senior.kind === "user") {
|
|
48
|
+
userLastActivity = input.lastActivityAt(lane);
|
|
49
|
+
if (!Number.isFinite(userLastActivity)) {
|
|
50
|
+
throw new LanesError("orphan_lane_timestamp_invalid", `Lane ${lane.lane_id} has an invalid last activity timestamp`, { lane_id: lane.lane_id, field: "last_activity" });
|
|
51
|
+
}
|
|
52
|
+
if (userLastActivity > nowMs || nowMs - userLastActivity < userIdleMs || input.hasOpenStory(lane))
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
30
55
|
const createdAt = Date.parse(lane.created_at);
|
|
31
56
|
if (!Number.isFinite(createdAt)) {
|
|
32
57
|
throw new LanesError("orphan_lane_timestamp_invalid", `Lane ${lane.lane_id} has an invalid created_at timestamp`, { lane_id: lane.lane_id, field: "created_at" });
|
|
@@ -38,20 +63,29 @@ export function findOrphanLanes(input) {
|
|
|
38
63
|
if (ageMs < minAgeMs)
|
|
39
64
|
continue;
|
|
40
65
|
let seniorState;
|
|
41
|
-
|
|
66
|
+
let reason;
|
|
67
|
+
if (lane.senior.kind === "user") {
|
|
68
|
+
seniorState = "user_idle";
|
|
69
|
+
reason = "user_idle";
|
|
70
|
+
}
|
|
71
|
+
else if (lane.senior.kind === "lane") {
|
|
42
72
|
const senior = byId.get(lane.senior.id ?? "");
|
|
43
73
|
if (!senior) {
|
|
44
74
|
seniorState = "missing";
|
|
75
|
+
reason = "senior_missing";
|
|
45
76
|
}
|
|
46
77
|
else if (isTerminalLaneState(senior.state)) {
|
|
47
78
|
seniorState = senior.state;
|
|
79
|
+
reason = "senior_ended";
|
|
48
80
|
}
|
|
49
81
|
}
|
|
50
82
|
else if (lane.senior.kind === "session" && lane.senior.id !== undefined) {
|
|
51
|
-
if (input.sessionState(lane.senior.id, input.now) === "ended")
|
|
83
|
+
if (input.sessionState(lane.senior.id, input.now) === "ended") {
|
|
52
84
|
seniorState = "ended";
|
|
85
|
+
reason = "senior_ended";
|
|
86
|
+
}
|
|
53
87
|
}
|
|
54
|
-
if (seniorState === undefined)
|
|
88
|
+
if (seniorState === undefined || reason === undefined)
|
|
55
89
|
continue;
|
|
56
90
|
findings.push({
|
|
57
91
|
lane_id: lane.lane_id,
|
|
@@ -60,6 +94,7 @@ export function findOrphanLanes(input) {
|
|
|
60
94
|
age_minutes: Math.floor(ageMs / 60_000),
|
|
61
95
|
account: lane.account ?? null,
|
|
62
96
|
host: { ...lane.host },
|
|
97
|
+
reason,
|
|
63
98
|
});
|
|
64
99
|
}
|
|
65
100
|
return findings.sort((a, b) => b.age_minutes - a.age_minutes || a.lane_id.localeCompare(b.lane_id));
|
|
@@ -10,25 +10,9 @@ export interface TrackerRecord {
|
|
|
10
10
|
updated_at: string;
|
|
11
11
|
[key: string]: unknown;
|
|
12
12
|
}
|
|
13
|
-
export interface
|
|
14
|
-
kind: TrackerKind;
|
|
15
|
-
id?: string;
|
|
16
|
-
company: string;
|
|
17
|
-
project_id: string;
|
|
18
|
-
[key: string]: unknown;
|
|
19
|
-
}
|
|
20
|
-
export type StoreValue<T> = T | Promise<T>;
|
|
21
|
-
export interface TrackerStore {
|
|
22
|
-
read(kind: TrackerKind, id: string): StoreValue<TrackerRecord | null>;
|
|
23
|
-
list(kind: TrackerKind): StoreValue<TrackerRecord[]>;
|
|
24
|
-
create(record: TrackerRecordInput): StoreValue<TrackerRecord>;
|
|
25
|
-
update(kind: TrackerKind, id: string, expectedVersion: number, change: (current: TrackerRecord) => TrackerRecord): StoreValue<TrackerRecord>;
|
|
26
|
-
}
|
|
27
|
-
export interface LocalTrackerStore extends TrackerStore {
|
|
13
|
+
export interface LocalTrackerStore {
|
|
28
14
|
read(kind: TrackerKind, id: string): TrackerRecord | null;
|
|
29
15
|
list(kind: TrackerKind): TrackerRecord[];
|
|
30
|
-
create(record: TrackerRecordInput): TrackerRecord;
|
|
31
|
-
update(kind: TrackerKind, id: string, expectedVersion: number, change: (current: TrackerRecord) => TrackerRecord): TrackerRecord;
|
|
32
16
|
}
|
|
33
|
-
export declare function createLocalTrackerStore(projectDirectory: string
|
|
17
|
+
export declare function createLocalTrackerStore(projectDirectory: string): LocalTrackerStore;
|
|
34
18
|
//# sourceMappingURL=tracker-store.d.ts.map
|