@indigoai-us/hq-cli 5.198.1 → 5.200.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 +38 -7
- package/dist/command-catalog.generated.d.ts +6 -0
- package/dist/command-catalog.generated.js +8 -0
- package/dist/commands/lanes.d.ts +3 -0
- package/dist/commands/lanes.js +685 -32
- package/dist/lib/core-utils/hook-timeout-debug-context-flag.js +5 -4
- package/dist/lib/doctor/client-health-reason-codes-flag.js +3 -2
- package/dist/lib/flag-registry-endpoint.d.ts +5 -0
- package/dist/lib/flag-registry-endpoint.js +9 -0
- package/dist/lib/flag-registry.d.ts +7 -3
- package/dist/lib/flag-registry.js +19 -7
- package/dist/lib/lanes/outpost-dispatch.d.ts +11 -2
- package/dist/lib/lanes/outpost-dispatch.js +27 -3
- package/dist/lib/lanes/outpost-hire-cache.d.ts +20 -0
- package/dist/lib/lanes/outpost-hire-cache.js +146 -0
- package/dist/lib/lanes/outpost-pruner-install.js +4 -3
- package/dist/lib/lanes/outpost-worktree-pruner-flag.js +3 -3
- package/dist/lib/lanes/registry-flag.js +3 -2
- package/dist/node-network-compat-flag.js +3 -3
- package/package.json +1 -1
package/dist/commands/lanes.js
CHANGED
|
@@ -22,7 +22,8 @@ import { createOutpostHireGrantContext, parseOutpostHireGrantContext, sameHireGr
|
|
|
22
22
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
23
23
|
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
24
24
|
export { LANE_HIRE_GRANTS_FLAG };
|
|
25
|
-
import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostQuestionAnswerCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
25
|
+
import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostQuestionAnswerCommand, buildOutpostQuestionListCommand, buildOutpostQuestionShowCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
26
|
+
import { outpostHireSummaryCacheKey, readOutpostHireSummaryCache, writeOutpostHireSummaryCache, } from "../lib/lanes/outpost-hire-cache.js";
|
|
26
27
|
import { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "../lib/lanes/host-capacity.js";
|
|
27
28
|
import { BALANCE_MOVABLE_STATES, CLAUDE_ACCOUNT_BALANCE_FLAG, accountLoadsForReport, assertBalanceApplyEnabled, planClaudeBalanceMoves, selectClaudeAccount, } from "../lib/lanes/claude-account-balance.js";
|
|
28
29
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
@@ -31,7 +32,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
|
|
|
31
32
|
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
32
33
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
|
|
33
34
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
34
|
-
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, 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, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
35
|
+
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, 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, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
35
36
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
36
37
|
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
37
38
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
@@ -55,6 +56,7 @@ const VENDOR_LOGIN = {
|
|
|
55
56
|
};
|
|
56
57
|
export { emitLaneDecisionEvents };
|
|
57
58
|
export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
59
|
+
export const LANE_OUTPOST_QUESTION_VISIBILITY_FLAG = "lanes.outpost-question-visibility";
|
|
58
60
|
function defaultLanePlacementEnabled() {
|
|
59
61
|
return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
|
|
60
62
|
}
|
|
@@ -72,6 +74,12 @@ function defaultLaneTrackerMeshEnabled(company) {
|
|
|
72
74
|
function defaultLaneHireGrantsEnabled(company) {
|
|
73
75
|
return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
|
|
74
76
|
}
|
|
77
|
+
function defaultLaneOutpostQuestionVisibilityEnabled(company, env) {
|
|
78
|
+
return readLanesRegistryFlag(LANE_OUTPOST_QUESTION_VISIBILITY_FLAG, {
|
|
79
|
+
company,
|
|
80
|
+
env,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
75
83
|
function withoutProviderHome(value) {
|
|
76
84
|
if (Array.isArray(value))
|
|
77
85
|
return value.map(withoutProviderHome);
|
|
@@ -271,6 +279,8 @@ function resolveDeps(deps = {}) {
|
|
|
271
279
|
isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
|
|
272
280
|
withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
|
|
273
281
|
laneClaudeAccountBalanceEnabled: deps.laneClaudeAccountBalanceEnabled ?? defaultLaneClaudeAccountBalanceEnabled,
|
|
282
|
+
laneOutpostQuestionVisibilityEnabled: deps.laneOutpostQuestionVisibilityEnabled ??
|
|
283
|
+
((company) => defaultLaneOutpostQuestionVisibilityEnabled(company, env)),
|
|
274
284
|
laneTrackerEnabled: deps.laneTrackerEnabled ?? defaultLaneTrackerEnabled,
|
|
275
285
|
laneTrackerMeshEnabled: deps.laneTrackerMeshEnabled ?? defaultLaneTrackerMeshEnabled,
|
|
276
286
|
trackerActor: deps.trackerActor ?? defaultTrackerActor,
|
|
@@ -569,6 +579,32 @@ function normalizeLaneRecordView(raw) {
|
|
|
569
579
|
return normalized;
|
|
570
580
|
}
|
|
571
581
|
async function ownerLaneHireFields(ctx, lane) {
|
|
582
|
+
if (lane.host.kind !== "outpost") {
|
|
583
|
+
throw new LanesError("outpost_lane_hire_summary_invalid", "Owner summary requires an Outpost-hosted lane", {
|
|
584
|
+
lane_id: lane.lane_id,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
const nowMs = ctx.now().getTime();
|
|
588
|
+
const cachedEntries = readOutpostHireSummaryCache(ctx.hqRoot(), nowMs);
|
|
589
|
+
const cached = cachedEntries.get(outpostHireSummaryCacheKey(lane.host.id, lane.lane_id));
|
|
590
|
+
if (cached) {
|
|
591
|
+
return {
|
|
592
|
+
hire_grant: cached.hire_grant,
|
|
593
|
+
hire_alive_children: cached.hire_alive_children,
|
|
594
|
+
hire: cached.hire,
|
|
595
|
+
hire_summary: cached.hire_summary,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
const fields = await fetchOwnerLaneHireFields(ctx, lane);
|
|
599
|
+
const cachedAtMs = ctx.now().getTime();
|
|
600
|
+
writeOutpostHireSummaryCache(ctx.hqRoot(), [{
|
|
601
|
+
outpost_id: lane.host.id,
|
|
602
|
+
lane_id: lane.lane_id,
|
|
603
|
+
...fields,
|
|
604
|
+
}], cachedAtMs, cachedEntries);
|
|
605
|
+
return fields;
|
|
606
|
+
}
|
|
607
|
+
async function fetchOwnerLaneHireFields(ctx, lane) {
|
|
572
608
|
if (lane.host.kind !== "outpost") {
|
|
573
609
|
throw new LanesError("outpost_lane_hire_summary_invalid", "Owner summary requires an Outpost-hosted lane", {
|
|
574
610
|
lane_id: lane.lane_id,
|
|
@@ -599,25 +635,42 @@ async function ownerLaneHireFields(ctx, lane) {
|
|
|
599
635
|
if (!remote || remote.ok !== true || remote.action !== "show") {
|
|
600
636
|
throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an invalid hire summary for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: outpostId, command_id: dispatched.commandId });
|
|
601
637
|
}
|
|
638
|
+
return validateOwnerLaneHireSummary({
|
|
639
|
+
outpostId,
|
|
640
|
+
laneId: lane.lane_id,
|
|
641
|
+
rawLane: remote.lane,
|
|
642
|
+
hireGrant: remote.hire_grant,
|
|
643
|
+
hireAliveChildren: remote.hire_alive_children,
|
|
644
|
+
hire: remote.hire,
|
|
645
|
+
hireSummary: remote.hire_summary,
|
|
646
|
+
commandId: dispatched.commandId,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
function validateOwnerLaneHireSummary(input) {
|
|
602
650
|
let ownerLane;
|
|
603
651
|
try {
|
|
604
|
-
ownerLane = parseLaneRecord(normalizeLaneRecordView(
|
|
652
|
+
ownerLane = parseLaneRecord(normalizeLaneRecordView(input.rawLane));
|
|
605
653
|
}
|
|
606
654
|
catch (err) {
|
|
607
|
-
throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an invalid lane snapshot for ${
|
|
655
|
+
throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${input.outpostId} returned an invalid lane snapshot for ${input.laneId}`, {
|
|
656
|
+
lane_id: input.laneId,
|
|
657
|
+
outpost_id: input.outpostId,
|
|
658
|
+
command_id: input.commandId,
|
|
659
|
+
cause: dispatchErrorMessage(err),
|
|
660
|
+
});
|
|
608
661
|
}
|
|
609
|
-
const grant =
|
|
610
|
-
const alive =
|
|
611
|
-
if (ownerLane.lane_id !==
|
|
662
|
+
const grant = input.hireGrant;
|
|
663
|
+
const alive = input.hireAliveChildren;
|
|
664
|
+
if (ownerLane.lane_id !== input.laneId ||
|
|
612
665
|
ownerLane.host.kind !== "outpost" ||
|
|
613
|
-
ownerLane.host.id !== outpostId ||
|
|
666
|
+
ownerLane.host.id !== input.outpostId ||
|
|
614
667
|
!Number.isSafeInteger(grant) ||
|
|
615
668
|
grant < 0 ||
|
|
616
669
|
!Number.isSafeInteger(alive) ||
|
|
617
670
|
alive < 0 ||
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an inconsistent hire summary for lane ${
|
|
671
|
+
input.hire !== `${String(alive)}/${String(grant)}` ||
|
|
672
|
+
input.hireSummary !== `hire ${String(alive)}/${String(grant)}`) {
|
|
673
|
+
throw new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${input.outpostId} returned an inconsistent hire summary for lane ${input.laneId}`, { lane_id: input.laneId, outpost_id: input.outpostId, command_id: input.commandId });
|
|
621
674
|
}
|
|
622
675
|
return {
|
|
623
676
|
hire_grant: grant,
|
|
@@ -626,6 +679,116 @@ async function ownerLaneHireFields(ctx, lane) {
|
|
|
626
679
|
hire_summary: `hire ${alive}/${grant}`,
|
|
627
680
|
};
|
|
628
681
|
}
|
|
682
|
+
async function fetchOutpostListHireSummaries(ctx, outpostId, lanes) {
|
|
683
|
+
const fallback = (error) => new Map(lanes.map((lane) => [lane.lane_id, {
|
|
684
|
+
error: new LanesError(error.error, error.message, {
|
|
685
|
+
...error.details,
|
|
686
|
+
outpost_id: outpostId,
|
|
687
|
+
lane_id: lane.lane_id,
|
|
688
|
+
}),
|
|
689
|
+
}]));
|
|
690
|
+
let hqBinary;
|
|
691
|
+
try {
|
|
692
|
+
hqBinary = outpostCliOverride(ctx, outpostId);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
if (isLanesError(err))
|
|
696
|
+
return fallback(err);
|
|
697
|
+
return fallback(new LanesError("outpost_lane_hire_summary_unavailable", `Could not read hire summaries from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId }));
|
|
698
|
+
}
|
|
699
|
+
let dispatched;
|
|
700
|
+
try {
|
|
701
|
+
// No filters: the owner list must return every lane requested by this host group.
|
|
702
|
+
dispatched = await ctx.dispatchOutpostExec({
|
|
703
|
+
outpostId,
|
|
704
|
+
command: buildOutpostLaneListCommand({ hqBinary }),
|
|
705
|
+
timeoutSeconds: OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS,
|
|
706
|
+
waitMs: OUTPOST_HIRE_SUMMARY_WAIT_MS,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
return fallback(new LanesError("outpost_lane_hire_summary_unavailable", `Could not read hire summaries from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { outpost_id: outpostId }));
|
|
711
|
+
}
|
|
712
|
+
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
|
|
713
|
+
return fallback(new LanesError("outpost_lane_hire_summary_unavailable", `Outpost ${outpostId} did not confirm its lane list for hire summaries`, { outpost_id: outpostId, command_id: dispatched.commandId }));
|
|
714
|
+
}
|
|
715
|
+
const remote = parseRemoteJson(dispatched.stdout, "Outpost lane hire summary list");
|
|
716
|
+
if (!remote || remote.ok !== true || remote.action !== "list" || !Array.isArray(remote.lanes)) {
|
|
717
|
+
return fallback(new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an invalid lane list for hire summaries`, { outpost_id: outpostId, command_id: dispatched.commandId }));
|
|
718
|
+
}
|
|
719
|
+
const outcomes = new Map();
|
|
720
|
+
for (const lane of lanes) {
|
|
721
|
+
const matchingRows = remote.lanes.filter((row) => row !== null && typeof row === "object" && !Array.isArray(row) &&
|
|
722
|
+
row.lane_id === lane.lane_id);
|
|
723
|
+
if (matchingRows.length === 0) {
|
|
724
|
+
outcomes.set(lane.lane_id, { error: new LanesError("outpost_lane_hire_summary_missing", `Outpost ${outpostId} did not return lane ${lane.lane_id} in its lane list`, { outpost_id: outpostId, lane_id: lane.lane_id, command_id: dispatched.commandId }) });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (matchingRows.length !== 1) {
|
|
728
|
+
outcomes.set(lane.lane_id, { error: new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned duplicate lane rows for ${lane.lane_id}`, { outpost_id: outpostId, lane_id: lane.lane_id, command_id: dispatched.commandId }) });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const row = matchingRows[0];
|
|
732
|
+
try {
|
|
733
|
+
outcomes.set(lane.lane_id, { fields: validateOwnerLaneHireSummary({
|
|
734
|
+
outpostId,
|
|
735
|
+
laneId: lane.lane_id,
|
|
736
|
+
rawLane: row,
|
|
737
|
+
hireGrant: row.hire_grant,
|
|
738
|
+
hireAliveChildren: row.hire_alive_children,
|
|
739
|
+
hire: row.hire,
|
|
740
|
+
hireSummary: row.hire_summary,
|
|
741
|
+
commandId: dispatched.commandId,
|
|
742
|
+
}) });
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
outcomes.set(lane.lane_id, { error: isLanesError(err)
|
|
746
|
+
? err
|
|
747
|
+
: new LanesError("outpost_lane_hire_summary_invalid", `Outpost ${outpostId} returned an invalid hire summary for lane ${lane.lane_id}`, { outpost_id: outpostId, lane_id: lane.lane_id, command_id: dispatched.commandId }) });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return outcomes;
|
|
751
|
+
}
|
|
752
|
+
async function batchOwnerLaneHireFields(ctx, lanes) {
|
|
753
|
+
const outcomes = new Map();
|
|
754
|
+
const groups = new Map();
|
|
755
|
+
const nowMs = ctx.now().getTime();
|
|
756
|
+
const cached = readOutpostHireSummaryCache(ctx.hqRoot(), nowMs);
|
|
757
|
+
for (const lane of lanes) {
|
|
758
|
+
if (lane.host.kind !== "outpost")
|
|
759
|
+
continue;
|
|
760
|
+
const cachedSummary = cached.get(outpostHireSummaryCacheKey(lane.host.id, lane.lane_id));
|
|
761
|
+
if (cachedSummary) {
|
|
762
|
+
outcomes.set(lane.lane_id, {
|
|
763
|
+
fields: {
|
|
764
|
+
hire_grant: cachedSummary.hire_grant,
|
|
765
|
+
hire_alive_children: cachedSummary.hire_alive_children,
|
|
766
|
+
hire: cachedSummary.hire,
|
|
767
|
+
hire_summary: cachedSummary.hire_summary,
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const group = groups.get(lane.host.id) ?? [];
|
|
773
|
+
group.push(lane);
|
|
774
|
+
groups.set(lane.host.id, group);
|
|
775
|
+
}
|
|
776
|
+
const fetchedGroups = await mapWithConcurrency([...groups.entries()], OUTPOST_HIRE_SUMMARY_CONCURRENCY, ([outpostId, group]) => fetchOutpostListHireSummaries(ctx, outpostId, group));
|
|
777
|
+
const cacheUpdates = [];
|
|
778
|
+
for (const groupOutcomes of fetchedGroups) {
|
|
779
|
+
for (const [laneId, outcome] of groupOutcomes) {
|
|
780
|
+
outcomes.set(laneId, outcome);
|
|
781
|
+
if ("fields" in outcome) {
|
|
782
|
+
const lane = lanes.find((candidate) => candidate.lane_id === laneId);
|
|
783
|
+
if (lane?.host.kind === "outpost") {
|
|
784
|
+
cacheUpdates.push({ outpost_id: lane.host.id, lane_id: laneId, ...outcome.fields });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
writeOutpostHireSummaryCache(ctx.hqRoot(), cacheUpdates, ctx.now().getTime(), cached);
|
|
790
|
+
return outcomes;
|
|
791
|
+
}
|
|
629
792
|
const OUTPOST_HIRE_SUMMARY_TIMEOUT_SECONDS = 20;
|
|
630
793
|
const OUTPOST_HIRE_SUMMARY_WAIT_MS = 15_000;
|
|
631
794
|
const OUTPOST_HIRE_SUMMARY_CONCURRENCY = 4;
|
|
@@ -1407,6 +1570,22 @@ function sameLaneCompany(a, b) {
|
|
|
1407
1570
|
return a === b;
|
|
1408
1571
|
return (a.uid !== "" && a.uid === b.uid) || (a.slug !== "" && a.slug === b.slug);
|
|
1409
1572
|
}
|
|
1573
|
+
function sameQuestionCompany(a, b) {
|
|
1574
|
+
if (a === null || b === null)
|
|
1575
|
+
return a === b;
|
|
1576
|
+
const aUid = a.uid.trim();
|
|
1577
|
+
const bUid = b.uid.trim();
|
|
1578
|
+
if (aUid || bUid)
|
|
1579
|
+
return aUid !== "" && aUid === bUid;
|
|
1580
|
+
const aSlug = a.slug.trim();
|
|
1581
|
+
const bSlug = b.slug.trim();
|
|
1582
|
+
return aSlug !== "" && aSlug === bSlug;
|
|
1583
|
+
}
|
|
1584
|
+
function questionCompanyRefMatches(recordCompany, filter) {
|
|
1585
|
+
return recordCompany !== null &&
|
|
1586
|
+
recordCompany !== undefined &&
|
|
1587
|
+
sameQuestionCompany(recordCompany, filter);
|
|
1588
|
+
}
|
|
1410
1589
|
function companyRefMatches(recordCompany, filter) {
|
|
1411
1590
|
return (recordCompany !== null &&
|
|
1412
1591
|
recordCompany !== undefined &&
|
|
@@ -3255,6 +3434,7 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
|
3255
3434
|
choice: options.choice,
|
|
3256
3435
|
freeform: options.freeform,
|
|
3257
3436
|
notes: options.notes,
|
|
3437
|
+
company: options.company,
|
|
3258
3438
|
}),
|
|
3259
3439
|
});
|
|
3260
3440
|
}
|
|
@@ -3318,7 +3498,9 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
|
3318
3498
|
});
|
|
3319
3499
|
}
|
|
3320
3500
|
const ownerAnswer = ownerQuestion.answer;
|
|
3321
|
-
if (!isQuestionAnswer(ownerAnswer)
|
|
3501
|
+
if (!isQuestionAnswer(ownerAnswer) ||
|
|
3502
|
+
ownerQuestion.lane_id !== lane.lane_id ||
|
|
3503
|
+
!sameQuestionCompany(ownerQuestion.company, lane.company)) {
|
|
3322
3504
|
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return the confirmed answer for question ${questionId}`, {
|
|
3323
3505
|
lane_id: lane.lane_id,
|
|
3324
3506
|
question_id: questionId,
|
|
@@ -3326,10 +3508,13 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
|
3326
3508
|
command_id: dispatched.commandId,
|
|
3327
3509
|
});
|
|
3328
3510
|
}
|
|
3329
|
-
const
|
|
3511
|
+
const updatePrimaryCopy = () => casUpdateQuestion(ctx.hqRoot(), questionId, (current) => {
|
|
3330
3512
|
if (current.lane_id !== lane.lane_id) {
|
|
3331
3513
|
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 });
|
|
3332
3514
|
}
|
|
3515
|
+
if (!sameQuestionCompany(current.company, lane.company)) {
|
|
3516
|
+
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 });
|
|
3517
|
+
}
|
|
3333
3518
|
if (current.status === "pending") {
|
|
3334
3519
|
return { ...current, status: "answered", answer: ownerAnswer };
|
|
3335
3520
|
}
|
|
@@ -3338,6 +3523,26 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
|
3338
3523
|
}
|
|
3339
3524
|
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 });
|
|
3340
3525
|
});
|
|
3526
|
+
let syncedQuestion;
|
|
3527
|
+
try {
|
|
3528
|
+
syncedQuestion = updatePrimaryCopy();
|
|
3529
|
+
}
|
|
3530
|
+
catch (err) {
|
|
3531
|
+
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
3532
|
+
throw err;
|
|
3533
|
+
const primaryLane = readLane(ctx.hqRoot(), lane.lane_id);
|
|
3534
|
+
if (!sameQuestionCompany(primaryLane.company, lane.company)) {
|
|
3535
|
+
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 });
|
|
3536
|
+
}
|
|
3537
|
+
try {
|
|
3538
|
+
syncedQuestion = createQuestion(ctx.hqRoot(), ownerQuestion);
|
|
3539
|
+
}
|
|
3540
|
+
catch (createErr) {
|
|
3541
|
+
if (!isLanesError(createErr) || createErr.error !== "question_exists")
|
|
3542
|
+
throw createErr;
|
|
3543
|
+
syncedQuestion = updatePrimaryCopy();
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3341
3546
|
printJson({
|
|
3342
3547
|
...remote,
|
|
3343
3548
|
lane_id: lane.lane_id,
|
|
@@ -3630,6 +3835,7 @@ async function runList(ctx, opts) {
|
|
|
3630
3835
|
return true;
|
|
3631
3836
|
})
|
|
3632
3837
|
.sort(compareLaneQueueOrder);
|
|
3838
|
+
const ownerEligibleLaneIds = new Set();
|
|
3633
3839
|
const companyContexts = new Map();
|
|
3634
3840
|
for (const lane of selectedLanes) {
|
|
3635
3841
|
if (lane.host.kind !== "outpost" ||
|
|
@@ -3637,14 +3843,58 @@ async function runList(ctx, opts) {
|
|
|
3637
3843
|
laneRunsHere(lane, host)) {
|
|
3638
3844
|
continue;
|
|
3639
3845
|
}
|
|
3640
|
-
const
|
|
3641
|
-
|
|
3846
|
+
const companyKey = JSON.stringify([
|
|
3847
|
+
lane.company?.uid ?? null,
|
|
3848
|
+
lane.company?.slug ?? null,
|
|
3849
|
+
]);
|
|
3850
|
+
ownerEligibleLaneIds.add(lane.lane_id);
|
|
3851
|
+
companyContexts.set(companyKey, lane.company);
|
|
3852
|
+
}
|
|
3853
|
+
const enabledByCompany = new Map(await Promise.all([...companyContexts.entries()].map(async ([companyKey, company]) => [
|
|
3854
|
+
companyKey,
|
|
3855
|
+
await ctx.laneHireGrantsEnabled(company),
|
|
3856
|
+
])));
|
|
3857
|
+
const ownerLanes = [];
|
|
3858
|
+
const hireByLane = new Map();
|
|
3859
|
+
for (const lane of selectedLanes) {
|
|
3860
|
+
if (!ownerEligibleLaneIds.has(lane.lane_id)) {
|
|
3861
|
+
hireByLane.set(lane.lane_id, {
|
|
3862
|
+
...laneHireFields(lane, allLanes),
|
|
3863
|
+
hire_source: "local",
|
|
3864
|
+
});
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
3867
|
+
const companyKey = JSON.stringify([
|
|
3868
|
+
lane.company?.uid ?? null,
|
|
3869
|
+
lane.company?.slug ?? null,
|
|
3870
|
+
]);
|
|
3871
|
+
if (enabledByCompany.get(companyKey))
|
|
3872
|
+
ownerLanes.push(lane);
|
|
3873
|
+
else {
|
|
3874
|
+
hireByLane.set(lane.lane_id, {
|
|
3875
|
+
...laneHireFields(lane, allLanes),
|
|
3876
|
+
hire_source: "local",
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
if (ownerLanes.length > 0) {
|
|
3881
|
+
const ownerSummaries = await batchOwnerLaneHireFields(ctx, ownerLanes);
|
|
3882
|
+
for (const lane of ownerLanes) {
|
|
3883
|
+
const outcome = ownerSummaries.get(lane.lane_id);
|
|
3884
|
+
if (outcome && "fields" in outcome) {
|
|
3885
|
+
hireByLane.set(lane.lane_id, { ...outcome.fields, hire_source: "owner" });
|
|
3886
|
+
continue;
|
|
3887
|
+
}
|
|
3888
|
+
const error = outcome && "error" in outcome
|
|
3889
|
+
? outcome.error
|
|
3890
|
+
: new LanesError("outpost_lane_hire_summary_missing", `No owner hire summary was returned for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: lane.host.id });
|
|
3891
|
+
hireByLane.set(lane.lane_id, {
|
|
3892
|
+
...laneHireFields(lane, allLanes),
|
|
3893
|
+
hire_source: "local",
|
|
3894
|
+
hire_warning: `${error.error}: ${error.message}`,
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3642
3897
|
}
|
|
3643
|
-
await Promise.all([...companyContexts.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
|
|
3644
|
-
const hireByLane = new Map(await mapWithConcurrency(selectedLanes, OUTPOST_HIRE_SUMMARY_CONCURRENCY, async (lane) => [
|
|
3645
|
-
lane.lane_id,
|
|
3646
|
-
await laneHirePresentation(ctx, lane, allLanes),
|
|
3647
|
-
]));
|
|
3648
3898
|
const lanes = selectedLanes.map((lane) => {
|
|
3649
3899
|
const remoteQueued = lane.state === "queued" &&
|
|
3650
3900
|
lane.host.kind === "outpost" &&
|
|
@@ -6582,6 +6832,271 @@ async function runAsk(ctx, opts) {
|
|
|
6582
6832
|
ask_delivery_failed: failure,
|
|
6583
6833
|
});
|
|
6584
6834
|
}
|
|
6835
|
+
const OUTPOST_QUESTION_LOOKUP_CONCURRENCY = 4;
|
|
6836
|
+
function groupQuestionLanesByCompany(lanes) {
|
|
6837
|
+
const groups = [];
|
|
6838
|
+
for (const lane of lanes) {
|
|
6839
|
+
const existing = groups.find((group) => sameQuestionCompany(group.company, lane.company));
|
|
6840
|
+
if (existing)
|
|
6841
|
+
existing.lanes.push(lane);
|
|
6842
|
+
else
|
|
6843
|
+
groups.push({ company: lane.company, lanes: [lane] });
|
|
6844
|
+
}
|
|
6845
|
+
return groups;
|
|
6846
|
+
}
|
|
6847
|
+
function questionListCompanyArg(company) {
|
|
6848
|
+
if (company === null) {
|
|
6849
|
+
throw new LanesError("outpost_question_company_unavailable", "An Outpost question lookup requires a recorded company identity", {});
|
|
6850
|
+
}
|
|
6851
|
+
const value = company.uid.trim() || company.slug.trim();
|
|
6852
|
+
if (!value) {
|
|
6853
|
+
throw new LanesError("outpost_question_company_invalid", "An Outpost question lookup needs a company slug or uid", {});
|
|
6854
|
+
}
|
|
6855
|
+
return value;
|
|
6856
|
+
}
|
|
6857
|
+
async function readOutpostQuestionRecords(ctx, outpostId, company) {
|
|
6858
|
+
const companyArg = questionListCompanyArg(company);
|
|
6859
|
+
let result;
|
|
6860
|
+
try {
|
|
6861
|
+
result = await ctx.dispatchOutpostExec({
|
|
6862
|
+
outpostId,
|
|
6863
|
+
command: buildOutpostQuestionListCommand({ company: companyArg }),
|
|
6864
|
+
timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
|
|
6865
|
+
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
6866
|
+
});
|
|
6867
|
+
}
|
|
6868
|
+
catch {
|
|
6869
|
+
throw new LanesError("outpost_question_list_unavailable", `Could not read questions from Outpost ${outpostId}`, { outpost_id: outpostId });
|
|
6870
|
+
}
|
|
6871
|
+
if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
|
|
6872
|
+
throw new LanesError("outpost_question_list_unavailable", `Outpost ${outpostId} did not return a successful question list`, { outpost_id: outpostId });
|
|
6873
|
+
}
|
|
6874
|
+
let body;
|
|
6875
|
+
try {
|
|
6876
|
+
body = JSON.parse(result.stdout);
|
|
6877
|
+
}
|
|
6878
|
+
catch {
|
|
6879
|
+
throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned invalid question-list JSON`, { outpost_id: outpostId });
|
|
6880
|
+
}
|
|
6881
|
+
if (body === null ||
|
|
6882
|
+
typeof body !== "object" ||
|
|
6883
|
+
Array.isArray(body) ||
|
|
6884
|
+
body.ok !== true ||
|
|
6885
|
+
body.action !== "questions.list" ||
|
|
6886
|
+
!Array.isArray(body.questions)) {
|
|
6887
|
+
throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned a malformed question list`, { outpost_id: outpostId });
|
|
6888
|
+
}
|
|
6889
|
+
const rows = body.questions;
|
|
6890
|
+
const questions = [];
|
|
6891
|
+
for (const [index, row] of rows.entries()) {
|
|
6892
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) {
|
|
6893
|
+
throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, index });
|
|
6894
|
+
}
|
|
6895
|
+
const rowObject = row;
|
|
6896
|
+
let question;
|
|
6897
|
+
try {
|
|
6898
|
+
question = parseQuestionRecord(row);
|
|
6899
|
+
}
|
|
6900
|
+
catch {
|
|
6901
|
+
throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, index });
|
|
6902
|
+
}
|
|
6903
|
+
const audience = parseOutpostQuestionAudience(rowObject.audience, outpostId, index);
|
|
6904
|
+
questions.push({ question, audience });
|
|
6905
|
+
}
|
|
6906
|
+
return questions;
|
|
6907
|
+
}
|
|
6908
|
+
function parseOutpostQuestionAudience(raw, outpostId, index) {
|
|
6909
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
6910
|
+
throw new LanesError("outpost_question_audience_invalid", `Outpost ${outpostId} returned a question without a valid owner-computed audience`, { outpost_id: outpostId, index });
|
|
6911
|
+
}
|
|
6912
|
+
const audience = raw;
|
|
6913
|
+
try {
|
|
6914
|
+
if (audience.kind === "user" && audience.id === undefined) {
|
|
6915
|
+
return parseSeniorRef("user");
|
|
6916
|
+
}
|
|
6917
|
+
if (typeof audience.kind !== "string" || typeof audience.id !== "string") {
|
|
6918
|
+
throw new Error("audience kind and id are invalid");
|
|
6919
|
+
}
|
|
6920
|
+
return parseSeniorRef(`${audience.kind}:${audience.id}`);
|
|
6921
|
+
}
|
|
6922
|
+
catch {
|
|
6923
|
+
throw new LanesError("outpost_question_audience_invalid", `Outpost ${outpostId} returned an invalid owner-computed question audience`, { outpost_id: outpostId, index });
|
|
6924
|
+
}
|
|
6925
|
+
}
|
|
6926
|
+
function parseOutpostQuestionBody(raw, outpostId, questionId) {
|
|
6927
|
+
if (raw === null)
|
|
6928
|
+
return null;
|
|
6929
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
6930
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned a malformed body for question ${questionId}`, { outpost_id: outpostId, question_id: questionId });
|
|
6931
|
+
}
|
|
6932
|
+
const body = raw;
|
|
6933
|
+
if (typeof body.question !== "string" ||
|
|
6934
|
+
typeof body.context !== "string" ||
|
|
6935
|
+
!Array.isArray(body.options) ||
|
|
6936
|
+
body.options.some((option) => typeof option !== "string") ||
|
|
6937
|
+
(body.recommended !== undefined && typeof body.recommended !== "string") ||
|
|
6938
|
+
(body.notes !== undefined && typeof body.notes !== "string")) {
|
|
6939
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned a malformed body for question ${questionId}`, { outpost_id: outpostId, question_id: questionId });
|
|
6940
|
+
}
|
|
6941
|
+
return parseQuestionBody(raw);
|
|
6942
|
+
}
|
|
6943
|
+
async function readOutpostQuestionDetail(ctx, owner) {
|
|
6944
|
+
if (owner.lane.host.kind !== "outpost") {
|
|
6945
|
+
throw new LanesError("outpost_question_owner_invalid", `Question ${owner.question.question_id} does not have an Outpost owner`, { question_id: owner.question.question_id, lane_id: owner.lane.lane_id });
|
|
6946
|
+
}
|
|
6947
|
+
const outpostId = owner.lane.host.id;
|
|
6948
|
+
let result;
|
|
6949
|
+
try {
|
|
6950
|
+
result = await ctx.dispatchOutpostExec({
|
|
6951
|
+
outpostId,
|
|
6952
|
+
command: buildOutpostQuestionShowCommand(owner.question.question_id),
|
|
6953
|
+
timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
|
|
6954
|
+
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
6955
|
+
});
|
|
6956
|
+
}
|
|
6957
|
+
catch {
|
|
6958
|
+
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 });
|
|
6959
|
+
}
|
|
6960
|
+
if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
|
|
6961
|
+
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 });
|
|
6962
|
+
}
|
|
6963
|
+
let raw;
|
|
6964
|
+
try {
|
|
6965
|
+
raw = JSON.parse(result.stdout);
|
|
6966
|
+
}
|
|
6967
|
+
catch {
|
|
6968
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned invalid question-show JSON`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
6969
|
+
}
|
|
6970
|
+
if (raw === null ||
|
|
6971
|
+
typeof raw !== "object" ||
|
|
6972
|
+
Array.isArray(raw) ||
|
|
6973
|
+
raw.ok !== true ||
|
|
6974
|
+
raw.action !== "questions.show" ||
|
|
6975
|
+
!Object.prototype.hasOwnProperty.call(raw, "body")) {
|
|
6976
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned a malformed question-show response`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
6977
|
+
}
|
|
6978
|
+
const response = raw;
|
|
6979
|
+
let question;
|
|
6980
|
+
try {
|
|
6981
|
+
question = parseQuestionRecord(response.question);
|
|
6982
|
+
}
|
|
6983
|
+
catch {
|
|
6984
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
6985
|
+
}
|
|
6986
|
+
if (question.question_id !== owner.question.question_id ||
|
|
6987
|
+
question.lane_id !== owner.lane.lane_id ||
|
|
6988
|
+
!sameQuestionCompany(question.company, owner.lane.company)) {
|
|
6989
|
+
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 });
|
|
6990
|
+
}
|
|
6991
|
+
const audience = parseOutpostQuestionAudience(response.audience, outpostId, 0);
|
|
6992
|
+
const body = parseOutpostQuestionBody(response.body, outpostId, question.question_id);
|
|
6993
|
+
return { question, audience, body };
|
|
6994
|
+
}
|
|
6995
|
+
async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
|
|
6996
|
+
const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
|
|
6997
|
+
(!companyFilter || questionCompanyRefMatches(lane.company, companyFilter)));
|
|
6998
|
+
const lanesByOutpost = new Map();
|
|
6999
|
+
for (const lane of hostedLanes) {
|
|
7000
|
+
if (lane.host.kind !== "outpost")
|
|
7001
|
+
continue;
|
|
7002
|
+
const lanes = lanesByOutpost.get(lane.host.id) ?? [];
|
|
7003
|
+
lanes.push(lane);
|
|
7004
|
+
lanesByOutpost.set(lane.host.id, lanes);
|
|
7005
|
+
}
|
|
7006
|
+
const questions = [];
|
|
7007
|
+
const errors = [];
|
|
7008
|
+
const eligibleGroups = [];
|
|
7009
|
+
for (const [outpostId, lanes] of [...lanesByOutpost.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
7010
|
+
for (const group of groupQuestionLanesByCompany(lanes)) {
|
|
7011
|
+
if (companyFilter && !questionCompanyRefMatches(group.company, companyFilter))
|
|
7012
|
+
continue;
|
|
7013
|
+
if (group.company === null) {
|
|
7014
|
+
errors.push({
|
|
7015
|
+
host: `outpost:${outpostId}`,
|
|
7016
|
+
error: "outpost_question_company_unavailable",
|
|
7017
|
+
});
|
|
7018
|
+
continue;
|
|
7019
|
+
}
|
|
7020
|
+
if (await ctx.laneOutpostQuestionVisibilityEnabled(group.company)) {
|
|
7021
|
+
eligibleGroups.push({ outpostId, group });
|
|
7022
|
+
}
|
|
7023
|
+
}
|
|
7024
|
+
}
|
|
7025
|
+
if (eligibleGroups.length === 0) {
|
|
7026
|
+
return { questions, errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()] };
|
|
7027
|
+
}
|
|
7028
|
+
let knownOutpostIds;
|
|
7029
|
+
try {
|
|
7030
|
+
knownOutpostIds = await ctx.listOutpostIds();
|
|
7031
|
+
}
|
|
7032
|
+
catch {
|
|
7033
|
+
return {
|
|
7034
|
+
questions,
|
|
7035
|
+
errors: [...new Map([
|
|
7036
|
+
...errors,
|
|
7037
|
+
...[...new Set(eligibleGroups.map(({ outpostId }) => outpostId))].map((outpostId) => ({
|
|
7038
|
+
host: `outpost:${outpostId}`,
|
|
7039
|
+
error: "outpost_list_unavailable",
|
|
7040
|
+
})),
|
|
7041
|
+
].map((entry) => [entry.host, entry])).values()],
|
|
7042
|
+
};
|
|
7043
|
+
}
|
|
7044
|
+
const normalizedOutpostIds = knownOutpostIds.map((id) => typeof id === "string" ? id.trim() : "");
|
|
7045
|
+
if (knownOutpostIds.length > MAX_PLACEMENT_OUTPOSTS ||
|
|
7046
|
+
normalizedOutpostIds.some((id) => id === "") ||
|
|
7047
|
+
new Set(normalizedOutpostIds).size !== normalizedOutpostIds.length) {
|
|
7048
|
+
throw new LanesError("outpost_list_invalid", "Outpost list is malformed or exceeds the placement limit", { count: knownOutpostIds.length, maximum: MAX_PLACEMENT_OUTPOSTS });
|
|
7049
|
+
}
|
|
7050
|
+
const knownOutpostIdSet = new Set(normalizedOutpostIds);
|
|
7051
|
+
const loadedGroups = await mapWithConcurrency(eligibleGroups, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, async ({ outpostId, group }) => {
|
|
7052
|
+
if (!knownOutpostIdSet.has(outpostId)) {
|
|
7053
|
+
return {
|
|
7054
|
+
questions: [],
|
|
7055
|
+
errors: [{ host: `outpost:${outpostId}`, error: "unknown_outpost" }],
|
|
7056
|
+
};
|
|
7057
|
+
}
|
|
7058
|
+
try {
|
|
7059
|
+
const remoteQuestions = await readOutpostQuestionRecords(ctx, outpostId, group.company);
|
|
7060
|
+
const localLanesById = new Map(group.lanes.map((lane) => [lane.lane_id, lane]));
|
|
7061
|
+
const groupQuestions = [];
|
|
7062
|
+
const seenQuestionIds = new Set();
|
|
7063
|
+
for (const { question, audience } of remoteQuestions) {
|
|
7064
|
+
const lane = localLanesById.get(question.lane_id);
|
|
7065
|
+
if (!lane)
|
|
7066
|
+
continue;
|
|
7067
|
+
if (!sameQuestionCompany(question.company, lane.company)) {
|
|
7068
|
+
throw new LanesError("outpost_question_company_mismatch", `Outpost ${outpostId} question ${question.question_id} does not match its primary lane company`, { outpost_id: outpostId, question_id: question.question_id });
|
|
7069
|
+
}
|
|
7070
|
+
if (seenQuestionIds.has(question.question_id)) {
|
|
7071
|
+
throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned a duplicate question id`, { outpost_id: outpostId, question_id: question.question_id });
|
|
7072
|
+
}
|
|
7073
|
+
seenQuestionIds.add(question.question_id);
|
|
7074
|
+
groupQuestions.push({
|
|
7075
|
+
question,
|
|
7076
|
+
lane,
|
|
7077
|
+
host: `outpost:${outpostId}`,
|
|
7078
|
+
audience,
|
|
7079
|
+
});
|
|
7080
|
+
}
|
|
7081
|
+
return { questions: groupQuestions, errors: [] };
|
|
7082
|
+
}
|
|
7083
|
+
catch (err) {
|
|
7084
|
+
return {
|
|
7085
|
+
questions: [],
|
|
7086
|
+
errors: [{
|
|
7087
|
+
host: `outpost:${outpostId}`,
|
|
7088
|
+
error: isLanesError(err) ? err.error : "outpost_question_list_unavailable",
|
|
7089
|
+
}],
|
|
7090
|
+
};
|
|
7091
|
+
}
|
|
7092
|
+
});
|
|
7093
|
+
questions.push(...loadedGroups.flatMap(({ questions: groupQuestions }) => groupQuestions));
|
|
7094
|
+
errors.push(...loadedGroups.flatMap(({ errors: groupErrors }) => groupErrors));
|
|
7095
|
+
return {
|
|
7096
|
+
questions,
|
|
7097
|
+
errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
|
|
7098
|
+
};
|
|
7099
|
+
}
|
|
6585
7100
|
async function runQuestionsList(ctx, opts) {
|
|
6586
7101
|
const hqRoot = ctx.hqRoot();
|
|
6587
7102
|
const status = opts.status ? parseQuestionStatus(opts.status) : undefined;
|
|
@@ -6589,7 +7104,8 @@ async function runQuestionsList(ctx, opts) {
|
|
|
6589
7104
|
let company;
|
|
6590
7105
|
if (opts.company)
|
|
6591
7106
|
company = await ctx.resolveCompany(opts.company);
|
|
6592
|
-
const
|
|
7107
|
+
const localLanes = opts.allHosts ? new Map(loadAllLanes(hqRoot).map((lane) => [lane.lane_id, lane])) : undefined;
|
|
7108
|
+
const localQuestions = loadAllQuestions(hqRoot)
|
|
6593
7109
|
.filter((q) => {
|
|
6594
7110
|
if (status && q.status !== status)
|
|
6595
7111
|
return false;
|
|
@@ -6599,26 +7115,116 @@ async function runQuestionsList(ctx, opts) {
|
|
|
6599
7115
|
return false;
|
|
6600
7116
|
if (opts.story && q.story_id !== opts.story)
|
|
6601
7117
|
return false;
|
|
6602
|
-
if (company && !
|
|
7118
|
+
if (company && !(opts.allHosts
|
|
7119
|
+
? questionCompanyRefMatches(q.company, company)
|
|
7120
|
+
: companyRefMatches(q.company, company))) {
|
|
6603
7121
|
return false;
|
|
6604
7122
|
}
|
|
7123
|
+
if (company && localLanes) {
|
|
7124
|
+
const lane = localLanes.get(q.lane_id);
|
|
7125
|
+
if (!lane || !questionCompanyRefMatches(lane.company, company) || !sameQuestionCompany(q.company, lane.company)) {
|
|
7126
|
+
return false;
|
|
7127
|
+
}
|
|
7128
|
+
}
|
|
6605
7129
|
if (forFilter) {
|
|
6606
7130
|
const audience = resolveAudience(hqRoot, q, ctx);
|
|
6607
7131
|
if (!audienceMatches(audience, forFilter))
|
|
6608
7132
|
return false;
|
|
6609
7133
|
}
|
|
6610
7134
|
return true;
|
|
6611
|
-
})
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
7135
|
+
});
|
|
7136
|
+
const outpostLoad = opts.allHosts
|
|
7137
|
+
? await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company)
|
|
7138
|
+
: { questions: [], errors: [] };
|
|
7139
|
+
const remoteQuestionIds = new Set(outpostLoad.questions.map(({ question }) => question.question_id));
|
|
7140
|
+
const questions = [
|
|
7141
|
+
...localQuestions
|
|
7142
|
+
.filter((q) => !remoteQuestionIds.has(q.question_id))
|
|
7143
|
+
.map((q) => {
|
|
7144
|
+
const row = {
|
|
7145
|
+
...q,
|
|
7146
|
+
audience: resolveAudience(hqRoot, q, ctx),
|
|
7147
|
+
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
7148
|
+
};
|
|
7149
|
+
if (!opts.allHosts)
|
|
7150
|
+
return row;
|
|
7151
|
+
const lane = localLanes?.get(q.lane_id);
|
|
7152
|
+
const host = lane?.host.kind === "outpost" ? `outpost:${lane.host.id}` : "local";
|
|
7153
|
+
return { ...row, host };
|
|
7154
|
+
}),
|
|
7155
|
+
...outpostLoad.questions
|
|
7156
|
+
.filter(({ question: q, lane, audience }) => {
|
|
7157
|
+
if (status && q.status !== status)
|
|
7158
|
+
return false;
|
|
7159
|
+
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
7160
|
+
return false;
|
|
7161
|
+
if (opts.project && q.project_id !== opts.project)
|
|
7162
|
+
return false;
|
|
7163
|
+
if (opts.story && q.story_id !== opts.story)
|
|
7164
|
+
return false;
|
|
7165
|
+
if (company && (!questionCompanyRefMatches(q.company, company) ||
|
|
7166
|
+
!questionCompanyRefMatches(lane.company, company) ||
|
|
7167
|
+
!sameQuestionCompany(q.company, lane.company))) {
|
|
7168
|
+
return false;
|
|
7169
|
+
}
|
|
7170
|
+
if (forFilter) {
|
|
7171
|
+
if (!audienceMatches(audience, forFilter))
|
|
7172
|
+
return false;
|
|
7173
|
+
}
|
|
7174
|
+
return true;
|
|
7175
|
+
})
|
|
7176
|
+
.map(({ question: q, host, audience }) => ({
|
|
7177
|
+
...q,
|
|
7178
|
+
host,
|
|
7179
|
+
audience,
|
|
7180
|
+
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
7181
|
+
})),
|
|
7182
|
+
];
|
|
7183
|
+
printJson({
|
|
7184
|
+
ok: true,
|
|
7185
|
+
action: "questions.list",
|
|
7186
|
+
questions,
|
|
7187
|
+
...(outpostLoad.errors.length > 0 ? { errors: outpostLoad.errors } : {}),
|
|
7188
|
+
});
|
|
6618
7189
|
}
|
|
6619
7190
|
async function runQuestionsShow(ctx, questionId) {
|
|
6620
7191
|
const hqRoot = ctx.hqRoot();
|
|
6621
|
-
|
|
7192
|
+
let question;
|
|
7193
|
+
try {
|
|
7194
|
+
question = readQuestion(hqRoot, questionId);
|
|
7195
|
+
}
|
|
7196
|
+
catch (err) {
|
|
7197
|
+
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
7198
|
+
throw err;
|
|
7199
|
+
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
7200
|
+
if (lookup.errors.length > 0) {
|
|
7201
|
+
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
7202
|
+
question_id: questionId,
|
|
7203
|
+
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
7204
|
+
});
|
|
7205
|
+
}
|
|
7206
|
+
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
7207
|
+
if (matches.length === 0) {
|
|
7208
|
+
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
7209
|
+
}
|
|
7210
|
+
if (matches.length > 1) {
|
|
7211
|
+
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} is present on more than one Outpost`, { question_id: questionId, owner_count: matches.length });
|
|
7212
|
+
}
|
|
7213
|
+
const owner = matches[0];
|
|
7214
|
+
if (!owner) {
|
|
7215
|
+
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} has no unique Outpost owner`, { question_id: questionId });
|
|
7216
|
+
}
|
|
7217
|
+
const detail = await readOutpostQuestionDetail(ctx, owner);
|
|
7218
|
+
printJson({
|
|
7219
|
+
ok: true,
|
|
7220
|
+
action: "questions.show",
|
|
7221
|
+
question: detail.question,
|
|
7222
|
+
audience: detail.audience,
|
|
7223
|
+
body: detail.body,
|
|
7224
|
+
host: owner.host,
|
|
7225
|
+
});
|
|
7226
|
+
return;
|
|
7227
|
+
}
|
|
6622
7228
|
const body = readQuestionBody(hqRoot, question);
|
|
6623
7229
|
printJson({
|
|
6624
7230
|
ok: true,
|
|
@@ -6630,11 +7236,56 @@ async function runQuestionsShow(ctx, questionId) {
|
|
|
6630
7236
|
}
|
|
6631
7237
|
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
6632
7238
|
const hqRoot = ctx.hqRoot();
|
|
6633
|
-
const
|
|
7239
|
+
const company = opts.company ? await ctx.resolveCompany(opts.company) : undefined;
|
|
7240
|
+
let question;
|
|
7241
|
+
try {
|
|
7242
|
+
question = readQuestion(hqRoot, questionId);
|
|
7243
|
+
}
|
|
7244
|
+
catch (err) {
|
|
7245
|
+
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
7246
|
+
throw err;
|
|
7247
|
+
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
|
|
7248
|
+
if (lookup.errors.length > 0) {
|
|
7249
|
+
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
7250
|
+
question_id: questionId,
|
|
7251
|
+
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
7252
|
+
});
|
|
7253
|
+
}
|
|
7254
|
+
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
7255
|
+
if (matches.length === 0) {
|
|
7256
|
+
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
7257
|
+
}
|
|
7258
|
+
if (matches.length > 1) {
|
|
7259
|
+
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} is present on more than one Outpost`, { question_id: questionId, owner_count: matches.length });
|
|
7260
|
+
}
|
|
7261
|
+
const owner = matches[0];
|
|
7262
|
+
if (!owner) {
|
|
7263
|
+
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
7264
|
+
}
|
|
7265
|
+
if (!sameQuestionCompany(owner.question.company, owner.lane.company)) {
|
|
7266
|
+
throw new LanesError("outpost_question_company_mismatch", `Question ${questionId} does not match its primary lane company`, { question_id: questionId, lane_id: owner.lane.lane_id });
|
|
7267
|
+
}
|
|
7268
|
+
throwIfQuestionNotPending(owner.question);
|
|
7269
|
+
await yieldEventLoop();
|
|
7270
|
+
await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
|
|
7271
|
+
...opts,
|
|
7272
|
+
company: questionListCompanyArg(owner.lane.company),
|
|
7273
|
+
});
|
|
7274
|
+
return;
|
|
7275
|
+
}
|
|
6634
7276
|
await yieldEventLoop();
|
|
6635
7277
|
const askingLane = readLane(hqRoot, laneIdForAnswerDelivery(question));
|
|
7278
|
+
if (company &&
|
|
7279
|
+
(!questionCompanyRefMatches(question.company, company) ||
|
|
7280
|
+
!questionCompanyRefMatches(askingLane.company, company) ||
|
|
7281
|
+
!sameQuestionCompany(question.company, askingLane.company))) {
|
|
7282
|
+
throw new LanesError("question_company_mismatch", `Question ${questionId} does not belong to the requested company`, { question_id: questionId, lane_id: askingLane.lane_id });
|
|
7283
|
+
}
|
|
6636
7284
|
if (askingLane.host.kind === "outpost" && !laneIsHostedHere(ctx, askingLane)) {
|
|
6637
|
-
await dispatchOutpostQuestionAnswer(ctx, askingLane, questionId,
|
|
7285
|
+
await dispatchOutpostQuestionAnswer(ctx, askingLane, questionId, {
|
|
7286
|
+
...opts,
|
|
7287
|
+
...(askingLane.company ? { company: questionListCompanyArg(askingLane.company) } : {}),
|
|
7288
|
+
});
|
|
6638
7289
|
return;
|
|
6639
7290
|
}
|
|
6640
7291
|
throwIfQuestionNotPending(question);
|
|
@@ -7587,6 +8238,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
7587
8238
|
questions
|
|
7588
8239
|
.command("list")
|
|
7589
8240
|
.description("List questions")
|
|
8241
|
+
.option("--all-hosts", "Include questions from Outposts that own this primary's lanes")
|
|
7590
8242
|
.option("--for <ref>", "Who must decide (ref | me | user)")
|
|
7591
8243
|
.option("--from <lane-id>", "Which lane asked")
|
|
7592
8244
|
.option("--company <slug|uid>", "Filter by company")
|
|
@@ -7610,6 +8262,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
7610
8262
|
.option("--choice <label|N>", "1-based index or option label")
|
|
7611
8263
|
.option("--notes <text>", "Free-text notes stored with the body")
|
|
7612
8264
|
.option("--freeform <text>", "Supply a free-form answer for a question with options")
|
|
8265
|
+
.option("--company <slug|uid>", "Require the question lane to match this company")
|
|
7613
8266
|
.action(wrap(async (questionId, opts) => {
|
|
7614
8267
|
await runQuestionsAnswer(ctx, questionId, opts);
|
|
7615
8268
|
}));
|