@indigoai-us/hq-cli 5.242.0 → 5.243.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/commands/auth.js +73 -16
- package/dist/commands/lanes-accounts.d.ts +25 -0
- package/dist/commands/lanes-accounts.js +527 -0
- package/dist/commands/lanes-questions.d.ts +34 -0
- package/dist/commands/lanes-questions.js +876 -0
- package/dist/commands/lanes-shared.d.ts +155 -0
- package/dist/commands/lanes-shared.js +2 -0
- package/dist/commands/lanes.d.ts +4 -6
- package/dist/commands/lanes.js +128 -1437
- package/dist/commands/safe-extract.d.ts +11 -2
- package/dist/commands/safe-extract.js +37 -8
- package/dist/lib/auth-login-provider-state.d.ts +8 -0
- package/dist/lib/auth-login-provider-state.js +63 -0
- package/dist/lib/install-path-compatibility-flag.d.ts +9 -0
- package/dist/lib/install-path-compatibility-flag.js +12 -0
- package/dist/main.js +9 -6
- package/dist/utils/login-provider.d.ts +7 -0
- package/dist/utils/login-provider.js +34 -0
- package/dist/utils/version-gate.d.ts +3 -3
- package/dist/utils/version-gate.js +19 -10
- package/package.json +2 -1
package/dist/commands/lanes.js
CHANGED
|
@@ -11,6 +11,9 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { Option } from "commander";
|
|
14
|
+
import { createLaneAccountsHandlers } from "./lanes-accounts.js";
|
|
15
|
+
import { createLaneQuestionsHandlers } from "./lanes-questions.js";
|
|
16
|
+
export { resolveAnswerChoice } from "./lanes-questions.js";
|
|
14
17
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
15
18
|
import { registerLaneChecksCommand } from "./lanes-checks.js";
|
|
16
19
|
import { registerLaneReportCommands } from "./lanes-report.js";
|
|
@@ -26,10 +29,10 @@ import { createOutpostProfileRepinContext, parseOutpostProfileRepinContext, } fr
|
|
|
26
29
|
import { createOutpostRegistryWorkerApprovalContext, parseOutpostRegistryWorkerApprovalContext, } from "../lib/lanes/outpost-registry-worker-approval.js";
|
|
27
30
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
28
31
|
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
29
|
-
import { buildOutpostCapacityCommand,
|
|
32
|
+
import { buildOutpostCapacityCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostQuestionListCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
30
33
|
import { outpostHireSummaryCacheKey, readOutpostHireSummaryCache, writeOutpostHireSummaryCache, } from "../lib/lanes/outpost-hire-cache.js";
|
|
31
34
|
import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
|
|
32
|
-
import { BALANCE_MOVABLE_STATES,
|
|
35
|
+
import { BALANCE_MOVABLE_STATES, selectClaudeAccount, } from "../lib/lanes/claude-balance.js";
|
|
33
36
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
34
37
|
import { backfillOutpostIdentityMarker as backfillOutpostIdentityMarkerImpl, createOutpostIdentityMarkerIfMissing, validateOutpostIdentityId, } from "../lib/lanes/outpost-identity.js";
|
|
35
38
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -40,21 +43,20 @@ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
|
40
43
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
|
|
41
44
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
42
45
|
import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
43
|
-
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds,
|
|
46
|
+
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, LANE_PROVIDERS, LANE_STATES, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listLaneIds, listQuestionIds, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseQuestionRecord, parseSeniorKind, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, registryWorkerRecord, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, appendDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, } from "../lib/lanes/index.js";
|
|
44
47
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
45
48
|
import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
|
|
46
49
|
import { keepAliveExclusion, LEAD_LANE_KEEP_ALIVE_FLAG, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
|
|
47
50
|
import { questionBodyAbsPath, resolveQuestionCompanyWithFallback, resolveProjectQuestionCompany, } from "../lib/lanes/questions.js";
|
|
48
51
|
import { assertQuestionBodySecretSafe } from "../lib/lanes/questions.js";
|
|
49
52
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
50
|
-
import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
51
53
|
import { parseCliEffort, resolveLaneEffort, } from "../lib/lanes/effort.js";
|
|
52
54
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
53
55
|
import { LANE_WAIT_INVALID_INPUT_EXIT_CODE, LANE_WAIT_TIMEOUT_EXIT_CODE, runLaneWait, } from "../lib/lanes/wait.js";
|
|
54
56
|
import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
|
|
55
57
|
import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages, } from "../lib/lanes/dropbox.js";
|
|
56
58
|
import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision, } from "../lib/lanes/watcher.js";
|
|
57
|
-
import {
|
|
59
|
+
import { loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
|
|
58
60
|
import { SESSION_MOVE_REASON } from "../lib/lanes/move-account.js";
|
|
59
61
|
import { ensureSessionHome } from "../lib/lanes/session-relink.js";
|
|
60
62
|
import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../lib/lanes/models.js";
|
|
@@ -64,11 +66,7 @@ export { spawnLaneWorker };
|
|
|
64
66
|
export { questionBodyAbsPath };
|
|
65
67
|
export { MAX_LANE_DEPTH };
|
|
66
68
|
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
67
|
-
|
|
68
|
-
claude: { command: "claude", args: ["auth", "login"] },
|
|
69
|
-
codex: { command: "codex", args: ["login"] },
|
|
70
|
-
grok: { command: "grok", args: ["login"] },
|
|
71
|
-
};
|
|
69
|
+
/** The lane → mesh seam. Defined in lib/lanes/mesh-events.ts; the watcher shares it. */
|
|
72
70
|
export { emitLaneDecisionEvents };
|
|
73
71
|
export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
74
72
|
function defaultLanePlacementEnabled() {
|
|
@@ -77,13 +75,6 @@ function defaultLanePlacementEnabled() {
|
|
|
77
75
|
function defaultLaneTrackerEnabled(company) {
|
|
78
76
|
return readLanesRegistryFlag(LANES_TRACKER_FLAG, { company });
|
|
79
77
|
}
|
|
80
|
-
async function resolveSelectedAccountHomeCompany(ctx, selector) {
|
|
81
|
-
const company = await ctx.resolveActiveCompany(selector);
|
|
82
|
-
if (!company) {
|
|
83
|
-
throw new CompanySelectionError(`No unique active company membership for ${selector}. Pass an active company UID with --company.`);
|
|
84
|
-
}
|
|
85
|
-
return company;
|
|
86
|
-
}
|
|
87
78
|
function withoutProviderHome(value) {
|
|
88
79
|
if (Array.isArray(value))
|
|
89
80
|
return value.map(withoutProviderHome);
|
|
@@ -354,21 +345,6 @@ function parseHireGrant(raw) {
|
|
|
354
345
|
function seniorKey(ref) {
|
|
355
346
|
return ref.kind === "user" ? "user" : `${ref.kind}:${ref.id ?? ""}`;
|
|
356
347
|
}
|
|
357
|
-
export function resolveAnswerChoice(choice, options, freeform) {
|
|
358
|
-
const trimmed = choice.trim();
|
|
359
|
-
if (/^[0-9]+$/.test(trimmed)) {
|
|
360
|
-
const index = Number(trimmed);
|
|
361
|
-
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
362
|
-
return options[index - 1];
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
if (options.includes(trimmed) || options.includes(choice)) {
|
|
366
|
-
return options.includes(trimmed) ? trimmed : choice;
|
|
367
|
-
}
|
|
368
|
-
if (freeform)
|
|
369
|
-
return choice;
|
|
370
|
-
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
371
|
-
}
|
|
372
348
|
function parsePriority(raw) {
|
|
373
349
|
if (raw === undefined || raw === null || raw === "")
|
|
374
350
|
return 1;
|
|
@@ -471,17 +447,6 @@ function readQuestionBody(hqRoot, question) {
|
|
|
471
447
|
}
|
|
472
448
|
return stored.body;
|
|
473
449
|
}
|
|
474
|
-
function requireQuestionBody(hqRoot, question) {
|
|
475
|
-
const body = readQuestionBody(hqRoot, question);
|
|
476
|
-
if (!body) {
|
|
477
|
-
throw new LanesError("question_body_unavailable", `Question body is not available on this host for ${question.question_id}`, {
|
|
478
|
-
question_id: question.question_id,
|
|
479
|
-
store: question.body.store,
|
|
480
|
-
ref: question.body.ref,
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
return body;
|
|
484
|
-
}
|
|
485
450
|
function loadAllLanes(hqRoot) {
|
|
486
451
|
return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
|
|
487
452
|
}
|
|
@@ -1664,18 +1629,6 @@ function assertQuestionCompanyMatchesLane(question, lane, code, hqRoot) {
|
|
|
1664
1629
|
throw new LanesError(code, `Question ${question.question_id} does not match lane ${lane.lane_id}'s company`, { question_id: question.question_id, lane_id: lane.lane_id });
|
|
1665
1630
|
}
|
|
1666
1631
|
}
|
|
1667
|
-
function assertQuestionCompanyAccess(question, lane, requestedCompany, hqRoot) {
|
|
1668
|
-
assertQuestionCompanyMatchesLane(question, lane, "question_company_mismatch", hqRoot);
|
|
1669
|
-
const questionCompany = questionCompanyForLane(question, lane, hqRoot);
|
|
1670
|
-
if (questionCompany !== null && !sameQuestionCompany(questionCompany, requestedCompany)) {
|
|
1671
|
-
throw new LanesError("question_company_mismatch", `Question ${question.question_id} does not belong to the requested or active company`, { question_id: question.question_id, lane_id: lane.lane_id });
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
function assertQuestionCompanyResolvedForAnswer(question, lane, hqRoot, resolvedCompany) {
|
|
1675
|
-
if (questionCompanyForLane(question, lane, hqRoot) !== null || resolvedCompany)
|
|
1676
|
-
return;
|
|
1677
|
-
throw new LanesError("question_company_unavailable", `Cannot answer legacy question ${question.question_id}: its company cannot be resolved from the question or lane; re-run with --company <slug>`, { question_id: question.question_id, lane_id: lane.lane_id });
|
|
1678
|
-
}
|
|
1679
1632
|
function questionCompanyRefMatches(recordCompany, filter) {
|
|
1680
1633
|
return recordCompany !== null &&
|
|
1681
1634
|
recordCompany !== undefined &&
|
|
@@ -1734,25 +1687,6 @@ function resolveAudience(hqRoot, question, ctx) {
|
|
|
1734
1687
|
}
|
|
1735
1688
|
return current;
|
|
1736
1689
|
}
|
|
1737
|
-
function audienceMatches(audience, filter) {
|
|
1738
|
-
if (filter.kind === "me") {
|
|
1739
|
-
return audience.kind === "session" && audience.id === filter.id;
|
|
1740
|
-
}
|
|
1741
|
-
if (filter.kind === "user")
|
|
1742
|
-
return audience.kind === "user";
|
|
1743
|
-
return audience.kind === filter.kind && audience.id === filter.id;
|
|
1744
|
-
}
|
|
1745
|
-
function parseForFilter(raw, ctx) {
|
|
1746
|
-
const trimmed = raw.trim();
|
|
1747
|
-
if (trimmed === "me") {
|
|
1748
|
-
const sessionId = sessionIdFromEnv(ctx.env);
|
|
1749
|
-
if (!sessionId) {
|
|
1750
|
-
throw new LanesError("missing_session_id", "--for me requires a current session id (HQ_SESSION_ID)", {});
|
|
1751
|
-
}
|
|
1752
|
-
return { kind: "me", id: sessionId };
|
|
1753
|
-
}
|
|
1754
|
-
return parseSeniorRef(trimmed);
|
|
1755
|
-
}
|
|
1756
1690
|
function timeoutPath(hqRoot, laneId) {
|
|
1757
1691
|
return path.join(runDir(hqRoot, laneId), "timeout.json");
|
|
1758
1692
|
}
|
|
@@ -1916,16 +1850,6 @@ function assertKnownOutpost(requested, known) {
|
|
|
1916
1850
|
throw new LanesError("unknown_outpost", `Unknown Outpost ${JSON.stringify(requested)}. Accepted: ${known.join(", ") || "(none)"}`, { accepted: known, value: requested });
|
|
1917
1851
|
}
|
|
1918
1852
|
}
|
|
1919
|
-
function copyHookRails(fromHome, toHome) {
|
|
1920
|
-
const src = path.join(fromHome, "hooks");
|
|
1921
|
-
if (!fs.existsSync(src))
|
|
1922
|
-
return false;
|
|
1923
|
-
fs.cpSync(src, path.join(toHome, "hooks"), { recursive: true });
|
|
1924
|
-
return true;
|
|
1925
|
-
}
|
|
1926
|
-
function railsPresent(home) {
|
|
1927
|
-
return fs.existsSync(path.join(home, "hooks"));
|
|
1928
|
-
}
|
|
1929
1853
|
/**
|
|
1930
1854
|
* Spec §5.1: `hq lanes ask` from inside a lane can hit a binary or dist
|
|
1931
1855
|
* being replaced mid-call (ENOENT / ETXTBSY / a module that no longer
|
|
@@ -1934,11 +1858,6 @@ function railsPresent(home) {
|
|
|
1934
1858
|
*/
|
|
1935
1859
|
export const ASK_BACKOFF_MS = [5_000, 15_000];
|
|
1936
1860
|
export const ASK_ATTEMPTS = ASK_BACKOFF_MS.length + 1;
|
|
1937
|
-
function yieldEventLoop() {
|
|
1938
|
-
return new Promise((resolve) => {
|
|
1939
|
-
setImmediate(resolve);
|
|
1940
|
-
});
|
|
1941
|
-
}
|
|
1942
1861
|
function laneWaitHumanLine(result) {
|
|
1943
1862
|
const details = Object.entries(result.change)
|
|
1944
1863
|
.filter(([key]) => key !== "type")
|
|
@@ -1996,11 +1915,6 @@ export function casUpdateLane(hqRoot, laneId, updater) {
|
|
|
1996
1915
|
return updateLane(hqRoot, laneId, current.version, "command", updater);
|
|
1997
1916
|
});
|
|
1998
1917
|
}
|
|
1999
|
-
function throwIfQuestionNotPending(current) {
|
|
2000
|
-
if (current.status === "pending")
|
|
2001
|
-
return;
|
|
2002
|
-
throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
|
|
2003
|
-
}
|
|
2004
1918
|
function formatUnknownValue(value) {
|
|
2005
1919
|
if (typeof value === "string")
|
|
2006
1920
|
return value;
|
|
@@ -3924,159 +3838,6 @@ async function readOutpostLaneSnapshot(ctx, outpostId, laneId) {
|
|
|
3924
3838
|
}
|
|
3925
3839
|
return remote.lane;
|
|
3926
3840
|
}
|
|
3927
|
-
async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
3928
|
-
if (lane.host.kind !== "outpost") {
|
|
3929
|
-
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 });
|
|
3930
|
-
}
|
|
3931
|
-
const outpostId = lane.host.id;
|
|
3932
|
-
let dispatched;
|
|
3933
|
-
try {
|
|
3934
|
-
dispatched = await ctx.dispatchOutpostExec({
|
|
3935
|
-
outpostId,
|
|
3936
|
-
command: buildOutpostQuestionAnswerCommand({
|
|
3937
|
-
questionId,
|
|
3938
|
-
choice: options.choice,
|
|
3939
|
-
freeform: options.freeform,
|
|
3940
|
-
notes: options.notes,
|
|
3941
|
-
company: options.company,
|
|
3942
|
-
}),
|
|
3943
|
-
});
|
|
3944
|
-
}
|
|
3945
|
-
catch (err) {
|
|
3946
|
-
throw new LanesError("outpost_dispatch_failed", `Could not dispatch question ${questionId} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
3947
|
-
}
|
|
3948
|
-
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
|
|
3949
|
-
throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} did not confirm the answer for question ${questionId}`, {
|
|
3950
|
-
lane_id: lane.lane_id,
|
|
3951
|
-
question_id: questionId,
|
|
3952
|
-
outpost_id: outpostId,
|
|
3953
|
-
command_id: dispatched.commandId,
|
|
3954
|
-
status: dispatched.status,
|
|
3955
|
-
done: dispatched.done ?? false,
|
|
3956
|
-
exit_code: dispatched.exitCode,
|
|
3957
|
-
});
|
|
3958
|
-
}
|
|
3959
|
-
const remote = parseRemoteJson(dispatched.stdout, "Outpost question answer");
|
|
3960
|
-
if (remote?.error !== undefined) {
|
|
3961
|
-
throw new LanesError("outpost_remote_command_failed", typeof remote.message === "string"
|
|
3962
|
-
? remote.message
|
|
3963
|
-
: `Outpost ${outpostId} rejected the answer for question ${questionId}`, {
|
|
3964
|
-
lane_id: lane.lane_id,
|
|
3965
|
-
question_id: questionId,
|
|
3966
|
-
outpost_id: outpostId,
|
|
3967
|
-
command_id: dispatched.commandId,
|
|
3968
|
-
remote_error: remote.error,
|
|
3969
|
-
});
|
|
3970
|
-
}
|
|
3971
|
-
const remoteQuestion = remote?.question !== null && typeof remote?.question === "object"
|
|
3972
|
-
? remote.question
|
|
3973
|
-
: null;
|
|
3974
|
-
const awaitingWakeConfirmed = lane.state !== "awaiting_input" ||
|
|
3975
|
-
(remote?.wake_status === "resumed" && remote.lane_state === "running") ||
|
|
3976
|
-
(remote?.wake_status === "queued" && remote.lane_state === "queued") ||
|
|
3977
|
-
(remote?.wake_status === "awaiting_answers" && remote.lane_state === "awaiting_input");
|
|
3978
|
-
if (remote?.ok !== true ||
|
|
3979
|
-
remote.action !== "questions.answer" ||
|
|
3980
|
-
remoteQuestion?.question_id !== questionId ||
|
|
3981
|
-
remoteQuestion.lane_id !== lane.lane_id ||
|
|
3982
|
-
remoteQuestion.status !== "answered" ||
|
|
3983
|
-
typeof remote.delivery !== "string" ||
|
|
3984
|
-
!awaitingWakeConfirmed) {
|
|
3985
|
-
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without confirming the answer and wake status for question ${questionId}`, {
|
|
3986
|
-
lane_id: lane.lane_id,
|
|
3987
|
-
question_id: questionId,
|
|
3988
|
-
outpost_id: outpostId,
|
|
3989
|
-
command_id: dispatched.commandId,
|
|
3990
|
-
});
|
|
3991
|
-
}
|
|
3992
|
-
let ownerQuestion;
|
|
3993
|
-
try {
|
|
3994
|
-
ownerQuestion = parseQuestionRecord(remoteQuestion);
|
|
3995
|
-
}
|
|
3996
|
-
catch (err) {
|
|
3997
|
-
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return a valid answered question record for ${questionId}: ${dispatchErrorMessage(err)}`, {
|
|
3998
|
-
lane_id: lane.lane_id,
|
|
3999
|
-
question_id: questionId,
|
|
4000
|
-
outpost_id: outpostId,
|
|
4001
|
-
command_id: dispatched.commandId,
|
|
4002
|
-
});
|
|
4003
|
-
}
|
|
4004
|
-
const ownerAnswer = ownerQuestion.answer;
|
|
4005
|
-
const ownerLaneCompany = effectiveLaneQuestionCompany(lane, ctx.hqRoot());
|
|
4006
|
-
if (!isQuestionAnswer(ownerAnswer) ||
|
|
4007
|
-
ownerQuestion.lane_id !== lane.lane_id ||
|
|
4008
|
-
(ownerQuestion.company !== null && ownerLaneCompany !== null &&
|
|
4009
|
-
!sameQuestionCompany(ownerQuestion.company, ownerLaneCompany))) {
|
|
4010
|
-
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return the confirmed answer for question ${questionId}`, {
|
|
4011
|
-
lane_id: lane.lane_id,
|
|
4012
|
-
question_id: questionId,
|
|
4013
|
-
outpost_id: outpostId,
|
|
4014
|
-
command_id: dispatched.commandId,
|
|
4015
|
-
});
|
|
4016
|
-
}
|
|
4017
|
-
const updatePrimaryCopy = () => casUpdateQuestion(ctx.hqRoot(), questionId, (current) => {
|
|
4018
|
-
if (current.lane_id !== lane.lane_id) {
|
|
4019
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} belongs to a different lane than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
4020
|
-
}
|
|
4021
|
-
if (current.company !== null &&
|
|
4022
|
-
effectiveLaneQuestionCompany(lane, ctx.hqRoot()) !== null &&
|
|
4023
|
-
!sameQuestionCompany(current.company, effectiveLaneQuestionCompany(lane, ctx.hqRoot()))) {
|
|
4024
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} belongs to a different company than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
4025
|
-
}
|
|
4026
|
-
if (current.company !== null &&
|
|
4027
|
-
ownerQuestion.company !== null &&
|
|
4028
|
-
!sameQuestionCompany(current.company, ownerQuestion.company)) {
|
|
4029
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} has a different company than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
4030
|
-
}
|
|
4031
|
-
if (current.status === "pending") {
|
|
4032
|
-
return {
|
|
4033
|
-
...current,
|
|
4034
|
-
company: current.company ?? ownerQuestion.company,
|
|
4035
|
-
status: "answered",
|
|
4036
|
-
answer: ownerAnswer,
|
|
4037
|
-
delivered_at: ownerQuestion.delivered_at ?? current.delivered_at ?? null,
|
|
4038
|
-
};
|
|
4039
|
-
}
|
|
4040
|
-
if (current.status === "answered" && sameQuestionAnswer(current.answer, ownerAnswer)) {
|
|
4041
|
-
const deliveredAt = ownerQuestion.delivered_at ?? current.delivered_at ?? null;
|
|
4042
|
-
const company = current.company ?? ownerQuestion.company;
|
|
4043
|
-
return deliveredAt === current.delivered_at && company === current.company
|
|
4044
|
-
? current
|
|
4045
|
-
: { ...current, company, delivered_at: deliveredAt };
|
|
4046
|
-
}
|
|
4047
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} is ${current.status} or has a different answer than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
4048
|
-
});
|
|
4049
|
-
let syncedQuestion;
|
|
4050
|
-
try {
|
|
4051
|
-
syncedQuestion = updatePrimaryCopy();
|
|
4052
|
-
}
|
|
4053
|
-
catch (err) {
|
|
4054
|
-
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
4055
|
-
throw err;
|
|
4056
|
-
const primaryLane = readLane(ctx.hqRoot(), lane.lane_id);
|
|
4057
|
-
const primaryCompany = effectiveLaneQuestionCompany(primaryLane, ctx.hqRoot());
|
|
4058
|
-
const ownerCompany = effectiveLaneQuestionCompany(lane, ctx.hqRoot());
|
|
4059
|
-
if (primaryCompany !== null && ownerCompany !== null && !sameQuestionCompany(primaryCompany, ownerCompany)) {
|
|
4060
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary lane ${lane.lane_id} belongs to a different company than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
|
|
4061
|
-
}
|
|
4062
|
-
try {
|
|
4063
|
-
syncedQuestion = createQuestion(ctx.hqRoot(), ownerQuestion);
|
|
4064
|
-
}
|
|
4065
|
-
catch (createErr) {
|
|
4066
|
-
if (!isLanesError(createErr) || createErr.error !== "question_exists")
|
|
4067
|
-
throw createErr;
|
|
4068
|
-
syncedQuestion = updatePrimaryCopy();
|
|
4069
|
-
}
|
|
4070
|
-
}
|
|
4071
|
-
printJson({
|
|
4072
|
-
...remote,
|
|
4073
|
-
lane_id: lane.lane_id,
|
|
4074
|
-
question: syncedQuestion,
|
|
4075
|
-
outpost_id: outpostId,
|
|
4076
|
-
outpost_command_id: dispatched.commandId,
|
|
4077
|
-
outpost_transport: "outposts_exec",
|
|
4078
|
-
});
|
|
4079
|
-
}
|
|
4080
3841
|
function isQuestionAnswer(value) {
|
|
4081
3842
|
if (value === null || typeof value !== "object")
|
|
4082
3843
|
return false;
|
|
@@ -6530,74 +6291,6 @@ function persistClaudeAccountSelection(hqRoot, lane, selection) {
|
|
|
6530
6291
|
return { ...current, account_selection: selection };
|
|
6531
6292
|
});
|
|
6532
6293
|
}
|
|
6533
|
-
async function applyClaudeBalanceMove(ctx, suggestion) {
|
|
6534
|
-
const currentHost = reconcileHostFromCtx(ctx);
|
|
6535
|
-
const suggestionRunsHere = suggestion.host.kind === "outpost"
|
|
6536
|
-
? currentHost.outpostId === suggestion.host.id
|
|
6537
|
-
: currentHost.outpostId === null && currentHost.hostname === suggestion.host.id;
|
|
6538
|
-
if (suggestion.host.kind === "outpost" && !suggestionRunsHere) {
|
|
6539
|
-
let result;
|
|
6540
|
-
try {
|
|
6541
|
-
result = await ctx.dispatchOutpostExec({
|
|
6542
|
-
outpostId: suggestion.host.id,
|
|
6543
|
-
command: buildOutpostBalanceMoveCommand({
|
|
6544
|
-
laneId: suggestion.lane_id,
|
|
6545
|
-
account: suggestion.to,
|
|
6546
|
-
fromAccount: suggestion.from,
|
|
6547
|
-
expectedState: suggestion.state,
|
|
6548
|
-
}),
|
|
6549
|
-
timeoutSeconds: 120,
|
|
6550
|
-
waitMs: 120_000,
|
|
6551
|
-
});
|
|
6552
|
-
}
|
|
6553
|
-
catch (err) {
|
|
6554
|
-
throw new LanesError("claude_account_balance_move_failed", `Could not move lane ${suggestion.lane_id} on Outpost ${suggestion.host.id}: ${dispatchErrorMessage(err)}`, { lane_id: suggestion.lane_id, host: suggestion.host, account: suggestion.to });
|
|
6555
|
-
}
|
|
6556
|
-
if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
|
|
6557
|
-
throw new LanesError("claude_account_balance_move_failed", `Outpost ${suggestion.host.id} did not confirm the account move for lane ${suggestion.lane_id}`, { lane_id: suggestion.lane_id, host: suggestion.host, account: suggestion.to, status: result.status, exit_code: result.exitCode });
|
|
6558
|
-
}
|
|
6559
|
-
const remote = parseRemoteJson(result.stdout, "Claude account balance move");
|
|
6560
|
-
if (remote?.ok !== true || remote.action !== "balance-move-target" ||
|
|
6561
|
-
remote.lane_id !== suggestion.lane_id || remote.from !== suggestion.from ||
|
|
6562
|
-
remote.account !== suggestion.to ||
|
|
6563
|
-
remote.state !== suggestion.state) {
|
|
6564
|
-
throw new LanesError("claude_account_balance_move_unconfirmed", `Outpost ${suggestion.host.id} returned an invalid account-move result`, {
|
|
6565
|
-
lane_id: suggestion.lane_id,
|
|
6566
|
-
host: suggestion.host,
|
|
6567
|
-
account: suggestion.to,
|
|
6568
|
-
});
|
|
6569
|
-
}
|
|
6570
|
-
return { lane_id: suggestion.lane_id, host: suggestion.host, from: suggestion.from, to: suggestion.to, state: suggestion.state };
|
|
6571
|
-
}
|
|
6572
|
-
const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), suggestion.lane_id));
|
|
6573
|
-
if (lane.state === "running") {
|
|
6574
|
-
throw new LanesError("lane_running", `Lane ${lane.lane_id} is running; balance never moves a running turn`, {
|
|
6575
|
-
lane_id: lane.lane_id,
|
|
6576
|
-
state: lane.state,
|
|
6577
|
-
});
|
|
6578
|
-
}
|
|
6579
|
-
if (lane.host.kind !== suggestion.host.kind || lane.host.id !== suggestion.host.id ||
|
|
6580
|
-
lane.account !== suggestion.from || lane.state !== suggestion.state) {
|
|
6581
|
-
throw new LanesError("claude_account_balance_lane_changed", `Lane ${lane.lane_id} changed after balance was calculated`, {
|
|
6582
|
-
lane_id: lane.lane_id,
|
|
6583
|
-
host: lane.host,
|
|
6584
|
-
account: lane.account ?? null,
|
|
6585
|
-
});
|
|
6586
|
-
}
|
|
6587
|
-
if (!BALANCE_MOVABLE_STATES.includes(lane.state)) {
|
|
6588
|
-
throw new LanesError("claude_account_balance_state_not_movable", `Lane ${lane.lane_id} is no longer between turns`, {
|
|
6589
|
-
lane_id: lane.lane_id,
|
|
6590
|
-
state: lane.state,
|
|
6591
|
-
});
|
|
6592
|
-
}
|
|
6593
|
-
const moved = await prepareAccountMove(ctx, lane, {
|
|
6594
|
-
account: suggestion.to,
|
|
6595
|
-
cliModel: undefined,
|
|
6596
|
-
accountHistoryReason: automaticBalanceMoveHistoryReason(ctx, lane),
|
|
6597
|
-
deferTurnStart: true,
|
|
6598
|
-
});
|
|
6599
|
-
return { lane_id: moved.lane.lane_id, host: moved.lane.host, from: moved.from ?? suggestion.from, to: moved.to, state: moved.lane.state };
|
|
6600
|
-
}
|
|
6601
6294
|
async function runClaudeBalanceMoveTarget(ctx, laneId, opts) {
|
|
6602
6295
|
const lane = laneWithRecoveredProviderSession(ctx.hqRoot(), readLane(ctx.hqRoot(), laneId));
|
|
6603
6296
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "balance-move-target");
|
|
@@ -8167,1119 +7860,70 @@ function parseOutpostQuestionBody(raw, outpostId, questionId) {
|
|
|
8167
7860
|
}
|
|
8168
7861
|
return parseQuestionBody(raw);
|
|
8169
7862
|
}
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
|
|
8181
|
-
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
8182
|
-
});
|
|
8183
|
-
}
|
|
8184
|
-
catch {
|
|
8185
|
-
throw new LanesError("outpost_question_show_unavailable", `Could not read question ${owner.question.question_id} from Outpost ${outpostId}`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
8186
|
-
}
|
|
8187
|
-
if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
|
|
8188
|
-
throw new LanesError("outpost_question_show_unavailable", `Outpost ${outpostId} did not return question ${owner.question.question_id}`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
8189
|
-
}
|
|
8190
|
-
let raw;
|
|
8191
|
-
try {
|
|
8192
|
-
raw = JSON.parse(result.stdout);
|
|
8193
|
-
}
|
|
8194
|
-
catch {
|
|
8195
|
-
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned invalid question-show JSON`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
8196
|
-
}
|
|
8197
|
-
if (raw === null ||
|
|
8198
|
-
typeof raw !== "object" ||
|
|
8199
|
-
Array.isArray(raw) ||
|
|
8200
|
-
raw.ok !== true ||
|
|
8201
|
-
raw.action !== "questions.show" ||
|
|
8202
|
-
!Object.prototype.hasOwnProperty.call(raw, "body")) {
|
|
8203
|
-
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned a malformed question-show response`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
8204
|
-
}
|
|
8205
|
-
const response = raw;
|
|
8206
|
-
let question;
|
|
8207
|
-
try {
|
|
8208
|
-
question = parseQuestionRecord(response.question);
|
|
8209
|
-
}
|
|
8210
|
-
catch {
|
|
8211
|
-
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
8212
|
-
}
|
|
8213
|
-
if (question.question_id !== owner.question.question_id ||
|
|
8214
|
-
question.lane_id !== owner.lane.lane_id) {
|
|
8215
|
-
throw new LanesError("outpost_question_company_mismatch", `Outpost ${outpostId} returned question ${owner.question.question_id} for a different lane or company`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
8216
|
-
}
|
|
8217
|
-
assertQuestionCompanyMatchesLane(question, owner.lane, "outpost_question_company_mismatch", ctx.hqRoot());
|
|
8218
|
-
const audience = parseOutpostQuestionAudience(response.audience, outpostId, 0);
|
|
8219
|
-
const body = parseOutpostQuestionBody(response.body, outpostId, question.question_id);
|
|
8220
|
-
return { question, audience, body };
|
|
8221
|
-
}
|
|
8222
|
-
async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
|
|
8223
|
-
const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
|
|
8224
|
-
(!companyFilter ||
|
|
8225
|
-
effectiveLaneQuestionCompany(lane, hqRoot) === null ||
|
|
8226
|
-
questionCompanyRefMatches(effectiveLaneQuestionCompany(lane, hqRoot), companyFilter)));
|
|
8227
|
-
const lanesByOutpost = new Map();
|
|
8228
|
-
for (const lane of hostedLanes) {
|
|
8229
|
-
if (lane.host.kind !== "outpost")
|
|
8230
|
-
continue;
|
|
8231
|
-
const lanes = lanesByOutpost.get(lane.host.id) ?? [];
|
|
8232
|
-
lanes.push(lane);
|
|
8233
|
-
lanesByOutpost.set(lane.host.id, lanes);
|
|
8234
|
-
}
|
|
8235
|
-
const questions = [];
|
|
8236
|
-
const errors = [];
|
|
8237
|
-
const eligibleGroups = [];
|
|
8238
|
-
for (const [outpostId, lanes] of [...lanesByOutpost.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
8239
|
-
for (const group of groupQuestionLanesByCompany(hqRoot, lanes, companyFilter)) {
|
|
8240
|
-
if (group.company === null) {
|
|
8241
|
-
errors.push({
|
|
8242
|
-
host: `outpost:${outpostId}`,
|
|
8243
|
-
error: "outpost_question_company_unavailable",
|
|
8244
|
-
});
|
|
8245
|
-
continue;
|
|
8246
|
-
}
|
|
8247
|
-
if (companyFilter && !questionCompanyRefMatches(group.company, companyFilter))
|
|
8248
|
-
continue;
|
|
8249
|
-
eligibleGroups.push({ outpostId, group });
|
|
8250
|
-
}
|
|
8251
|
-
}
|
|
8252
|
-
if (eligibleGroups.length === 0) {
|
|
8253
|
-
return { questions, errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()] };
|
|
8254
|
-
}
|
|
8255
|
-
let knownOutpostIds;
|
|
7863
|
+
function writeAnswerDeliveryFailed(hqRoot, laneId, questionId, err, at) {
|
|
7864
|
+
const failure = {
|
|
7865
|
+
schema_version: "hq-lane-ask-delivery-failed.v1",
|
|
7866
|
+
at,
|
|
7867
|
+
lane_id: laneId,
|
|
7868
|
+
question_id: questionId,
|
|
7869
|
+
error: err instanceof Error
|
|
7870
|
+
? { name: err.name, message: err.message }
|
|
7871
|
+
: { name: "Error", message: String(err) },
|
|
7872
|
+
};
|
|
8256
7873
|
try {
|
|
8257
|
-
|
|
8258
|
-
}
|
|
8259
|
-
catch {
|
|
8260
|
-
return {
|
|
8261
|
-
questions,
|
|
8262
|
-
errors: [...new Map([
|
|
8263
|
-
...errors,
|
|
8264
|
-
...[...new Set(eligibleGroups.map(({ outpostId }) => outpostId))].map((outpostId) => ({
|
|
8265
|
-
host: `outpost:${outpostId}`,
|
|
8266
|
-
error: "outpost_list_unavailable",
|
|
8267
|
-
})),
|
|
8268
|
-
].map((entry) => [entry.host, entry])).values()],
|
|
8269
|
-
};
|
|
7874
|
+
writeJsonFile(askDeliveryFailedPath(hqRoot, laneId), failure);
|
|
8270
7875
|
}
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
normalizedOutpostIds.some((id) => id === "") ||
|
|
8274
|
-
new Set(normalizedOutpostIds).size !== normalizedOutpostIds.length) {
|
|
8275
|
-
throw new LanesError("outpost_list_invalid", "Outpost list is malformed or exceeds the placement limit", { count: knownOutpostIds.length, maximum: MAX_PLACEMENT_OUTPOSTS });
|
|
7876
|
+
catch (writeErr) {
|
|
7877
|
+
process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${laneId}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
|
|
8276
7878
|
}
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
}],
|
|
8315
|
-
};
|
|
8316
|
-
}
|
|
7879
|
+
}
|
|
7880
|
+
/**
|
|
7881
|
+
* Record is already durable. Delivery is best-effort onto the asking lane's
|
|
7882
|
+
* drop box (spec §6.5). Target is question.lane_id, never a project/story lookup.
|
|
7883
|
+
*/
|
|
7884
|
+
async function runReconcile(ctx, opts) {
|
|
7885
|
+
const fromEnv = ctx.env.HQ_ROOT?.trim();
|
|
7886
|
+
const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
|
|
7887
|
+
const summary = await runReconcilePass(hqRoot, {
|
|
7888
|
+
now: ctx.now,
|
|
7889
|
+
host: reconcileHostFromCtx(ctx),
|
|
7890
|
+
dryRun: Boolean(opts.dryRun),
|
|
7891
|
+
spawnLaneDeps: {
|
|
7892
|
+
env: ctx.env,
|
|
7893
|
+
modelDeps: {
|
|
7894
|
+
readFileSync: ctx.readFileSync,
|
|
7895
|
+
probe: ctx.probeProvider,
|
|
7896
|
+
env: ctx.env,
|
|
7897
|
+
},
|
|
7898
|
+
existsSync: ctx.existsSync,
|
|
7899
|
+
spawnProcess: ctx.spawnProcess,
|
|
7900
|
+
proveDetached: ctx.proveDetached,
|
|
7901
|
+
killProcess: ctx.killProcess,
|
|
7902
|
+
now: ctx.now,
|
|
7903
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
7904
|
+
runCompaction: ctx.runCompaction,
|
|
7905
|
+
locateTranscript: ctx.locateTranscript,
|
|
7906
|
+
},
|
|
7907
|
+
throttleDeps: ctx.throttleDeps,
|
|
7908
|
+
livenessDeps: livenessDepsFromCtx(ctx),
|
|
7909
|
+
presenceConnected: ctx.presenceConnected,
|
|
7910
|
+
resolveQuestionActiveCompany: () => resolveActiveQuestionCompany(ctx),
|
|
7911
|
+
recoverQueuedMessages: async (lane) => {
|
|
7912
|
+
await recoverQueuedMessages(ctx, lane.lane_id);
|
|
7913
|
+
},
|
|
7914
|
+
mesh: ctx.mesh,
|
|
7915
|
+
env: ctx.env,
|
|
8317
7916
|
});
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
|
|
8323
|
-
};
|
|
7917
|
+
printJson(summary);
|
|
7918
|
+
if (!summary.ok) {
|
|
7919
|
+
process.exit(1);
|
|
7920
|
+
}
|
|
8324
7921
|
}
|
|
8325
|
-
function
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
casUpdateQuestion(hqRoot, owner.question.question_id, (current) => {
|
|
8331
|
-
if (current.lane_id !== owner.lane.lane_id) {
|
|
8332
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary question ${current.question_id} belongs to a different lane than its Outpost owner`, { lane_id: owner.lane.lane_id, question_id: current.question_id });
|
|
8333
|
-
}
|
|
8334
|
-
if (current.company !== null &&
|
|
8335
|
-
owner.question.company !== null &&
|
|
8336
|
-
!sameQuestionCompany(current.company, owner.question.company)) {
|
|
8337
|
-
throw new LanesError("outpost_question_sync_conflict", `Primary question ${current.question_id} belongs to a different company than its Outpost owner`, { lane_id: owner.lane.lane_id, question_id: current.question_id });
|
|
8338
|
-
}
|
|
8339
|
-
const company = current.company ?? owner.question.company;
|
|
8340
|
-
const nextDeliveredAt = current.delivered_at ?? deliveredAt;
|
|
8341
|
-
return company === current.company && nextDeliveredAt === current.delivered_at
|
|
8342
|
-
? current
|
|
8343
|
-
: { ...current, company, delivered_at: nextDeliveredAt };
|
|
8344
|
-
});
|
|
8345
|
-
}
|
|
8346
|
-
catch (err) {
|
|
8347
|
-
if (isLanesError(err) && err.error === "question_not_found")
|
|
8348
|
-
return;
|
|
8349
|
-
throw err;
|
|
8350
|
-
}
|
|
8351
|
-
}
|
|
8352
|
-
async function runQuestionsList(ctx, opts) {
|
|
8353
|
-
const hqRoot = ctx.hqRoot();
|
|
8354
|
-
const status = opts.status ? parseQuestionStatus(opts.status) : undefined;
|
|
8355
|
-
const forFilter = opts.for ? parseForFilter(opts.for, ctx) : undefined;
|
|
8356
|
-
const company = opts.company
|
|
8357
|
-
? await ctx.resolveCompany(opts.company)
|
|
8358
|
-
: await resolveActiveQuestionCompany(ctx, "Cannot access lane questions because the active HQ company could not be resolved; re-run with --company <slug>");
|
|
8359
|
-
const localLanes = new Map(loadAllLanes(hqRoot).map((lane) => [lane.lane_id, lane]));
|
|
8360
|
-
const targeted = opts.from !== undefined || opts.project !== undefined || opts.story !== undefined;
|
|
8361
|
-
const localQuestions = loadAllQuestions(hqRoot)
|
|
8362
|
-
.filter((q) => {
|
|
8363
|
-
if (status && q.status !== status)
|
|
8364
|
-
return false;
|
|
8365
|
-
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
8366
|
-
return false;
|
|
8367
|
-
if (opts.project && q.project_id !== opts.project)
|
|
8368
|
-
return false;
|
|
8369
|
-
if (opts.story && q.story_id !== opts.story)
|
|
8370
|
-
return false;
|
|
8371
|
-
const lane = localLanes.get(q.lane_id);
|
|
8372
|
-
if (lane)
|
|
8373
|
-
assertQuestionCompanyMatchesLane(q, lane, "question_company_mismatch", hqRoot);
|
|
8374
|
-
const questionCompany = lane ? questionCompanyForLane(q, lane, hqRoot) : q.company;
|
|
8375
|
-
if (questionCompany !== null && !sameQuestionCompany(questionCompany, company)) {
|
|
8376
|
-
if (targeted) {
|
|
8377
|
-
throw new LanesError("question_company_mismatch", `Question ${q.question_id} does not belong to the requested or active company`, { question_id: q.question_id, lane_id: q.lane_id });
|
|
8378
|
-
}
|
|
8379
|
-
return false;
|
|
8380
|
-
}
|
|
8381
|
-
if (forFilter) {
|
|
8382
|
-
const audience = resolveAudience(hqRoot, q, ctx);
|
|
8383
|
-
if (!audienceMatches(audience, forFilter))
|
|
8384
|
-
return false;
|
|
8385
|
-
}
|
|
8386
|
-
return true;
|
|
8387
|
-
});
|
|
8388
|
-
const outpostLoad = opts.allHosts
|
|
8389
|
-
? await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company)
|
|
8390
|
-
: { questions: [], errors: [] };
|
|
8391
|
-
for (const owner of outpostLoad.questions) {
|
|
8392
|
-
assertQuestionCompanyAccess(owner.question, owner.lane, company, hqRoot);
|
|
8393
|
-
syncPrimaryQuestionDelivery(hqRoot, owner);
|
|
8394
|
-
}
|
|
8395
|
-
const remoteQuestionIds = new Set(outpostLoad.questions.map(({ question }) => question.question_id));
|
|
8396
|
-
const questions = [
|
|
8397
|
-
...localQuestions
|
|
8398
|
-
.filter((q) => !remoteQuestionIds.has(q.question_id))
|
|
8399
|
-
.map((q) => {
|
|
8400
|
-
const row = {
|
|
8401
|
-
...q,
|
|
8402
|
-
audience: resolveAudience(hqRoot, q, ctx),
|
|
8403
|
-
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
8404
|
-
};
|
|
8405
|
-
if (!opts.allHosts)
|
|
8406
|
-
return row;
|
|
8407
|
-
const lane = localLanes.get(q.lane_id);
|
|
8408
|
-
const host = lane?.host.kind === "outpost" ? `outpost:${lane.host.id}` : "local";
|
|
8409
|
-
return { ...row, host };
|
|
8410
|
-
}),
|
|
8411
|
-
...outpostLoad.questions
|
|
8412
|
-
.filter(({ question: q, lane, audience }) => {
|
|
8413
|
-
if (status && q.status !== status)
|
|
8414
|
-
return false;
|
|
8415
|
-
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
8416
|
-
return false;
|
|
8417
|
-
if (opts.project && q.project_id !== opts.project)
|
|
8418
|
-
return false;
|
|
8419
|
-
if (opts.story && q.story_id !== opts.story)
|
|
8420
|
-
return false;
|
|
8421
|
-
assertQuestionCompanyAccess(q, lane, company, hqRoot);
|
|
8422
|
-
if (forFilter) {
|
|
8423
|
-
if (!audienceMatches(audience, forFilter))
|
|
8424
|
-
return false;
|
|
8425
|
-
}
|
|
8426
|
-
return true;
|
|
8427
|
-
})
|
|
8428
|
-
.map(({ question: q, host, audience }) => ({
|
|
8429
|
-
...q,
|
|
8430
|
-
host,
|
|
8431
|
-
audience,
|
|
8432
|
-
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
8433
|
-
})),
|
|
8434
|
-
];
|
|
8435
|
-
printJson({
|
|
8436
|
-
ok: true,
|
|
8437
|
-
action: "questions.list",
|
|
8438
|
-
questions,
|
|
8439
|
-
...(outpostLoad.errors.length > 0 ? { errors: outpostLoad.errors } : {}),
|
|
8440
|
-
});
|
|
8441
|
-
}
|
|
8442
|
-
async function runQuestionsShow(ctx, questionId) {
|
|
8443
|
-
const hqRoot = ctx.hqRoot();
|
|
8444
|
-
let question;
|
|
8445
|
-
try {
|
|
8446
|
-
question = readQuestion(hqRoot, questionId);
|
|
8447
|
-
}
|
|
8448
|
-
catch (err) {
|
|
8449
|
-
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
8450
|
-
throw err;
|
|
8451
|
-
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
8452
|
-
if (lookup.errors.length > 0) {
|
|
8453
|
-
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
8454
|
-
question_id: questionId,
|
|
8455
|
-
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
8456
|
-
});
|
|
8457
|
-
}
|
|
8458
|
-
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
8459
|
-
if (matches.length === 0) {
|
|
8460
|
-
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
8461
|
-
}
|
|
8462
|
-
if (matches.length > 1) {
|
|
8463
|
-
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} is present on more than one Outpost`, { question_id: questionId, owner_count: matches.length });
|
|
8464
|
-
}
|
|
8465
|
-
const owner = matches[0];
|
|
8466
|
-
if (!owner) {
|
|
8467
|
-
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} has no unique Outpost owner`, { question_id: questionId });
|
|
8468
|
-
}
|
|
8469
|
-
const detail = await readOutpostQuestionDetail(ctx, owner);
|
|
8470
|
-
printJson({
|
|
8471
|
-
ok: true,
|
|
8472
|
-
action: "questions.show",
|
|
8473
|
-
question: detail.question,
|
|
8474
|
-
audience: detail.audience,
|
|
8475
|
-
body: detail.body,
|
|
8476
|
-
host: owner.host,
|
|
8477
|
-
});
|
|
8478
|
-
return;
|
|
8479
|
-
}
|
|
8480
|
-
const body = readQuestionBody(hqRoot, question);
|
|
8481
|
-
printJson({
|
|
8482
|
-
ok: true,
|
|
8483
|
-
action: "questions.show",
|
|
8484
|
-
question,
|
|
8485
|
-
audience: resolveAudience(hqRoot, question, ctx),
|
|
8486
|
-
body,
|
|
8487
|
-
});
|
|
8488
|
-
}
|
|
8489
|
-
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
8490
|
-
const hqRoot = ctx.hqRoot();
|
|
8491
|
-
if (opts.choice !== undefined)
|
|
8492
|
-
assertNoSecretText("answer choice", opts.choice);
|
|
8493
|
-
if (opts.freeform !== undefined)
|
|
8494
|
-
assertNoSecretText("freeform answer", opts.freeform);
|
|
8495
|
-
if (opts.notes !== undefined)
|
|
8496
|
-
assertNoSecretText("answer notes", opts.notes);
|
|
8497
|
-
const explicitCompany = opts.company ? await ctx.resolveCompany(opts.company) : undefined;
|
|
8498
|
-
let question;
|
|
8499
|
-
try {
|
|
8500
|
-
question = readQuestion(hqRoot, questionId);
|
|
8501
|
-
}
|
|
8502
|
-
catch (err) {
|
|
8503
|
-
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
8504
|
-
throw err;
|
|
8505
|
-
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>");
|
|
8506
|
-
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
|
|
8507
|
-
if (lookup.errors.length > 0) {
|
|
8508
|
-
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
8509
|
-
question_id: questionId,
|
|
8510
|
-
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
8511
|
-
});
|
|
8512
|
-
}
|
|
8513
|
-
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
8514
|
-
if (matches.length === 0) {
|
|
8515
|
-
const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
8516
|
-
const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
|
|
8517
|
-
if (otherCompanyMatch) {
|
|
8518
|
-
assertQuestionCompanyAccess(otherCompanyMatch.question, otherCompanyMatch.lane, company, hqRoot);
|
|
8519
|
-
}
|
|
8520
|
-
if (allCompanyLookup.errors.length > 0) {
|
|
8521
|
-
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
8522
|
-
question_id: questionId,
|
|
8523
|
-
hosts: allCompanyLookup.errors.map(({ host, error }) => ({ host, error })),
|
|
8524
|
-
});
|
|
8525
|
-
}
|
|
8526
|
-
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
8527
|
-
}
|
|
8528
|
-
if (matches.length > 1) {
|
|
8529
|
-
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} is present on more than one Outpost`, { question_id: questionId, owner_count: matches.length });
|
|
8530
|
-
}
|
|
8531
|
-
const owner = matches[0];
|
|
8532
|
-
if (!owner) {
|
|
8533
|
-
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
8534
|
-
}
|
|
8535
|
-
assertQuestionCompanyAccess(owner.question, owner.lane, company, hqRoot);
|
|
8536
|
-
const ownerCompany = await resolveQuestionCompanyWithFallback(hqRoot, owner.lane, async () => explicitCompany ?? company);
|
|
8537
|
-
assertQuestionCompanyResolvedForAnswer(owner.question, owner.lane, hqRoot, ownerCompany);
|
|
8538
|
-
throwIfQuestionNotPending(owner.question);
|
|
8539
|
-
await yieldEventLoop();
|
|
8540
|
-
await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
|
|
8541
|
-
...opts,
|
|
8542
|
-
company: questionListCompanyArg(ownerCompany),
|
|
8543
|
-
});
|
|
8544
|
-
return;
|
|
8545
|
-
}
|
|
8546
|
-
await yieldEventLoop();
|
|
8547
|
-
const askingLane = readLane(hqRoot, laneIdForAnswerDelivery(question));
|
|
8548
|
-
const resolvedCompany = await resolveQuestionCompanyWithFallback(hqRoot, askingLane, async () => explicitCompany ?? question.company ?? await resolveActiveQuestionCompany(ctx, `Cannot answer legacy question ${questionId}: its company cannot be resolved from the question or lane; re-run with --company <slug>`));
|
|
8549
|
-
const requestedCompany = explicitCompany ?? resolvedCompany;
|
|
8550
|
-
assertQuestionCompanyAccess(question, askingLane, requestedCompany, hqRoot);
|
|
8551
|
-
assertQuestionCompanyResolvedForAnswer(question, askingLane, hqRoot, resolvedCompany);
|
|
8552
|
-
const questionCompany = question.company ?? effectiveLaneQuestionCompany(askingLane, hqRoot) ?? resolvedCompany;
|
|
8553
|
-
const questionForBody = question.company === null
|
|
8554
|
-
? { ...question, company: questionCompany }
|
|
8555
|
-
: question;
|
|
8556
|
-
if (askingLane.host.kind === "outpost" && !laneIsHostedHere(ctx, askingLane)) {
|
|
8557
|
-
await dispatchOutpostQuestionAnswer(ctx, askingLane, questionId, {
|
|
8558
|
-
...opts,
|
|
8559
|
-
company: questionListCompanyArg(questionCompany),
|
|
8560
|
-
});
|
|
8561
|
-
return;
|
|
8562
|
-
}
|
|
8563
|
-
throwIfQuestionNotPending(question);
|
|
8564
|
-
if (askingLane.state === "awaiting_input") {
|
|
8565
|
-
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "wake after answering");
|
|
8566
|
-
}
|
|
8567
|
-
if (laneAcceptsDropboxDelivery(askingLane)) {
|
|
8568
|
-
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "deliver an answer to");
|
|
8569
|
-
}
|
|
8570
|
-
const body = requireQuestionBody(hqRoot, questionForBody);
|
|
8571
|
-
// A trimmed-empty --freeform is absent: it must not win over --choice and
|
|
8572
|
-
// must not become a freeform answer of "".
|
|
8573
|
-
const freeform = opts.freeform?.trim() || undefined;
|
|
8574
|
-
if (freeform !== undefined && body.options.length === 0) {
|
|
8575
|
-
throw new LanesError("freeform_not_allowed", "--freeform is only allowed for a question with options", { question_id: questionId });
|
|
8576
|
-
}
|
|
8577
|
-
if (!freeform && !opts.choice) {
|
|
8578
|
-
throw new LanesError("missing_choice", "--choice or --freeform is required", {});
|
|
8579
|
-
}
|
|
8580
|
-
const choice = freeform ?? resolveAnswerChoice(opts.choice, body.options, false);
|
|
8581
|
-
const at = ctx.now().toISOString();
|
|
8582
|
-
const answeredBy = sessionIdFromEnv(ctx.env)
|
|
8583
|
-
? `session:${sessionIdFromEnv(ctx.env)}`
|
|
8584
|
-
: "user";
|
|
8585
|
-
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
8586
|
-
throwIfQuestionNotPending(current);
|
|
8587
|
-
assertQuestionCompanyAccess(current, askingLane, requestedCompany, hqRoot);
|
|
8588
|
-
const recordedCompany = current.company ?? questionCompany;
|
|
8589
|
-
const currentForBody = current.company === null
|
|
8590
|
-
? { ...current, company: recordedCompany }
|
|
8591
|
-
: current;
|
|
8592
|
-
let notesRef;
|
|
8593
|
-
let nextBody = current.body;
|
|
8594
|
-
if (opts.notes) {
|
|
8595
|
-
const stored = writeQuestionBody(hqRoot, currentForBody, {
|
|
8596
|
-
...body,
|
|
8597
|
-
notes: opts.notes,
|
|
8598
|
-
});
|
|
8599
|
-
notesRef = stored.ref;
|
|
8600
|
-
nextBody = stored;
|
|
8601
|
-
}
|
|
8602
|
-
return {
|
|
8603
|
-
...current,
|
|
8604
|
-
company: recordedCompany,
|
|
8605
|
-
body: nextBody,
|
|
8606
|
-
status: "answered",
|
|
8607
|
-
answer: {
|
|
8608
|
-
choice,
|
|
8609
|
-
answer_kind: freeform ? "freeform" : "option",
|
|
8610
|
-
notes_ref: notesRef,
|
|
8611
|
-
answered_by: answeredBy,
|
|
8612
|
-
answered_at: at,
|
|
8613
|
-
answered_level: current.hop,
|
|
8614
|
-
},
|
|
8615
|
-
};
|
|
8616
|
-
});
|
|
8617
|
-
const latestAskingLane = readLane(hqRoot, askingLane.lane_id);
|
|
8618
|
-
if (latestAskingLane.state === "awaiting_input") {
|
|
8619
|
-
let wake;
|
|
8620
|
-
try {
|
|
8621
|
-
wake = await wakeAnsweredLaneNow(hqRoot, askingLane.lane_id, {
|
|
8622
|
-
now: ctx.now,
|
|
8623
|
-
host: reconcileHostFromCtx(ctx),
|
|
8624
|
-
dryRun: false,
|
|
8625
|
-
spawnLaneDeps: {
|
|
8626
|
-
env: ctx.env,
|
|
8627
|
-
existsSync: ctx.existsSync,
|
|
8628
|
-
spawnProcess: ctx.spawnProcess,
|
|
8629
|
-
proveDetached: ctx.proveDetached,
|
|
8630
|
-
killProcess: ctx.killProcess,
|
|
8631
|
-
now: ctx.now,
|
|
8632
|
-
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
8633
|
-
runCompaction: ctx.runCompaction,
|
|
8634
|
-
locateTranscript: ctx.locateTranscript,
|
|
8635
|
-
},
|
|
8636
|
-
throttleDeps: ctx.throttleDeps,
|
|
8637
|
-
livenessDeps: livenessDepsFromCtx(ctx),
|
|
8638
|
-
presenceConnected: ctx.presenceConnected,
|
|
8639
|
-
mesh: ctx.mesh,
|
|
8640
|
-
env: ctx.env,
|
|
8641
|
-
});
|
|
8642
|
-
}
|
|
8643
|
-
catch (err) {
|
|
8644
|
-
throw new LanesError("question_wake_failed", `Question ${questionId} was recorded, but lane ${askingLane.lane_id} could not be woken: ${dispatchErrorMessage(err)}`, { lane_id: askingLane.lane_id, question_id: questionId });
|
|
8645
|
-
}
|
|
8646
|
-
if (wake.status === "failed") {
|
|
8647
|
-
throw new LanesError("question_wake_failed", `Question ${questionId} was recorded, but lane ${askingLane.lane_id} could not be woken: ${wake.error}`, { lane_id: askingLane.lane_id, question_id: questionId, wake_error: wake.error });
|
|
8648
|
-
}
|
|
8649
|
-
printJson({
|
|
8650
|
-
ok: true,
|
|
8651
|
-
action: "questions.answer",
|
|
8652
|
-
lane_id: askingLane.lane_id,
|
|
8653
|
-
question: updated,
|
|
8654
|
-
delivery: wake.status === "awaiting_answers" ? "awaiting_other_answers" : wake.status,
|
|
8655
|
-
wake_status: wake.status,
|
|
8656
|
-
lane_state: wake.lane_state,
|
|
8657
|
-
...(wake.queued_reason !== undefined ? { queued_reason: wake.queued_reason } : {}),
|
|
8658
|
-
...(wake.remaining_question_ids !== undefined
|
|
8659
|
-
? { remaining_question_ids: wake.remaining_question_ids }
|
|
8660
|
-
: {}),
|
|
8661
|
-
});
|
|
8662
|
-
return;
|
|
8663
|
-
}
|
|
8664
|
-
const delivery = deliverAnswerToAskingLane(ctx, updated, choice, at);
|
|
8665
|
-
printJson({
|
|
8666
|
-
ok: true,
|
|
8667
|
-
action: "questions.answer",
|
|
8668
|
-
question: updated,
|
|
8669
|
-
delivery: delivery.status,
|
|
8670
|
-
});
|
|
8671
|
-
}
|
|
8672
|
-
function writeAnswerDeliveryFailed(hqRoot, laneId, questionId, err, at) {
|
|
8673
|
-
const failure = {
|
|
8674
|
-
schema_version: "hq-lane-ask-delivery-failed.v1",
|
|
8675
|
-
at,
|
|
8676
|
-
lane_id: laneId,
|
|
8677
|
-
question_id: questionId,
|
|
8678
|
-
error: err instanceof Error
|
|
8679
|
-
? { name: err.name, message: err.message }
|
|
8680
|
-
: { name: "Error", message: String(err) },
|
|
8681
|
-
};
|
|
8682
|
-
try {
|
|
8683
|
-
writeJsonFile(askDeliveryFailedPath(hqRoot, laneId), failure);
|
|
8684
|
-
}
|
|
8685
|
-
catch (writeErr) {
|
|
8686
|
-
process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${laneId}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
|
|
8687
|
-
}
|
|
8688
|
-
}
|
|
8689
|
-
/**
|
|
8690
|
-
* Record is already durable. Delivery is best-effort onto the asking lane's
|
|
8691
|
-
* drop box (spec §6.5). Target is question.lane_id, never a project/story lookup.
|
|
8692
|
-
*/
|
|
8693
|
-
function deliverAnswerToAskingLane(ctx, question, choice, at) {
|
|
8694
|
-
const hqRoot = ctx.hqRoot();
|
|
8695
|
-
const laneId = laneIdForAnswerDelivery(question);
|
|
8696
|
-
try {
|
|
8697
|
-
const lane = readLane(hqRoot, laneId);
|
|
8698
|
-
if (!laneAcceptsDropboxDelivery(lane)) {
|
|
8699
|
-
return { status: "recorded_only" };
|
|
8700
|
-
}
|
|
8701
|
-
assertLaneRunsHereForOperatorMutation(ctx, lane, "deliver an answer to");
|
|
8702
|
-
appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
|
|
8703
|
-
return { status: "awaiting_next_tool_call" };
|
|
8704
|
-
}
|
|
8705
|
-
catch (err) {
|
|
8706
|
-
writeAnswerDeliveryFailed(hqRoot, laneId, question.question_id, err, at);
|
|
8707
|
-
return { status: "failed" };
|
|
8708
|
-
}
|
|
8709
|
-
}
|
|
8710
|
-
async function runQuestionsEscalate(ctx, questionId, opts) {
|
|
8711
|
-
const hqRoot = ctx.hqRoot();
|
|
8712
|
-
const question = readQuestion(hqRoot, questionId);
|
|
8713
|
-
throwIfQuestionNotPending(question);
|
|
8714
|
-
await yieldEventLoop();
|
|
8715
|
-
const originalHop = question.hop;
|
|
8716
|
-
const audience = resolveAudience(hqRoot, question, ctx);
|
|
8717
|
-
if (audience.kind === "user") {
|
|
8718
|
-
throw new LanesError("already_at_top_authority", "Cannot escalate past the user", { question_id: questionId });
|
|
8719
|
-
}
|
|
8720
|
-
const at = ctx.now().toISOString();
|
|
8721
|
-
if (opts.note) {
|
|
8722
|
-
requireQuestionBody(hqRoot, question);
|
|
8723
|
-
}
|
|
8724
|
-
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
8725
|
-
throwIfQuestionNotPending(current);
|
|
8726
|
-
if (current.hop !== originalHop) {
|
|
8727
|
-
throw new LanesError("question_already_escalated", `Question ${questionId} hop already advanced`, {
|
|
8728
|
-
question_id: questionId,
|
|
8729
|
-
hop: current.hop,
|
|
8730
|
-
expected_hop: originalHop,
|
|
8731
|
-
});
|
|
8732
|
-
}
|
|
8733
|
-
let nextBody = current.body;
|
|
8734
|
-
if (opts.note) {
|
|
8735
|
-
const body = requireQuestionBody(hqRoot, current);
|
|
8736
|
-
nextBody = writeQuestionBody(hqRoot, current, {
|
|
8737
|
-
...body,
|
|
8738
|
-
notes: [body.notes, opts.note].filter(Boolean).join("\n"),
|
|
8739
|
-
});
|
|
8740
|
-
}
|
|
8741
|
-
const nextHop = current.hop + 1;
|
|
8742
|
-
return {
|
|
8743
|
-
...current,
|
|
8744
|
-
body: nextBody,
|
|
8745
|
-
hop: nextHop,
|
|
8746
|
-
routing_history: appendCappedHistory(current.routing_history, {
|
|
8747
|
-
hop: nextHop,
|
|
8748
|
-
audience: resolveAudience(hqRoot, { ...current, hop: nextHop }, ctx),
|
|
8749
|
-
at,
|
|
8750
|
-
reason: "escalated",
|
|
8751
|
-
}),
|
|
8752
|
-
};
|
|
8753
|
-
});
|
|
8754
|
-
const nextAudience = resolveAudience(hqRoot, updated, ctx);
|
|
8755
|
-
if (nextAudience.kind === "user") {
|
|
8756
|
-
const lane = readLane(hqRoot, updated.lane_id);
|
|
8757
|
-
await emitLaneMeshEvent(ctx, lane.mesh_company ?? lane.company, lane.thread_id, "lane_question_at_user", { lane_id: lane.lane_id, question_id: updated.question_id });
|
|
8758
|
-
}
|
|
8759
|
-
printJson({ ok: true, action: "questions.escalate", question: updated });
|
|
8760
|
-
}
|
|
8761
|
-
async function runQuestionsCancel(ctx, questionId, opts) {
|
|
8762
|
-
const hqRoot = ctx.hqRoot();
|
|
8763
|
-
const question = readQuestion(hqRoot, questionId);
|
|
8764
|
-
throwIfQuestionNotPending(question);
|
|
8765
|
-
await yieldEventLoop();
|
|
8766
|
-
const at = ctx.now().toISOString();
|
|
8767
|
-
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
8768
|
-
throwIfQuestionNotPending(current);
|
|
8769
|
-
const entry = {
|
|
8770
|
-
hop: current.hop,
|
|
8771
|
-
audience: resolveAudience(hqRoot, current, ctx),
|
|
8772
|
-
at,
|
|
8773
|
-
reason: "cancelled",
|
|
8774
|
-
...(opts.reason ? { detail: opts.reason } : {}),
|
|
8775
|
-
};
|
|
8776
|
-
return {
|
|
8777
|
-
...current,
|
|
8778
|
-
status: "cancelled",
|
|
8779
|
-
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
8780
|
-
};
|
|
8781
|
-
});
|
|
8782
|
-
printJson({ ok: true, action: "questions.cancel", question: updated });
|
|
8783
|
-
}
|
|
8784
|
-
async function runAccountsList(ctx, opts) {
|
|
8785
|
-
const hqRoot = ctx.hqRoot();
|
|
8786
|
-
const config = loadLanesConfig(hqRoot);
|
|
8787
|
-
const providers = opts.provider
|
|
8788
|
-
? [parseLaneProvider(opts.provider)]
|
|
8789
|
-
: [...LANE_PROVIDERS];
|
|
8790
|
-
// model and effort are what a spawn on each account would start from
|
|
8791
|
-
// (spec: --model/--effort, then the account, then the provider).
|
|
8792
|
-
const quota = loadQuotaState(hqRoot);
|
|
8793
|
-
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config, {
|
|
8794
|
-
existsSync: ctx.existsSync,
|
|
8795
|
-
hostname: ctx.hostname(),
|
|
8796
|
-
probe: ctx.probeProvider,
|
|
8797
|
-
env: ctx.env,
|
|
8798
|
-
}).map((status) => {
|
|
8799
|
-
const accountCfg = config.providers[provider].accounts[status.id];
|
|
8800
|
-
const model = resolveLaneModel({
|
|
8801
|
-
accountModel: accountCfg?.model,
|
|
8802
|
-
providerModel: config.providers[provider].model,
|
|
8803
|
-
});
|
|
8804
|
-
const effort = resolveLaneEffort({
|
|
8805
|
-
accountEffort: accountCfg?.effort,
|
|
8806
|
-
providerEffort: config.providers[provider].effort,
|
|
8807
|
-
model,
|
|
8808
|
-
});
|
|
8809
|
-
return {
|
|
8810
|
-
...status,
|
|
8811
|
-
effective_home: status.home,
|
|
8812
|
-
model: accountCfg?.model ?? null,
|
|
8813
|
-
effort: accountCfg?.effort ?? null,
|
|
8814
|
-
effective_model: model ?? null,
|
|
8815
|
-
effective_effort: effort.effort ?? null,
|
|
8816
|
-
effective_effort_source: effort.source,
|
|
8817
|
-
...accountQuotaView(quota, provider, status.id),
|
|
8818
|
-
};
|
|
8819
|
-
}));
|
|
8820
|
-
const providerDefaults = providers.map((provider) => ({
|
|
8821
|
-
provider,
|
|
8822
|
-
model: config.providers[provider].model ?? null,
|
|
8823
|
-
effort: config.providers[provider].effort ?? null,
|
|
8824
|
-
compact_after_ms: providerCompactAfterMs(config.providers[provider]),
|
|
8825
|
-
}));
|
|
8826
|
-
printJson({
|
|
8827
|
-
ok: true,
|
|
8828
|
-
action: "accounts.list",
|
|
8829
|
-
providers: providerDefaults,
|
|
8830
|
-
accounts,
|
|
8831
|
-
});
|
|
8832
|
-
}
|
|
8833
|
-
async function runAccountsBalance(ctx, opts) {
|
|
8834
|
-
const provider = parseLaneProvider(opts.provider ?? "");
|
|
8835
|
-
if (provider !== "claude") {
|
|
8836
|
-
throw new LanesError("claude_account_balance_provider_unsupported", "Account balance is available only for --provider claude", {
|
|
8837
|
-
provider,
|
|
8838
|
-
accepted: ["claude"],
|
|
8839
|
-
});
|
|
8840
|
-
}
|
|
8841
|
-
const hqRoot = ctx.hqRoot();
|
|
8842
|
-
const config = loadLanesConfig(hqRoot);
|
|
8843
|
-
const loaded = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
|
|
8844
|
-
// Retain the established response field for clients; balancing is now
|
|
8845
|
-
// unconditional, so this compatibility value is always true.
|
|
8846
|
-
const balanceGateEnabled = true;
|
|
8847
|
-
const loads = accountLoadsForReport({ hosts: loaded.snapshots, config });
|
|
8848
|
-
const suggestions = planClaudeBalanceMoves({ hosts: loaded.snapshots, config });
|
|
8849
|
-
const selections = loaded.snapshots.map((host) => {
|
|
8850
|
-
try {
|
|
8851
|
-
const selection = selectClaudeAccount({
|
|
8852
|
-
hosts: loaded.snapshots,
|
|
8853
|
-
targetHost: host.capacity.host,
|
|
8854
|
-
config,
|
|
8855
|
-
unavailableHosts: loaded.unavailableHosts,
|
|
8856
|
-
});
|
|
8857
|
-
return {
|
|
8858
|
-
target_host: host.capacity.host,
|
|
8859
|
-
account: selection.account,
|
|
8860
|
-
signal: selection.signal,
|
|
8861
|
-
skipped_accounts: selection.skipped_accounts,
|
|
8862
|
-
};
|
|
8863
|
-
}
|
|
8864
|
-
catch (err) {
|
|
8865
|
-
return {
|
|
8866
|
-
target_host: host.capacity.host,
|
|
8867
|
-
error: isLanesError(err) ? err.error : "claude_account_balance_selection_failed",
|
|
8868
|
-
message: dispatchErrorMessage(err),
|
|
8869
|
-
};
|
|
8870
|
-
}
|
|
8871
|
-
});
|
|
8872
|
-
const accounts = Object.keys(config.providers.claude.accounts).sort().map((account) => {
|
|
8873
|
-
const load = loads.find((row) => row.account === account);
|
|
8874
|
-
return {
|
|
8875
|
-
account,
|
|
8876
|
-
enabled: config.providers.claude.accounts[account]?.enabled === true,
|
|
8877
|
-
live_lanes: load?.live_lanes ?? 0,
|
|
8878
|
-
counts_complete: load?.counts_complete ?? false,
|
|
8879
|
-
hosts: loaded.snapshots.map((host) => {
|
|
8880
|
-
const accountStatus = host.capacity.providers.claude.accounts[account];
|
|
8881
|
-
const accountLoad = load?.hosts.find((row) => row.host.kind === host.capacity.host.kind && row.host.id === host.capacity.host.id);
|
|
8882
|
-
return {
|
|
8883
|
-
host: host.capacity.host,
|
|
8884
|
-
enabled: accountStatus?.enabled ?? false,
|
|
8885
|
-
logged_in: accountStatus?.logged_in ?? null,
|
|
8886
|
-
live_lanes: accountLoad?.live_lanes ?? null,
|
|
8887
|
-
lane_counts: accountLoad?.lane_counts ?? { running: null, queued: null, awaiting_input: null },
|
|
8888
|
-
count_source: accountLoad?.count_source ?? "unavailable",
|
|
8889
|
-
usage: accountStatus?.usage ?? null,
|
|
8890
|
-
};
|
|
8891
|
-
}),
|
|
8892
|
-
};
|
|
8893
|
-
});
|
|
8894
|
-
const moved = [];
|
|
8895
|
-
const skipped = [];
|
|
8896
|
-
if (opts.apply) {
|
|
8897
|
-
for (const suggestion of suggestions) {
|
|
8898
|
-
if (!suggestion.apply) {
|
|
8899
|
-
skipped.push({
|
|
8900
|
-
lane_id: suggestion.lane_id,
|
|
8901
|
-
host: suggestion.host,
|
|
8902
|
-
from: suggestion.from,
|
|
8903
|
-
to: suggestion.to,
|
|
8904
|
-
reason: suggestion.skip_reason ?? "not_movable",
|
|
8905
|
-
});
|
|
8906
|
-
continue;
|
|
8907
|
-
}
|
|
8908
|
-
try {
|
|
8909
|
-
moved.push(await applyClaudeBalanceMove(ctx, suggestion));
|
|
8910
|
-
}
|
|
8911
|
-
catch (err) {
|
|
8912
|
-
skipped.push({
|
|
8913
|
-
lane_id: suggestion.lane_id,
|
|
8914
|
-
host: suggestion.host,
|
|
8915
|
-
from: suggestion.from,
|
|
8916
|
-
to: suggestion.to,
|
|
8917
|
-
reason: isLanesError(err) ? `${err.error}: ${err.message}` : dispatchErrorMessage(err),
|
|
8918
|
-
});
|
|
8919
|
-
}
|
|
8920
|
-
}
|
|
8921
|
-
}
|
|
8922
|
-
printJson({
|
|
8923
|
-
ok: true,
|
|
8924
|
-
action: "accounts.balance",
|
|
8925
|
-
provider: "claude",
|
|
8926
|
-
balance_gate_enabled: balanceGateEnabled,
|
|
8927
|
-
accounts,
|
|
8928
|
-
selections,
|
|
8929
|
-
unavailable_hosts: loaded.unavailableHosts,
|
|
8930
|
-
suggestions,
|
|
8931
|
-
...(opts.apply ? { moved, skipped } : {}),
|
|
8932
|
-
});
|
|
8933
|
-
}
|
|
8934
|
-
async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
8935
|
-
const callerCwd = ctx.callerCwd();
|
|
8936
|
-
const hqRoot = ctx.hqRoot();
|
|
8937
|
-
const provider = parseLaneProvider(providerRaw);
|
|
8938
|
-
const config = loadLanesConfig(hqRoot);
|
|
8939
|
-
const id = accountId.trim();
|
|
8940
|
-
if (!id) {
|
|
8941
|
-
throw new LanesError("invalid_account", "account-id must be non-empty", {});
|
|
8942
|
-
}
|
|
8943
|
-
if (opts.deviceAuth && provider !== "codex") {
|
|
8944
|
-
throw new LanesError("device_auth_unsupported", "--device-auth is supported only for codex account login", { provider });
|
|
8945
|
-
}
|
|
8946
|
-
const registeredAccount = config.providers[provider].accounts[id];
|
|
8947
|
-
const hostname = ctx.hostname().trim();
|
|
8948
|
-
if (!hostname) {
|
|
8949
|
-
throw new LanesError("host_identity_unknown", "Cannot resolve this host's hostname for account login", {});
|
|
8950
|
-
}
|
|
8951
|
-
const requestedHome = opts.home?.trim();
|
|
8952
|
-
if (opts.home !== undefined && !requestedHome) {
|
|
8953
|
-
throw new LanesError("invalid_account_home", "--home must be a non-empty path", { provider, account: id });
|
|
8954
|
-
}
|
|
8955
|
-
if (opts.company !== undefined) {
|
|
8956
|
-
await resolveSelectedAccountHomeCompany(ctx, opts.company);
|
|
8957
|
-
}
|
|
8958
|
-
const homeRaw = requestedHome ??
|
|
8959
|
-
(registeredAccount !== undefined
|
|
8960
|
-
? effectiveAccountHome(registeredAccount, hostname).home
|
|
8961
|
-
: id === "a"
|
|
8962
|
-
? DEFAULT_ACCOUNT_HOMES[provider]
|
|
8963
|
-
: `~/.${provider}-${id}`);
|
|
8964
|
-
const homeResolved = homeRaw.startsWith("~") || path.isAbsolute(homeRaw)
|
|
8965
|
-
? homeRaw
|
|
8966
|
-
: resolveCallerPath(callerCwd, homeRaw);
|
|
8967
|
-
const home = canonicalizeHome(homeResolved, {
|
|
8968
|
-
homedir: ctx.homedir,
|
|
8969
|
-
realpathSync: (p) => fs.realpathSync(p),
|
|
8970
|
-
existsSync: (p) => fs.existsSync(p),
|
|
8971
|
-
});
|
|
8972
|
-
const nextAccount = registeredAccount === undefined
|
|
8973
|
-
? { home, enabled: true }
|
|
8974
|
-
: { ...registeredAccount, enabled: true };
|
|
8975
|
-
if (registeredAccount !== undefined && requestedHome !== undefined) {
|
|
8976
|
-
if (home === registeredAccount.home) {
|
|
8977
|
-
if (nextAccount.hosts !== undefined) {
|
|
8978
|
-
delete nextAccount.hosts[hostname];
|
|
8979
|
-
if (Object.keys(nextAccount.hosts).length === 0)
|
|
8980
|
-
delete nextAccount.hosts;
|
|
8981
|
-
}
|
|
8982
|
-
}
|
|
8983
|
-
else {
|
|
8984
|
-
nextAccount.hosts = {
|
|
8985
|
-
...(registeredAccount.hosts ?? {}),
|
|
8986
|
-
[hostname]: { home },
|
|
8987
|
-
};
|
|
8988
|
-
}
|
|
8989
|
-
}
|
|
8990
|
-
delete nextAccount.disabled_reason;
|
|
8991
|
-
assertUniqueAccountHomes(provider, {
|
|
8992
|
-
...config.providers[provider].accounts,
|
|
8993
|
-
[id]: nextAccount,
|
|
8994
|
-
});
|
|
8995
|
-
ensureDir(home);
|
|
8996
|
-
const defaultId = config.providers[provider].default_account;
|
|
8997
|
-
const defaultAccount = defaultId !== undefined
|
|
8998
|
-
? config.providers[provider].accounts[defaultId]
|
|
8999
|
-
: Object.values(config.providers[provider].accounts).find((account) => account.enabled);
|
|
9000
|
-
const defaultHome = (defaultAccount !== undefined
|
|
9001
|
-
? effectiveAccountHome(defaultAccount, hostname).home
|
|
9002
|
-
: undefined) ??
|
|
9003
|
-
canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], {
|
|
9004
|
-
homedir: ctx.homedir,
|
|
9005
|
-
realpathSync: (p) => fs.realpathSync(p),
|
|
9006
|
-
existsSync: (p) => fs.existsSync(p),
|
|
9007
|
-
});
|
|
9008
|
-
const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
|
|
9009
|
-
installDrainHook(provider, home);
|
|
9010
|
-
const envVar = PROVIDER_HOME_ENV[provider];
|
|
9011
|
-
const login = VENDOR_LOGIN[provider];
|
|
9012
|
-
const loginArgs = provider === "codex" && opts.deviceAuth
|
|
9013
|
-
? [...login.args, "--device-auth"]
|
|
9014
|
-
: login.args;
|
|
9015
|
-
const result = await ctx.spawnLogin({
|
|
9016
|
-
command: login.command,
|
|
9017
|
-
args: loginArgs,
|
|
9018
|
-
env: { ...ctx.env, [envVar]: home },
|
|
9019
|
-
cwd: home,
|
|
9020
|
-
});
|
|
9021
|
-
if (result.status !== 0) {
|
|
9022
|
-
throw new LanesError("login_failed", `${login.command} ${loginArgs.join(" ")} exited ${result.status}`, { provider, account: id, status: result.status });
|
|
9023
|
-
}
|
|
9024
|
-
const grokNeedsRails = provider === "grok";
|
|
9025
|
-
if ((grokNeedsRails || copied) && !railsPresent(home)) {
|
|
9026
|
-
throw new LanesError("rails_missing", `Hook rails are missing under ${home}; account not enabled`, { provider, account: id, home });
|
|
9027
|
-
}
|
|
9028
|
-
config.providers[provider].accounts[id] = nextAccount;
|
|
9029
|
-
const status = accountStatus(provider, id, config, {
|
|
9030
|
-
existsSync: ctx.existsSync,
|
|
9031
|
-
hostname,
|
|
9032
|
-
probe: ctx.probeProvider,
|
|
9033
|
-
env: ctx.env,
|
|
9034
|
-
});
|
|
9035
|
-
if (status.logged_in !== true) {
|
|
9036
|
-
throw new LanesError(status.logged_in === false ? "account_not_logged_in" : "account_login_status_unknown", status.logged_in === false
|
|
9037
|
-
? `Account ${id} for ${provider} is not logged in`
|
|
9038
|
-
: `Could not verify that account ${id} for ${provider} is logged in`, { provider, account: id, home });
|
|
9039
|
-
}
|
|
9040
|
-
writeLanesConfig(hqRoot, config);
|
|
9041
|
-
printJson({ ok: true, action: "accounts.login", account: status });
|
|
9042
|
-
}
|
|
9043
|
-
async function runAccountsSetHome(ctx, providerRaw, accountIdRaw, homeRaw, opts) {
|
|
9044
|
-
if (opts.company !== undefined)
|
|
9045
|
-
await resolveSelectedAccountHomeCompany(ctx, opts.company);
|
|
9046
|
-
const callerCwd = ctx.callerCwd();
|
|
9047
|
-
const hqRoot = ctx.hqRoot();
|
|
9048
|
-
const provider = parseLaneProvider(providerRaw);
|
|
9049
|
-
const id = accountIdRaw.trim();
|
|
9050
|
-
if (!id) {
|
|
9051
|
-
throw new AccountResolutionError("unknown_account", "account-id must be non-empty", {
|
|
9052
|
-
provider,
|
|
9053
|
-
account: accountIdRaw,
|
|
9054
|
-
enabled: [],
|
|
9055
|
-
});
|
|
9056
|
-
}
|
|
9057
|
-
const hostname = opts.host === undefined ? ctx.hostname().trim() : opts.host.trim();
|
|
9058
|
-
if (!hostname || (opts.host !== undefined && hostname !== opts.host)) {
|
|
9059
|
-
throw new LanesError("invalid_host", "--host must be a non-empty hostname without surrounding whitespace", {
|
|
9060
|
-
host: opts.host ?? "",
|
|
9061
|
-
});
|
|
9062
|
-
}
|
|
9063
|
-
if (!homeRaw.trim()) {
|
|
9064
|
-
throw new LanesError("invalid_account_home", "home path must be a non-empty path", {
|
|
9065
|
-
provider,
|
|
9066
|
-
account: id,
|
|
9067
|
-
});
|
|
9068
|
-
}
|
|
9069
|
-
const config = loadLanesConfig(hqRoot);
|
|
9070
|
-
const account = config.providers[provider].accounts[id];
|
|
9071
|
-
if (account === undefined) {
|
|
9072
|
-
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(id)} for ${provider}`, {
|
|
9073
|
-
provider,
|
|
9074
|
-
account: id,
|
|
9075
|
-
enabled: Object.keys(config.providers[provider].accounts).filter((key) => config.providers[provider].accounts[key]?.enabled),
|
|
9076
|
-
});
|
|
9077
|
-
}
|
|
9078
|
-
const homePath = homeRaw.trim();
|
|
9079
|
-
const isCurrentHost = hostname === ctx.hostname().trim();
|
|
9080
|
-
let home;
|
|
9081
|
-
if (isCurrentHost) {
|
|
9082
|
-
const homeResolved = homePath.startsWith("~") || path.isAbsolute(homePath)
|
|
9083
|
-
? homePath
|
|
9084
|
-
: resolveCallerPath(callerCwd, homePath);
|
|
9085
|
-
home = canonicalizeHome(homeResolved, {
|
|
9086
|
-
homedir: ctx.homedir,
|
|
9087
|
-
realpathSync: (p) => fs.realpathSync(p),
|
|
9088
|
-
existsSync: (p) => fs.existsSync(p),
|
|
9089
|
-
});
|
|
9090
|
-
}
|
|
9091
|
-
else if (homePath === "~" || homePath.startsWith("~/")) {
|
|
9092
|
-
home = homePath;
|
|
9093
|
-
}
|
|
9094
|
-
else if (path.isAbsolute(homePath)) {
|
|
9095
|
-
home = path.normalize(homePath);
|
|
9096
|
-
}
|
|
9097
|
-
else {
|
|
9098
|
-
throw new LanesError("invalid_remote_account_home", "A home for another host must be an absolute path or start with ~/", { provider, account: id, hostname, home: homePath });
|
|
9099
|
-
}
|
|
9100
|
-
for (const [otherId, other] of Object.entries(config.providers[provider].accounts)) {
|
|
9101
|
-
if (otherId !== id && effectiveAccountHome(other, hostname).home === home) {
|
|
9102
|
-
throw new LanesError("duplicate_account_home", `Home ${home} is already registered as ${provider} account ${otherId} on host ${hostname}`, { provider, accounts: [otherId, id], home, hostname });
|
|
9103
|
-
}
|
|
9104
|
-
}
|
|
9105
|
-
if (home === account.home) {
|
|
9106
|
-
if (account.hosts !== undefined) {
|
|
9107
|
-
delete account.hosts[hostname];
|
|
9108
|
-
if (Object.keys(account.hosts).length === 0)
|
|
9109
|
-
delete account.hosts;
|
|
9110
|
-
}
|
|
9111
|
-
}
|
|
9112
|
-
else {
|
|
9113
|
-
const hosts = Object.assign(Object.create(null), account.hosts ?? {});
|
|
9114
|
-
hosts[hostname] = { home };
|
|
9115
|
-
account.hosts = hosts;
|
|
9116
|
-
}
|
|
9117
|
-
assertUniqueAccountHomes(provider, config.providers[provider].accounts);
|
|
9118
|
-
writeLanesConfig(hqRoot, config);
|
|
9119
|
-
printJson({
|
|
9120
|
-
ok: true,
|
|
9121
|
-
action: "accounts.set-home",
|
|
9122
|
-
provider,
|
|
9123
|
-
account: id,
|
|
9124
|
-
hostname,
|
|
9125
|
-
home,
|
|
9126
|
-
home_source: home === account.home ? "default" : "host",
|
|
9127
|
-
});
|
|
9128
|
-
}
|
|
9129
|
-
async function runAccountsEnable(ctx, providerRaw, accountId) {
|
|
9130
|
-
const hqRoot = ctx.hqRoot();
|
|
9131
|
-
const provider = parseLaneProvider(providerRaw);
|
|
9132
|
-
const config = loadLanesConfig(hqRoot);
|
|
9133
|
-
const account = config.providers[provider].accounts[accountId];
|
|
9134
|
-
if (!account) {
|
|
9135
|
-
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(accountId)} for ${provider}`, {
|
|
9136
|
-
provider,
|
|
9137
|
-
account: accountId,
|
|
9138
|
-
enabled: Object.entries(config.providers[provider].accounts)
|
|
9139
|
-
.filter(([, a]) => a.enabled)
|
|
9140
|
-
.map(([id]) => id),
|
|
9141
|
-
});
|
|
9142
|
-
}
|
|
9143
|
-
const status = accountStatus(provider, accountId, config, {
|
|
9144
|
-
existsSync: ctx.existsSync,
|
|
9145
|
-
hostname: ctx.hostname(),
|
|
9146
|
-
probe: ctx.probeProvider,
|
|
9147
|
-
env: ctx.env,
|
|
9148
|
-
});
|
|
9149
|
-
if (status.logged_in !== true) {
|
|
9150
|
-
throw new LanesError(status.logged_in === false ? "account_not_logged_in" : "account_login_status_unknown", status.logged_in === false
|
|
9151
|
-
? `Account ${accountId} for ${provider} is not logged in`
|
|
9152
|
-
: `Could not verify that account ${accountId} for ${provider} is logged in`, { provider, account: accountId });
|
|
9153
|
-
}
|
|
9154
|
-
account.enabled = true;
|
|
9155
|
-
delete account.disabled_reason;
|
|
9156
|
-
writeLanesConfig(hqRoot, config);
|
|
9157
|
-
printJson({
|
|
9158
|
-
ok: true,
|
|
9159
|
-
action: "accounts.enable",
|
|
9160
|
-
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot), {
|
|
9161
|
-
existsSync: ctx.existsSync,
|
|
9162
|
-
hostname: ctx.hostname(),
|
|
9163
|
-
probe: ctx.probeProvider,
|
|
9164
|
-
env: ctx.env,
|
|
9165
|
-
}),
|
|
9166
|
-
});
|
|
9167
|
-
}
|
|
9168
|
-
async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
|
|
9169
|
-
const hqRoot = ctx.hqRoot();
|
|
9170
|
-
const provider = parseLaneProvider(providerRaw);
|
|
9171
|
-
const config = loadLanesConfig(hqRoot);
|
|
9172
|
-
const account = config.providers[provider].accounts[accountId];
|
|
9173
|
-
if (!account) {
|
|
9174
|
-
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(accountId)} for ${provider}`, {
|
|
9175
|
-
provider,
|
|
9176
|
-
account: accountId,
|
|
9177
|
-
enabled: Object.entries(config.providers[provider].accounts)
|
|
9178
|
-
.filter(([, a]) => a.enabled)
|
|
9179
|
-
.map(([id]) => id),
|
|
9180
|
-
});
|
|
9181
|
-
}
|
|
9182
|
-
account.enabled = false;
|
|
9183
|
-
if (opts.reason)
|
|
9184
|
-
account.disabled_reason = opts.reason;
|
|
9185
|
-
writeLanesConfig(hqRoot, config);
|
|
9186
|
-
printJson({
|
|
9187
|
-
ok: true,
|
|
9188
|
-
action: "accounts.disable",
|
|
9189
|
-
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot), {
|
|
9190
|
-
existsSync: ctx.existsSync,
|
|
9191
|
-
hostname: ctx.hostname(),
|
|
9192
|
-
probe: ctx.probeProvider,
|
|
9193
|
-
env: ctx.env,
|
|
9194
|
-
}),
|
|
9195
|
-
});
|
|
9196
|
-
}
|
|
9197
|
-
async function runSessionsList(ctx, opts) {
|
|
9198
|
-
const hqRoot = ctx.hqRoot();
|
|
9199
|
-
const state = opts.state ? parseLivenessState(opts.state) : undefined;
|
|
9200
|
-
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
9201
|
-
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
9202
|
-
const rows = listSessionLiveness(hqRoot, {
|
|
9203
|
-
nowMs: ctx.now().getTime(),
|
|
9204
|
-
presenceConnected,
|
|
9205
|
-
deps: livenessDeps,
|
|
9206
|
-
}).filter((row) => {
|
|
9207
|
-
if (state && row.state !== state)
|
|
9208
|
-
return false;
|
|
9209
|
-
if (!opts.company)
|
|
9210
|
-
return true;
|
|
9211
|
-
const meta = readSessionMeta(hqRoot, row.sessionId, ctx);
|
|
9212
|
-
const slug = typeof meta?.company_slug === "string" ? meta.company_slug : "";
|
|
9213
|
-
const uid = typeof meta?.company_uid === "string" ? meta.company_uid : "";
|
|
9214
|
-
return slug === opts.company || uid === opts.company;
|
|
9215
|
-
});
|
|
9216
|
-
printJson({ ok: true, action: "sessions.list", sessions: rows });
|
|
9217
|
-
}
|
|
9218
|
-
async function runSessionsShow(ctx, sessionId) {
|
|
9219
|
-
const hqRoot = ctx.hqRoot();
|
|
9220
|
-
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
9221
|
-
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
9222
|
-
const state = sessionLiveness(hqRoot, sessionId, {
|
|
9223
|
-
nowMs: ctx.now().getTime(),
|
|
9224
|
-
presenceConnected,
|
|
9225
|
-
deps: livenessDeps,
|
|
9226
|
-
});
|
|
9227
|
-
if (state === null) {
|
|
9228
|
-
throw new LanesError("session_not_found", `Session ${sessionId} is not in the liveness index`, { session_id: sessionId });
|
|
9229
|
-
}
|
|
9230
|
-
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
9231
|
-
printJson({
|
|
9232
|
-
ok: true,
|
|
9233
|
-
action: "sessions.show",
|
|
9234
|
-
session_id: sessionId,
|
|
9235
|
-
state,
|
|
9236
|
-
senior: meta ? seniorOfSession(hqRoot, sessionId, ctx) : { kind: "user" },
|
|
9237
|
-
company_slug: typeof meta?.company_slug === "string" ? meta.company_slug : null,
|
|
9238
|
-
});
|
|
9239
|
-
}
|
|
9240
|
-
async function runReconcile(ctx, opts) {
|
|
9241
|
-
const fromEnv = ctx.env.HQ_ROOT?.trim();
|
|
9242
|
-
const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
|
|
9243
|
-
const summary = await runReconcilePass(hqRoot, {
|
|
9244
|
-
now: ctx.now,
|
|
9245
|
-
host: reconcileHostFromCtx(ctx),
|
|
9246
|
-
dryRun: Boolean(opts.dryRun),
|
|
9247
|
-
spawnLaneDeps: {
|
|
9248
|
-
env: ctx.env,
|
|
9249
|
-
modelDeps: {
|
|
9250
|
-
readFileSync: ctx.readFileSync,
|
|
9251
|
-
probe: ctx.probeProvider,
|
|
9252
|
-
env: ctx.env,
|
|
9253
|
-
},
|
|
9254
|
-
existsSync: ctx.existsSync,
|
|
9255
|
-
spawnProcess: ctx.spawnProcess,
|
|
9256
|
-
proveDetached: ctx.proveDetached,
|
|
9257
|
-
killProcess: ctx.killProcess,
|
|
9258
|
-
now: ctx.now,
|
|
9259
|
-
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
9260
|
-
runCompaction: ctx.runCompaction,
|
|
9261
|
-
locateTranscript: ctx.locateTranscript,
|
|
9262
|
-
},
|
|
9263
|
-
throttleDeps: ctx.throttleDeps,
|
|
9264
|
-
livenessDeps: livenessDepsFromCtx(ctx),
|
|
9265
|
-
presenceConnected: ctx.presenceConnected,
|
|
9266
|
-
resolveQuestionActiveCompany: () => resolveActiveQuestionCompany(ctx),
|
|
9267
|
-
recoverQueuedMessages: async (lane) => {
|
|
9268
|
-
await recoverQueuedMessages(ctx, lane.lane_id);
|
|
9269
|
-
},
|
|
9270
|
-
mesh: ctx.mesh,
|
|
9271
|
-
env: ctx.env,
|
|
9272
|
-
});
|
|
9273
|
-
printJson(summary);
|
|
9274
|
-
if (!summary.ok) {
|
|
9275
|
-
process.exit(1);
|
|
9276
|
-
}
|
|
9277
|
-
}
|
|
9278
|
-
async function runReconcileInstall(ctx, opts) {
|
|
9279
|
-
// `hq daemon` runs lanes-reconcile on this machine; a separate timer would run it twice.
|
|
9280
|
-
const handedOff = opts.systemdRoot ? null : handOffInstallHere(["lanes-reconcile"]);
|
|
9281
|
-
if (handedOff) {
|
|
9282
|
-
printJson({ ok: true, action: "hq-daemon", message: handedOff });
|
|
7922
|
+
async function runReconcileInstall(ctx, opts) {
|
|
7923
|
+
// `hq daemon` runs lanes-reconcile on this machine; a separate timer would run it twice.
|
|
7924
|
+
const handedOff = opts.systemdRoot ? null : handOffInstallHere(["lanes-reconcile"]);
|
|
7925
|
+
if (handedOff) {
|
|
7926
|
+
printJson({ ok: true, action: "hq-daemon", message: handedOff });
|
|
9283
7927
|
return;
|
|
9284
7928
|
}
|
|
9285
7929
|
const callerCwd = ctx.callerCwd();
|
|
@@ -9336,6 +7980,53 @@ function attachJsonNoop(cmd) {
|
|
|
9336
7980
|
}
|
|
9337
7981
|
export function registerLanesCommand(parent, deps = {}) {
|
|
9338
7982
|
const ctx = resolveDeps(deps);
|
|
7983
|
+
const accountHandlers = createLaneAccountsHandlers({
|
|
7984
|
+
automaticBalanceMoveHistoryReason,
|
|
7985
|
+
ensureDir,
|
|
7986
|
+
laneWithRecoveredProviderSession,
|
|
7987
|
+
loadClaudeBalanceHostSnapshots,
|
|
7988
|
+
parseRemoteJson,
|
|
7989
|
+
prepareAccountMove,
|
|
7990
|
+
printJson,
|
|
7991
|
+
reconcileHostFromCtx,
|
|
7992
|
+
resolveCallerPath,
|
|
7993
|
+
});
|
|
7994
|
+
const questionHandlers = createLaneQuestionsHandlers({
|
|
7995
|
+
CAPACITY_PROBE_TIMEOUT_MS,
|
|
7996
|
+
MAX_PLACEMENT_OUTPOSTS,
|
|
7997
|
+
OUTPOST_QUESTION_LOOKUP_CONCURRENCY,
|
|
7998
|
+
assertLaneRunsHereForOperatorMutation,
|
|
7999
|
+
assertQuestionCompanyMatchesLane,
|
|
8000
|
+
casUpdateQuestion,
|
|
8001
|
+
effectiveLaneQuestionCompany,
|
|
8002
|
+
emitLaneMeshEvent,
|
|
8003
|
+
groupQuestionLanesByCompany,
|
|
8004
|
+
isQuestionAnswer,
|
|
8005
|
+
laneIsHostedHere,
|
|
8006
|
+
livenessDepsFromCtx,
|
|
8007
|
+
loadAllLanes,
|
|
8008
|
+
loadAllQuestions,
|
|
8009
|
+
mapWithConcurrency,
|
|
8010
|
+
mintMessageId,
|
|
8011
|
+
parseOutpostQuestionAudience,
|
|
8012
|
+
parseOutpostQuestionBody,
|
|
8013
|
+
parseRemoteJson,
|
|
8014
|
+
parseSeniorRef,
|
|
8015
|
+
questionCompanyForLane,
|
|
8016
|
+
questionCompanyRefMatches,
|
|
8017
|
+
questionListCompanyArg,
|
|
8018
|
+
readOutpostQuestionRecords,
|
|
8019
|
+
readQuestionBody,
|
|
8020
|
+
printJson,
|
|
8021
|
+
readSessionMeta,
|
|
8022
|
+
reconcileHostFromCtx,
|
|
8023
|
+
resolveActiveQuestionCompany,
|
|
8024
|
+
resolveAudience,
|
|
8025
|
+
sameQuestionAnswer,
|
|
8026
|
+
sameQuestionCompany,
|
|
8027
|
+
seniorOfSession,
|
|
8028
|
+
writeAnswerDeliveryFailed,
|
|
8029
|
+
});
|
|
9339
8030
|
const lanes = parent
|
|
9340
8031
|
.command("lanes", { hidden: true })
|
|
9341
8032
|
.description("Create, supervise, and steer worker lanes; routed decision queue");
|
|
@@ -9370,7 +8061,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9370
8061
|
trackerEnabled: ctx.laneTrackerEnabled,
|
|
9371
8062
|
now: ctx.now,
|
|
9372
8063
|
readQuestionBody: (root, question) => {
|
|
9373
|
-
const body = requireQuestionBody(root, question);
|
|
8064
|
+
const body = questionHandlers.requireQuestionBody(root, question);
|
|
9374
8065
|
assertQuestionBodySecretSafe(body);
|
|
9375
8066
|
return body;
|
|
9376
8067
|
},
|
|
@@ -9743,14 +8434,14 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9743
8434
|
.option("--story <id>", "Filter by story")
|
|
9744
8435
|
.option("--status <status>", `Filter by status (${QUESTION_STATUSES.join("|")})`)
|
|
9745
8436
|
.action(wrap(async (opts) => {
|
|
9746
|
-
await runQuestionsList(ctx, opts);
|
|
8437
|
+
await questionHandlers.runQuestionsList(ctx, opts);
|
|
9747
8438
|
}));
|
|
9748
8439
|
questions
|
|
9749
8440
|
.command("show")
|
|
9750
8441
|
.description("Show one question")
|
|
9751
8442
|
.argument("<question-id>", "Question id")
|
|
9752
8443
|
.action(wrap(async (questionId) => {
|
|
9753
|
-
await runQuestionsShow(ctx, questionId);
|
|
8444
|
+
await questionHandlers.runQuestionsShow(ctx, questionId);
|
|
9754
8445
|
}));
|
|
9755
8446
|
questions
|
|
9756
8447
|
.command("answer")
|
|
@@ -9761,7 +8452,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9761
8452
|
.option("--freeform <text>", "Supply a free-form answer for a question with options")
|
|
9762
8453
|
.option("--company <slug|uid>", "Require the question lane to match this company")
|
|
9763
8454
|
.action(wrap(async (questionId, opts) => {
|
|
9764
|
-
await runQuestionsAnswer(ctx, questionId, opts);
|
|
8455
|
+
await questionHandlers.runQuestionsAnswer(ctx, questionId, opts);
|
|
9765
8456
|
}));
|
|
9766
8457
|
questions
|
|
9767
8458
|
.command("escalate")
|
|
@@ -9769,7 +8460,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9769
8460
|
.argument("<question-id>", "Question id")
|
|
9770
8461
|
.option("--note <text>", "Note recorded with the escalation")
|
|
9771
8462
|
.action(wrap(async (questionId, opts) => {
|
|
9772
|
-
await runQuestionsEscalate(ctx, questionId, opts);
|
|
8463
|
+
await questionHandlers.runQuestionsEscalate(ctx, questionId, opts);
|
|
9773
8464
|
}));
|
|
9774
8465
|
questions
|
|
9775
8466
|
.command("cancel")
|
|
@@ -9777,7 +8468,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9777
8468
|
.argument("<question-id>", "Question id")
|
|
9778
8469
|
.option("--reason <text>", "Why it was cancelled")
|
|
9779
8470
|
.action(wrap(async (questionId, opts) => {
|
|
9780
|
-
await runQuestionsCancel(ctx, questionId, opts);
|
|
8471
|
+
await questionHandlers.runQuestionsCancel(ctx, questionId, opts);
|
|
9781
8472
|
}));
|
|
9782
8473
|
const accounts = lanes
|
|
9783
8474
|
.command("accounts")
|
|
@@ -9787,7 +8478,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9787
8478
|
.description("List accounts")
|
|
9788
8479
|
.option("--provider <name>", "claude | codex | grok")
|
|
9789
8480
|
.action(wrap(async (opts) => {
|
|
9790
|
-
await runAccountsList(ctx, opts);
|
|
8481
|
+
await accountHandlers.runAccountsList(ctx, opts);
|
|
9791
8482
|
}));
|
|
9792
8483
|
accounts
|
|
9793
8484
|
.command("balance")
|
|
@@ -9796,7 +8487,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9796
8487
|
.option("--apply", "Move eligible between-turn sessions to balance Claude accounts")
|
|
9797
8488
|
.option("--json", "JSON on stdout (always)")
|
|
9798
8489
|
.action(wrap(async (opts) => {
|
|
9799
|
-
await runAccountsBalance(ctx, opts);
|
|
8490
|
+
await accountHandlers.runAccountsBalance(ctx, opts);
|
|
9800
8491
|
}));
|
|
9801
8492
|
accounts
|
|
9802
8493
|
.command("set-home")
|
|
@@ -9808,7 +8499,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9808
8499
|
.option("--company <slug|uid>", "Company scope for account-home validation")
|
|
9809
8500
|
.option("--json", "JSON on stdout (always)")
|
|
9810
8501
|
.action(wrap(async (provider, accountId, home, opts) => {
|
|
9811
|
-
await runAccountsSetHome(ctx, provider, accountId, home, opts);
|
|
8502
|
+
await accountHandlers.runAccountsSetHome(ctx, provider, accountId, home, opts);
|
|
9812
8503
|
}));
|
|
9813
8504
|
accounts
|
|
9814
8505
|
.command("login")
|
|
@@ -9819,7 +8510,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9819
8510
|
.option("--company <slug|uid>", "Company scope for account-home validation")
|
|
9820
8511
|
.option("--device-auth", "Use Codex device authentication instead of localhost callback")
|
|
9821
8512
|
.action(wrap(async (provider, accountId, opts) => {
|
|
9822
|
-
await runAccountsLogin(ctx, provider, accountId, opts);
|
|
8513
|
+
await accountHandlers.runAccountsLogin(ctx, provider, accountId, opts);
|
|
9823
8514
|
}));
|
|
9824
8515
|
accounts
|
|
9825
8516
|
.command("enable")
|
|
@@ -9827,7 +8518,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9827
8518
|
.argument("<provider>", "claude | codex | grok")
|
|
9828
8519
|
.argument("<account-id>", "Account id")
|
|
9829
8520
|
.action(wrap(async (provider, accountId) => {
|
|
9830
|
-
await runAccountsEnable(ctx, provider, accountId);
|
|
8521
|
+
await accountHandlers.runAccountsEnable(ctx, provider, accountId);
|
|
9831
8522
|
}));
|
|
9832
8523
|
accounts
|
|
9833
8524
|
.command("disable")
|
|
@@ -9836,7 +8527,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9836
8527
|
.argument("<account-id>", "Account id")
|
|
9837
8528
|
.option("--reason <text>", "Why the account is disabled")
|
|
9838
8529
|
.action(wrap(async (provider, accountId, opts) => {
|
|
9839
|
-
await runAccountsDisable(ctx, provider, accountId, opts);
|
|
8530
|
+
await accountHandlers.runAccountsDisable(ctx, provider, accountId, opts);
|
|
9840
8531
|
}));
|
|
9841
8532
|
const sessions = lanes
|
|
9842
8533
|
.command("sessions")
|
|
@@ -9847,14 +8538,14 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9847
8538
|
.option("--state <state>", `live | idle | ended | lost`)
|
|
9848
8539
|
.option("--company <slug>", "Filter by session meta company_slug")
|
|
9849
8540
|
.action(wrap(async (opts) => {
|
|
9850
|
-
await runSessionsList(ctx, opts);
|
|
8541
|
+
await questionHandlers.runSessionsList(ctx, opts);
|
|
9851
8542
|
}));
|
|
9852
8543
|
sessions
|
|
9853
8544
|
.command("show")
|
|
9854
8545
|
.description("Show one session's liveness")
|
|
9855
8546
|
.argument("<session-id>", "Session id")
|
|
9856
8547
|
.action(wrap(async (sessionId) => {
|
|
9857
|
-
await runSessionsShow(ctx, sessionId);
|
|
8548
|
+
await questionHandlers.runSessionsShow(ctx, sessionId);
|
|
9858
8549
|
}));
|
|
9859
8550
|
lanes
|
|
9860
8551
|
.command("pickup", { hidden: true })
|