@indigoai-us/hq-cli 5.273.0-alpha.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 +25 -2
- package/dist/command-catalog.generated.d.ts +11 -536
- package/dist/command-catalog.generated.js +14 -691
- package/dist/commands/daemon.d.ts +0 -3
- package/dist/commands/daemon.js +1 -173
- 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/commands/sync-mode.d.ts +0 -11
- package/dist/commands/sync-mode.js +0 -30
- package/dist/lib/daemon/catalog.d.ts +2 -18
- package/dist/lib/daemon/catalog.js +2 -18
- package/dist/lib/daemon/config.d.ts +0 -6
- package/dist/lib/daemon/config.js +2 -15
- package/dist/lib/daemon/requests.d.ts +0 -4
- package/dist/lib/daemon/requests.js +0 -2
- package/dist/lib/daemon/run.js +19 -78
- package/dist/lib/daemon/supervisor.d.ts +0 -8
- package/dist/lib/daemon/supervisor.js +1 -101
- 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/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- 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 +2 -2
- 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
- package/dist/lib/daemon/sync-runner.d.ts +0 -58
- package/dist/lib/daemon/sync-runner.js +0 -277
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import type { Command } from "commander";
|
|
17
17
|
import { type LegacyUnit } from "../lib/daemon/legacy.js";
|
|
18
18
|
import type { HostApp } from "../lib/daemon/supervisor.js";
|
|
19
|
-
import { type SyncModeVaultClient } from "./sync-mode.js";
|
|
20
19
|
import type { RunCommand } from "../lib/service-manager/launchd.js";
|
|
21
20
|
export interface DaemonCommandDeps {
|
|
22
21
|
env: NodeJS.ProcessEnv;
|
|
@@ -39,8 +38,6 @@ export interface DaemonCommandDeps {
|
|
|
39
38
|
userHome: (user: string) => string;
|
|
40
39
|
/** The pid is a live `hq daemon run`. */
|
|
41
40
|
daemonAlive: (pid: number) => boolean;
|
|
42
|
-
/** Test seam for the company sync-mode commands. */
|
|
43
|
-
syncModeClient?: () => Promise<SyncModeVaultClient>;
|
|
44
41
|
windowsUser?: string;
|
|
45
42
|
windowsHeadlessLauncher?: string;
|
|
46
43
|
systemUnitDir?: string;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -18,7 +18,7 @@ import * as os from "node:os";
|
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import { major as semverMajor, valid as validSemver } from "semver";
|
|
20
20
|
import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits } from "../lib/daemon/catalog.js";
|
|
21
|
-
import { applyServiceToggle,
|
|
21
|
+
import { applyServiceToggle, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
|
|
22
22
|
import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
|
|
23
23
|
import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
|
|
24
24
|
import { ROOT_REFUSAL, SYSTEM_UNIT_DIR, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, } from "../lib/daemon/install.js";
|
|
@@ -27,10 +27,7 @@ import { isDaemonProcess, runningDaemonPid } from "../lib/daemon/lock.js";
|
|
|
27
27
|
import { bundledHqCloud } from "../lib/daemon/hq-cloud.js";
|
|
28
28
|
import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "../lib/daemon/machine-type.js";
|
|
29
29
|
import { daemonPaths } from "../lib/daemon/paths.js";
|
|
30
|
-
import { processCommandLine } from "../lib/daemon/lock.js";
|
|
31
30
|
import { writeControlRequest } from "../lib/daemon/requests.js";
|
|
32
|
-
import { isSyncRunnerCommandLine, readWatchRunnerOwnerStatus, watchRunnerOwnerStatusPath } from "../lib/daemon/sync-runner.js";
|
|
33
|
-
import { getSyncModeForCompany, setSyncMode } from "./sync-mode.js";
|
|
34
31
|
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
35
32
|
class DaemonCommandError extends Error {
|
|
36
33
|
}
|
|
@@ -99,7 +96,6 @@ async function loadDefaults(over) {
|
|
|
99
96
|
windowsUser: over.windowsUser ?? (platform === "win32" ? mesh.resolveWindowsUser(env) : ""),
|
|
100
97
|
windowsHeadlessLauncher: over.windowsHeadlessLauncher ?? (platform === "win32" ? mesh.resolveWindowsHeadlessLauncher(env) : undefined),
|
|
101
98
|
systemUnitDir: over.systemUnitDir ?? SYSTEM_UNIT_DIR,
|
|
102
|
-
syncModeClient: over.syncModeClient,
|
|
103
99
|
};
|
|
104
100
|
}
|
|
105
101
|
function detect(d, flag, config) {
|
|
@@ -376,135 +372,6 @@ function queueRequest(d, request, what) {
|
|
|
376
372
|
const running = runningDaemonPid(paths.lockPath, d.daemonAlive) !== null;
|
|
377
373
|
d.out(running ? `Asked the daemon to ${what}.` : `Queued "${what}"; the daemon is not running, so it applies when it starts.`);
|
|
378
374
|
}
|
|
379
|
-
function daemonSyncModeClient(d) {
|
|
380
|
-
if (d.syncModeClient)
|
|
381
|
-
return d.syncModeClient();
|
|
382
|
-
return (async () => {
|
|
383
|
-
const [{ VaultClient }, { buildVaultConfig, ensureCognitoToken }] = await Promise.all([
|
|
384
|
-
import("@indigoai-us/hq-cloud"),
|
|
385
|
-
import("../utils/cognito-session.js"),
|
|
386
|
-
]);
|
|
387
|
-
const token = await ensureCognitoToken();
|
|
388
|
-
return new VaultClient(buildVaultConfig(token));
|
|
389
|
-
})();
|
|
390
|
-
}
|
|
391
|
-
function daemonSyncStatus(d) {
|
|
392
|
-
const host = currentHost(d);
|
|
393
|
-
const { paths } = host;
|
|
394
|
-
const config = readDaemonConfig(paths.configPath);
|
|
395
|
-
const daemonPid = runningDaemonPid(paths.lockPath, d.daemonAlive);
|
|
396
|
-
const daemonState = daemonPid === null ? null : readState(paths);
|
|
397
|
-
const syncUnit = daemonState?.units.find((unit) => unit.id === "sync");
|
|
398
|
-
const hqRoot = config.hqRoot ?? d.env.HQ_ROOT?.trim();
|
|
399
|
-
let ownerStatusCandidate = null;
|
|
400
|
-
if (hqRoot) {
|
|
401
|
-
try {
|
|
402
|
-
ownerStatusCandidate = readWatchRunnerOwnerStatus(watchRunnerOwnerStatusPath(hqRoot, d.home, d.env));
|
|
403
|
-
}
|
|
404
|
-
catch (error) {
|
|
405
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
406
|
-
d.err(`Could not read sync runner owner status; treating it as unavailable: ${detail}`);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
const ownerCommandLine = ownerStatusCandidate
|
|
410
|
-
? processCommandLine(ownerStatusCandidate.pid, { platform: d.platform, run: d.run })
|
|
411
|
-
: null;
|
|
412
|
-
const ownerStatus = ownerCommandLine && isSyncRunnerCommandLine(ownerCommandLine)
|
|
413
|
-
? ownerStatusCandidate
|
|
414
|
-
: null;
|
|
415
|
-
return {
|
|
416
|
-
running: syncUnit?.status === "running",
|
|
417
|
-
paused: config.syncPaused === true,
|
|
418
|
-
syncOwner: effectiveSyncOwner(config, host.detected.type),
|
|
419
|
-
owner: ownerStatus?.owner ?? (syncUnit?.status === "running" ? "hq-daemon" : null),
|
|
420
|
-
runnerPid: ownerStatus?.pid ?? syncUnit?.pid ?? null,
|
|
421
|
-
hqCloudVersion: ownerStatus?.version ?? null,
|
|
422
|
-
lastHeartbeat: syncUnit?.lastHeartbeatAt ?? ownerStatus?.heartbeatAt ?? null,
|
|
423
|
-
lastPassResult: syncUnit?.lastPassResult ?? null,
|
|
424
|
-
unitStatus: syncUnit?.status ?? "not-running",
|
|
425
|
-
reason: syncUnit?.reason ?? null,
|
|
426
|
-
logPath: paths.unitLogPath("sync"),
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
async function runDaemonSyncStatus(d, opts) {
|
|
430
|
-
const status = daemonSyncStatus(d);
|
|
431
|
-
if (opts.json) {
|
|
432
|
-
d.out(JSON.stringify(status, null, 2));
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
d.out(`Sync ${status.running ? "running" : status.unitStatus}${status.paused ? " (paused)" : ""}.`);
|
|
436
|
-
d.out(`Configured sync owner: ${status.syncOwner}.`);
|
|
437
|
-
d.out(`Owner: ${status.owner ?? "none"}; runner pid: ${status.runnerPid ?? "none"}; hq-cloud: ${status.hqCloudVersion ?? "unknown"}.`);
|
|
438
|
-
if (status.reason)
|
|
439
|
-
d.out(`Reason: ${status.reason}`);
|
|
440
|
-
d.out(`Log: ${status.logPath}`);
|
|
441
|
-
}
|
|
442
|
-
function queueDaemonSyncControl(d, request) {
|
|
443
|
-
const host = currentHost(d);
|
|
444
|
-
writeControlRequest(host.paths.requestsDir, request);
|
|
445
|
-
handOver(d, host);
|
|
446
|
-
return runningDaemonPid(host.paths.lockPath, d.daemonAlive) !== null;
|
|
447
|
-
}
|
|
448
|
-
async function runDaemonSyncNow(d, opts) {
|
|
449
|
-
const host = currentHost(d);
|
|
450
|
-
const config = readDaemonConfig(host.paths.configPath);
|
|
451
|
-
if (config.syncPaused)
|
|
452
|
-
throw new DaemonCommandError("sync is paused; run `hq daemon sync resume` first");
|
|
453
|
-
if (!enabledServices(host.detected.type, config).includes("sync")) {
|
|
454
|
-
throw new DaemonCommandError("sync is disabled for this machine; run `hq daemon enable sync` first");
|
|
455
|
-
}
|
|
456
|
-
const running = queueDaemonSyncControl(d, { action: "restart", unit: "sync" });
|
|
457
|
-
const result = { accepted: true, action: "restart-sync-runner", daemonRunning: running };
|
|
458
|
-
d.out(opts.json ? JSON.stringify(result, null, 2) : running ? "Asked the daemon to start a fresh sync pass." : "Queued a fresh sync pass for the next daemon start.");
|
|
459
|
-
}
|
|
460
|
-
async function runDaemonSyncPause(d, paused, opts) {
|
|
461
|
-
const host = currentHost(d);
|
|
462
|
-
const config = readDaemonConfig(host.paths.configPath);
|
|
463
|
-
if (config.syncPaused !== paused)
|
|
464
|
-
writeDaemonConfig(host.paths.configPath, { ...config, syncPaused: paused });
|
|
465
|
-
const daemonRunning = queueDaemonSyncControl(d, { action: "reconcile" });
|
|
466
|
-
const result = { paused, persisted: true, daemonRunning };
|
|
467
|
-
d.out(opts.json ? JSON.stringify(result, null, 2) : `Sync ${paused ? "paused" : "resumed"}${daemonRunning ? "" : "; the setting will apply when the daemon starts"}.`);
|
|
468
|
-
}
|
|
469
|
-
async function runDaemonSyncTakeOwnership(d, opts) {
|
|
470
|
-
const host = currentHost(d);
|
|
471
|
-
const config = readDaemonConfig(host.paths.configPath);
|
|
472
|
-
if (config.syncOwner !== "daemon")
|
|
473
|
-
writeDaemonConfig(host.paths.configPath, { ...config, syncOwner: "daemon" });
|
|
474
|
-
const daemonRunning = queueDaemonSyncControl(d, { action: "reconcile" });
|
|
475
|
-
const result = { syncOwner: "daemon", persisted: true, daemonRunning };
|
|
476
|
-
d.out(opts.json ? JSON.stringify(result, null, 2) : `Daemon sync ownership persisted${daemonRunning ? "; asked the daemon to reconcile" : "; it applies when the daemon starts"}.`);
|
|
477
|
-
}
|
|
478
|
-
async function runDaemonSyncModeGet(d, companySlug, opts) {
|
|
479
|
-
const result = await getSyncModeForCompany({ companySlug, vaultClient: await daemonSyncModeClient(d) });
|
|
480
|
-
const value = {
|
|
481
|
-
companySlug: result.companySlug,
|
|
482
|
-
membershipId: result.membershipId,
|
|
483
|
-
mode: result.config.syncMode,
|
|
484
|
-
isDefault: result.config.isDefault,
|
|
485
|
-
...(result.config.updatedAt ? { updatedAt: result.config.updatedAt } : {}),
|
|
486
|
-
};
|
|
487
|
-
d.out(opts.json ? JSON.stringify(value, null, 2) : `${value.companySlug}: ${value.mode}${value.isDefault ? " (default)" : ""}`);
|
|
488
|
-
}
|
|
489
|
-
async function runDaemonSyncModeSet(d, companySlug, mode, opts) {
|
|
490
|
-
if (mode !== "all" && mode !== "shared") {
|
|
491
|
-
throw new DaemonCommandError(`Invalid sync mode '${mode}'. Use all or shared.`);
|
|
492
|
-
}
|
|
493
|
-
const result = await setSyncMode({
|
|
494
|
-
mode,
|
|
495
|
-
companySlug,
|
|
496
|
-
vaultClient: await daemonSyncModeClient(d),
|
|
497
|
-
});
|
|
498
|
-
const value = {
|
|
499
|
-
companySlug: result.companySlug,
|
|
500
|
-
membershipId: result.membershipId,
|
|
501
|
-
previousMode: result.previousMode,
|
|
502
|
-
mode: result.newMode,
|
|
503
|
-
isDefault: result.newConfig.isDefault,
|
|
504
|
-
...(result.newConfig.updatedAt ? { updatedAt: result.newConfig.updatedAt } : {}),
|
|
505
|
-
};
|
|
506
|
-
d.out(opts.json ? JSON.stringify(value, null, 2) : `${value.companySlug}: ${value.previousMode} → ${value.mode}`);
|
|
507
|
-
}
|
|
508
375
|
async function runToggle(d, action, services) {
|
|
509
376
|
const host = currentHost(d);
|
|
510
377
|
const { paths } = host;
|
|
@@ -736,45 +603,6 @@ export function registerDaemonCommand(program, over = {}) {
|
|
|
736
603
|
.description("Show the machine type and each service's state")
|
|
737
604
|
.option("--json", "Print machine-readable JSON")
|
|
738
605
|
.action(wrap((d, opts) => runStatus(d, opts)));
|
|
739
|
-
const sync = daemon
|
|
740
|
-
.command("sync")
|
|
741
|
-
.description("Inspect and control the daemon's company sync runner");
|
|
742
|
-
sync
|
|
743
|
-
.command("status")
|
|
744
|
-
.description("Show sync ownership, heartbeat, last pass and log path")
|
|
745
|
-
.option("--json", "Print machine-readable JSON")
|
|
746
|
-
.action(wrap((d, opts) => runDaemonSyncStatus(d, opts)));
|
|
747
|
-
sync
|
|
748
|
-
.command("now")
|
|
749
|
-
.description("Start a fresh sync pass immediately")
|
|
750
|
-
.option("--json", "Print machine-readable JSON")
|
|
751
|
-
.action(wrap((d, opts) => runDaemonSyncNow(d, opts)));
|
|
752
|
-
sync
|
|
753
|
-
.command("pause")
|
|
754
|
-
.description("Pause daemon sync until resumed")
|
|
755
|
-
.option("--json", "Print machine-readable JSON")
|
|
756
|
-
.action(wrap((d, opts) => runDaemonSyncPause(d, true, opts)));
|
|
757
|
-
sync
|
|
758
|
-
.command("resume")
|
|
759
|
-
.description("Resume daemon sync after a pause")
|
|
760
|
-
.option("--json", "Print machine-readable JSON")
|
|
761
|
-
.action(wrap((d, opts) => runDaemonSyncPause(d, false, opts)));
|
|
762
|
-
sync
|
|
763
|
-
.command("take-ownership")
|
|
764
|
-
.description("Persist daemon ownership of desktop sync and reconcile")
|
|
765
|
-
.option("--json", "Print machine-readable JSON")
|
|
766
|
-
.action(wrap((d, opts) => runDaemonSyncTakeOwnership(d, opts)));
|
|
767
|
-
const mode = sync.command("mode").description("Read or change a company's sync footprint");
|
|
768
|
-
mode
|
|
769
|
-
.command("get <company>")
|
|
770
|
-
.description("Read a company's current all/shared sync mode")
|
|
771
|
-
.option("--json", "Print machine-readable JSON")
|
|
772
|
-
.action(wrap((d, company, opts) => runDaemonSyncModeGet(d, company, opts)));
|
|
773
|
-
mode
|
|
774
|
-
.command("set <company> <mode>")
|
|
775
|
-
.description("Set a company's sync mode to all or shared")
|
|
776
|
-
.option("--json", "Print machine-readable JSON")
|
|
777
|
-
.action(wrap((d, company, mode, opts) => runDaemonSyncModeSet(d, company, mode, opts)));
|
|
778
606
|
daemon
|
|
779
607
|
.command("logs [unit]")
|
|
780
608
|
.description("Print the supervisor log, or one service's log")
|
|
@@ -4,7 +4,6 @@ import type { LaneCompanyRef, QuestionRecord } from "../lib/lanes/types.js";
|
|
|
4
4
|
export interface LaneChecksDeps {
|
|
5
5
|
hqRoot: () => string;
|
|
6
6
|
resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
|
|
7
|
-
trackerEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
8
7
|
now: () => Date;
|
|
9
8
|
stopLane?: (laneId: string, reason: string) => Promise<void>;
|
|
10
9
|
sessionState?: (root: string, sessionId: string, now: Date) => string | null;
|
|
@@ -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({
|