@indigoai-us/hq-cli 5.242.0 → 5.244.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 +42 -0
- package/assets/bot-workers/setup/skills/agents-and-team.md +17 -4
- package/assets/bot-workers/setup/skills/your-business.md +11 -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/bot/progress.d.ts +0 -3
- package/dist/lib/bot/progress.js +0 -3
- package/dist/lib/bot/run.js +5 -7
- 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/cognito-session.js +2 -1
- package/dist/utils/exclusive-lock.d.ts +32 -0
- package/dist/utils/exclusive-lock.js +75 -0
- 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
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
|
|
2
|
+
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
3
|
+
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
4
|
+
import { appendCappedHistory, createQuestion, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
|
|
5
|
+
import { resolveQuestionCompanyWithFallback } from "../lib/lanes/questions.js";
|
|
6
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
7
|
+
export function resolveAnswerChoice(choice, options, freeform) {
|
|
8
|
+
const trimmed = choice.trim();
|
|
9
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
10
|
+
const index = Number(trimmed);
|
|
11
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
12
|
+
return options[index - 1];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (options.includes(trimmed) || options.includes(choice)) {
|
|
16
|
+
return options.includes(trimmed) ? trimmed : choice;
|
|
17
|
+
}
|
|
18
|
+
if (freeform)
|
|
19
|
+
return choice;
|
|
20
|
+
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
21
|
+
}
|
|
22
|
+
export function createLaneQuestionsHandlers(deps) {
|
|
23
|
+
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
24
|
+
function requireQuestionBody(hqRoot, question) {
|
|
25
|
+
const body = readQuestionBody(hqRoot, question);
|
|
26
|
+
if (!body) {
|
|
27
|
+
throw new LanesError("question_body_unavailable", `Question body is not available on this host for ${question.question_id}`, {
|
|
28
|
+
question_id: question.question_id,
|
|
29
|
+
store: question.body.store,
|
|
30
|
+
ref: question.body.ref,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
function assertQuestionCompanyAccess(question, lane, requestedCompany, hqRoot) {
|
|
36
|
+
assertQuestionCompanyMatchesLane(question, lane, "question_company_mismatch", hqRoot);
|
|
37
|
+
const questionCompany = questionCompanyForLane(question, lane, hqRoot);
|
|
38
|
+
if (questionCompany !== null && !sameQuestionCompany(questionCompany, requestedCompany)) {
|
|
39
|
+
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 });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function assertQuestionCompanyResolvedForAnswer(question, lane, hqRoot, resolvedCompany) {
|
|
43
|
+
if (questionCompanyForLane(question, lane, hqRoot) !== null || resolvedCompany)
|
|
44
|
+
return;
|
|
45
|
+
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 });
|
|
46
|
+
}
|
|
47
|
+
function audienceMatches(audience, filter) {
|
|
48
|
+
if (filter.kind === "me") {
|
|
49
|
+
return audience.kind === "session" && audience.id === filter.id;
|
|
50
|
+
}
|
|
51
|
+
if (filter.kind === "user")
|
|
52
|
+
return audience.kind === "user";
|
|
53
|
+
return audience.kind === filter.kind && audience.id === filter.id;
|
|
54
|
+
}
|
|
55
|
+
function parseForFilter(raw, ctx) {
|
|
56
|
+
const trimmed = raw.trim();
|
|
57
|
+
if (trimmed === "me") {
|
|
58
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
59
|
+
if (!sessionId) {
|
|
60
|
+
throw new LanesError("missing_session_id", "--for me requires a current session id (HQ_SESSION_ID)", {});
|
|
61
|
+
}
|
|
62
|
+
return { kind: "me", id: sessionId };
|
|
63
|
+
}
|
|
64
|
+
return parseSeniorRef(trimmed);
|
|
65
|
+
}
|
|
66
|
+
function yieldEventLoop() {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
setImmediate(resolve);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function throwIfQuestionNotPending(current) {
|
|
72
|
+
if (current.status === "pending")
|
|
73
|
+
return;
|
|
74
|
+
throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
|
|
75
|
+
}
|
|
76
|
+
async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
|
|
77
|
+
if (lane.host.kind !== "outpost") {
|
|
78
|
+
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to answer its question`, { lane_id: lane.lane_id, question_id: questionId });
|
|
79
|
+
}
|
|
80
|
+
const outpostId = lane.host.id;
|
|
81
|
+
let dispatched;
|
|
82
|
+
try {
|
|
83
|
+
dispatched = await ctx.dispatchOutpostExec({
|
|
84
|
+
outpostId,
|
|
85
|
+
command: buildOutpostQuestionAnswerCommand({
|
|
86
|
+
questionId,
|
|
87
|
+
choice: options.choice,
|
|
88
|
+
freeform: options.freeform,
|
|
89
|
+
notes: options.notes,
|
|
90
|
+
company: options.company,
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
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 });
|
|
96
|
+
}
|
|
97
|
+
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0) {
|
|
98
|
+
throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} did not confirm the answer for question ${questionId}`, {
|
|
99
|
+
lane_id: lane.lane_id,
|
|
100
|
+
question_id: questionId,
|
|
101
|
+
outpost_id: outpostId,
|
|
102
|
+
command_id: dispatched.commandId,
|
|
103
|
+
status: dispatched.status,
|
|
104
|
+
done: dispatched.done ?? false,
|
|
105
|
+
exit_code: dispatched.exitCode,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const remote = parseRemoteJson(dispatched.stdout, "Outpost question answer");
|
|
109
|
+
if (remote?.error !== undefined) {
|
|
110
|
+
throw new LanesError("outpost_remote_command_failed", typeof remote.message === "string"
|
|
111
|
+
? remote.message
|
|
112
|
+
: `Outpost ${outpostId} rejected the answer for question ${questionId}`, {
|
|
113
|
+
lane_id: lane.lane_id,
|
|
114
|
+
question_id: questionId,
|
|
115
|
+
outpost_id: outpostId,
|
|
116
|
+
command_id: dispatched.commandId,
|
|
117
|
+
remote_error: remote.error,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const remoteQuestion = remote?.question !== null && typeof remote?.question === "object"
|
|
121
|
+
? remote.question
|
|
122
|
+
: null;
|
|
123
|
+
const awaitingWakeConfirmed = lane.state !== "awaiting_input" ||
|
|
124
|
+
(remote?.wake_status === "resumed" && remote.lane_state === "running") ||
|
|
125
|
+
(remote?.wake_status === "queued" && remote.lane_state === "queued") ||
|
|
126
|
+
(remote?.wake_status === "awaiting_answers" && remote.lane_state === "awaiting_input");
|
|
127
|
+
if (remote?.ok !== true ||
|
|
128
|
+
remote.action !== "questions.answer" ||
|
|
129
|
+
remoteQuestion?.question_id !== questionId ||
|
|
130
|
+
remoteQuestion.lane_id !== lane.lane_id ||
|
|
131
|
+
remoteQuestion.status !== "answered" ||
|
|
132
|
+
typeof remote.delivery !== "string" ||
|
|
133
|
+
!awaitingWakeConfirmed) {
|
|
134
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without confirming the answer and wake status for question ${questionId}`, {
|
|
135
|
+
lane_id: lane.lane_id,
|
|
136
|
+
question_id: questionId,
|
|
137
|
+
outpost_id: outpostId,
|
|
138
|
+
command_id: dispatched.commandId,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
let ownerQuestion;
|
|
142
|
+
try {
|
|
143
|
+
ownerQuestion = parseQuestionRecord(remoteQuestion);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return a valid answered question record for ${questionId}: ${dispatchErrorMessage(err)}`, {
|
|
147
|
+
lane_id: lane.lane_id,
|
|
148
|
+
question_id: questionId,
|
|
149
|
+
outpost_id: outpostId,
|
|
150
|
+
command_id: dispatched.commandId,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const ownerAnswer = ownerQuestion.answer;
|
|
154
|
+
const ownerLaneCompany = effectiveLaneQuestionCompany(lane, ctx.hqRoot());
|
|
155
|
+
if (!isQuestionAnswer(ownerAnswer) ||
|
|
156
|
+
ownerQuestion.lane_id !== lane.lane_id ||
|
|
157
|
+
(ownerQuestion.company !== null && ownerLaneCompany !== null &&
|
|
158
|
+
!sameQuestionCompany(ownerQuestion.company, ownerLaneCompany))) {
|
|
159
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return the confirmed answer for question ${questionId}`, {
|
|
160
|
+
lane_id: lane.lane_id,
|
|
161
|
+
question_id: questionId,
|
|
162
|
+
outpost_id: outpostId,
|
|
163
|
+
command_id: dispatched.commandId,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const updatePrimaryCopy = () => casUpdateQuestion(ctx.hqRoot(), questionId, (current) => {
|
|
167
|
+
if (current.lane_id !== lane.lane_id) {
|
|
168
|
+
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 });
|
|
169
|
+
}
|
|
170
|
+
if (current.company !== null &&
|
|
171
|
+
effectiveLaneQuestionCompany(lane, ctx.hqRoot()) !== null &&
|
|
172
|
+
!sameQuestionCompany(current.company, effectiveLaneQuestionCompany(lane, ctx.hqRoot()))) {
|
|
173
|
+
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 });
|
|
174
|
+
}
|
|
175
|
+
if (current.company !== null &&
|
|
176
|
+
ownerQuestion.company !== null &&
|
|
177
|
+
!sameQuestionCompany(current.company, ownerQuestion.company)) {
|
|
178
|
+
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 });
|
|
179
|
+
}
|
|
180
|
+
if (current.status === "pending") {
|
|
181
|
+
return {
|
|
182
|
+
...current,
|
|
183
|
+
company: current.company ?? ownerQuestion.company,
|
|
184
|
+
status: "answered",
|
|
185
|
+
answer: ownerAnswer,
|
|
186
|
+
delivered_at: ownerQuestion.delivered_at ?? current.delivered_at ?? null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (current.status === "answered" && sameQuestionAnswer(current.answer, ownerAnswer)) {
|
|
190
|
+
const deliveredAt = ownerQuestion.delivered_at ?? current.delivered_at ?? null;
|
|
191
|
+
const company = current.company ?? ownerQuestion.company;
|
|
192
|
+
return deliveredAt === current.delivered_at && company === current.company
|
|
193
|
+
? current
|
|
194
|
+
: { ...current, company, delivered_at: deliveredAt };
|
|
195
|
+
}
|
|
196
|
+
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 });
|
|
197
|
+
});
|
|
198
|
+
let syncedQuestion;
|
|
199
|
+
try {
|
|
200
|
+
syncedQuestion = updatePrimaryCopy();
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
204
|
+
throw err;
|
|
205
|
+
const primaryLane = readLane(ctx.hqRoot(), lane.lane_id);
|
|
206
|
+
const primaryCompany = effectiveLaneQuestionCompany(primaryLane, ctx.hqRoot());
|
|
207
|
+
const ownerCompany = effectiveLaneQuestionCompany(lane, ctx.hqRoot());
|
|
208
|
+
if (primaryCompany !== null && ownerCompany !== null && !sameQuestionCompany(primaryCompany, ownerCompany)) {
|
|
209
|
+
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 });
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
syncedQuestion = createQuestion(ctx.hqRoot(), ownerQuestion);
|
|
213
|
+
}
|
|
214
|
+
catch (createErr) {
|
|
215
|
+
if (!isLanesError(createErr) || createErr.error !== "question_exists")
|
|
216
|
+
throw createErr;
|
|
217
|
+
syncedQuestion = updatePrimaryCopy();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
printJson({
|
|
221
|
+
...remote,
|
|
222
|
+
lane_id: lane.lane_id,
|
|
223
|
+
question: syncedQuestion,
|
|
224
|
+
outpost_id: outpostId,
|
|
225
|
+
outpost_command_id: dispatched.commandId,
|
|
226
|
+
outpost_transport: "outposts_exec",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async function readOutpostQuestionDetail(ctx, owner) {
|
|
230
|
+
if (owner.lane.host.kind !== "outpost") {
|
|
231
|
+
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 });
|
|
232
|
+
}
|
|
233
|
+
const outpostId = owner.lane.host.id;
|
|
234
|
+
let result;
|
|
235
|
+
try {
|
|
236
|
+
result = await ctx.dispatchOutpostExec({
|
|
237
|
+
outpostId,
|
|
238
|
+
command: buildOutpostQuestionShowCommand(owner.question.question_id),
|
|
239
|
+
timeoutSeconds: CAPACITY_PROBE_TIMEOUT_MS / 1000,
|
|
240
|
+
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
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 });
|
|
245
|
+
}
|
|
246
|
+
if (result.done !== true || result.status !== "Success" || result.exitCode !== 0) {
|
|
247
|
+
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 });
|
|
248
|
+
}
|
|
249
|
+
let raw;
|
|
250
|
+
try {
|
|
251
|
+
raw = JSON.parse(result.stdout);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned invalid question-show JSON`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
255
|
+
}
|
|
256
|
+
if (raw === null ||
|
|
257
|
+
typeof raw !== "object" ||
|
|
258
|
+
Array.isArray(raw) ||
|
|
259
|
+
raw.ok !== true ||
|
|
260
|
+
raw.action !== "questions.show" ||
|
|
261
|
+
!Object.prototype.hasOwnProperty.call(raw, "body")) {
|
|
262
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned a malformed question-show response`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
263
|
+
}
|
|
264
|
+
const response = raw;
|
|
265
|
+
let question;
|
|
266
|
+
try {
|
|
267
|
+
question = parseQuestionRecord(response.question);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
throw new LanesError("outpost_question_show_invalid", `Outpost ${outpostId} returned an invalid question record`, { outpost_id: outpostId, question_id: owner.question.question_id });
|
|
271
|
+
}
|
|
272
|
+
if (question.question_id !== owner.question.question_id ||
|
|
273
|
+
question.lane_id !== owner.lane.lane_id) {
|
|
274
|
+
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 });
|
|
275
|
+
}
|
|
276
|
+
assertQuestionCompanyMatchesLane(question, owner.lane, "outpost_question_company_mismatch", ctx.hqRoot());
|
|
277
|
+
const audience = parseOutpostQuestionAudience(response.audience, outpostId, 0);
|
|
278
|
+
const body = parseOutpostQuestionBody(response.body, outpostId, question.question_id);
|
|
279
|
+
return { question, audience, body };
|
|
280
|
+
}
|
|
281
|
+
async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
|
|
282
|
+
const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
|
|
283
|
+
(!companyFilter ||
|
|
284
|
+
effectiveLaneQuestionCompany(lane, hqRoot) === null ||
|
|
285
|
+
questionCompanyRefMatches(effectiveLaneQuestionCompany(lane, hqRoot), companyFilter)));
|
|
286
|
+
const lanesByOutpost = new Map();
|
|
287
|
+
for (const lane of hostedLanes) {
|
|
288
|
+
if (lane.host.kind !== "outpost")
|
|
289
|
+
continue;
|
|
290
|
+
const lanes = lanesByOutpost.get(lane.host.id) ?? [];
|
|
291
|
+
lanes.push(lane);
|
|
292
|
+
lanesByOutpost.set(lane.host.id, lanes);
|
|
293
|
+
}
|
|
294
|
+
const questions = [];
|
|
295
|
+
const errors = [];
|
|
296
|
+
const eligibleGroups = [];
|
|
297
|
+
for (const [outpostId, lanes] of [...lanesByOutpost.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
298
|
+
for (const group of groupQuestionLanesByCompany(hqRoot, lanes, companyFilter)) {
|
|
299
|
+
if (group.company === null) {
|
|
300
|
+
errors.push({
|
|
301
|
+
host: `outpost:${outpostId}`,
|
|
302
|
+
error: "outpost_question_company_unavailable",
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (companyFilter && !questionCompanyRefMatches(group.company, companyFilter))
|
|
307
|
+
continue;
|
|
308
|
+
eligibleGroups.push({ outpostId, group });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (eligibleGroups.length === 0) {
|
|
312
|
+
return { questions, errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()] };
|
|
313
|
+
}
|
|
314
|
+
let knownOutpostIds;
|
|
315
|
+
try {
|
|
316
|
+
knownOutpostIds = await ctx.listOutpostIds();
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return {
|
|
320
|
+
questions,
|
|
321
|
+
errors: [...new Map([
|
|
322
|
+
...errors,
|
|
323
|
+
...[...new Set(eligibleGroups.map(({ outpostId }) => outpostId))].map((outpostId) => ({
|
|
324
|
+
host: `outpost:${outpostId}`,
|
|
325
|
+
error: "outpost_list_unavailable",
|
|
326
|
+
})),
|
|
327
|
+
].map((entry) => [entry.host, entry])).values()],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const normalizedOutpostIds = knownOutpostIds.map((id) => typeof id === "string" ? id.trim() : "");
|
|
331
|
+
if (knownOutpostIds.length > MAX_PLACEMENT_OUTPOSTS ||
|
|
332
|
+
normalizedOutpostIds.some((id) => id === "") ||
|
|
333
|
+
new Set(normalizedOutpostIds).size !== normalizedOutpostIds.length) {
|
|
334
|
+
throw new LanesError("outpost_list_invalid", "Outpost list is malformed or exceeds the placement limit", { count: knownOutpostIds.length, maximum: MAX_PLACEMENT_OUTPOSTS });
|
|
335
|
+
}
|
|
336
|
+
const knownOutpostIdSet = new Set(normalizedOutpostIds);
|
|
337
|
+
const loadedGroups = await mapWithConcurrency(eligibleGroups, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, async ({ outpostId, group }) => {
|
|
338
|
+
if (!knownOutpostIdSet.has(outpostId)) {
|
|
339
|
+
return {
|
|
340
|
+
questions: [],
|
|
341
|
+
errors: [{ host: `outpost:${outpostId}`, error: "unknown_outpost" }],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const remoteQuestions = await readOutpostQuestionRecords(ctx, outpostId, group.company);
|
|
346
|
+
const localLanesById = new Map(group.lanes.map((lane) => [lane.lane_id, lane]));
|
|
347
|
+
const groupQuestions = [];
|
|
348
|
+
const seenQuestionIds = new Set();
|
|
349
|
+
for (const { question, audience } of remoteQuestions) {
|
|
350
|
+
const lane = localLanesById.get(question.lane_id);
|
|
351
|
+
if (!lane)
|
|
352
|
+
continue;
|
|
353
|
+
assertQuestionCompanyMatchesLane(question, lane, "outpost_question_company_mismatch", hqRoot);
|
|
354
|
+
if (seenQuestionIds.has(question.question_id)) {
|
|
355
|
+
throw new LanesError("outpost_question_list_invalid", `Outpost ${outpostId} returned a duplicate question id`, { outpost_id: outpostId, question_id: question.question_id });
|
|
356
|
+
}
|
|
357
|
+
seenQuestionIds.add(question.question_id);
|
|
358
|
+
groupQuestions.push({
|
|
359
|
+
question,
|
|
360
|
+
lane,
|
|
361
|
+
host: `outpost:${outpostId}`,
|
|
362
|
+
audience,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return { questions: groupQuestions, errors: [] };
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
return {
|
|
369
|
+
questions: [],
|
|
370
|
+
errors: [{
|
|
371
|
+
host: `outpost:${outpostId}`,
|
|
372
|
+
error: isLanesError(err) ? err.error : "outpost_question_list_unavailable",
|
|
373
|
+
}],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
questions.push(...loadedGroups.flatMap(({ questions: groupQuestions }) => groupQuestions));
|
|
378
|
+
errors.push(...loadedGroups.flatMap(({ errors: groupErrors }) => groupErrors));
|
|
379
|
+
return {
|
|
380
|
+
questions,
|
|
381
|
+
errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function syncPrimaryQuestionDelivery(hqRoot, owner) {
|
|
385
|
+
const deliveredAt = owner.question.delivered_at;
|
|
386
|
+
if (!deliveredAt)
|
|
387
|
+
return;
|
|
388
|
+
try {
|
|
389
|
+
casUpdateQuestion(hqRoot, owner.question.question_id, (current) => {
|
|
390
|
+
if (current.lane_id !== owner.lane.lane_id) {
|
|
391
|
+
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 });
|
|
392
|
+
}
|
|
393
|
+
if (current.company !== null &&
|
|
394
|
+
owner.question.company !== null &&
|
|
395
|
+
!sameQuestionCompany(current.company, owner.question.company)) {
|
|
396
|
+
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 });
|
|
397
|
+
}
|
|
398
|
+
const company = current.company ?? owner.question.company;
|
|
399
|
+
const nextDeliveredAt = current.delivered_at ?? deliveredAt;
|
|
400
|
+
return company === current.company && nextDeliveredAt === current.delivered_at
|
|
401
|
+
? current
|
|
402
|
+
: { ...current, company, delivered_at: nextDeliveredAt };
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
if (isLanesError(err) && err.error === "question_not_found")
|
|
407
|
+
return;
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function deliverAnswerToAskingLane(ctx, question, choice, at) {
|
|
412
|
+
const hqRoot = ctx.hqRoot();
|
|
413
|
+
const laneId = laneIdForAnswerDelivery(question);
|
|
414
|
+
try {
|
|
415
|
+
const lane = readLane(hqRoot, laneId);
|
|
416
|
+
if (!laneAcceptsDropboxDelivery(lane)) {
|
|
417
|
+
return { status: "recorded_only" };
|
|
418
|
+
}
|
|
419
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "deliver an answer to");
|
|
420
|
+
appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
|
|
421
|
+
return { status: "awaiting_next_tool_call" };
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
writeAnswerDeliveryFailed(hqRoot, laneId, question.question_id, err, at);
|
|
425
|
+
return { status: "failed" };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function runQuestionsList(ctx, opts) {
|
|
429
|
+
const hqRoot = ctx.hqRoot();
|
|
430
|
+
const status = opts.status ? parseQuestionStatus(opts.status) : undefined;
|
|
431
|
+
const forFilter = opts.for ? parseForFilter(opts.for, ctx) : undefined;
|
|
432
|
+
const company = opts.company
|
|
433
|
+
? await ctx.resolveCompany(opts.company)
|
|
434
|
+
: await resolveActiveQuestionCompany(ctx, "Cannot access lane questions because the active HQ company could not be resolved; re-run with --company <slug>");
|
|
435
|
+
const localLanes = new Map(loadAllLanes(hqRoot).map((lane) => [lane.lane_id, lane]));
|
|
436
|
+
const targeted = opts.from !== undefined || opts.project !== undefined || opts.story !== undefined;
|
|
437
|
+
const localQuestions = loadAllQuestions(hqRoot)
|
|
438
|
+
.filter((q) => {
|
|
439
|
+
if (status && q.status !== status)
|
|
440
|
+
return false;
|
|
441
|
+
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
442
|
+
return false;
|
|
443
|
+
if (opts.project && q.project_id !== opts.project)
|
|
444
|
+
return false;
|
|
445
|
+
if (opts.story && q.story_id !== opts.story)
|
|
446
|
+
return false;
|
|
447
|
+
const lane = localLanes.get(q.lane_id);
|
|
448
|
+
if (lane)
|
|
449
|
+
assertQuestionCompanyMatchesLane(q, lane, "question_company_mismatch", hqRoot);
|
|
450
|
+
const questionCompany = lane ? questionCompanyForLane(q, lane, hqRoot) : q.company;
|
|
451
|
+
if (questionCompany !== null && !sameQuestionCompany(questionCompany, company)) {
|
|
452
|
+
if (targeted) {
|
|
453
|
+
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 });
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
if (forFilter) {
|
|
458
|
+
const audience = resolveAudience(hqRoot, q, ctx);
|
|
459
|
+
if (!audienceMatches(audience, forFilter))
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
return true;
|
|
463
|
+
});
|
|
464
|
+
const outpostLoad = opts.allHosts
|
|
465
|
+
? await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company)
|
|
466
|
+
: { questions: [], errors: [] };
|
|
467
|
+
for (const owner of outpostLoad.questions) {
|
|
468
|
+
assertQuestionCompanyAccess(owner.question, owner.lane, company, hqRoot);
|
|
469
|
+
syncPrimaryQuestionDelivery(hqRoot, owner);
|
|
470
|
+
}
|
|
471
|
+
const remoteQuestionIds = new Set(outpostLoad.questions.map(({ question }) => question.question_id));
|
|
472
|
+
const questions = [
|
|
473
|
+
...localQuestions
|
|
474
|
+
.filter((q) => !remoteQuestionIds.has(q.question_id))
|
|
475
|
+
.map((q) => {
|
|
476
|
+
const row = {
|
|
477
|
+
...q,
|
|
478
|
+
audience: resolveAudience(hqRoot, q, ctx),
|
|
479
|
+
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
480
|
+
};
|
|
481
|
+
if (!opts.allHosts)
|
|
482
|
+
return row;
|
|
483
|
+
const lane = localLanes.get(q.lane_id);
|
|
484
|
+
const host = lane?.host.kind === "outpost" ? `outpost:${lane.host.id}` : "local";
|
|
485
|
+
return { ...row, host };
|
|
486
|
+
}),
|
|
487
|
+
...outpostLoad.questions
|
|
488
|
+
.filter(({ question: q, lane, audience }) => {
|
|
489
|
+
if (status && q.status !== status)
|
|
490
|
+
return false;
|
|
491
|
+
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
492
|
+
return false;
|
|
493
|
+
if (opts.project && q.project_id !== opts.project)
|
|
494
|
+
return false;
|
|
495
|
+
if (opts.story && q.story_id !== opts.story)
|
|
496
|
+
return false;
|
|
497
|
+
assertQuestionCompanyAccess(q, lane, company, hqRoot);
|
|
498
|
+
if (forFilter) {
|
|
499
|
+
if (!audienceMatches(audience, forFilter))
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
return true;
|
|
503
|
+
})
|
|
504
|
+
.map(({ question: q, host, audience }) => ({
|
|
505
|
+
...q,
|
|
506
|
+
host,
|
|
507
|
+
audience,
|
|
508
|
+
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
509
|
+
})),
|
|
510
|
+
];
|
|
511
|
+
printJson({
|
|
512
|
+
ok: true,
|
|
513
|
+
action: "questions.list",
|
|
514
|
+
questions,
|
|
515
|
+
...(outpostLoad.errors.length > 0 ? { errors: outpostLoad.errors } : {}),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
async function runQuestionsShow(ctx, questionId) {
|
|
519
|
+
const hqRoot = ctx.hqRoot();
|
|
520
|
+
let question;
|
|
521
|
+
try {
|
|
522
|
+
question = readQuestion(hqRoot, questionId);
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
526
|
+
throw err;
|
|
527
|
+
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
528
|
+
if (lookup.errors.length > 0) {
|
|
529
|
+
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
530
|
+
question_id: questionId,
|
|
531
|
+
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
535
|
+
if (matches.length === 0) {
|
|
536
|
+
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
537
|
+
}
|
|
538
|
+
if (matches.length > 1) {
|
|
539
|
+
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} is present on more than one Outpost`, { question_id: questionId, owner_count: matches.length });
|
|
540
|
+
}
|
|
541
|
+
const owner = matches[0];
|
|
542
|
+
if (!owner) {
|
|
543
|
+
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} has no unique Outpost owner`, { question_id: questionId });
|
|
544
|
+
}
|
|
545
|
+
const detail = await readOutpostQuestionDetail(ctx, owner);
|
|
546
|
+
printJson({
|
|
547
|
+
ok: true,
|
|
548
|
+
action: "questions.show",
|
|
549
|
+
question: detail.question,
|
|
550
|
+
audience: detail.audience,
|
|
551
|
+
body: detail.body,
|
|
552
|
+
host: owner.host,
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const body = readQuestionBody(hqRoot, question);
|
|
557
|
+
printJson({
|
|
558
|
+
ok: true,
|
|
559
|
+
action: "questions.show",
|
|
560
|
+
question,
|
|
561
|
+
audience: resolveAudience(hqRoot, question, ctx),
|
|
562
|
+
body,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
566
|
+
const hqRoot = ctx.hqRoot();
|
|
567
|
+
if (opts.choice !== undefined)
|
|
568
|
+
assertNoSecretText("answer choice", opts.choice);
|
|
569
|
+
if (opts.freeform !== undefined)
|
|
570
|
+
assertNoSecretText("freeform answer", opts.freeform);
|
|
571
|
+
if (opts.notes !== undefined)
|
|
572
|
+
assertNoSecretText("answer notes", opts.notes);
|
|
573
|
+
const explicitCompany = opts.company ? await ctx.resolveCompany(opts.company) : undefined;
|
|
574
|
+
let question;
|
|
575
|
+
try {
|
|
576
|
+
question = readQuestion(hqRoot, questionId);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
if (!isLanesError(err) || err.error !== "question_not_found")
|
|
580
|
+
throw err;
|
|
581
|
+
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>");
|
|
582
|
+
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
|
|
583
|
+
if (lookup.errors.length > 0) {
|
|
584
|
+
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
585
|
+
question_id: questionId,
|
|
586
|
+
hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
|
|
590
|
+
if (matches.length === 0) {
|
|
591
|
+
const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
592
|
+
const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
|
|
593
|
+
if (otherCompanyMatch) {
|
|
594
|
+
assertQuestionCompanyAccess(otherCompanyMatch.question, otherCompanyMatch.lane, company, hqRoot);
|
|
595
|
+
}
|
|
596
|
+
if (allCompanyLookup.errors.length > 0) {
|
|
597
|
+
throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
|
|
598
|
+
question_id: questionId,
|
|
599
|
+
hosts: allCompanyLookup.errors.map(({ host, error }) => ({ host, error })),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
603
|
+
}
|
|
604
|
+
if (matches.length > 1) {
|
|
605
|
+
throw new LanesError("outpost_question_ambiguous", `Question ${questionId} is present on more than one Outpost`, { question_id: questionId, owner_count: matches.length });
|
|
606
|
+
}
|
|
607
|
+
const owner = matches[0];
|
|
608
|
+
if (!owner) {
|
|
609
|
+
throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
|
|
610
|
+
}
|
|
611
|
+
assertQuestionCompanyAccess(owner.question, owner.lane, company, hqRoot);
|
|
612
|
+
const ownerCompany = await resolveQuestionCompanyWithFallback(hqRoot, owner.lane, async () => explicitCompany ?? company);
|
|
613
|
+
assertQuestionCompanyResolvedForAnswer(owner.question, owner.lane, hqRoot, ownerCompany);
|
|
614
|
+
throwIfQuestionNotPending(owner.question);
|
|
615
|
+
await yieldEventLoop();
|
|
616
|
+
await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
|
|
617
|
+
...opts,
|
|
618
|
+
company: questionListCompanyArg(ownerCompany),
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
await yieldEventLoop();
|
|
623
|
+
const askingLane = readLane(hqRoot, laneIdForAnswerDelivery(question));
|
|
624
|
+
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>`));
|
|
625
|
+
const requestedCompany = explicitCompany ?? resolvedCompany;
|
|
626
|
+
assertQuestionCompanyAccess(question, askingLane, requestedCompany, hqRoot);
|
|
627
|
+
assertQuestionCompanyResolvedForAnswer(question, askingLane, hqRoot, resolvedCompany);
|
|
628
|
+
const questionCompany = question.company ?? effectiveLaneQuestionCompany(askingLane, hqRoot) ?? resolvedCompany;
|
|
629
|
+
const questionForBody = question.company === null
|
|
630
|
+
? { ...question, company: questionCompany }
|
|
631
|
+
: question;
|
|
632
|
+
if (askingLane.host.kind === "outpost" && !laneIsHostedHere(ctx, askingLane)) {
|
|
633
|
+
await dispatchOutpostQuestionAnswer(ctx, askingLane, questionId, {
|
|
634
|
+
...opts,
|
|
635
|
+
company: questionListCompanyArg(questionCompany),
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
throwIfQuestionNotPending(question);
|
|
640
|
+
if (askingLane.state === "awaiting_input") {
|
|
641
|
+
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "wake after answering");
|
|
642
|
+
}
|
|
643
|
+
if (laneAcceptsDropboxDelivery(askingLane)) {
|
|
644
|
+
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "deliver an answer to");
|
|
645
|
+
}
|
|
646
|
+
const body = requireQuestionBody(hqRoot, questionForBody);
|
|
647
|
+
// A trimmed-empty --freeform is absent: it must not win over --choice and
|
|
648
|
+
// must not become a freeform answer of "".
|
|
649
|
+
const freeform = opts.freeform?.trim() || undefined;
|
|
650
|
+
if (freeform !== undefined && body.options.length === 0) {
|
|
651
|
+
throw new LanesError("freeform_not_allowed", "--freeform is only allowed for a question with options", { question_id: questionId });
|
|
652
|
+
}
|
|
653
|
+
if (!freeform && !opts.choice) {
|
|
654
|
+
throw new LanesError("missing_choice", "--choice or --freeform is required", {});
|
|
655
|
+
}
|
|
656
|
+
const choice = freeform ?? resolveAnswerChoice(opts.choice, body.options, false);
|
|
657
|
+
const at = ctx.now().toISOString();
|
|
658
|
+
const answeredBy = sessionIdFromEnv(ctx.env)
|
|
659
|
+
? `session:${sessionIdFromEnv(ctx.env)}`
|
|
660
|
+
: "user";
|
|
661
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
662
|
+
throwIfQuestionNotPending(current);
|
|
663
|
+
assertQuestionCompanyAccess(current, askingLane, requestedCompany, hqRoot);
|
|
664
|
+
const recordedCompany = current.company ?? questionCompany;
|
|
665
|
+
const currentForBody = current.company === null
|
|
666
|
+
? { ...current, company: recordedCompany }
|
|
667
|
+
: current;
|
|
668
|
+
let notesRef;
|
|
669
|
+
let nextBody = current.body;
|
|
670
|
+
if (opts.notes) {
|
|
671
|
+
const stored = writeQuestionBody(hqRoot, currentForBody, {
|
|
672
|
+
...body,
|
|
673
|
+
notes: opts.notes,
|
|
674
|
+
});
|
|
675
|
+
notesRef = stored.ref;
|
|
676
|
+
nextBody = stored;
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
...current,
|
|
680
|
+
company: recordedCompany,
|
|
681
|
+
body: nextBody,
|
|
682
|
+
status: "answered",
|
|
683
|
+
answer: {
|
|
684
|
+
choice,
|
|
685
|
+
answer_kind: freeform ? "freeform" : "option",
|
|
686
|
+
notes_ref: notesRef,
|
|
687
|
+
answered_by: answeredBy,
|
|
688
|
+
answered_at: at,
|
|
689
|
+
answered_level: current.hop,
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
const latestAskingLane = readLane(hqRoot, askingLane.lane_id);
|
|
694
|
+
if (latestAskingLane.state === "awaiting_input") {
|
|
695
|
+
let wake;
|
|
696
|
+
try {
|
|
697
|
+
wake = await wakeAnsweredLaneNow(hqRoot, askingLane.lane_id, {
|
|
698
|
+
now: ctx.now,
|
|
699
|
+
host: reconcileHostFromCtx(ctx),
|
|
700
|
+
dryRun: false,
|
|
701
|
+
spawnLaneDeps: {
|
|
702
|
+
env: ctx.env,
|
|
703
|
+
existsSync: ctx.existsSync,
|
|
704
|
+
spawnProcess: ctx.spawnProcess,
|
|
705
|
+
proveDetached: ctx.proveDetached,
|
|
706
|
+
killProcess: ctx.killProcess,
|
|
707
|
+
now: ctx.now,
|
|
708
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
709
|
+
runCompaction: ctx.runCompaction,
|
|
710
|
+
locateTranscript: ctx.locateTranscript,
|
|
711
|
+
},
|
|
712
|
+
throttleDeps: ctx.throttleDeps,
|
|
713
|
+
livenessDeps: livenessDepsFromCtx(ctx),
|
|
714
|
+
presenceConnected: ctx.presenceConnected,
|
|
715
|
+
mesh: ctx.mesh,
|
|
716
|
+
env: ctx.env,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
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 });
|
|
721
|
+
}
|
|
722
|
+
if (wake.status === "failed") {
|
|
723
|
+
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 });
|
|
724
|
+
}
|
|
725
|
+
printJson({
|
|
726
|
+
ok: true,
|
|
727
|
+
action: "questions.answer",
|
|
728
|
+
lane_id: askingLane.lane_id,
|
|
729
|
+
question: updated,
|
|
730
|
+
delivery: wake.status === "awaiting_answers" ? "awaiting_other_answers" : wake.status,
|
|
731
|
+
wake_status: wake.status,
|
|
732
|
+
lane_state: wake.lane_state,
|
|
733
|
+
...(wake.queued_reason !== undefined ? { queued_reason: wake.queued_reason } : {}),
|
|
734
|
+
...(wake.remaining_question_ids !== undefined
|
|
735
|
+
? { remaining_question_ids: wake.remaining_question_ids }
|
|
736
|
+
: {}),
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const delivery = deliverAnswerToAskingLane(ctx, updated, choice, at);
|
|
741
|
+
printJson({
|
|
742
|
+
ok: true,
|
|
743
|
+
action: "questions.answer",
|
|
744
|
+
question: updated,
|
|
745
|
+
delivery: delivery.status,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
async function runQuestionsEscalate(ctx, questionId, opts) {
|
|
749
|
+
const hqRoot = ctx.hqRoot();
|
|
750
|
+
const question = readQuestion(hqRoot, questionId);
|
|
751
|
+
throwIfQuestionNotPending(question);
|
|
752
|
+
await yieldEventLoop();
|
|
753
|
+
const originalHop = question.hop;
|
|
754
|
+
const audience = resolveAudience(hqRoot, question, ctx);
|
|
755
|
+
if (audience.kind === "user") {
|
|
756
|
+
throw new LanesError("already_at_top_authority", "Cannot escalate past the user", { question_id: questionId });
|
|
757
|
+
}
|
|
758
|
+
const at = ctx.now().toISOString();
|
|
759
|
+
if (opts.note) {
|
|
760
|
+
requireQuestionBody(hqRoot, question);
|
|
761
|
+
}
|
|
762
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
763
|
+
throwIfQuestionNotPending(current);
|
|
764
|
+
if (current.hop !== originalHop) {
|
|
765
|
+
throw new LanesError("question_already_escalated", `Question ${questionId} hop already advanced`, {
|
|
766
|
+
question_id: questionId,
|
|
767
|
+
hop: current.hop,
|
|
768
|
+
expected_hop: originalHop,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
let nextBody = current.body;
|
|
772
|
+
if (opts.note) {
|
|
773
|
+
const body = requireQuestionBody(hqRoot, current);
|
|
774
|
+
nextBody = writeQuestionBody(hqRoot, current, {
|
|
775
|
+
...body,
|
|
776
|
+
notes: [body.notes, opts.note].filter(Boolean).join("\n"),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
const nextHop = current.hop + 1;
|
|
780
|
+
return {
|
|
781
|
+
...current,
|
|
782
|
+
body: nextBody,
|
|
783
|
+
hop: nextHop,
|
|
784
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
785
|
+
hop: nextHop,
|
|
786
|
+
audience: resolveAudience(hqRoot, { ...current, hop: nextHop }, ctx),
|
|
787
|
+
at,
|
|
788
|
+
reason: "escalated",
|
|
789
|
+
}),
|
|
790
|
+
};
|
|
791
|
+
});
|
|
792
|
+
const nextAudience = resolveAudience(hqRoot, updated, ctx);
|
|
793
|
+
if (nextAudience.kind === "user") {
|
|
794
|
+
const lane = readLane(hqRoot, updated.lane_id);
|
|
795
|
+
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 });
|
|
796
|
+
}
|
|
797
|
+
printJson({ ok: true, action: "questions.escalate", question: updated });
|
|
798
|
+
}
|
|
799
|
+
async function runQuestionsCancel(ctx, questionId, opts) {
|
|
800
|
+
const hqRoot = ctx.hqRoot();
|
|
801
|
+
const question = readQuestion(hqRoot, questionId);
|
|
802
|
+
throwIfQuestionNotPending(question);
|
|
803
|
+
await yieldEventLoop();
|
|
804
|
+
const at = ctx.now().toISOString();
|
|
805
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
806
|
+
throwIfQuestionNotPending(current);
|
|
807
|
+
const entry = {
|
|
808
|
+
hop: current.hop,
|
|
809
|
+
audience: resolveAudience(hqRoot, current, ctx),
|
|
810
|
+
at,
|
|
811
|
+
reason: "cancelled",
|
|
812
|
+
...(opts.reason ? { detail: opts.reason } : {}),
|
|
813
|
+
};
|
|
814
|
+
return {
|
|
815
|
+
...current,
|
|
816
|
+
status: "cancelled",
|
|
817
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
818
|
+
};
|
|
819
|
+
});
|
|
820
|
+
printJson({ ok: true, action: "questions.cancel", question: updated });
|
|
821
|
+
}
|
|
822
|
+
async function runSessionsList(ctx, opts) {
|
|
823
|
+
const hqRoot = ctx.hqRoot();
|
|
824
|
+
const state = opts.state ? parseLivenessState(opts.state) : undefined;
|
|
825
|
+
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
826
|
+
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
827
|
+
const rows = listSessionLiveness(hqRoot, {
|
|
828
|
+
nowMs: ctx.now().getTime(),
|
|
829
|
+
presenceConnected,
|
|
830
|
+
deps: livenessDeps,
|
|
831
|
+
}).filter((row) => {
|
|
832
|
+
if (state && row.state !== state)
|
|
833
|
+
return false;
|
|
834
|
+
if (!opts.company)
|
|
835
|
+
return true;
|
|
836
|
+
const meta = readSessionMeta(hqRoot, row.sessionId, ctx);
|
|
837
|
+
const slug = typeof meta?.company_slug === "string" ? meta.company_slug : "";
|
|
838
|
+
const uid = typeof meta?.company_uid === "string" ? meta.company_uid : "";
|
|
839
|
+
return slug === opts.company || uid === opts.company;
|
|
840
|
+
});
|
|
841
|
+
printJson({ ok: true, action: "sessions.list", sessions: rows });
|
|
842
|
+
}
|
|
843
|
+
async function runSessionsShow(ctx, sessionId) {
|
|
844
|
+
const hqRoot = ctx.hqRoot();
|
|
845
|
+
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
846
|
+
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
847
|
+
const state = sessionLiveness(hqRoot, sessionId, {
|
|
848
|
+
nowMs: ctx.now().getTime(),
|
|
849
|
+
presenceConnected,
|
|
850
|
+
deps: livenessDeps,
|
|
851
|
+
});
|
|
852
|
+
if (state === null) {
|
|
853
|
+
throw new LanesError("session_not_found", `Session ${sessionId} is not in the liveness index`, { session_id: sessionId });
|
|
854
|
+
}
|
|
855
|
+
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
856
|
+
printJson({
|
|
857
|
+
ok: true,
|
|
858
|
+
action: "sessions.show",
|
|
859
|
+
session_id: sessionId,
|
|
860
|
+
state,
|
|
861
|
+
senior: meta ? seniorOfSession(hqRoot, sessionId, ctx) : { kind: "user" },
|
|
862
|
+
company_slug: typeof meta?.company_slug === "string" ? meta.company_slug : null,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
runQuestionsList,
|
|
867
|
+
runQuestionsShow,
|
|
868
|
+
runQuestionsAnswer,
|
|
869
|
+
runQuestionsEscalate,
|
|
870
|
+
runQuestionsCancel,
|
|
871
|
+
runSessionsList,
|
|
872
|
+
runSessionsShow,
|
|
873
|
+
requireQuestionBody
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
//# sourceMappingURL=lanes-questions.js.map
|