@indigoai-us/hq-cli 5.293.0 → 5.295.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 +39 -0
- package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +5 -1
- package/dist/command-catalog.generated.d.ts +54 -33
- package/dist/command-catalog.generated.js +68 -42
- package/dist/commands/auth.d.ts +1 -0
- package/dist/commands/auth.js +76 -1
- package/dist/commands/core-command-catalog.js +3 -2
- package/dist/commands/core-delegate-w1b.d.ts +2 -0
- package/dist/commands/core-delegate-w1b.js +28 -2
- package/dist/commands/core-delegate.d.ts +3 -1
- package/dist/commands/core-delegate.js +35 -5
- package/dist/commands/core-handoff-fast.d.ts +18 -0
- package/dist/commands/core-handoff-fast.js +103 -0
- package/dist/commands/core-handoff-open-steps.d.ts +21 -0
- package/dist/commands/core-handoff-open-steps.js +264 -0
- package/dist/commands/core-handoff.d.ts +4 -2
- package/dist/commands/core-handoff.js +22 -249
- package/dist/commands/core-jobs-native.d.ts +2 -0
- package/dist/commands/core-jobs-native.js +22 -11
- package/dist/commands/core-knowledge-prefs.d.ts +2 -0
- package/dist/commands/core-knowledge-prefs.js +13 -1
- package/dist/commands/core-ontology-native.d.ts +2 -0
- package/dist/commands/core-ontology-native.js +22 -2
- package/dist/commands/core-policy.d.ts +2 -0
- package/dist/commands/core-policy.js +18 -1
- package/dist/commands/core.js +2 -1
- package/dist/commands/daemon.d.ts +2 -1
- package/dist/commands/daemon.js +22 -7
- package/dist/commands/doctor.js +0 -74
- package/dist/commands/lanes-autopilot.d.ts +11 -0
- package/dist/commands/lanes-autopilot.js +336 -0
- package/dist/commands/lanes-message.js +23 -5
- package/dist/commands/lanes-questions.js +8 -5
- package/dist/commands/lanes-resume.js +0 -3
- package/dist/commands/lanes-shared.d.ts +18 -2
- package/dist/commands/lanes-stories.d.ts +14 -0
- package/dist/commands/lanes-stories.js +308 -0
- package/dist/commands/lanes.d.ts +1 -9
- package/dist/commands/lanes.js +216 -481
- package/dist/fast-core.js +53 -25
- package/dist/fast-scaffold.d.ts +15 -7
- package/dist/fast-scaffold.js +167 -17
- package/dist/index.js +1 -10
- package/dist/lib/core-utils/sentry-report.d.ts +0 -19
- package/dist/lib/core-utils/sentry-report.js +8 -61
- package/dist/lib/core-utils/worktree-resume.d.ts +2 -0
- package/dist/lib/core-utils/worktree-resume.js +16 -0
- package/dist/lib/daemon/migrate.d.ts +8 -1
- package/dist/lib/daemon/migrate.js +19 -7
- package/dist/lib/doctor/checks/claude-wiring.js +9 -0
- package/dist/lib/lanes/autopilot-metrics.d.ts +99 -0
- package/dist/lib/lanes/autopilot-metrics.js +301 -0
- package/dist/lib/lanes/flag-keys.d.ts +2 -0
- package/dist/lib/lanes/flag-keys.js +2 -0
- package/dist/lib/lanes/manager-status-gate.d.ts +0 -17
- package/dist/lib/lanes/manager-status-gate.js +2 -43
- package/dist/lib/lanes/outpost-dispatch.d.ts +2 -0
- package/dist/lib/lanes/outpost-dispatch.js +7 -1
- package/dist/lib/lanes/outpost-hire-cache.d.ts +0 -1
- package/dist/lib/lanes/outpost-hire-cache.js +0 -1
- package/dist/lib/lanes/outpost-snapshot-memo.d.ts +6 -0
- package/dist/lib/lanes/outpost-snapshot-memo.js +14 -0
- package/dist/lib/lanes/reconcile.d.ts +2 -3
- package/dist/lib/lanes/reconcile.js +44 -38
- package/dist/lib/lanes/spawn.d.ts +0 -2
- package/dist/lib/lanes/spawn.js +18 -5
- package/dist/lib/lanes/store.d.ts +2 -0
- package/dist/lib/lanes/store.js +54 -3
- package/dist/lib/lanes/types.d.ts +18 -1
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.d.ts +4 -3
- package/dist/lib/lanes/watcher.js +102 -34
- package/dist/lib/mesh/live/daemon/run.js +18 -1
- package/dist/native-fast-route.d.ts +3 -1
- package/dist/native-fast-route.js +10 -8
- package/dist/utils/cognito-session.d.ts +6 -0
- package/dist/utils/cognito-session.js +9 -0
- package/dist/utils/contribution-table.d.ts +1 -1
- package/dist/utils/fast-stream-errors.d.ts +12 -0
- package/dist/utils/fast-stream-errors.js +18 -0
- package/dist/utils/scaffold-fast-import-guard.d.ts +2 -2
- package/dist/utils/scaffold-fast-import-guard.js +6 -3
- package/dist/utils/self-update.js +31 -0
- package/dist/utils/version-gate.js +22 -3
- package/package.json +1 -1
- package/dist/fast-lanes-list-route.d.ts +0 -8
- package/dist/fast-lanes-list-route.js +0 -32
- package/dist/fast-lanes-list.d.ts +0 -3
- package/dist/fast-lanes-list.js +0 -224
- package/dist/lib/doctor/client-health-sentry-flag.d.ts +0 -23
- package/dist/lib/doctor/client-health-sentry-flag.js +0 -99
- package/dist/lib/doctor/client-health-sentry.d.ts +0 -71
- package/dist/lib/doctor/client-health-sentry.js +0 -286
- package/dist/lib/lanes/outpost-snapshot-cache.d.ts +0 -26
- package/dist/lib/lanes/outpost-snapshot-cache.js +0 -215
- package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +0 -21
- package/dist/lib/lanes/outpost-snapshot-refresh.js +0 -329
package/dist/commands/daemon.js
CHANGED
|
@@ -86,9 +86,9 @@ async function loadDefaults(over) {
|
|
|
86
86
|
fs.writeFileSync(p, text, { mode: 0o644 });
|
|
87
87
|
}),
|
|
88
88
|
legacyUnits: over.legacyUnits ??
|
|
89
|
-
(() => findLegacyUnits({
|
|
89
|
+
((dir) => findLegacyUnits({
|
|
90
90
|
platform,
|
|
91
|
-
home,
|
|
91
|
+
home: dir ?? home,
|
|
92
92
|
existsSync: fs.existsSync,
|
|
93
93
|
listDir: (dir) => fs.readdirSync(dir),
|
|
94
94
|
windowsTaskExists: (task) => run("schtasks.exe", ["/Query", "/TN", task]).status === 0,
|
|
@@ -246,11 +246,25 @@ async function runInstall(d, opts) {
|
|
|
246
246
|
}
|
|
247
247
|
d.out(`Installed and started: ${result.unitPath}`);
|
|
248
248
|
}
|
|
249
|
-
|
|
249
|
+
/** Root acting for a box's login user needs that user's uid to reach their systemd manager. */
|
|
250
|
+
function loginUid(d, user) {
|
|
251
|
+
const r = d.run("id", ["-u", user]);
|
|
252
|
+
const uid = Number(r.stdout.trim());
|
|
253
|
+
if (r.status !== 0 || !Number.isInteger(uid) || uid < 0 || r.stdout.trim() === "") {
|
|
254
|
+
throw new DaemonCommandError(`Could not look up the uid of ${user}.`);
|
|
255
|
+
}
|
|
256
|
+
return uid;
|
|
257
|
+
}
|
|
258
|
+
/** Where the older units are: a box's login user's home when root runs this for them. */
|
|
259
|
+
function legacyUnitsFor(d, host) {
|
|
260
|
+
return d.isRoot && host.runAsUser ? d.legacyUnits(d.userHome(host.runAsUser)) : d.legacyUnits();
|
|
261
|
+
}
|
|
262
|
+
function migrateDeps(d, paths, runAsUser) {
|
|
250
263
|
return {
|
|
251
264
|
run: d.run,
|
|
252
265
|
uid: d.uid,
|
|
253
266
|
isRoot: d.isRoot,
|
|
267
|
+
userUnitsOwner: d.isRoot && runAsUser ? { user: runAsUser, uid: loginUid(d, runAsUser) } : undefined,
|
|
254
268
|
adoptedDir: path.join(paths.root, "adopted"),
|
|
255
269
|
recordPath: path.join(paths.root, "adopted.json"),
|
|
256
270
|
// Box system units: root-owned, outside the login user's daemon folder (which handOver chowns to that user).
|
|
@@ -268,7 +282,8 @@ function unrestoredUnits(d, migrate) {
|
|
|
268
282
|
async function runUninstall(d, opts) {
|
|
269
283
|
const host = currentHost(d);
|
|
270
284
|
const { paths, detected } = host;
|
|
271
|
-
|
|
285
|
+
// Only a restore acts on the login user's units.
|
|
286
|
+
const migrate = migrateDeps(d, paths, opts.restoreLegacy ? host.runAsUser : undefined);
|
|
272
287
|
const adopted = unrestoredUnits(d, migrate);
|
|
273
288
|
if (opts.purge && !opts.restoreLegacy && adopted.length > 0) {
|
|
274
289
|
throw new DaemonCommandError(`hq daemon took over ${adopted.map((u) => u.unitId).join(", ")}, and their original units are kept in ${migrate.adoptedDir}. ` +
|
|
@@ -547,13 +562,13 @@ async function runPinHqCloud(d, version, opts) {
|
|
|
547
562
|
d.out("Run `hq daemon trigger updater`; wait for the hq-cloud update or current-version message in `hq daemon logs updater`, then restart `hq daemon restart sync`, `hq daemon restart heartbeat`, and `hq daemon restart session-host`.");
|
|
548
563
|
}
|
|
549
564
|
async function runMigrate(d, opts) {
|
|
550
|
-
const
|
|
565
|
+
const host = currentHost(d);
|
|
566
|
+
const units = legacyUnitsFor(d, host);
|
|
551
567
|
if (units.length === 0) {
|
|
552
568
|
d.out("No older HQ background units found. Nothing to migrate.");
|
|
553
569
|
return;
|
|
554
570
|
}
|
|
555
571
|
if (opts.apply) {
|
|
556
|
-
const host = currentHost(d);
|
|
557
572
|
// Stopping the older units with nothing installed to replace them would
|
|
558
573
|
// leave their services off.
|
|
559
574
|
const installed = daemonUnitInstalled({
|
|
@@ -565,7 +580,7 @@ async function runMigrate(d, opts) {
|
|
|
565
580
|
if (!installed) {
|
|
566
581
|
throw new DaemonCommandError("hq daemon is not installed here yet. Run `hq daemon install` first, then `hq daemon migrate --apply`.");
|
|
567
582
|
}
|
|
568
|
-
const result = adoptLegacyUnits(units, migrateDeps(d, host.paths));
|
|
583
|
+
const result = adoptLegacyUnits(units, migrateDeps(d, host.paths, host.runAsUser));
|
|
569
584
|
handOver(d, host);
|
|
570
585
|
for (const a of result.adopted)
|
|
571
586
|
d.out(`Took over ${a.unitId} from ${describeLegacyUnit(a)}`);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -33,9 +33,6 @@ import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/
|
|
|
33
33
|
import { applyFixes } from "../lib/doctor/fix/apply.js";
|
|
34
34
|
import { renderDoctorFixJson } from "../lib/doctor/fix/json-output.js";
|
|
35
35
|
import { clientHealthReasonCodesEnabled as isActiveCompanyClientHealthReasonCodesEnabled, clientHealthReasonCodesEnabledForCompany, } from "../lib/doctor/client-health-reason-codes-flag.js";
|
|
36
|
-
import { CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES, CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE, handleClientHealthSentryReport, } from "../lib/doctor/client-health-sentry.js";
|
|
37
|
-
import { clientHealthSentryDirectEnabled, clientHealthSentryDirectEnabledForCompany, } from "../lib/doctor/client-health-sentry-flag.js";
|
|
38
|
-
import { reportClientHealthEvents } from "../lib/core-utils/sentry-report.js";
|
|
39
36
|
import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
40
37
|
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
41
38
|
import * as readline from "node:readline";
|
|
@@ -244,26 +241,6 @@ function createClientHealthReasonCodeCompanyResolver(hqRoot) {
|
|
|
244
241
|
});
|
|
245
242
|
};
|
|
246
243
|
}
|
|
247
|
-
function createClientHealthSentryCompanyResolver(hqRoot) {
|
|
248
|
-
const companies = readCompaniesManifestMap(hqRoot);
|
|
249
|
-
return async (companySlug) => {
|
|
250
|
-
if (companySlug === null)
|
|
251
|
-
return clientHealthSentryDirectEnabled();
|
|
252
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug))
|
|
253
|
-
return false;
|
|
254
|
-
const manifestUid = companies?.[companySlug]?.cloud_uid;
|
|
255
|
-
const activeSlug = process.env.HQ_COMPANY_SLUG?.trim() || "";
|
|
256
|
-
const activeUid = process.env.HQ_COMPANY_UID?.trim() || "";
|
|
257
|
-
const companyUid = typeof manifestUid === "string" && manifestUid.trim()
|
|
258
|
-
? manifestUid.trim()
|
|
259
|
-
: companySlug === activeSlug
|
|
260
|
-
? activeUid
|
|
261
|
-
: "";
|
|
262
|
-
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid))
|
|
263
|
-
return false;
|
|
264
|
-
return clientHealthSentryDirectEnabledForCompany({ companyUid, companySlug });
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
244
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
268
245
|
export function registerDoctorCommand(program) {
|
|
269
246
|
program
|
|
@@ -279,9 +256,6 @@ export function registerDoctorCommand(program) {
|
|
|
279
256
|
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
280
257
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
281
258
|
.addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
|
|
282
|
-
.addOption(new Option("--client-health-sentry", "Send the opt-in client-health Sentry report from a doctor JSON document.").hideHelp())
|
|
283
|
-
.addOption(new Option("--json-input <file>", "Doctor JSON input for the client-health report.").hideHelp())
|
|
284
|
-
.addOption(new Option("--client-health-fix-outcome <outcome>", "Safe fix outcome label for the client-health report.").hideHelp())
|
|
285
259
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
286
260
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
287
261
|
.action(async (opts) => {
|
|
@@ -298,54 +272,6 @@ export function registerDoctorCommand(program) {
|
|
|
298
272
|
process.exitCode = 1;
|
|
299
273
|
return;
|
|
300
274
|
}
|
|
301
|
-
if (opts.clientHealthSentry === true) {
|
|
302
|
-
const hqRoot = resolveHqRoot();
|
|
303
|
-
if (!hqRoot || !opts.jsonInput) {
|
|
304
|
-
process.stderr.write("hq doctor --client-health-sentry requires an HQ root and --json-input.\n");
|
|
305
|
-
process.exitCode = 1;
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
try {
|
|
309
|
-
const workspacePath = path.join(hqRoot, "workspace");
|
|
310
|
-
const workspaceStat = fs.lstatSync(workspacePath);
|
|
311
|
-
if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
|
|
312
|
-
throw new Error("workspace is not a local directory");
|
|
313
|
-
}
|
|
314
|
-
const statePath = path.join(hqRoot, "workspace", ".hq-client-health");
|
|
315
|
-
const stateStat = fs.lstatSync(statePath);
|
|
316
|
-
if (!stateStat.isDirectory() || stateStat.isSymbolicLink()) {
|
|
317
|
-
throw new Error("client-health state is not a local directory");
|
|
318
|
-
}
|
|
319
|
-
const stateRoot = fs.realpathSync(statePath);
|
|
320
|
-
const inputPath = fs.realpathSync(opts.jsonInput);
|
|
321
|
-
const relativeInput = path.relative(stateRoot, inputPath);
|
|
322
|
-
if (relativeInput === ".." || relativeInput.startsWith(`..${path.sep}`) || path.isAbsolute(relativeInput)) {
|
|
323
|
-
throw new Error("input path is outside client-health state");
|
|
324
|
-
}
|
|
325
|
-
if (fs.statSync(inputPath).size > CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES) {
|
|
326
|
-
throw new Error("input exceeds client-health size limit");
|
|
327
|
-
}
|
|
328
|
-
const result = await handleClientHealthSentryReport({
|
|
329
|
-
hqRoot,
|
|
330
|
-
doctorJson: fs.readFileSync(inputPath, "utf8"),
|
|
331
|
-
fixOutcome: opts.clientHealthFixOutcome ?? "not_auto_fixable",
|
|
332
|
-
}, {
|
|
333
|
-
resolveFlagForCompany: createClientHealthSentryCompanyResolver(hqRoot),
|
|
334
|
-
sendEvents: async (events) => (await reportClientHealthEvents(events)).code === 0,
|
|
335
|
-
});
|
|
336
|
-
process.exitCode =
|
|
337
|
-
result === "not_handled"
|
|
338
|
-
? CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE
|
|
339
|
-
: result === "send_failed"
|
|
340
|
-
? 1
|
|
341
|
-
: 0;
|
|
342
|
-
}
|
|
343
|
-
catch {
|
|
344
|
-
process.stderr.write("hq doctor --client-health-sentry could not handle the report.\n");
|
|
345
|
-
process.exitCode = 1;
|
|
346
|
-
}
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
275
|
// `--fix` is the only write path. It resolves the tree, applies the
|
|
350
276
|
// allowlisted repairs behind a backup + confirmation, and returns its own
|
|
351
277
|
// exit code; the read-only report below never runs in this branch.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type ReconcileHost } from "../lib/lanes/reconcile.js";
|
|
3
|
+
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
4
|
+
export interface LaneAutopilotReportDeps {
|
|
5
|
+
hqRoot: () => string;
|
|
6
|
+
now: () => Date;
|
|
7
|
+
currentHost: () => ReconcileHost;
|
|
8
|
+
}
|
|
9
|
+
export declare function registerLaneAutopilotReportCommand(parent: Command, deps: LaneAutopilotReportDeps, wrap: WrapHandler): void;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=lanes-autopilot.d.ts.map
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { laneRunsHere } from "../lib/lanes/reconcile.js";
|
|
4
|
+
import { listLaneReviews } from "../lib/lanes/reviews.js";
|
|
5
|
+
import { readAllLanes } from "../lib/lanes/store.js";
|
|
6
|
+
import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
|
|
7
|
+
import { normalizeIsoTimestamp } from "../lib/lanes/timestamp.js";
|
|
8
|
+
import { buildAutopilotMetricsReport, } from "../lib/lanes/autopilot-metrics.js";
|
|
9
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
10
|
+
const OPERATOR_NUDGE_DEFECTS_REL = path.join("workspace", "lane-briefs", "operator-nudge-defects.jsonl");
|
|
11
|
+
const JUNIORS_REL = path.join("workspace", "lane-briefs", "juniors.json");
|
|
12
|
+
const DROPBOX_NAME = "dropbox";
|
|
13
|
+
const RESUME_SUFFIX = ".resume.jsonl";
|
|
14
|
+
function fail(error, message, details = {}) {
|
|
15
|
+
throw new LanesError(error, message, details);
|
|
16
|
+
}
|
|
17
|
+
function isObject(value) {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function safeReadText(root, file, missingOk = false) {
|
|
21
|
+
let stat;
|
|
22
|
+
try {
|
|
23
|
+
stat = fs.lstatSync(file);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (missingOk && err.code === "ENOENT")
|
|
27
|
+
return null;
|
|
28
|
+
return fail("autopilot_metrics_input_unreadable", `Could not inspect report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
|
|
29
|
+
}
|
|
30
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
31
|
+
return fail("autopilot_metrics_input_invalid", "Report input must be a regular file", { path: file });
|
|
32
|
+
}
|
|
33
|
+
let realRoot;
|
|
34
|
+
let realFile;
|
|
35
|
+
try {
|
|
36
|
+
realRoot = fs.realpathSync(root);
|
|
37
|
+
realFile = fs.realpathSync(file);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return fail("autopilot_metrics_input_unreadable", `Could not resolve report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
|
|
41
|
+
}
|
|
42
|
+
if (!realFile.startsWith(`${realRoot}${path.sep}`)) {
|
|
43
|
+
return fail("autopilot_metrics_input_invalid", "Report input must stay under the HQ root", { path: file });
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return fs.readFileSync(file, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return fail("autopilot_metrics_input_unreadable", `Could not read report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function parseJsonObject(root, file) {
|
|
53
|
+
const raw = safeReadText(root, file);
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw ?? "");
|
|
56
|
+
if (isObject(parsed))
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return fail("autopilot_metrics_input_invalid", `Report input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { path: file });
|
|
61
|
+
}
|
|
62
|
+
return fail("autopilot_metrics_input_invalid", "Report input must be a JSON object", { path: file });
|
|
63
|
+
}
|
|
64
|
+
function isCurrentHostLabel(label, host) {
|
|
65
|
+
return label === "local" ? host.outpostId === null : label === host.outpostId || label === host.hostname;
|
|
66
|
+
}
|
|
67
|
+
function loadJuniors(root, host) {
|
|
68
|
+
const file = path.join(root, JUNIORS_REL);
|
|
69
|
+
const parsed = parseJsonObject(root, file);
|
|
70
|
+
if (!Array.isArray(parsed.juniors))
|
|
71
|
+
return fail("autopilot_metrics_input_invalid", "Team registry must contain a juniors array", { path: file });
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
return parsed.juniors.flatMap((value, index) => {
|
|
74
|
+
if (!isObject(value) || typeof value.host !== "string" || !value.host.trim()) {
|
|
75
|
+
return fail("autopilot_metrics_input_invalid", "Team registry row must identify its host", { path: file, index });
|
|
76
|
+
}
|
|
77
|
+
if (!isCurrentHostLabel(value.host, host))
|
|
78
|
+
return [];
|
|
79
|
+
if (["team", "lane", "status", "prd"].some((field) => typeof value[field] !== "string" || !value[field].trim())) {
|
|
80
|
+
return fail("autopilot_metrics_input_invalid", "Selected team registry row must contain team, lane, status, and prd strings", { path: file, index });
|
|
81
|
+
}
|
|
82
|
+
const entry = value;
|
|
83
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(entry.team) || entry.lane.includes("/") || entry.lane.includes("\\")) {
|
|
84
|
+
return fail("autopilot_metrics_input_invalid", "Team registry row contains an unsafe team or lane id", { path: file, index });
|
|
85
|
+
}
|
|
86
|
+
if (seen.has(entry.lane))
|
|
87
|
+
return fail("autopilot_metrics_mapping_ambiguous", "A lead lane appears more than once in the selected host registry", { path: file, lane_id: entry.lane });
|
|
88
|
+
seen.add(entry.lane);
|
|
89
|
+
return [entry];
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function projectPath(root, raw, registryPath) {
|
|
93
|
+
if (path.isAbsolute(raw))
|
|
94
|
+
return fail("autopilot_metrics_mapping_invalid", "Project path must be relative to the HQ root", { path: registryPath, project_path: raw });
|
|
95
|
+
const absolute = path.resolve(root, raw);
|
|
96
|
+
if (!absolute.startsWith(`${path.resolve(root)}${path.sep}`)) {
|
|
97
|
+
return fail("autopilot_metrics_mapping_invalid", "Project path must stay under the HQ root", { path: registryPath, project_path: raw });
|
|
98
|
+
}
|
|
99
|
+
return absolute;
|
|
100
|
+
}
|
|
101
|
+
function loadProject(root, projectId, rawPrd) {
|
|
102
|
+
const registryPath = path.join(root, JUNIORS_REL);
|
|
103
|
+
const file = projectPath(root, rawPrd, registryPath);
|
|
104
|
+
const parsed = parseJsonObject(root, file);
|
|
105
|
+
const storiesValue = parsed.userStories === undefined ? parsed.stories : parsed.userStories;
|
|
106
|
+
if (!Array.isArray(storiesValue))
|
|
107
|
+
return fail("autopilot_metrics_input_invalid", "Project must contain a userStories or stories array", { path: file });
|
|
108
|
+
const stories = storiesValue.map((value, index) => {
|
|
109
|
+
if (!isObject(value) || typeof value.id !== "string" || !value.id.trim()) {
|
|
110
|
+
return fail("autopilot_metrics_input_invalid", "Project story must have a non-empty id", { path: file, index });
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
});
|
|
114
|
+
return { project_id: projectId, stories };
|
|
115
|
+
}
|
|
116
|
+
function parseIso(value, field, file) {
|
|
117
|
+
const normalized = normalizeIsoTimestamp(value);
|
|
118
|
+
if (normalized !== null)
|
|
119
|
+
return normalized;
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
const minutePrecision = /^(\d{4}-\d\d-\d\dT\d\d:\d\d)(Z|[+-]\d\d:\d\d)$/.exec(value);
|
|
122
|
+
if (minutePrecision) {
|
|
123
|
+
const withSeconds = normalizeIsoTimestamp(`${minutePrecision[1]}:00${minutePrecision[2]}`);
|
|
124
|
+
if (withSeconds !== null)
|
|
125
|
+
return withSeconds;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return fail("autopilot_metrics_input_invalid", `${field} must be an ISO timestamp`, { path: file, field });
|
|
129
|
+
}
|
|
130
|
+
function readJsonl(root, file, missingOk = false) {
|
|
131
|
+
const text = safeReadText(root, file, missingOk);
|
|
132
|
+
if (text === null)
|
|
133
|
+
return [];
|
|
134
|
+
const records = [];
|
|
135
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
136
|
+
if (!line.trim())
|
|
137
|
+
continue;
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(line);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return fail("autopilot_metrics_input_invalid", `Report JSONL contains malformed JSON: ${err instanceof Error ? err.message : String(err)}`, { path: file, line: index + 1 });
|
|
144
|
+
}
|
|
145
|
+
if (!isObject(parsed))
|
|
146
|
+
return fail("autopilot_metrics_input_invalid", "Report JSONL rows must be objects", { path: file, line: index + 1 });
|
|
147
|
+
records.push(parsed);
|
|
148
|
+
}
|
|
149
|
+
return records;
|
|
150
|
+
}
|
|
151
|
+
function listRegularFiles(root, directory, output, depth = 0) {
|
|
152
|
+
let names;
|
|
153
|
+
try {
|
|
154
|
+
const stat = fs.lstatSync(directory);
|
|
155
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
156
|
+
return fail("autopilot_metrics_input_invalid", "Report path must be a regular directory", { path: directory });
|
|
157
|
+
const realRoot = fs.realpathSync(root);
|
|
158
|
+
const realDirectory = fs.realpathSync(directory);
|
|
159
|
+
if (!realDirectory.startsWith(`${realRoot}${path.sep}`))
|
|
160
|
+
return fail("autopilot_metrics_input_invalid", "Report directory must stay under the HQ root", { path: directory });
|
|
161
|
+
names = fs.readdirSync(directory);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (err.code === "ENOENT")
|
|
165
|
+
return;
|
|
166
|
+
if (err instanceof LanesError)
|
|
167
|
+
throw err;
|
|
168
|
+
return fail("autopilot_metrics_input_unreadable", `Could not list report directory: ${err instanceof Error ? err.message : String(err)}`, { path: directory });
|
|
169
|
+
}
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
if (!name || name.startsWith("."))
|
|
172
|
+
continue;
|
|
173
|
+
const candidate = path.join(directory, name);
|
|
174
|
+
let stat;
|
|
175
|
+
try {
|
|
176
|
+
stat = fs.lstatSync(candidate);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
return fail("autopilot_metrics_input_unreadable", `Could not inspect report file: ${err instanceof Error ? err.message : String(err)}`, { path: candidate });
|
|
180
|
+
}
|
|
181
|
+
if (stat.isSymbolicLink())
|
|
182
|
+
return fail("autopilot_metrics_input_invalid", "Report directory may not contain symbolic links", { path: candidate });
|
|
183
|
+
if (stat.isFile() && name.endsWith(".jsonl"))
|
|
184
|
+
output.push(candidate);
|
|
185
|
+
else if (stat.isDirectory() && depth < 2)
|
|
186
|
+
listRegularFiles(root, candidate, output, depth + 1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function readLeadMessages(root, laneId) {
|
|
190
|
+
const dropbox = path.join(root, LANES_RUNS_REL, laneId, DROPBOX_NAME);
|
|
191
|
+
const allFiles = [];
|
|
192
|
+
for (const name of ["draining", "inflight", "delivered", "undeliverable"])
|
|
193
|
+
listRegularFiles(root, path.join(dropbox, name), allFiles);
|
|
194
|
+
const inbox = path.join(dropbox, "inbox.jsonl");
|
|
195
|
+
try {
|
|
196
|
+
const stat = fs.lstatSync(inbox);
|
|
197
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
198
|
+
return fail("autopilot_metrics_input_invalid", "Lead inbox must be a regular file", { path: inbox });
|
|
199
|
+
allFiles.push(inbox);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (err.code !== "ENOENT")
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const nudges = [];
|
|
207
|
+
const resumes = [];
|
|
208
|
+
for (const file of new Set(allFiles)) {
|
|
209
|
+
const resumeFile = file.endsWith(RESUME_SUFFIX);
|
|
210
|
+
for (const row of readJsonl(root, file)) {
|
|
211
|
+
// Undated records cannot be assigned to a report day, regardless of their file.
|
|
212
|
+
if (typeof row.at !== "string")
|
|
213
|
+
continue;
|
|
214
|
+
const at = parseIso(row.at, "at", file);
|
|
215
|
+
const text = typeof row.text === "string" ? row.text : "";
|
|
216
|
+
const messageId = typeof row.message_id === "string" ? row.message_id : `${at}\u0000${text}`;
|
|
217
|
+
if (!seen.has(messageId)) {
|
|
218
|
+
seen.add(messageId);
|
|
219
|
+
if (resumeFile || row.kind !== "monitor_event")
|
|
220
|
+
nudges.push({ team: "", at });
|
|
221
|
+
}
|
|
222
|
+
if (resumeFile)
|
|
223
|
+
resumes.push({ lane_id: laneId, at });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { nudges, resumes };
|
|
227
|
+
}
|
|
228
|
+
function loadNudgeDefects(root, selectedTeams) {
|
|
229
|
+
const file = path.join(root, OPERATOR_NUDGE_DEFECTS_REL);
|
|
230
|
+
return readJsonl(root, file, true).flatMap((row, index) => {
|
|
231
|
+
if (typeof row.team === "string" && !selectedTeams.has(row.team))
|
|
232
|
+
return [];
|
|
233
|
+
const defect = typeof row.defect === "string" && row.defect.trim() ? row.defect : row.why;
|
|
234
|
+
if (typeof row.team !== "string" || !row.team.trim() || typeof defect !== "string" || !defect.trim()) {
|
|
235
|
+
return fail("autopilot_metrics_input_invalid", "Nudge-defect row must contain at, team, and defect (or legacy why)", { path: file, line: index + 1 });
|
|
236
|
+
}
|
|
237
|
+
return [{ at: parseIso(row.at, "at", file), team: row.team, defect }];
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function loadReviews(root) {
|
|
241
|
+
const listing = listLaneReviews(root);
|
|
242
|
+
if (listing.skipped > 0)
|
|
243
|
+
return fail("autopilot_metrics_review_record_invalid", "One or more lane review records could not be read", { skipped: listing.skipped });
|
|
244
|
+
return listing.reviews.flatMap((review) => review.history.map((entry) => ({
|
|
245
|
+
repo: entry.repo,
|
|
246
|
+
pr: entry.pr,
|
|
247
|
+
recorded_at: entry.recorded_at,
|
|
248
|
+
worker_lane: entry.worker_lane,
|
|
249
|
+
})));
|
|
250
|
+
}
|
|
251
|
+
function selectLeads(root, host) {
|
|
252
|
+
const juniors = loadJuniors(root, host);
|
|
253
|
+
const lanes = readAllLanes(root);
|
|
254
|
+
const leads = [];
|
|
255
|
+
const projects = [];
|
|
256
|
+
const laneIds = new Set();
|
|
257
|
+
const projectIds = new Set();
|
|
258
|
+
for (const entry of juniors) {
|
|
259
|
+
const matchingLanes = lanes.filter((candidate) => candidate.lane_id === entry.lane || candidate.lane_id.startsWith(`${entry.lane}_`));
|
|
260
|
+
const lane = matchingLanes.find((candidate) => laneRunsHere(candidate, host));
|
|
261
|
+
if (!lane) {
|
|
262
|
+
return fail("autopilot_metrics_lead_missing", "Registered lead lane could not be read on this host", { team: entry.team, lane_id: entry.lane });
|
|
263
|
+
}
|
|
264
|
+
if (laneIds.has(lane.lane_id))
|
|
265
|
+
return fail("autopilot_metrics_mapping_ambiguous", "A lead lane appears more than once in the host report", { lane_id: lane.lane_id });
|
|
266
|
+
const projectKey = JSON.stringify([lane.project_id, projectPath(root, entry.prd, path.join(root, JUNIORS_REL))]);
|
|
267
|
+
const lead = { team: entry.team, lane_id: lane.lane_id, project_id: projectKey };
|
|
268
|
+
leads.push(lead);
|
|
269
|
+
laneIds.add(lane.lane_id);
|
|
270
|
+
if (!projectIds.has(projectKey)) {
|
|
271
|
+
projects.push(loadProject(root, projectKey, entry.prd));
|
|
272
|
+
projectIds.add(projectKey);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { leads, projects, laneIds };
|
|
276
|
+
}
|
|
277
|
+
function windowFromOptions(now, options) {
|
|
278
|
+
const until = normalizeIsoTimestamp(now.toISOString());
|
|
279
|
+
if (until === null)
|
|
280
|
+
return fail("autopilot_metrics_window_invalid", "Current time is invalid");
|
|
281
|
+
if (options.since !== undefined && options.days !== undefined)
|
|
282
|
+
return fail("autopilot_metrics_window_invalid", "Use either --since or --days, not both");
|
|
283
|
+
if (options.since !== undefined) {
|
|
284
|
+
const since = normalizeIsoTimestamp(options.since);
|
|
285
|
+
if (since === null || Date.parse(since) >= Date.parse(until))
|
|
286
|
+
return fail("autopilot_metrics_window_invalid", "--since must be an ISO timestamp before now", { since: options.since });
|
|
287
|
+
return { since, until };
|
|
288
|
+
}
|
|
289
|
+
const days = options.days === undefined ? 3 : Number(options.days);
|
|
290
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > 366)
|
|
291
|
+
return fail("autopilot_metrics_window_invalid", "--days must be an integer from 1 to 366", { days: options.days });
|
|
292
|
+
return { since: new Date(Date.parse(until) - days * 24 * 60 * 60 * 1000).toISOString(), until };
|
|
293
|
+
}
|
|
294
|
+
function printJson(value) {
|
|
295
|
+
console.log(JSON.stringify(value));
|
|
296
|
+
}
|
|
297
|
+
export function registerLaneAutopilotReportCommand(parent, deps, wrap) {
|
|
298
|
+
const autopilot = parent.command("autopilot").description("Measure automated lead-lane follow-through");
|
|
299
|
+
autopilot.command("report")
|
|
300
|
+
.description("Report operator nudges, review latency, and idle slots with ready work")
|
|
301
|
+
.option("--since <iso>", "Include activity since this ISO timestamp")
|
|
302
|
+
.option("--days <n>", "Include the most recent number of days")
|
|
303
|
+
.option("--json", "Print JSON")
|
|
304
|
+
.action(wrap(async (options) => {
|
|
305
|
+
const root = deps.hqRoot();
|
|
306
|
+
const window = windowFromOptions(deps.now(), options);
|
|
307
|
+
const host = deps.currentHost();
|
|
308
|
+
const selected = selectLeads(root, host);
|
|
309
|
+
const lanes = readAllLanes(root);
|
|
310
|
+
const nudges = [];
|
|
311
|
+
const resumes = [];
|
|
312
|
+
for (const lead of selected.leads) {
|
|
313
|
+
const events = readLeadMessages(root, lead.lane_id);
|
|
314
|
+
nudges.push(...events.nudges.map((event) => ({ ...event, team: lead.team })));
|
|
315
|
+
resumes.push(...events.resumes);
|
|
316
|
+
const record = lanes.find((lane) => lane.lane_id === lead.lane_id);
|
|
317
|
+
const lastResume = record?.last_resume?.at;
|
|
318
|
+
if (lastResume && !resumes.some((resume) => resume.lane_id === lead.lane_id && resume.at === lastResume))
|
|
319
|
+
resumes.push({ lane_id: lead.lane_id, at: lastResume });
|
|
320
|
+
}
|
|
321
|
+
const reviewListing = loadReviews(root);
|
|
322
|
+
const defects = loadNudgeDefects(root, new Set(selected.leads.map((lead) => lead.team)));
|
|
323
|
+
const result = buildAutopilotMetricsReport({
|
|
324
|
+
...window,
|
|
325
|
+
leads: selected.leads,
|
|
326
|
+
lanes: lanes,
|
|
327
|
+
projects: selected.projects,
|
|
328
|
+
reviews: reviewListing,
|
|
329
|
+
nudges,
|
|
330
|
+
nudgeDefects: defects,
|
|
331
|
+
seniorResumes: resumes,
|
|
332
|
+
});
|
|
333
|
+
printJson(result);
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
//# sourceMappingURL=lanes-autopilot.js.map
|
|
@@ -10,12 +10,11 @@ import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, w
|
|
|
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
12
|
import { keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
|
|
13
|
-
import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
|
|
14
13
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
15
14
|
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
16
15
|
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, callerMatchesSessionSenior, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
17
16
|
export function createLaneMessageHandlers(deps) {
|
|
18
|
-
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
17
|
+
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, dispatchPrimaryLaneMessage, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
19
18
|
function currentSuccessorOf(lane, lanes, lineageAware, companyForLane) {
|
|
20
19
|
const validLanes = lanes.filter((candidate) => Number.isFinite(Date.parse(candidate.created_at)));
|
|
21
20
|
const newestFirst = [...validLanes].sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at));
|
|
@@ -221,7 +220,27 @@ export function createLaneMessageHandlers(deps) {
|
|
|
221
220
|
if (!text) {
|
|
222
221
|
throw new LanesError("missing_text", "--text is required", {});
|
|
223
222
|
}
|
|
224
|
-
|
|
223
|
+
let resolution;
|
|
224
|
+
try {
|
|
225
|
+
resolution = await deps.resolveCommandLane(ctx, laneId);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const identity = ctx.readOutpostIdentity();
|
|
229
|
+
const failedOutpostIds = err instanceof LanesError ? err.details.failed_outpost_ids : undefined;
|
|
230
|
+
const probedOutpostIds = err instanceof LanesError ? err.details.probed_outpost_ids : undefined;
|
|
231
|
+
const primaryLookupFailed = Array.isArray(failedOutpostIds) && failedOutpostIds.includes("primary");
|
|
232
|
+
const primaryReportedMissing = Array.isArray(probedOutpostIds) && probedOutpostIds.includes("primary");
|
|
233
|
+
if (!(err instanceof LanesError) ||
|
|
234
|
+
err.error !== "lane_not_found" ||
|
|
235
|
+
identity.kind !== "id" ||
|
|
236
|
+
identity.id === "primary" ||
|
|
237
|
+
primaryLookupFailed ||
|
|
238
|
+
primaryReportedMissing) {
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
await dispatchPrimaryLaneMessage(ctx, laneId, { ...opts, text });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
225
244
|
let lane = resolution.lane;
|
|
226
245
|
if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
|
|
227
246
|
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
@@ -465,7 +484,6 @@ export function createLaneMessageHandlers(deps) {
|
|
|
465
484
|
markAccepted();
|
|
466
485
|
}
|
|
467
486
|
else {
|
|
468
|
-
const managerStatusGateEnabled = await managerStatusGateEnabledForLane(hqRoot, lane, { env: ctx.env });
|
|
469
487
|
const dispatched = await dispatchLaneDelivery(lane, {
|
|
470
488
|
dropbox: () => {
|
|
471
489
|
// Live (or about-to-be-live) process: the PreToolUse drain delivers it.
|
|
@@ -523,7 +541,6 @@ export function createLaneMessageHandlers(deps) {
|
|
|
523
541
|
}
|
|
524
542
|
const started = spawnLaneWorker(hqRoot, lane, {
|
|
525
543
|
env: ctx.env,
|
|
526
|
-
managerStatusGateEnabled,
|
|
527
544
|
modelDeps: {
|
|
528
545
|
readFileSync: ctx.readFileSync,
|
|
529
546
|
probe: ctx.probeProvider,
|
|
@@ -624,6 +641,7 @@ export function createLaneMessageHandlers(deps) {
|
|
|
624
641
|
accepted: true,
|
|
625
642
|
delivered: false,
|
|
626
643
|
delivery: mechanism,
|
|
644
|
+
delivered_to: { host: lane.host.id, lane_id: laneId, mechanism },
|
|
627
645
|
delivery_status: deliveryStatus,
|
|
628
646
|
inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
|
|
629
647
|
drain_hook: drainHook,
|
|
@@ -20,7 +20,7 @@ export function resolveAnswerChoice(choice, options, freeform) {
|
|
|
20
20
|
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
21
21
|
}
|
|
22
22
|
export function createLaneQuestionsHandlers(deps) {
|
|
23
|
-
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
23
|
+
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
24
24
|
function requireQuestionBody(hqRoot, question) {
|
|
25
25
|
const body = readQuestionBody(hqRoot, question);
|
|
26
26
|
if (!body) {
|
|
@@ -73,10 +73,11 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
73
73
|
return;
|
|
74
74
|
throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
|
|
75
75
|
}
|
|
76
|
-
async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
76
|
+
async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options, lookupErrors = []) {
|
|
77
77
|
if (lane.host.kind !== "outpost") {
|
|
78
78
|
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to answer its question`, { lane_id: lane.lane_id, question_id: questionId });
|
|
79
79
|
}
|
|
80
|
+
assertQuestionAnswerAuthorized(ctx, lane);
|
|
80
81
|
const outpostId = lane.host.id;
|
|
81
82
|
let dispatched;
|
|
82
83
|
try {
|
|
@@ -224,6 +225,7 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
224
225
|
outpost_id: outpostId,
|
|
225
226
|
outpost_command_id: dispatched.commandId,
|
|
226
227
|
outpost_transport: "outposts_exec",
|
|
228
|
+
...(lookupErrors.length > 0 ? { lookup_errors: lookupErrors } : {}),
|
|
227
229
|
});
|
|
228
230
|
}
|
|
229
231
|
async function readOutpostQuestionDetail(ctx, owner) {
|
|
@@ -280,6 +282,7 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
280
282
|
}
|
|
281
283
|
async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
|
|
282
284
|
const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
|
|
285
|
+
!laneIsHostedHere(ctx, lane) &&
|
|
283
286
|
(!companyFilter ||
|
|
284
287
|
effectiveLaneQuestionCompany(lane, hqRoot) === null ||
|
|
285
288
|
questionCompanyRefMatches(effectiveLaneQuestionCompany(lane, hqRoot), companyFilter)));
|
|
@@ -584,13 +587,13 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
584
587
|
throw err;
|
|
585
588
|
const company = explicitCompany ?? await resolveActiveQuestionCompany(ctx, "Cannot look up this lane question because the active HQ company could not be resolved; re-run with --company <slug>");
|
|
586
589
|
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
|
|
587
|
-
|
|
590
|
+
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
591
|
+
if (matches.length === 0 && lookup.errors.length > 0) {
|
|
588
592
|
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
589
593
|
question_id: questionId,
|
|
590
594
|
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
591
595
|
});
|
|
592
596
|
}
|
|
593
|
-
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
594
597
|
if (matches.length === 0) {
|
|
595
598
|
const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
596
599
|
const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
|
|
@@ -620,7 +623,7 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
620
623
|
await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
|
|
621
624
|
...opts,
|
|
622
625
|
company: questionListCompanyArg(ownerCompany),
|
|
623
|
-
});
|
|
626
|
+
}, lookup.errors);
|
|
624
627
|
return;
|
|
625
628
|
}
|
|
626
629
|
await yieldEventLoop();
|