@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
|
@@ -7,10 +7,11 @@ import { evaluateTrackerChecks } from "../lib/lanes/tracker-checks.js";
|
|
|
7
7
|
import { LANE_STORE_UPGRADE_COMMAND, isLaneStoreVersionNewer, laneRecordPath, listLaneIds, listQuestionIds, questionRecordPath, readLane, readQuestion } from "../lib/lanes/store.js";
|
|
8
8
|
import { createLocalTrackerStore } from "../lib/lanes/tracker-store.js";
|
|
9
9
|
import { sessionLiveness } from "../lib/lanes/liveness.js";
|
|
10
|
-
import {
|
|
10
|
+
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
11
|
+
import { DEFAULT_ORPHAN_USER_IDLE_HOURS, findOrphanLanes, parseOrphanMinAge, parseOrphanUserIdleHours, } from "../lib/lanes/orphans.js";
|
|
12
|
+
import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
|
|
11
13
|
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
12
14
|
import { checkCompanyPath } from "../lib/lanes/checked-company-path.js";
|
|
13
|
-
import { LANES_TRACKER_FLAG } from "./lanes-tracker.js";
|
|
14
15
|
function fail(error, message, details = {}) {
|
|
15
16
|
throw new LanesError(error, message, details);
|
|
16
17
|
}
|
|
@@ -293,46 +294,176 @@ function readScopedLanes(root, company, projects) {
|
|
|
293
294
|
return [readLane(root, id)];
|
|
294
295
|
});
|
|
295
296
|
}
|
|
296
|
-
function readCompanyLanes(root, company, projectId) {
|
|
297
|
-
return listLaneIds(root).flatMap((id) => {
|
|
298
|
-
const file = laneRecordPath(root, id);
|
|
299
|
-
const metadata = readJsonObject(file);
|
|
300
|
-
if (metadata === null)
|
|
301
|
-
return fail("tracker_checks_input_unreadable", "Lane record could not be read while checking its scope", { path: file });
|
|
302
|
-
const laneCompany = isObject(metadata.company) ? metadata.company : null;
|
|
303
|
-
if (laneCompany?.slug !== company.slug || laneCompany.uid !== company.uid)
|
|
304
|
-
return [];
|
|
305
|
-
if (projectId !== undefined && !laneMetadataMatchesScope(metadata, company, new Set([projectId])))
|
|
306
|
-
return [];
|
|
307
|
-
return [readLane(root, id)];
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
297
|
function orphanSessionState(root, sessionId, now, deps) {
|
|
311
298
|
if (deps.sessionState)
|
|
312
299
|
return deps.sessionState(root, sessionId, now);
|
|
313
300
|
return sessionLiveness(root, sessionId, { nowMs: now.getTime() });
|
|
314
301
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
302
|
+
const MAX_ORPHAN_SENIOR_DEPTH = 64;
|
|
303
|
+
function readOrphanLaneScopes(root) {
|
|
304
|
+
return new Map(listLaneIds(root).map((id) => {
|
|
305
|
+
const file = laneRecordPath(root, id);
|
|
306
|
+
const metadata = readJsonObject(file);
|
|
307
|
+
if (metadata === null)
|
|
308
|
+
return fail("tracker_checks_input_unreadable", "Lane record could not be read while resolving orphan scope", { path: file });
|
|
309
|
+
const companyRef = (value) => isObject(value) && typeof value.slug === "string" && typeof value.uid === "string"
|
|
310
|
+
? { slug: value.slug, uid: value.uid }
|
|
311
|
+
: null;
|
|
312
|
+
const meshCompany = companyRef(metadata.mesh_company);
|
|
313
|
+
const lane = {
|
|
314
|
+
lane_id: id,
|
|
315
|
+
company: companyRef(metadata.company),
|
|
316
|
+
...(meshCompany === null ? {} : { mesh_company: meshCompany }),
|
|
317
|
+
project_id: typeof metadata.project_id === "string" ? metadata.project_id : "",
|
|
318
|
+
};
|
|
319
|
+
const seniorLaneId = isObject(metadata.senior) && metadata.senior.kind === "lane" && typeof metadata.senior.id === "string"
|
|
320
|
+
? metadata.senior.id
|
|
321
|
+
: undefined;
|
|
322
|
+
const projectIds = [lane.project_id];
|
|
323
|
+
if (Array.isArray(metadata.stories)) {
|
|
324
|
+
for (const story of metadata.stories) {
|
|
325
|
+
if (isObject(story) && typeof story.project_id === "string")
|
|
326
|
+
projectIds.push(story.project_id);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return [id, { lane, ...(seniorLaneId === undefined ? {} : { seniorLaneId }), projectIds }];
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
function orphanCompanyResolution(root, scope, scopesById) {
|
|
333
|
+
const direct = resolveLaneCompany(root, scope.lane);
|
|
334
|
+
if (direct.company !== null)
|
|
335
|
+
return direct;
|
|
336
|
+
const visited = new Set([scope.lane.lane_id]);
|
|
337
|
+
let current = scope;
|
|
338
|
+
for (let depth = 0; depth < MAX_ORPHAN_SENIOR_DEPTH; depth += 1) {
|
|
339
|
+
const seniorId = current.seniorLaneId;
|
|
340
|
+
if (seniorId === undefined)
|
|
341
|
+
break;
|
|
342
|
+
if (visited.has(seniorId))
|
|
343
|
+
break;
|
|
344
|
+
visited.add(seniorId);
|
|
345
|
+
const senior = scopesById.get(seniorId);
|
|
346
|
+
if (senior === undefined)
|
|
347
|
+
break;
|
|
348
|
+
const resolved = resolveLaneCompany(root, senior.lane);
|
|
349
|
+
if (resolved.company !== null)
|
|
350
|
+
return { company: resolved.company, source: "senior" };
|
|
351
|
+
current = senior;
|
|
352
|
+
}
|
|
353
|
+
return { company: null, source: "unresolved" };
|
|
354
|
+
}
|
|
355
|
+
function orphanLastActivityAt(root, lane) {
|
|
356
|
+
const updatedAt = Date.parse(lane.updated_at);
|
|
357
|
+
if (!Number.isFinite(updatedAt))
|
|
358
|
+
return Number.NaN;
|
|
359
|
+
const recordedEnvelopeAt = lane.last_envelope === undefined ? Number.NaN : Date.parse(lane.last_envelope.at);
|
|
360
|
+
let latest = Number.isFinite(recordedEnvelopeAt) ? Math.max(updatedAt, recordedEnvelopeAt) : updatedAt;
|
|
361
|
+
const envelopesDirectory = path.join(root, LANES_RUNS_REL, lane.lane_id, "envelopes");
|
|
362
|
+
let entries;
|
|
363
|
+
try {
|
|
364
|
+
entries = fs.readdirSync(envelopesDirectory, { withFileTypes: true });
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (err.code === "ENOENT")
|
|
368
|
+
return latest;
|
|
369
|
+
return fail("orphan_envelope_unreadable", "Lane envelope directory could not be read", { lane_id: lane.lane_id });
|
|
370
|
+
}
|
|
371
|
+
for (const entry of entries) {
|
|
372
|
+
if (!entry.name.endsWith(".json") || entry.name.startsWith("."))
|
|
321
373
|
continue;
|
|
322
|
-
const file =
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
374
|
+
const file = path.join(envelopesDirectory, entry.name);
|
|
375
|
+
try {
|
|
376
|
+
const stat = fs.lstatSync(file);
|
|
377
|
+
if (stat.isFile() && !stat.isSymbolicLink() && Number.isFinite(stat.mtimeMs)) {
|
|
378
|
+
latest = Math.max(latest, stat.mtimeMs);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (err.code === "ENOENT")
|
|
383
|
+
continue;
|
|
384
|
+
return fail("orphan_envelope_unreadable", "Lane envelope timestamp could not be read", { lane_id: lane.lane_id });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return latest;
|
|
388
|
+
}
|
|
389
|
+
function orphanLaneHasOpenStory(root, lane, company) {
|
|
390
|
+
const carriedStories = lane.stories ?? [];
|
|
391
|
+
const references = [];
|
|
392
|
+
const addReference = (project_id, story_id) => {
|
|
393
|
+
if (!references.some((reference) => reference.project_id === project_id && reference.story_id === story_id)) {
|
|
394
|
+
references.push({ project_id, story_id });
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
if (lane.story_id !== "")
|
|
398
|
+
addReference(lane.project_id, lane.story_id);
|
|
399
|
+
for (const story of carriedStories) {
|
|
400
|
+
addReference(story.project_id, story.story_id);
|
|
401
|
+
if (story.status !== "done")
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
if (references.length === 0)
|
|
405
|
+
return false;
|
|
406
|
+
if (company === null)
|
|
407
|
+
return true;
|
|
408
|
+
for (const reference of references) {
|
|
409
|
+
const resolution = resolveLaneCompany(root, {
|
|
410
|
+
lane_id: lane.lane_id,
|
|
411
|
+
company: lane.company,
|
|
412
|
+
project_id: reference.project_id,
|
|
413
|
+
});
|
|
414
|
+
if (resolution.company === null || resolution.company.slug !== company.slug || resolution.company.uid !== company.uid) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
const projectId = validSegment(reference.project_id, "project");
|
|
418
|
+
const prdPath = path.join(root, "companies", company.slug, "projects", projectId, "prd.json");
|
|
419
|
+
const stories = readProject(root, company.slug, prdPath, true).stories;
|
|
420
|
+
const matching = stories.filter((story) => story.id === reference.story_id);
|
|
421
|
+
if (matching.some((story) => !storyIsDone(story)))
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
function orphanFindings(root, company, now, minAgeMinutes, deps, projectId, unscoped = company === null, userIdleHours = DEFAULT_ORPHAN_USER_IDLE_HOURS) {
|
|
427
|
+
const scopesById = readOrphanLaneScopes(root);
|
|
428
|
+
const companyByLane = new Map();
|
|
429
|
+
const selectedScopes = [...scopesById.values()].filter((scope) => {
|
|
430
|
+
if (projectId !== undefined && !scope.projectIds.includes(projectId))
|
|
431
|
+
return false;
|
|
432
|
+
const rawCompany = scope.lane.company;
|
|
433
|
+
if (company !== null && rawCompany !== null && (rawCompany.slug !== company.slug || rawCompany.uid !== company.uid))
|
|
434
|
+
return false;
|
|
435
|
+
const resolution = orphanCompanyResolution(root, scope, scopesById);
|
|
436
|
+
companyByLane.set(scope.lane.lane_id, resolution);
|
|
437
|
+
if (unscoped)
|
|
438
|
+
return resolution.company === null;
|
|
439
|
+
return company !== null && resolution.company?.slug === company.slug && resolution.company.uid === company.uid;
|
|
440
|
+
});
|
|
441
|
+
const selectedLanes = selectedScopes.map((scope) => readLane(root, scope.lane.lane_id));
|
|
442
|
+
const candidates = selectedLanes.filter((lane) => projectId === undefined ||
|
|
443
|
+
lane.project_id === projectId || (lane.stories ?? []).some((story) => story.project_id === projectId));
|
|
444
|
+
const candidateIds = new Set(candidates.map((lane) => lane.lane_id));
|
|
445
|
+
const lanesForFinding = new Map(candidates.map((lane) => [lane.lane_id, lane]));
|
|
446
|
+
for (const lane of candidates) {
|
|
447
|
+
if (lane.senior.kind !== "lane" || lane.senior.id === undefined || lanesForFinding.has(lane.senior.id))
|
|
326
448
|
continue;
|
|
327
|
-
|
|
328
|
-
|
|
449
|
+
if (scopesById.has(lane.senior.id))
|
|
450
|
+
lanesForFinding.set(lane.senior.id, readLane(root, lane.senior.id));
|
|
329
451
|
}
|
|
330
452
|
return findOrphanLanes({
|
|
331
|
-
lanes,
|
|
453
|
+
lanes: [...lanesForFinding.values()],
|
|
454
|
+
candidateLaneIds: candidateIds,
|
|
332
455
|
now,
|
|
333
456
|
minAgeMinutes,
|
|
457
|
+
userIdleHours,
|
|
334
458
|
sessionState: (sessionId, at) => orphanSessionState(root, sessionId, at, deps),
|
|
335
|
-
|
|
459
|
+
lastActivityAt: (lane) => orphanLastActivityAt(root, lane),
|
|
460
|
+
hasOpenStory: (lane) => orphanLaneHasOpenStory(root, lane, companyByLane.get(lane.lane_id)?.company ?? null),
|
|
461
|
+
})
|
|
462
|
+
.filter((finding) => candidateIds.has(finding.lane_id))
|
|
463
|
+
.map((finding) => ({
|
|
464
|
+
...finding,
|
|
465
|
+
company_source: companyByLane.get(finding.lane_id)?.source ?? "unresolved",
|
|
466
|
+
}));
|
|
336
467
|
}
|
|
337
468
|
function sameCompanyLane(lane, company) {
|
|
338
469
|
return lane.company?.slug === company.slug && lane.company.uid === company.uid;
|
|
@@ -582,9 +713,6 @@ export async function resolveTrackerCompany(deps, requested) {
|
|
|
582
713
|
const company = await deps.resolveCompany(requested);
|
|
583
714
|
if (!company.slug)
|
|
584
715
|
fail("tracker_company_unresolved", "The active HQ company could not be resolved", {});
|
|
585
|
-
if (!await deps.trackerEnabled(company)) {
|
|
586
|
-
fail("tracker_disabled", `hq lanes tracker commands require the ${LANES_TRACKER_FLAG} flag`, { company: company.slug });
|
|
587
|
-
}
|
|
588
716
|
return company;
|
|
589
717
|
}
|
|
590
718
|
async function checkReport(root, company, projects, directiveProjects, projectFilter, minAgeMinutes, now, deps) {
|
|
@@ -631,8 +759,8 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
|
|
|
631
759
|
result: orphanedLanes.length === 0 ? "ok" : "failing",
|
|
632
760
|
items: orphanedLanes.map((finding) => ({
|
|
633
761
|
id: finding.lane_id,
|
|
634
|
-
reason: finding.
|
|
635
|
-
|
|
762
|
+
reason: finding.reason,
|
|
763
|
+
company_source: finding.company_source,
|
|
636
764
|
senior: finding.senior,
|
|
637
765
|
senior_state: finding.senior_state,
|
|
638
766
|
age_minutes: finding.age_minutes,
|
|
@@ -648,7 +776,7 @@ async function checkReport(root, company, projects, directiveProjects, projectFi
|
|
|
648
776
|
checks,
|
|
649
777
|
};
|
|
650
778
|
}
|
|
651
|
-
async function orphanReport(root, company, now, minAgeMinutes, deps, options) {
|
|
779
|
+
async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, deps, options) {
|
|
652
780
|
const reason = options.reason?.trim();
|
|
653
781
|
if (options.stop && !reason) {
|
|
654
782
|
fail("orphan_stop_reason_required", "--stop requires a non-empty --reason", {});
|
|
@@ -658,39 +786,63 @@ async function orphanReport(root, company, now, minAgeMinutes, deps, options) {
|
|
|
658
786
|
}
|
|
659
787
|
if (reason !== undefined)
|
|
660
788
|
assertNoSecretText("stop reason", reason);
|
|
661
|
-
const
|
|
662
|
-
|
|
789
|
+
const unscoped = options.unscoped === true;
|
|
790
|
+
const orphans = orphanFindings(root, company, now, minAgeMinutes, deps, undefined, unscoped, userIdleHours);
|
|
791
|
+
const output = (action, stopped) => {
|
|
792
|
+
if (options.human) {
|
|
793
|
+
const companyText = unscoped ? "unscoped" : company?.slug ?? "unknown";
|
|
794
|
+
console.log(`company: ${companyText}`);
|
|
795
|
+
console.log("lane_id\tsenior\tsenior_state\treason\tcompany_source\tage_minutes\taccount\thost");
|
|
796
|
+
for (const finding of orphans) {
|
|
797
|
+
console.log([
|
|
798
|
+
finding.lane_id,
|
|
799
|
+
`${finding.senior.kind}:${finding.senior.id ?? ""}`,
|
|
800
|
+
finding.senior_state,
|
|
801
|
+
finding.reason,
|
|
802
|
+
finding.company_source,
|
|
803
|
+
finding.age_minutes,
|
|
804
|
+
finding.account ?? "",
|
|
805
|
+
`${finding.host.kind}:${finding.host.id}`,
|
|
806
|
+
].join("\t"));
|
|
807
|
+
}
|
|
808
|
+
if (orphans.length === 0)
|
|
809
|
+
console.log("(none)");
|
|
810
|
+
if (stopped !== undefined) {
|
|
811
|
+
console.log("stopped:");
|
|
812
|
+
for (const entry of stopped)
|
|
813
|
+
console.log(`${entry.lane_id}\t${entry.state}`);
|
|
814
|
+
}
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
663
817
|
console.log(JSON.stringify({
|
|
664
818
|
ok: true,
|
|
665
|
-
action
|
|
666
|
-
company: company
|
|
819
|
+
action,
|
|
820
|
+
company: company?.slug ?? null,
|
|
821
|
+
...(unscoped ? { unscoped: true } : {}),
|
|
667
822
|
generated_at: now.toISOString(),
|
|
668
823
|
min_age_minutes: minAgeMinutes,
|
|
824
|
+
user_idle_hours: userIdleHours,
|
|
825
|
+
...(reason === undefined ? {} : { reason }),
|
|
669
826
|
orphans,
|
|
827
|
+
...(stopped === undefined ? {} : { stopped }),
|
|
670
828
|
}, null, 2));
|
|
829
|
+
};
|
|
830
|
+
if (!options.stop) {
|
|
831
|
+
output("orphans");
|
|
671
832
|
return;
|
|
672
833
|
}
|
|
673
834
|
if (!deps.stopLane)
|
|
674
835
|
fail("orphan_stop_unavailable", "The normal lane stop path is unavailable", {});
|
|
675
836
|
const stopped = [];
|
|
676
837
|
for (const finding of orphans) {
|
|
677
|
-
const fresh = orphanFindings(root, company, deps.now(), minAgeMinutes, deps)
|
|
838
|
+
const fresh = orphanFindings(root, company, deps.now(), minAgeMinutes, deps, undefined, unscoped, userIdleHours)
|
|
678
839
|
.find((candidate) => candidate.lane_id === finding.lane_id);
|
|
679
840
|
if (!fresh)
|
|
680
841
|
continue;
|
|
681
842
|
await deps.stopLane(finding.lane_id, reason);
|
|
682
843
|
stopped.push({ lane_id: finding.lane_id, state: readLane(root, finding.lane_id).state });
|
|
683
844
|
}
|
|
684
|
-
|
|
685
|
-
ok: true,
|
|
686
|
-
action: "orphans.stop",
|
|
687
|
-
company: company.slug,
|
|
688
|
-
generated_at: now.toISOString(),
|
|
689
|
-
min_age_minutes: minAgeMinutes,
|
|
690
|
-
reason,
|
|
691
|
-
orphans,
|
|
692
|
-
stopped,
|
|
693
|
-
}, null, 2));
|
|
845
|
+
output("orphans.stop", stopped);
|
|
694
846
|
}
|
|
695
847
|
export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
696
848
|
parent.command("checks")
|
|
@@ -713,16 +865,27 @@ export function registerLaneChecksCommand(parent, deps, wrap) {
|
|
|
713
865
|
process.exitCode = 2;
|
|
714
866
|
}));
|
|
715
867
|
parent.command("orphans")
|
|
716
|
-
.description("Report lanes whose
|
|
868
|
+
.description("Report lanes whose senior has ended or gone idle")
|
|
717
869
|
.option("--company <slug>", "Select the company by slug")
|
|
718
870
|
.option("--min-age <minutes>", "Minimum orphan lane age in minutes (default: 30)")
|
|
871
|
+
.option("--user-idle-hours <hours>", "Idle hours for user-senior lanes (default: 72)")
|
|
872
|
+
.option("--unscoped", "Report candidates whose company cannot be resolved")
|
|
719
873
|
.option("--stop", "Stop the reported lanes through their normal owner path")
|
|
720
874
|
.option("--reason <text>", "Required audit reason when --stop is set")
|
|
875
|
+
.option("--json", "Print machine-readable JSON")
|
|
876
|
+
.option("--human", "Print a human-readable table")
|
|
721
877
|
.action(wrap(async (options) => {
|
|
722
|
-
|
|
878
|
+
if (options.unscoped && options.company !== undefined) {
|
|
879
|
+
fail("orphan_scope_conflict", "--unscoped cannot be combined with --company", {});
|
|
880
|
+
}
|
|
881
|
+
if (options.human && options.json) {
|
|
882
|
+
fail("conflicting_output_format", "Choose either --json or --human output", {});
|
|
883
|
+
}
|
|
884
|
+
const company = options.unscoped ? null : await deps.resolveCompany(options.company);
|
|
723
885
|
const root = deps.hqRoot();
|
|
724
886
|
const minAgeMinutes = parseOrphanMinAge(options.minAge);
|
|
725
|
-
|
|
887
|
+
const userIdleHours = parseOrphanUserIdleHours(options.userIdleHours);
|
|
888
|
+
await orphanReport(root, company, deps.now(), minAgeMinutes, userIdleHours, deps, options);
|
|
726
889
|
}));
|
|
727
890
|
}
|
|
728
891
|
//# sourceMappingURL=lanes-checks.js.map
|
|
@@ -547,13 +547,23 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
|
|
|
547
547
|
let reviewContext = null;
|
|
548
548
|
if (holdFlagOff) {
|
|
549
549
|
reviewContext = await reviewCheckContext(hqRoot, deps);
|
|
550
|
-
|
|
550
|
+
}
|
|
551
|
+
let parsed;
|
|
552
|
+
try {
|
|
553
|
+
parsed = parseMergeCommands(options.command);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
if (holdFlagOff && !reviewContext) {
|
|
551
557
|
printJson({ ok: true, hold_enforcement: "off" });
|
|
552
558
|
return;
|
|
553
559
|
}
|
|
560
|
+
throw error;
|
|
554
561
|
}
|
|
555
|
-
const parsed = parseMergeCommands(options.command);
|
|
556
562
|
repoUnresolved = parsed.targets.some((target) => target.repo === null);
|
|
563
|
+
if (holdFlagOff && !reviewContext && !parsed.unparseableMerge) {
|
|
564
|
+
printJson({ ok: true, hold_enforcement: "off" });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
557
567
|
const repos = parsed.targets.flatMap((target) => target.repo === null ? [] : [target.repo]);
|
|
558
568
|
const now = deps.now();
|
|
559
569
|
if (!holdFlagOff) {
|
|
@@ -581,6 +591,29 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
|
|
|
581
591
|
return;
|
|
582
592
|
}
|
|
583
593
|
}
|
|
594
|
+
if (parsed.unparseableMerge) {
|
|
595
|
+
const activeHolds = listHolds(hqRoot)
|
|
596
|
+
.filter((existing) => holdView(existing, now).active);
|
|
597
|
+
let deniedUnresolvedHold;
|
|
598
|
+
for (const existing of activeHolds) {
|
|
599
|
+
const enabled = await (deps.mergeHoldEnabled ?? ((company) => readLanesRegistryFlag(LANES_REPO_MERGE_HOLD_FLAG, { company, env: deps.env })))(existing.company);
|
|
600
|
+
if (enabled) {
|
|
601
|
+
deniedUnresolvedHold ??= existing;
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
gateWasOff = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (deniedUnresolvedHold) {
|
|
608
|
+
printJson({
|
|
609
|
+
ok: false,
|
|
610
|
+
error: "repo_hold_command_invalid",
|
|
611
|
+
repo_unresolved: true,
|
|
612
|
+
});
|
|
613
|
+
process.exitCode = 3;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
584
617
|
reviewContext ??= await reviewCheckContext(hqRoot, deps);
|
|
585
618
|
if (reviewContext) {
|
|
586
619
|
const reviewCheck = checkReviewTargets(hqRoot, reviewContext, parsed.targets);
|
|
@@ -589,6 +622,11 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
|
|
|
589
622
|
process.exitCode = 3;
|
|
590
623
|
return;
|
|
591
624
|
}
|
|
625
|
+
if (parsed.unparseableMerge) {
|
|
626
|
+
printJson({ ok: false, error: "review_record_missing", repo_unresolved: true });
|
|
627
|
+
process.exitCode = 3;
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
592
630
|
reviewUnresolved = reviewCheck.unresolved;
|
|
593
631
|
}
|
|
594
632
|
printJson({
|
|
@@ -3,38 +3,115 @@ import * as path from "node:path";
|
|
|
3
3
|
import { appendCappedHistory, appendDropboxMessage, deliveryRefusalReason, dropboxInboxPath, laneAcceptsOperatorMessage, laneDeliveryMechanism, operatorMessageRefusalReason, providerSessionPortable, providerSupportsSessionResume, readLane, sessionNotPortableMessage, validateProjectStories, withLaneAdmissionLock } from "../lib/lanes/index.js";
|
|
4
4
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
5
5
|
import { parseCliEffort } from "../lib/lanes/effort.js";
|
|
6
|
-
import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
|
|
6
|
+
import { parseOutpostHireGrantContext, sameHireGrantSenior } from "../lib/lanes/hire-grant-dispatch.js";
|
|
7
7
|
import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
|
|
8
8
|
import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
|
|
9
9
|
import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
|
|
10
10
|
import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
|
|
11
11
|
import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
12
|
-
import { keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
|
|
12
|
+
import { keepAliveExclusion, keepAliveSupersededBy, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
|
|
13
13
|
import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
|
|
14
14
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
15
15
|
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
16
16
|
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
17
17
|
export function createLaneMessageHandlers(deps) {
|
|
18
18
|
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
19
|
-
|
|
19
|
+
function currentSuccessorOf(lane, lanes, lineageAware, companyForLane) {
|
|
20
|
+
const validLanes = lanes.filter((candidate) => Number.isFinite(Date.parse(candidate.created_at)));
|
|
21
|
+
const newestFirst = [...validLanes].sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at));
|
|
22
|
+
const visited = new Set([lane.lane_id]);
|
|
23
|
+
let current = lane;
|
|
24
|
+
for (let depth = 0; depth < validLanes.length; depth += 1) {
|
|
25
|
+
const next = keepAliveSupersededBy(current, newestFirst, lineageAware, companyForLane);
|
|
26
|
+
if (!next || visited.has(next.lane_id))
|
|
27
|
+
break;
|
|
28
|
+
current = next;
|
|
29
|
+
visited.add(next.lane_id);
|
|
30
|
+
}
|
|
31
|
+
return current.lane_id === lane.lane_id ? undefined : current;
|
|
32
|
+
}
|
|
33
|
+
function callerMayMessageSupersededLane(ctx, lane, lanes, lineageAware, companyForLane, callerLaneIdOverride) {
|
|
34
|
+
if (lane.senior.kind !== "lane")
|
|
35
|
+
return false;
|
|
36
|
+
const callerLaneId = callerLaneIdOverride ?? ctx.env.HQ_LANE_ID?.trim();
|
|
37
|
+
if (!callerLaneId)
|
|
38
|
+
return false;
|
|
39
|
+
const senior = lanes.find((candidate) => candidate.lane_id === lane.senior.id);
|
|
40
|
+
const caller = lanes.find((candidate) => candidate.lane_id === callerLaneId);
|
|
41
|
+
if (!senior || !caller)
|
|
42
|
+
return false;
|
|
43
|
+
const workerCompany = companyForLane(lane);
|
|
44
|
+
const seniorCompany = companyForLane(senior);
|
|
45
|
+
const callerCompany = companyForLane(caller);
|
|
46
|
+
const projectId = lane.project_id;
|
|
47
|
+
if (!workerCompany ||
|
|
48
|
+
seniorCompany?.uid !== workerCompany.uid ||
|
|
49
|
+
callerCompany?.uid !== workerCompany.uid ||
|
|
50
|
+
!projectId ||
|
|
51
|
+
senior.project_id !== projectId ||
|
|
52
|
+
caller.project_id !== projectId)
|
|
53
|
+
return false;
|
|
54
|
+
if (caller.lane_id === senior.lane_id)
|
|
55
|
+
return true;
|
|
56
|
+
const projectLanes = lanes.filter((candidate) => companyForLane(candidate)?.uid === workerCompany.uid && candidate.project_id === projectId);
|
|
57
|
+
return currentSuccessorOf(lane, projectLanes, lineageAware, companyForLane)?.lane_id === caller.lane_id;
|
|
58
|
+
}
|
|
59
|
+
function keepAliveLaneFromSnapshot(snapshot) {
|
|
60
|
+
if (snapshot.created_at === undefined || !Object.hasOwn(snapshot, "company"))
|
|
61
|
+
return undefined;
|
|
62
|
+
return {
|
|
63
|
+
lane_id: snapshot.lane_id,
|
|
64
|
+
created_at: snapshot.created_at,
|
|
65
|
+
senior: snapshot.senior,
|
|
66
|
+
company: snapshot.company ?? null,
|
|
67
|
+
project_id: snapshot.project_id ?? "",
|
|
68
|
+
...(snapshot.worker_profile === undefined ? {} : { worker_profile: snapshot.worker_profile }),
|
|
69
|
+
...(snapshot.hire_grant === undefined ? {} : { hire_grant: snapshot.hire_grant }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function messageLaneExclusion(ctx, lane, grantContext) {
|
|
20
73
|
if (lane.state === "killed" || lane.state === "cancelled") {
|
|
21
74
|
return { lane, exclusion: lane.state };
|
|
22
75
|
}
|
|
23
|
-
const
|
|
76
|
+
const companyForLane = (candidate) => resolveLaneCompany(ctx.hqRoot(), candidate).company;
|
|
24
77
|
const lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
|
|
25
|
-
company:
|
|
78
|
+
company: companyForLane(lane),
|
|
26
79
|
env: ctx.env,
|
|
27
80
|
});
|
|
28
|
-
const currentLane = readLane(hqRoot, lane.lane_id);
|
|
81
|
+
const currentLane = readLane(ctx.hqRoot(), lane.lane_id);
|
|
29
82
|
if (currentLane.state === "killed" || currentLane.state === "cancelled") {
|
|
30
83
|
return { lane: currentLane, exclusion: currentLane.state };
|
|
31
84
|
}
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
85
|
+
const localLanes = loadAllLanes(ctx.hqRoot());
|
|
86
|
+
let lanes = localLanes;
|
|
87
|
+
const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
|
|
88
|
+
const dispatchedCallerId = grantContext?.superseded_message_caller_id;
|
|
89
|
+
const dispatchedContextAuthorized = grantContext !== undefined &&
|
|
90
|
+
dispatchedCallerId !== undefined &&
|
|
91
|
+
ctx.isSsmOwnerDispatch() &&
|
|
92
|
+
sameHireGrantSenior(grantContext.expected_senior, currentLane.senior);
|
|
93
|
+
if (dispatchedContextAuthorized && grantContext) {
|
|
94
|
+
const merged = new Map(localLanes.map((record) => [record.lane_id, record]));
|
|
95
|
+
for (const snapshot of grantContext.hierarchy) {
|
|
96
|
+
const contextLane = keepAliveLaneFromSnapshot(snapshot);
|
|
97
|
+
if (contextLane)
|
|
98
|
+
merged.set(contextLane.lane_id, contextLane);
|
|
99
|
+
}
|
|
100
|
+
merged.set(currentLane.lane_id, currentLane);
|
|
101
|
+
lanes = [...merged.values()];
|
|
102
|
+
}
|
|
103
|
+
const exclusion = keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware, companyForLane);
|
|
104
|
+
const callerLaneId = dispatchedContextAuthorized ? dispatchedCallerId : undefined;
|
|
105
|
+
if (exclusion === "superseded" && callerMayMessageSupersededLane(ctx, currentLane, lanes, lineageAware, companyForLane, callerLaneId)) {
|
|
106
|
+
return {
|
|
107
|
+
lane: currentLane,
|
|
108
|
+
exclusion: undefined,
|
|
109
|
+
...(!dispatchedContextAuthorized && ctx.env.HQ_LANE_ID?.trim()
|
|
110
|
+
? { supersededMessageCallerId: ctx.env.HQ_LANE_ID.trim() }
|
|
111
|
+
: {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { lane: currentLane, exclusion };
|
|
38
115
|
}
|
|
39
116
|
function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
|
|
40
117
|
writeWatcherDecision(watcherOutLogPath(ctx.hqRoot(), lane.lane_id), {
|
|
@@ -68,7 +145,14 @@ export function createLaneMessageHandlers(deps) {
|
|
|
68
145
|
const resolution = await deps.resolveCommandLane(ctx, laneId);
|
|
69
146
|
let lane = resolution.lane;
|
|
70
147
|
if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
|
|
71
|
-
|
|
148
|
+
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
149
|
+
const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
|
|
150
|
+
? undefined
|
|
151
|
+
: (await messageLaneExclusion(ctx, lane)).supersededMessageCallerId;
|
|
152
|
+
await dispatchOutpostLaneCommand(ctx, lane, "message", {
|
|
153
|
+
...opts,
|
|
154
|
+
...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
|
|
155
|
+
}, resolution.remoteOnly
|
|
72
156
|
? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
|
|
73
157
|
: undefined);
|
|
74
158
|
return;
|
|
@@ -115,7 +199,7 @@ export function createLaneMessageHandlers(deps) {
|
|
|
115
199
|
}
|
|
116
200
|
}
|
|
117
201
|
try {
|
|
118
|
-
const eligibility = await messageLaneExclusion(ctx, lane);
|
|
202
|
+
const eligibility = await messageLaneExclusion(ctx, lane, hireGrantContext);
|
|
119
203
|
lane = eligibility.lane;
|
|
120
204
|
const exclusion = eligibility.exclusion;
|
|
121
205
|
if (exclusion !== undefined) {
|
|
@@ -41,6 +41,8 @@ export interface LaneMessageOptions {
|
|
|
41
41
|
effort?: string;
|
|
42
42
|
worker?: string;
|
|
43
43
|
grantContext?: string;
|
|
44
|
+
/** Primary-verified caller identity for a superseded Outpost message. */
|
|
45
|
+
supersededMessageCallerId?: string;
|
|
44
46
|
monitorEvent?: {
|
|
45
47
|
monitor_id: string;
|
|
46
48
|
description: string;
|
|
@@ -237,9 +239,8 @@ export interface Ctx {
|
|
|
237
239
|
storyLedgerEnabled: () => boolean;
|
|
238
240
|
isSsmOwnerDispatch: () => boolean;
|
|
239
241
|
withGrantHireLocks: typeof withLaneHireLocks;
|
|
240
|
-
|
|
242
|
+
mergeHoldEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
241
243
|
mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
242
|
-
trackerActor: () => string | null;
|
|
243
244
|
}
|
|
244
245
|
export interface LaneWaitCommandOptions {
|
|
245
246
|
any?: string[];
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -90,12 +90,10 @@ export interface LanesCommandDeps {
|
|
|
90
90
|
isSsmOwnerDispatch?: () => boolean;
|
|
91
91
|
/** Test seam for changing a lane between pre-lock and locked grant reads. */
|
|
92
92
|
withGrantHireLocks?: typeof withLaneHireLocks;
|
|
93
|
-
/** Test seam for the hq-flags gated tracker commands. */
|
|
94
|
-
laneTrackerEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
95
93
|
/** Test seam for the review-before-merge registry flag. */
|
|
96
94
|
mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
97
|
-
/** Test seam for
|
|
98
|
-
|
|
95
|
+
/** Test seam for the repository-merge-hold registry flag. */
|
|
96
|
+
mergeHoldEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
99
97
|
}
|
|
100
98
|
export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
101
99
|
export declare function parseSeniorRef(raw: string): SeniorRef;
|