@indigoai-us/hq-cli 5.124.0 → 5.126.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 +149 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/dist/command-catalog.generated.d.ts +414 -5
- package/dist/command-catalog.generated.js +531 -5
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +75 -0
- package/dist/commands/lanes.js +2032 -0
- package/dist/commands/login.js +49 -6
- package/dist/commands/mesh.js +133 -28
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/doctor/checks/work-context.js +4 -2
- package/dist/lib/lanes/accounts.d.ts +43 -0
- package/dist/lib/lanes/accounts.js +128 -0
- package/dist/lib/lanes/config.d.ts +56 -0
- package/dist/lib/lanes/config.js +351 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/index.d.ts +19 -0
- package/dist/lib/lanes/index.js +12 -0
- package/dist/lib/lanes/liveness.d.ts +89 -0
- package/dist/lib/lanes/liveness.js +273 -0
- package/dist/lib/lanes/registry.d.ts +103 -0
- package/dist/lib/lanes/registry.js +247 -0
- package/dist/lib/lanes/store.d.ts +64 -0
- package/dist/lib/lanes/store.js +627 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +193 -0
- package/dist/lib/lanes/types.js +217 -0
- package/dist/lib/mesh/live/backfill-held.d.ts +2 -0
- package/dist/lib/mesh/live/backfill-held.js +8 -4
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +6 -0
- package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +21 -1
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +11 -0
- package/dist/lib/mesh/live/daemon/run.js +129 -10
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/flush.d.ts +3 -0
- package/dist/lib/mesh/live/flush.js +11 -0
- package/dist/lib/scan-packages/index.js +17 -12
- package/dist/lib/work-context/company.d.ts +8 -0
- package/dist/lib/work-context/company.js +62 -18
- package/dist/lib/work-context/config.d.ts +55 -0
- package/dist/lib/work-context/config.js +333 -67
- package/dist/lib/work-context/contract.d.ts +2 -0
- package/dist/lib/work-context/default-company.d.ts +43 -0
- package/dist/lib/work-context/default-company.js +83 -0
- package/dist/lib/work-context/outbox.d.ts +5 -1
- package/dist/lib/work-context/outbox.js +5 -0
- package/dist/lib/work-context/reconcile.d.ts +4 -0
- package/dist/lib/work-context/reconcile.js +86 -9
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +14 -0
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- package/package.json +1 -1
|
@@ -0,0 +1,2032 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq lanes` — worker lane management and decision queue (spec §5–§7, §10–§11).
|
|
3
|
+
*
|
|
4
|
+
* Every command prints JSON on stdout and takes no interactive input, except
|
|
5
|
+
* `accounts login`, which hands the terminal to the vendor's own sign-in.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { OutpostsClient } from "@indigoai-us/hq-cloud/outposts";
|
|
13
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
14
|
+
import { findHqRoot } from "../utils/manifest.js";
|
|
15
|
+
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
16
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
17
|
+
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
18
|
+
import { appendThreadEvent, ensureProjectThread, patchStoryStatus, } from "../lib/mesh/api.js";
|
|
19
|
+
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
20
|
+
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_BODY_STORES, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultThrottleDeps, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readLane, readQuestion, resolveAccount, resolveLaneWorker, sessionLiveness, tryReadLane, updateLane, updateQuestion, writeLanesConfig, } from "../lib/lanes/index.js";
|
|
21
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
22
|
+
export const MAX_LANE_DEPTH = 2;
|
|
23
|
+
export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
24
|
+
export const OUTPOST_DIR = "/etc/outpost";
|
|
25
|
+
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
26
|
+
const VENDOR_LOGIN = {
|
|
27
|
+
claude: { command: "claude", args: ["auth", "login"] },
|
|
28
|
+
codex: { command: "codex", args: ["login"] },
|
|
29
|
+
grok: { command: "grok", args: ["login"] },
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Spawn seam for the worker process (spec §5). Process spawning and the
|
|
33
|
+
* reconcile daemon are out of scope for this command surface; calling this
|
|
34
|
+
* throws rather than faking a running lane.
|
|
35
|
+
*/
|
|
36
|
+
export function spawnLaneWorker(lane) {
|
|
37
|
+
throw new LanesError("not_implemented", `Lane process spawn is not implemented (lane ${lane.lane_id})`, { lane_id: lane.lane_id });
|
|
38
|
+
}
|
|
39
|
+
function printJson(value) {
|
|
40
|
+
console.log(JSON.stringify(value, null, 2));
|
|
41
|
+
}
|
|
42
|
+
function failJson(err) {
|
|
43
|
+
if (isLanesError(err)) {
|
|
44
|
+
printJson(err.toJSON());
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
process.stderr.write(`hq lanes: ${message}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
|
|
49
|
+
printJson({ error: "internal", message });
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
function wrap(action) {
|
|
53
|
+
return async (...args) => {
|
|
54
|
+
try {
|
|
55
|
+
await action(...args);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
failJson(err);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function collectRepeatable(value, previous = []) {
|
|
63
|
+
return previous.concat(value);
|
|
64
|
+
}
|
|
65
|
+
async function defaultResolveCompany(company) {
|
|
66
|
+
const { company: mesh } = await openMeshTransport({ company });
|
|
67
|
+
return { slug: mesh.companySlug, uid: mesh.companyUid };
|
|
68
|
+
}
|
|
69
|
+
async function defaultListOutpostIds() {
|
|
70
|
+
const client = new OutpostsClient({
|
|
71
|
+
transport: vaultApiFetch,
|
|
72
|
+
token: () => ensureCognitoToken({ interactive: false }),
|
|
73
|
+
});
|
|
74
|
+
const rows = await client.list();
|
|
75
|
+
return rows.map((row) => row.outpostId);
|
|
76
|
+
}
|
|
77
|
+
export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
|
|
78
|
+
const hasMarker = existsSync(OUTPOST_ID_PATH);
|
|
79
|
+
const hasDir = existsSync(OUTPOST_DIR);
|
|
80
|
+
if (hasMarker) {
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(OUTPOST_ID_PATH);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
process.stderr.write(`hq lanes: failed to read ${OUTPOST_ID_PATH}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
const id = text.trim();
|
|
90
|
+
if (!id)
|
|
91
|
+
return hasDir ? { kind: "unknown" } : { kind: "not_outpost" };
|
|
92
|
+
return { kind: "id", id };
|
|
93
|
+
}
|
|
94
|
+
if (hasDir)
|
|
95
|
+
return { kind: "unknown" };
|
|
96
|
+
return { kind: "not_outpost" };
|
|
97
|
+
}
|
|
98
|
+
function defaultSpawnLogin(opts) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const child = spawn(opts.command, opts.args, {
|
|
101
|
+
env: opts.env,
|
|
102
|
+
cwd: opts.cwd,
|
|
103
|
+
stdio: "inherit",
|
|
104
|
+
});
|
|
105
|
+
child.on("error", (err) => {
|
|
106
|
+
reject(err);
|
|
107
|
+
});
|
|
108
|
+
child.on("close", (code) => {
|
|
109
|
+
resolve({ status: code ?? 1 });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function toMeshCompany(company) {
|
|
114
|
+
return { companyUid: company.uid, companySlug: company.slug };
|
|
115
|
+
}
|
|
116
|
+
function createDefaultMesh() {
|
|
117
|
+
return {
|
|
118
|
+
async ensureProjectThread(company, projectId) {
|
|
119
|
+
const { token } = await openMeshTransport({ company: company.slug });
|
|
120
|
+
const ensured = await ensureProjectThread(token, toMeshCompany(company), projectId, { summary: `HQ lane ${projectId}` });
|
|
121
|
+
return ensured.threadId;
|
|
122
|
+
},
|
|
123
|
+
async appendThreadEvent(company, threadId, eventKind, payload) {
|
|
124
|
+
const { token } = await openMeshTransport({ company: company.slug });
|
|
125
|
+
await appendThreadEvent(token, company.uid, threadId, eventKind, payload);
|
|
126
|
+
},
|
|
127
|
+
async patchStoryStatus(company, projectId, storyId, status) {
|
|
128
|
+
const { token } = await openMeshTransport({ company: company.slug });
|
|
129
|
+
await patchStoryStatus(token, company.uid, projectId, storyId, status);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function defaultSleep(ms) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
setTimeout(resolve, ms);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function resolveDeps(deps = {}) {
|
|
139
|
+
const env = deps.env ?? process.env;
|
|
140
|
+
return {
|
|
141
|
+
hqRoot: deps.hqRoot ?? (() => findHqRoot()),
|
|
142
|
+
env,
|
|
143
|
+
now: deps.now ?? (() => new Date()),
|
|
144
|
+
hostname: deps.hostname ?? (() => os.hostname()),
|
|
145
|
+
resolveCompany: deps.resolveCompany ?? defaultResolveCompany,
|
|
146
|
+
listOutpostIds: deps.listOutpostIds ?? defaultListOutpostIds,
|
|
147
|
+
readOutpostIdentity: deps.readOutpostIdentity ?? (() => readOutpostIdentity()),
|
|
148
|
+
spawnLogin: deps.spawnLogin ?? defaultSpawnLogin,
|
|
149
|
+
throttleDeps: deps.throttleDeps ?? defaultThrottleDeps(env),
|
|
150
|
+
existsSync: deps.existsSync ?? ((p) => fs.existsSync(p)),
|
|
151
|
+
readFileSync: deps.readFileSync ?? ((p, encoding) => fs.readFileSync(p, encoding)),
|
|
152
|
+
homedir: deps.homedir ?? (() => os.homedir()),
|
|
153
|
+
mesh: deps.mesh ?? createDefaultMesh(),
|
|
154
|
+
sleep: deps.sleep ?? defaultSleep,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function parseSeniorRef(raw) {
|
|
158
|
+
const trimmed = raw.trim();
|
|
159
|
+
if (trimmed.length === 0) {
|
|
160
|
+
throw new LanesError("unknown_senior_kind", `Unknown senior ref ${JSON.stringify(raw)}. Accepted kinds: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value: raw });
|
|
161
|
+
}
|
|
162
|
+
if (trimmed === "user")
|
|
163
|
+
return { kind: "user" };
|
|
164
|
+
const colon = trimmed.indexOf(":");
|
|
165
|
+
if (colon <= 0) {
|
|
166
|
+
throw new LanesError("unknown_senior_kind", `Unknown senior ref ${JSON.stringify(raw)}. Accepted kinds: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value: raw });
|
|
167
|
+
}
|
|
168
|
+
const kindRaw = trimmed.slice(0, colon);
|
|
169
|
+
const id = trimmed.slice(colon + 1);
|
|
170
|
+
const kind = parseSeniorKind(kindRaw);
|
|
171
|
+
if (kind === "user") {
|
|
172
|
+
if (id.length > 0) {
|
|
173
|
+
throw new LanesError("invalid_senior_ref", "Senior kind user takes no id", { value: raw });
|
|
174
|
+
}
|
|
175
|
+
return { kind: "user" };
|
|
176
|
+
}
|
|
177
|
+
if (id.length === 0) {
|
|
178
|
+
throw new LanesError("invalid_senior_ref", `Senior kind ${kind} requires an id (got ${JSON.stringify(raw)})`, { kind, value: raw });
|
|
179
|
+
}
|
|
180
|
+
return { kind, id };
|
|
181
|
+
}
|
|
182
|
+
function seniorKey(ref) {
|
|
183
|
+
return ref.kind === "user" ? "user" : `${ref.kind}:${ref.id ?? ""}`;
|
|
184
|
+
}
|
|
185
|
+
export function resolveAnswerChoice(choice, options, freeform) {
|
|
186
|
+
const trimmed = choice.trim();
|
|
187
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
188
|
+
const index = Number(trimmed);
|
|
189
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
190
|
+
return options[index - 1];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (options.includes(trimmed) || options.includes(choice)) {
|
|
194
|
+
return options.includes(trimmed) ? trimmed : choice;
|
|
195
|
+
}
|
|
196
|
+
if (freeform)
|
|
197
|
+
return choice;
|
|
198
|
+
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
199
|
+
}
|
|
200
|
+
function parsePriority(raw) {
|
|
201
|
+
if (raw === undefined || raw === null || raw === "")
|
|
202
|
+
return 1;
|
|
203
|
+
const n = typeof raw === "number" ? raw : Number(String(raw));
|
|
204
|
+
if (!Number.isInteger(n)) {
|
|
205
|
+
throw new LanesError("invalid_priority", `--priority must be an integer`, {
|
|
206
|
+
value: raw,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return n;
|
|
210
|
+
}
|
|
211
|
+
function parseOptionalInt(raw, flag) {
|
|
212
|
+
if (raw === undefined || raw === null || raw === "")
|
|
213
|
+
return undefined;
|
|
214
|
+
const n = typeof raw === "number" ? raw : Number(String(raw));
|
|
215
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
216
|
+
throw new LanesError("invalid_integer", `${flag} must be a non-negative integer`, { flag, value: raw });
|
|
217
|
+
}
|
|
218
|
+
return n;
|
|
219
|
+
}
|
|
220
|
+
function runDir(hqRoot, laneId) {
|
|
221
|
+
return path.join(hqRoot, LANES_RUNS_REL, laneId);
|
|
222
|
+
}
|
|
223
|
+
function sha256Text(text) {
|
|
224
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
225
|
+
}
|
|
226
|
+
function ensureDir(dir) {
|
|
227
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
228
|
+
}
|
|
229
|
+
function writeJsonFile(filePath, value) {
|
|
230
|
+
ensureDir(path.dirname(filePath));
|
|
231
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
232
|
+
}
|
|
233
|
+
function readJsonFile(filePath) {
|
|
234
|
+
try {
|
|
235
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const code = err.code;
|
|
239
|
+
if (code === "ENOENT")
|
|
240
|
+
return null;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function parseQuestionBodyStore(value) {
|
|
245
|
+
if (typeof value === "string" &&
|
|
246
|
+
QUESTION_BODY_STORES.includes(value)) {
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
|
|
250
|
+
}
|
|
251
|
+
function bodyStoreForLane(lane) {
|
|
252
|
+
return lane.company.slug ? "company" : "personal";
|
|
253
|
+
}
|
|
254
|
+
/** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
|
|
255
|
+
export function questionBodyAbsPath(hqRoot, question) {
|
|
256
|
+
const store = parseQuestionBodyStore(question.body.store);
|
|
257
|
+
if (store === "company") {
|
|
258
|
+
if (!question.company.slug) {
|
|
259
|
+
throw new LanesError("question_body_store_unresolved", "Company question body is missing a company slug", { question_id: question.question_id });
|
|
260
|
+
}
|
|
261
|
+
return path.join(hqRoot, "companies", question.company.slug, "lanes", "questions", `${question.question_id}.json`);
|
|
262
|
+
}
|
|
263
|
+
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
264
|
+
}
|
|
265
|
+
function questionBodyRef(questionId) {
|
|
266
|
+
return path.posix.join("lanes", "questions", `${questionId}.json`);
|
|
267
|
+
}
|
|
268
|
+
function parseQuestionBody(raw) {
|
|
269
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
270
|
+
return null;
|
|
271
|
+
const obj = raw;
|
|
272
|
+
const options = Array.isArray(obj.options)
|
|
273
|
+
? obj.options.filter((item) => typeof item === "string")
|
|
274
|
+
: [];
|
|
275
|
+
const body = {
|
|
276
|
+
question: typeof obj.question === "string" ? obj.question : "",
|
|
277
|
+
context: typeof obj.context === "string" ? obj.context : "",
|
|
278
|
+
options,
|
|
279
|
+
};
|
|
280
|
+
if (typeof obj.recommended === "string")
|
|
281
|
+
body.recommended = obj.recommended;
|
|
282
|
+
if (typeof obj.notes === "string")
|
|
283
|
+
body.notes = obj.notes;
|
|
284
|
+
return body;
|
|
285
|
+
}
|
|
286
|
+
function readQuestionBodyFile(hqRoot, question) {
|
|
287
|
+
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
288
|
+
let serialized;
|
|
289
|
+
try {
|
|
290
|
+
serialized = fs.readFileSync(filePath, "utf8");
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
const code = err.code;
|
|
294
|
+
if (code === "ENOENT")
|
|
295
|
+
return null;
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
const digest = sha256Text(serialized);
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(serialized);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
throw new LanesError("question_body_unreadable", `Question body at ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { question_id: question.question_id, path: filePath });
|
|
305
|
+
}
|
|
306
|
+
const body = parseQuestionBody(parsed);
|
|
307
|
+
if (!body) {
|
|
308
|
+
throw new LanesError("question_body_unreadable", `Question body at ${filePath} is not an object`, { question_id: question.question_id, path: filePath });
|
|
309
|
+
}
|
|
310
|
+
return { body, digest, serialized };
|
|
311
|
+
}
|
|
312
|
+
function readQuestionBody(hqRoot, question) {
|
|
313
|
+
const stored = readQuestionBodyFile(hqRoot, question);
|
|
314
|
+
if (!stored)
|
|
315
|
+
return null;
|
|
316
|
+
if (question.body.digest && stored.digest !== question.body.digest) {
|
|
317
|
+
throw new LanesError("question_body_digest_mismatch", `Question body digest does not match the routing record for ${question.question_id}`, {
|
|
318
|
+
question_id: question.question_id,
|
|
319
|
+
expected: question.body.digest,
|
|
320
|
+
actual: stored.digest,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return stored.body;
|
|
324
|
+
}
|
|
325
|
+
function requireQuestionBody(hqRoot, question) {
|
|
326
|
+
const body = readQuestionBody(hqRoot, question);
|
|
327
|
+
if (!body) {
|
|
328
|
+
throw new LanesError("question_body_unavailable", `Question body is not available on this host for ${question.question_id}`, {
|
|
329
|
+
question_id: question.question_id,
|
|
330
|
+
store: question.body.store,
|
|
331
|
+
ref: question.body.ref,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return body;
|
|
335
|
+
}
|
|
336
|
+
function writeQuestionBody(hqRoot, question, body) {
|
|
337
|
+
const store = parseQuestionBodyStore(question.body.store);
|
|
338
|
+
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
339
|
+
const serialized = `${JSON.stringify(body, null, 2)}\n`;
|
|
340
|
+
ensureDir(path.dirname(filePath));
|
|
341
|
+
fs.writeFileSync(filePath, serialized, "utf8");
|
|
342
|
+
return {
|
|
343
|
+
store,
|
|
344
|
+
ref: questionBodyRef(question.question_id),
|
|
345
|
+
digest: sha256Text(serialized),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function loadAllLanes(hqRoot) {
|
|
349
|
+
return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
|
|
350
|
+
}
|
|
351
|
+
function loadAllQuestions(hqRoot) {
|
|
352
|
+
return listQuestionIds(hqRoot).map((id) => readQuestion(hqRoot, id));
|
|
353
|
+
}
|
|
354
|
+
function sessionMetaPath(hqRoot, sessionId) {
|
|
355
|
+
return path.join(hqRoot, "workspace", "sessions", sessionId, "meta.yaml");
|
|
356
|
+
}
|
|
357
|
+
function readSessionMeta(hqRoot, sessionId, ctx) {
|
|
358
|
+
const filePath = sessionMetaPath(hqRoot, sessionId);
|
|
359
|
+
if (!ctx.existsSync(filePath))
|
|
360
|
+
return null;
|
|
361
|
+
const text = ctx.readFileSync(filePath, "utf8");
|
|
362
|
+
const doc = parseUserYaml(text, filePath);
|
|
363
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
364
|
+
return null;
|
|
365
|
+
return doc;
|
|
366
|
+
}
|
|
367
|
+
function seniorOfSession(hqRoot, sessionId, ctx) {
|
|
368
|
+
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
369
|
+
if (!meta)
|
|
370
|
+
return { kind: "user" };
|
|
371
|
+
const senior = meta.senior;
|
|
372
|
+
if (typeof senior === "string" && senior.trim()) {
|
|
373
|
+
return parseSeniorRef(senior);
|
|
374
|
+
}
|
|
375
|
+
if (senior !== null && typeof senior === "object" && !Array.isArray(senior)) {
|
|
376
|
+
const rec = senior;
|
|
377
|
+
const kind = parseSeniorKind(rec.kind);
|
|
378
|
+
if (kind === "user")
|
|
379
|
+
return { kind: "user" };
|
|
380
|
+
if (typeof rec.id === "string" && rec.id.length > 0) {
|
|
381
|
+
return { kind, id: rec.id };
|
|
382
|
+
}
|
|
383
|
+
throw new LanesError("invalid_senior_ref", `Session ${sessionId} senior is missing an id`, { session_id: sessionId });
|
|
384
|
+
}
|
|
385
|
+
return { kind: "user" };
|
|
386
|
+
}
|
|
387
|
+
function seniorExists(hqRoot, ref, ctx) {
|
|
388
|
+
if (ref.kind === "user")
|
|
389
|
+
return true;
|
|
390
|
+
if (!ref.id)
|
|
391
|
+
return false;
|
|
392
|
+
if (ref.kind === "lane")
|
|
393
|
+
return tryReadLane(hqRoot, ref.id) !== null;
|
|
394
|
+
const dir = path.join(hqRoot, "workspace", "sessions", ref.id);
|
|
395
|
+
if (ctx.existsSync(dir))
|
|
396
|
+
return true;
|
|
397
|
+
try {
|
|
398
|
+
const index = loadLivenessIndex(hqRoot);
|
|
399
|
+
return index.sessions[ref.id] !== undefined;
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
process.stderr.write(`hq lanes: liveness index read failed while checking session ${ref.id}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function assertSeniorExists(hqRoot, ref, ctx) {
|
|
407
|
+
if (seniorExists(hqRoot, ref, ctx))
|
|
408
|
+
return;
|
|
409
|
+
throw new LanesError("unknown_senior", `Senior ${seniorKey(ref)} does not exist`, { senior: ref });
|
|
410
|
+
}
|
|
411
|
+
function depthOfRef(hqRoot, ref, ctx, visited = new Set(), override) {
|
|
412
|
+
if (ref.kind === "user")
|
|
413
|
+
return 0;
|
|
414
|
+
const key = seniorKey(ref);
|
|
415
|
+
if (visited.has(key)) {
|
|
416
|
+
throw new LanesError("senior_cycle", `Cycle in senior chain at ${key}`, {
|
|
417
|
+
at: ref,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
visited.add(key);
|
|
421
|
+
if (ref.kind === "lane" && override && ref.id === override.laneId) {
|
|
422
|
+
return override.depth;
|
|
423
|
+
}
|
|
424
|
+
if (ref.kind === "session") {
|
|
425
|
+
if (!ref.id) {
|
|
426
|
+
throw new LanesError("invalid_senior_ref", "Session senior is missing an id", {
|
|
427
|
+
senior: ref,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return depthOfRef(hqRoot, seniorOfSession(hqRoot, ref.id, ctx), ctx, visited, override) + 1;
|
|
431
|
+
}
|
|
432
|
+
if (!ref.id) {
|
|
433
|
+
throw new LanesError("invalid_senior_ref", "Lane senior is missing an id", {
|
|
434
|
+
senior: ref,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const lane = readLane(hqRoot, ref.id);
|
|
438
|
+
return depthOfRef(hqRoot, lane.senior, ctx, visited, override) + 1;
|
|
439
|
+
}
|
|
440
|
+
function derivedLaneDepth(hqRoot, senior, ctx, override) {
|
|
441
|
+
return depthOfRef(hqRoot, senior, ctx, new Set(), override) + 1;
|
|
442
|
+
}
|
|
443
|
+
function callerDepth(hqRoot, ctx) {
|
|
444
|
+
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
445
|
+
if (laneId) {
|
|
446
|
+
const lane = tryReadLane(hqRoot, laneId);
|
|
447
|
+
if (!lane) {
|
|
448
|
+
throw new LanesError("lane_not_found", `Caller lane ${laneId} not found`, {
|
|
449
|
+
lane_id: laneId,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return derivedLaneDepth(hqRoot, lane.senior, ctx);
|
|
453
|
+
}
|
|
454
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
455
|
+
if (sessionId) {
|
|
456
|
+
return depthOfRef(hqRoot, { kind: "session", id: sessionId }, ctx);
|
|
457
|
+
}
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
function defaultSenior(ctx) {
|
|
461
|
+
const parent = ctx.env.HQ_PARENT_SESSION_ID?.trim();
|
|
462
|
+
if (parent)
|
|
463
|
+
return { kind: "session", id: parent };
|
|
464
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
465
|
+
if (sessionId)
|
|
466
|
+
return { kind: "session", id: sessionId };
|
|
467
|
+
return { kind: "user" };
|
|
468
|
+
}
|
|
469
|
+
function companyOfSenior(hqRoot, ref, ctx) {
|
|
470
|
+
if (ref.kind === "user")
|
|
471
|
+
return null;
|
|
472
|
+
if (ref.kind === "lane") {
|
|
473
|
+
if (!ref.id)
|
|
474
|
+
return null;
|
|
475
|
+
const lane = readLane(hqRoot, ref.id);
|
|
476
|
+
return lane.company;
|
|
477
|
+
}
|
|
478
|
+
if (!ref.id)
|
|
479
|
+
return null;
|
|
480
|
+
const meta = readSessionMeta(hqRoot, ref.id, ctx);
|
|
481
|
+
if (!meta)
|
|
482
|
+
return null;
|
|
483
|
+
const slug = typeof meta.company_slug === "string" ? meta.company_slug : undefined;
|
|
484
|
+
const uid = typeof meta.company_uid === "string" ? meta.company_uid : undefined;
|
|
485
|
+
if (!slug && !uid)
|
|
486
|
+
return null;
|
|
487
|
+
return { slug, uid };
|
|
488
|
+
}
|
|
489
|
+
function sameTenant(laneCompany, senior, seniorCompany) {
|
|
490
|
+
if (senior.kind === "user")
|
|
491
|
+
return true;
|
|
492
|
+
if (!seniorCompany || (!seniorCompany.slug && !seniorCompany.uid))
|
|
493
|
+
return false;
|
|
494
|
+
if (seniorCompany.uid && seniorCompany.uid === laneCompany.uid)
|
|
495
|
+
return true;
|
|
496
|
+
if (seniorCompany.slug && seniorCompany.slug === laneCompany.slug)
|
|
497
|
+
return true;
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
function assertSameTenant(laneCompany, senior, seniorCompany, extra = {}) {
|
|
501
|
+
if (sameTenant(laneCompany, senior, seniorCompany))
|
|
502
|
+
return;
|
|
503
|
+
throw new LanesError("tenancy_mismatch", seniorCompany
|
|
504
|
+
? "Cannot attach a lane to a senior in a different company"
|
|
505
|
+
: "Cannot attach a lane to a senior whose company is unknown", {
|
|
506
|
+
lane_company: laneCompany,
|
|
507
|
+
senior,
|
|
508
|
+
senior_company: seniorCompany,
|
|
509
|
+
...extra,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
function walkHitsLane(hqRoot, start, laneId, ctx) {
|
|
513
|
+
const seen = new Set();
|
|
514
|
+
let current = start;
|
|
515
|
+
while (current.kind !== "user") {
|
|
516
|
+
const key = seniorKey(current);
|
|
517
|
+
if (seen.has(key)) {
|
|
518
|
+
throw new LanesError("senior_cycle", `Cycle in senior chain at ${key}`, {
|
|
519
|
+
at: current,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
seen.add(key);
|
|
523
|
+
if (current.kind === "lane" && current.id === laneId)
|
|
524
|
+
return true;
|
|
525
|
+
if (current.kind === "lane") {
|
|
526
|
+
if (!current.id)
|
|
527
|
+
return false;
|
|
528
|
+
const rec = tryReadLane(hqRoot, current.id);
|
|
529
|
+
if (!rec)
|
|
530
|
+
return false;
|
|
531
|
+
current = rec.senior;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (!current.id)
|
|
535
|
+
return false;
|
|
536
|
+
current = seniorOfSession(hqRoot, current.id, ctx);
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
function resolveAudience(hqRoot, question, ctx) {
|
|
541
|
+
let current = { kind: "lane", id: question.origin.id };
|
|
542
|
+
for (let hop = 0; hop < question.hop; hop += 1) {
|
|
543
|
+
if (current.kind === "user")
|
|
544
|
+
return current;
|
|
545
|
+
if (current.kind === "lane") {
|
|
546
|
+
if (!current.id)
|
|
547
|
+
return current;
|
|
548
|
+
const lane = tryReadLane(hqRoot, current.id);
|
|
549
|
+
if (!lane)
|
|
550
|
+
return current;
|
|
551
|
+
current = lane.senior;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!current.id)
|
|
555
|
+
return current;
|
|
556
|
+
current = seniorOfSession(hqRoot, current.id, ctx);
|
|
557
|
+
}
|
|
558
|
+
return current;
|
|
559
|
+
}
|
|
560
|
+
function audienceMatches(audience, filter) {
|
|
561
|
+
if (filter.kind === "me") {
|
|
562
|
+
return audience.kind === "session" && audience.id === filter.id;
|
|
563
|
+
}
|
|
564
|
+
if (filter.kind === "user")
|
|
565
|
+
return audience.kind === "user";
|
|
566
|
+
return audience.kind === filter.kind && audience.id === filter.id;
|
|
567
|
+
}
|
|
568
|
+
function parseForFilter(raw, ctx) {
|
|
569
|
+
const trimmed = raw.trim();
|
|
570
|
+
if (trimmed === "me") {
|
|
571
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
572
|
+
if (!sessionId) {
|
|
573
|
+
throw new LanesError("missing_session_id", "--for me requires a current session id (HQ_SESSION_ID)", {});
|
|
574
|
+
}
|
|
575
|
+
return { kind: "me", id: sessionId };
|
|
576
|
+
}
|
|
577
|
+
return parseSeniorRef(trimmed);
|
|
578
|
+
}
|
|
579
|
+
function timeoutPath(hqRoot, laneId) {
|
|
580
|
+
return path.join(runDir(hqRoot, laneId), "timeout.json");
|
|
581
|
+
}
|
|
582
|
+
function isOverdue(hqRoot, laneId, now) {
|
|
583
|
+
const raw = readJsonFile(timeoutPath(hqRoot, laneId));
|
|
584
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
585
|
+
return false;
|
|
586
|
+
const deadline = raw.deadline_at;
|
|
587
|
+
if (typeof deadline !== "string")
|
|
588
|
+
return false;
|
|
589
|
+
const at = Date.parse(deadline);
|
|
590
|
+
if (!Number.isFinite(at))
|
|
591
|
+
return false;
|
|
592
|
+
return now.getTime() > at;
|
|
593
|
+
}
|
|
594
|
+
function compareQueueOrder(a, b) {
|
|
595
|
+
if (b.priority !== a.priority)
|
|
596
|
+
return b.priority - a.priority;
|
|
597
|
+
return a.created_at.localeCompare(b.created_at);
|
|
598
|
+
}
|
|
599
|
+
function admitCreate(input) {
|
|
600
|
+
const { hqRoot, provider, pinnedAccount, priority, createdAt, config } = input;
|
|
601
|
+
const providerCfg = config.providers[provider];
|
|
602
|
+
const lanes = loadAllLanes(hqRoot);
|
|
603
|
+
const runningProvider = lanes.filter((lane) => lane.provider === provider && lane.state === "running");
|
|
604
|
+
const queuedSame = lanes.filter((lane) => {
|
|
605
|
+
if (lane.provider !== provider || lane.state !== "queued")
|
|
606
|
+
return false;
|
|
607
|
+
if (pinnedAccount)
|
|
608
|
+
return lane.account === pinnedAccount;
|
|
609
|
+
return !lane.account;
|
|
610
|
+
});
|
|
611
|
+
const runningCount = pinnedAccount
|
|
612
|
+
? runningProvider.filter((lane) => lane.account === pinnedAccount).length
|
|
613
|
+
: runningProvider.length;
|
|
614
|
+
const providerRunning = runningProvider.length;
|
|
615
|
+
const queuedCount = queuedSame.length;
|
|
616
|
+
const maxQueued = providerCfg.max_queued;
|
|
617
|
+
const atCap = providerRunning >= providerCfg.max_running ||
|
|
618
|
+
(pinnedAccount !== undefined &&
|
|
619
|
+
runningCount >= accountMaxRunning(providerCfg, pinnedAccount));
|
|
620
|
+
const synthetic = {
|
|
621
|
+
schema_version: "hq-lane.v1",
|
|
622
|
+
lane_id: "__incoming__",
|
|
623
|
+
company: { slug: "", uid: "" },
|
|
624
|
+
project_id: "",
|
|
625
|
+
story_id: "",
|
|
626
|
+
depth: 1,
|
|
627
|
+
senior: { kind: "user" },
|
|
628
|
+
senior_history: [],
|
|
629
|
+
state: "queued",
|
|
630
|
+
priority,
|
|
631
|
+
provider,
|
|
632
|
+
account: pinnedAccount,
|
|
633
|
+
host: { kind: "local", id: "local" },
|
|
634
|
+
version: 0,
|
|
635
|
+
created_at: createdAt,
|
|
636
|
+
updated_at: createdAt,
|
|
637
|
+
attribution_history: [],
|
|
638
|
+
};
|
|
639
|
+
const ranked = [...queuedSame, synthetic].sort(compareQueueOrder);
|
|
640
|
+
const first = ranked[0];
|
|
641
|
+
const isHead = first?.lane_id === "__incoming__";
|
|
642
|
+
const wouldStart = !atCap && isHead;
|
|
643
|
+
if (wouldStart)
|
|
644
|
+
return { action: "start" };
|
|
645
|
+
if (queuedCount >= maxQueued) {
|
|
646
|
+
return {
|
|
647
|
+
action: "refuse",
|
|
648
|
+
error: new LanesError("lane_queue_full", `Lane queue is full for ${pinnedAccount ? `account ${pinnedAccount}` : provider}`, {
|
|
649
|
+
provider,
|
|
650
|
+
...(pinnedAccount ? { account: pinnedAccount } : {}),
|
|
651
|
+
running: pinnedAccount ? runningCount : providerRunning,
|
|
652
|
+
queued: queuedCount,
|
|
653
|
+
}),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (atCap) {
|
|
657
|
+
return { action: "queue", reason: isHead ? "cap" : "queue_position" };
|
|
658
|
+
}
|
|
659
|
+
return { action: "queue", reason: "queue_position" };
|
|
660
|
+
}
|
|
661
|
+
async function applyThrottle(hqRoot, config, ctx, current, provider, pinnedAccount) {
|
|
662
|
+
if (current.action !== "start")
|
|
663
|
+
return current;
|
|
664
|
+
const { decision } = await decideAdmission(hqRoot, config.throttle, ctx.throttleDeps);
|
|
665
|
+
if (decision.admit)
|
|
666
|
+
return current;
|
|
667
|
+
const providerCfg = config.providers[provider];
|
|
668
|
+
const queuedCount = loadAllLanes(hqRoot).filter((lane) => {
|
|
669
|
+
if (lane.provider !== provider || lane.state !== "queued")
|
|
670
|
+
return false;
|
|
671
|
+
if (pinnedAccount)
|
|
672
|
+
return lane.account === pinnedAccount;
|
|
673
|
+
return !lane.account;
|
|
674
|
+
}).length;
|
|
675
|
+
if (queuedCount >= providerCfg.max_queued) {
|
|
676
|
+
const running = loadAllLanes(hqRoot).filter((lane) => lane.provider === provider && lane.state === "running").length;
|
|
677
|
+
return {
|
|
678
|
+
action: "refuse",
|
|
679
|
+
error: new LanesError("lane_queue_full", `Lane queue is full for ${pinnedAccount ? `account ${pinnedAccount}` : provider}`, {
|
|
680
|
+
provider,
|
|
681
|
+
...(pinnedAccount ? { account: pinnedAccount } : {}),
|
|
682
|
+
running,
|
|
683
|
+
queued: queuedCount,
|
|
684
|
+
}),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
return { action: "queue", reason: "throttle" };
|
|
688
|
+
}
|
|
689
|
+
function assertKnownOutpost(requested, known, identity) {
|
|
690
|
+
if (!known.includes(requested)) {
|
|
691
|
+
throw new LanesError("unknown_outpost", `Unknown Outpost ${JSON.stringify(requested)}. Accepted: ${known.join(", ") || "(none)"}`, { accepted: known, value: requested });
|
|
692
|
+
}
|
|
693
|
+
if (identity.kind === "unknown") {
|
|
694
|
+
throw new LanesError("outpost_identity_unknown", "This host is an Outpost but its id is unknown; refusing all --outpost targets", { outpost: requested });
|
|
695
|
+
}
|
|
696
|
+
if (identity.kind === "id" && identity.id === requested) {
|
|
697
|
+
throw new LanesError("outpost_self_target", `A host may not target its own Outpost id ${JSON.stringify(requested)}`, { outpost: requested });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function copyHookRails(fromHome, toHome) {
|
|
701
|
+
const src = path.join(fromHome, "hooks");
|
|
702
|
+
if (!fs.existsSync(src))
|
|
703
|
+
return false;
|
|
704
|
+
fs.cpSync(src, path.join(toHome, "hooks"), { recursive: true });
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
function railsPresent(home) {
|
|
708
|
+
return fs.existsSync(path.join(home, "hooks"));
|
|
709
|
+
}
|
|
710
|
+
const ADMISSION_LOCK_STALE_MS = 30_000;
|
|
711
|
+
const ADMISSION_LOCK_TIMEOUT_MS = 5_000;
|
|
712
|
+
const ASK_BACKOFF_MS = [50, 100, 200, 400];
|
|
713
|
+
function yieldEventLoop() {
|
|
714
|
+
return new Promise((resolve) => {
|
|
715
|
+
setImmediate(resolve);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
function admissionLockPath(hqRoot, provider, pinnedAccount) {
|
|
719
|
+
const name = pinnedAccount
|
|
720
|
+
? `${provider}-${pinnedAccount}`
|
|
721
|
+
: `${provider}-unpinned`;
|
|
722
|
+
return path.join(hqRoot, LANES_RUNS_REL, ".admission-locks", `${name}.lock`);
|
|
723
|
+
}
|
|
724
|
+
async function withAdmissionLock(lockPath, fn, sleep) {
|
|
725
|
+
ensureDir(path.dirname(lockPath));
|
|
726
|
+
const started = Date.now();
|
|
727
|
+
while (true) {
|
|
728
|
+
let fd;
|
|
729
|
+
try {
|
|
730
|
+
fd = fs.openSync(lockPath, "wx");
|
|
731
|
+
try {
|
|
732
|
+
fs.writeSync(fd, Buffer.from(`${process.pid}\n${Date.now()}\n`, "utf8"));
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
fs.closeSync(fd);
|
|
736
|
+
fd = undefined;
|
|
737
|
+
throw err;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
await yieldEventLoop();
|
|
741
|
+
return await fn();
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
try {
|
|
745
|
+
fs.closeSync(fd);
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
process.stderr.write(`hq lanes: admission lock close failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
fs.unlinkSync(lockPath);
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
const code = err.code;
|
|
755
|
+
if (code !== "ENOENT") {
|
|
756
|
+
process.stderr.write(`hq lanes: admission lock unlink failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
const code = err.code;
|
|
763
|
+
if (code !== "EEXIST")
|
|
764
|
+
throw err;
|
|
765
|
+
if (Date.now() - started > ADMISSION_LOCK_TIMEOUT_MS) {
|
|
766
|
+
throw new LanesError("store_lock_timeout", `Timed out waiting for admission lock ${lockPath}`, { path: lockPath });
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
770
|
+
const tsLine = raw.split("\n")[1];
|
|
771
|
+
const ts = tsLine ? Number(tsLine) : NaN;
|
|
772
|
+
if (Number.isFinite(ts) && Date.now() - ts > ADMISSION_LOCK_STALE_MS) {
|
|
773
|
+
try {
|
|
774
|
+
fs.unlinkSync(lockPath);
|
|
775
|
+
}
|
|
776
|
+
catch (unlinkErr) {
|
|
777
|
+
const unlinkCode = unlinkErr.code;
|
|
778
|
+
if (unlinkCode !== "ENOENT")
|
|
779
|
+
throw unlinkErr;
|
|
780
|
+
}
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch (statErr) {
|
|
785
|
+
const statCode = statErr.code;
|
|
786
|
+
if (statCode !== "ENOENT") {
|
|
787
|
+
process.stderr.write(`hq lanes: stale admission-lock probe failed for ${lockPath}: ${statErr instanceof Error ? statErr.message : String(statErr)}\n`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
await sleep(10);
|
|
791
|
+
await yieldEventLoop();
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function withCasRetry(fn, attempts = 5) {
|
|
796
|
+
let last;
|
|
797
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
798
|
+
try {
|
|
799
|
+
return fn();
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
last = err;
|
|
803
|
+
if (!isLaneVersionConflict(err) || i === attempts - 1)
|
|
804
|
+
throw err;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
throw last;
|
|
808
|
+
}
|
|
809
|
+
export function casUpdateQuestion(hqRoot, questionId, updater) {
|
|
810
|
+
return withCasRetry(() => {
|
|
811
|
+
const current = readQuestion(hqRoot, questionId);
|
|
812
|
+
return updateQuestion(hqRoot, questionId, current.version, updater);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
export function casUpdateLane(hqRoot, laneId, updater) {
|
|
816
|
+
return withCasRetry(() => {
|
|
817
|
+
const current = readLane(hqRoot, laneId);
|
|
818
|
+
return updateLane(hqRoot, laneId, current.version, updater);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function throwIfQuestionNotPending(current) {
|
|
822
|
+
if (current.status === "pending")
|
|
823
|
+
return;
|
|
824
|
+
throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
|
|
825
|
+
}
|
|
826
|
+
function formatUnknownValue(value) {
|
|
827
|
+
if (typeof value === "string")
|
|
828
|
+
return value;
|
|
829
|
+
if (Array.isArray(value)) {
|
|
830
|
+
return value
|
|
831
|
+
.map((item) => (typeof item === "string" ? `- ${item}` : `- ${JSON.stringify(item)}`))
|
|
832
|
+
.join("\n");
|
|
833
|
+
}
|
|
834
|
+
if (value && typeof value === "object") {
|
|
835
|
+
return JSON.stringify(value, null, 2);
|
|
836
|
+
}
|
|
837
|
+
return "";
|
|
838
|
+
}
|
|
839
|
+
export function composeWorkerBrief(worker, taskBrief) {
|
|
840
|
+
const yaml = worker.workerYaml;
|
|
841
|
+
const name = yaml.worker?.name ?? worker.id;
|
|
842
|
+
const description = yaml.worker?.description ?? worker.description ?? "";
|
|
843
|
+
const checkpoints = [
|
|
844
|
+
worker.approvalRequired ? "- approval_required: true" : "",
|
|
845
|
+
...worker.humanCheckpoints.map((item) => `- ${item}`),
|
|
846
|
+
]
|
|
847
|
+
.filter(Boolean)
|
|
848
|
+
.join("\n");
|
|
849
|
+
return [
|
|
850
|
+
`# ${name}`,
|
|
851
|
+
description,
|
|
852
|
+
"",
|
|
853
|
+
"## Standing instructions",
|
|
854
|
+
formatUnknownValue(yaml.instructions) || "(none)",
|
|
855
|
+
"",
|
|
856
|
+
"## Skills",
|
|
857
|
+
worker.skillFiles.map((item) => `- ${item}`).join("\n") || "(none)",
|
|
858
|
+
"",
|
|
859
|
+
"## Context (resolved)",
|
|
860
|
+
worker.resolvedContextBase.map((item) => `- ${item}`).join("\n") || "(none)",
|
|
861
|
+
"",
|
|
862
|
+
"## Knowledge",
|
|
863
|
+
formatUnknownValue(yaml.knowledge) || "(none)",
|
|
864
|
+
"",
|
|
865
|
+
"## Verification checkpoints",
|
|
866
|
+
checkpoints || "(none)",
|
|
867
|
+
"",
|
|
868
|
+
"## Task",
|
|
869
|
+
taskBrief.replace(/\s+$/, ""),
|
|
870
|
+
"",
|
|
871
|
+
].join("\n");
|
|
872
|
+
}
|
|
873
|
+
export function parseMaxRuntimeSeconds(raw, workerId) {
|
|
874
|
+
if (raw === undefined || raw === null || raw === "")
|
|
875
|
+
return undefined;
|
|
876
|
+
if (typeof raw === "number") {
|
|
877
|
+
if (!Number.isFinite(raw) || raw < 0 || !Number.isInteger(raw)) {
|
|
878
|
+
throw new LanesError("invalid_worker_timeout", `Worker ${workerId} execution.max_runtime is not a non-negative integer number of seconds`, { worker: workerId, value: raw });
|
|
879
|
+
}
|
|
880
|
+
return raw;
|
|
881
|
+
}
|
|
882
|
+
if (typeof raw !== "string") {
|
|
883
|
+
throw new LanesError("invalid_worker_timeout", `Worker ${workerId} execution.max_runtime is not a duration`, { worker: workerId, value: raw });
|
|
884
|
+
}
|
|
885
|
+
const trimmed = raw.trim();
|
|
886
|
+
if (/^\d+$/.test(trimmed))
|
|
887
|
+
return Number(trimmed);
|
|
888
|
+
const seconds = /^(\d+)\s*s$/i.exec(trimmed);
|
|
889
|
+
if (seconds)
|
|
890
|
+
return Number(seconds[1]);
|
|
891
|
+
const minutes = /^(\d+)\s*m$/i.exec(trimmed);
|
|
892
|
+
if (minutes)
|
|
893
|
+
return Number(minutes[1]) * 60;
|
|
894
|
+
const hours = /^(\d+)\s*h$/i.exec(trimmed);
|
|
895
|
+
if (hours)
|
|
896
|
+
return Number(hours[1]) * 3600;
|
|
897
|
+
throw new LanesError("invalid_worker_timeout", `Worker ${workerId} execution.max_runtime ${JSON.stringify(raw)} is not a recognised duration`, { worker: workerId, value: raw });
|
|
898
|
+
}
|
|
899
|
+
function askDeliveryFailedPath(hqRoot, laneId) {
|
|
900
|
+
return path.join(runDir(hqRoot, laneId), "ask_delivery_failed.json");
|
|
901
|
+
}
|
|
902
|
+
async function emitLaneMeshEvent(ctx, company, threadId, eventKind, payload) {
|
|
903
|
+
if (!threadId)
|
|
904
|
+
return;
|
|
905
|
+
await ctx.mesh.appendThreadEvent(company, threadId, eventKind, payload);
|
|
906
|
+
}
|
|
907
|
+
export async function emitLaneDecisionEvents(mesh, lane, decision, extra = {}) {
|
|
908
|
+
const eventKind = decision === "blocked" ? "lane_blocked" : "lane_done";
|
|
909
|
+
if (lane.thread_id) {
|
|
910
|
+
await mesh.appendThreadEvent(lane.company, lane.thread_id, eventKind, {
|
|
911
|
+
lane_id: lane.lane_id,
|
|
912
|
+
project_id: lane.project_id,
|
|
913
|
+
story_id: lane.story_id,
|
|
914
|
+
...extra,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
if (decision === "done") {
|
|
918
|
+
await mesh.patchStoryStatus(lane.company, lane.project_id, lane.story_id, "review");
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
export const PICKUP_TERMINAL_REASONS = [
|
|
922
|
+
"account_not_logged_in",
|
|
923
|
+
"unknown_account",
|
|
924
|
+
"account_disabled",
|
|
925
|
+
];
|
|
926
|
+
function parsePickupTerminalReason(value) {
|
|
927
|
+
if (PICKUP_TERMINAL_REASONS.includes(value)) {
|
|
928
|
+
return value;
|
|
929
|
+
}
|
|
930
|
+
throw new LanesError("unknown_pickup_reason", `Unknown Outpost pickup reason ${JSON.stringify(value)}. Accepted: ${PICKUP_TERMINAL_REASONS.join(", ")}`, { accepted: [...PICKUP_TERMINAL_REASONS], value });
|
|
931
|
+
}
|
|
932
|
+
function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
|
|
933
|
+
const reason = parsePickupTerminalReason(reasonRaw);
|
|
934
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
935
|
+
const existing = current
|
|
936
|
+
.terminal_reason;
|
|
937
|
+
if (isTerminalLaneState(current.state) && existing === reason) {
|
|
938
|
+
return current;
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
...current,
|
|
942
|
+
state: "failed",
|
|
943
|
+
queued_reason: undefined,
|
|
944
|
+
terminal_reason: reason,
|
|
945
|
+
terminal_details: details,
|
|
946
|
+
};
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Outpost-side account bind (spec §5.2). Writes a named terminal reason,
|
|
951
|
+
* never a plain `failed`.
|
|
952
|
+
*/
|
|
953
|
+
export function applyOutpostAccountPickup(hqRoot, laneId) {
|
|
954
|
+
const lane = readLane(hqRoot, laneId);
|
|
955
|
+
const config = loadLanesConfig(hqRoot);
|
|
956
|
+
try {
|
|
957
|
+
const resolved = resolveAccount(lane.provider, lane.account, config);
|
|
958
|
+
const status = accountStatus(lane.provider, resolved.id, config);
|
|
959
|
+
if (!status.logged_in) {
|
|
960
|
+
return markPickupFailure(hqRoot, laneId, "account_not_logged_in", {
|
|
961
|
+
provider: lane.provider,
|
|
962
|
+
account: resolved.id,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
if (lane.account === resolved.id)
|
|
966
|
+
return lane;
|
|
967
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
968
|
+
if (current.account === resolved.id)
|
|
969
|
+
return current;
|
|
970
|
+
return { ...current, account: resolved.id };
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
if (err instanceof AccountResolutionError) {
|
|
975
|
+
if (err.error === "unknown_account") {
|
|
976
|
+
return markPickupFailure(hqRoot, laneId, "unknown_account", err.details);
|
|
977
|
+
}
|
|
978
|
+
if (err.error === "account_disabled") {
|
|
979
|
+
return markPickupFailure(hqRoot, laneId, "account_disabled", err.details);
|
|
980
|
+
}
|
|
981
|
+
if (err.error === "no_enabled_account") {
|
|
982
|
+
return markPickupFailure(hqRoot, laneId, "account_disabled", err.details);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
throw err;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function pendingQuestionsForLane(hqRoot, laneId) {
|
|
989
|
+
return loadAllQuestions(hqRoot).filter((q) => q.origin.id === laneId && q.status === "pending");
|
|
990
|
+
}
|
|
991
|
+
export function cancelPendingQuestionsForLane(hqRoot, laneId, nowIso, operatorReason) {
|
|
992
|
+
const cancelled = [];
|
|
993
|
+
for (const question of pendingQuestionsForLane(hqRoot, laneId)) {
|
|
994
|
+
const next = casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
995
|
+
if (current.status !== "pending")
|
|
996
|
+
return current;
|
|
997
|
+
const entry = {
|
|
998
|
+
hop: current.hop,
|
|
999
|
+
audience: { kind: "user" },
|
|
1000
|
+
at: nowIso,
|
|
1001
|
+
reason: "cancelled",
|
|
1002
|
+
...(operatorReason ? { detail: operatorReason } : {}),
|
|
1003
|
+
};
|
|
1004
|
+
return {
|
|
1005
|
+
...current,
|
|
1006
|
+
status: "cancelled",
|
|
1007
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
1008
|
+
};
|
|
1009
|
+
});
|
|
1010
|
+
cancelled.push(next);
|
|
1011
|
+
}
|
|
1012
|
+
return cancelled;
|
|
1013
|
+
}
|
|
1014
|
+
function mintMessageId() {
|
|
1015
|
+
return `msg_${Date.now().toString(36)}_${randomBytes(3).toString("hex")}`;
|
|
1016
|
+
}
|
|
1017
|
+
async function runCreate(ctx, opts) {
|
|
1018
|
+
const hqRoot = ctx.hqRoot();
|
|
1019
|
+
const companyFlag = opts.company?.trim();
|
|
1020
|
+
const project = opts.project?.trim();
|
|
1021
|
+
const story = opts.story?.trim();
|
|
1022
|
+
const briefFile = opts.briefFile?.trim();
|
|
1023
|
+
if (!companyFlag) {
|
|
1024
|
+
throw new LanesError("missing_company", "--company is required", {});
|
|
1025
|
+
}
|
|
1026
|
+
if (!project) {
|
|
1027
|
+
throw new LanesError("missing_project", "--project is required", {});
|
|
1028
|
+
}
|
|
1029
|
+
if (!story) {
|
|
1030
|
+
throw new LanesError("missing_story", "--story is required", {});
|
|
1031
|
+
}
|
|
1032
|
+
if (!briefFile) {
|
|
1033
|
+
throw new LanesError("missing_brief_file", "--brief-file is required", {});
|
|
1034
|
+
}
|
|
1035
|
+
const provider = opts.provider
|
|
1036
|
+
? parseLaneProvider(opts.provider)
|
|
1037
|
+
: parseLaneProvider("codex");
|
|
1038
|
+
if (opts.outpost !== undefined && opts.outpost !== "") {
|
|
1039
|
+
const requested = opts.outpost.trim();
|
|
1040
|
+
let known;
|
|
1041
|
+
try {
|
|
1042
|
+
known = await ctx.listOutpostIds();
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
throw new LanesError("outpost_list_unavailable", `Failed to list Outpost ids: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
1046
|
+
}
|
|
1047
|
+
assertKnownOutpost(requested, known, ctx.readOutpostIdentity());
|
|
1048
|
+
}
|
|
1049
|
+
const depth = callerDepth(hqRoot, ctx);
|
|
1050
|
+
if (depth >= MAX_LANE_DEPTH) {
|
|
1051
|
+
throw new LanesError("max_lane_depth", `A depth-${depth} caller may not spawn lanes (cap ${MAX_LANE_DEPTH})`, { depth, max: MAX_LANE_DEPTH });
|
|
1052
|
+
}
|
|
1053
|
+
const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
|
|
1054
|
+
assertSeniorExists(hqRoot, senior, ctx);
|
|
1055
|
+
const laneDepth = derivedLaneDepth(hqRoot, senior, ctx);
|
|
1056
|
+
if (laneDepth > MAX_LANE_DEPTH) {
|
|
1057
|
+
throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
|
|
1058
|
+
}
|
|
1059
|
+
const company = await ctx.resolveCompany(companyFlag);
|
|
1060
|
+
const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
|
|
1061
|
+
assertSameTenant(company, senior, seniorCompany);
|
|
1062
|
+
let resolvedWorker;
|
|
1063
|
+
if (opts.worker) {
|
|
1064
|
+
resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company.slug);
|
|
1065
|
+
}
|
|
1066
|
+
const briefAbs = path.resolve(briefFile);
|
|
1067
|
+
if (!ctx.existsSync(briefAbs)) {
|
|
1068
|
+
throw new LanesError("brief_not_found", `Brief file not found: ${briefAbs}`, {
|
|
1069
|
+
path: briefAbs,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
const config = loadLanesConfig(hqRoot);
|
|
1073
|
+
const outpostId = opts.outpost?.trim() || undefined;
|
|
1074
|
+
let pinnedAccount;
|
|
1075
|
+
if (outpostId) {
|
|
1076
|
+
if (opts.account !== undefined && opts.account.trim() !== "") {
|
|
1077
|
+
pinnedAccount = opts.account.trim();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
else if (opts.account !== undefined) {
|
|
1081
|
+
const requested = opts.account.trim();
|
|
1082
|
+
if (!requested) {
|
|
1083
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(opts.account)} for ${provider}`, { provider, account: opts.account, enabled: [] });
|
|
1084
|
+
}
|
|
1085
|
+
const resolved = resolveAccount(provider, requested, config);
|
|
1086
|
+
pinnedAccount = resolved.id;
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
// Unpinned: still refuse at the bind point when every account is disabled
|
|
1090
|
+
// (spec §5.5). The chosen login is not stored; admission binds later.
|
|
1091
|
+
resolveAccount(provider, undefined, config);
|
|
1092
|
+
}
|
|
1093
|
+
const priority = parsePriority(opts.priority);
|
|
1094
|
+
let timeoutSecs = parseOptionalInt(opts.timeout, "--timeout");
|
|
1095
|
+
if (timeoutSecs === undefined && resolvedWorker) {
|
|
1096
|
+
timeoutSecs = parseMaxRuntimeSeconds(resolvedWorker.defaultTimeout, resolvedWorker.id);
|
|
1097
|
+
}
|
|
1098
|
+
const now = ctx.now();
|
|
1099
|
+
const createdAt = now.toISOString();
|
|
1100
|
+
const threadId = await ctx.mesh.ensureProjectThread(company, project);
|
|
1101
|
+
const persist = async () => {
|
|
1102
|
+
let admission;
|
|
1103
|
+
let validation;
|
|
1104
|
+
if (outpostId) {
|
|
1105
|
+
admission = { action: "queue" };
|
|
1106
|
+
validation = "pending";
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
admission = admitCreate({
|
|
1110
|
+
hqRoot,
|
|
1111
|
+
provider,
|
|
1112
|
+
pinnedAccount,
|
|
1113
|
+
priority,
|
|
1114
|
+
createdAt,
|
|
1115
|
+
config,
|
|
1116
|
+
});
|
|
1117
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1118
|
+
throw admission.error;
|
|
1119
|
+
}
|
|
1120
|
+
admission = await applyThrottle(hqRoot, config, ctx, admission, provider, pinnedAccount);
|
|
1121
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1122
|
+
throw admission.error;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const host = outpostId !== undefined
|
|
1126
|
+
? { kind: "outpost", id: outpostId }
|
|
1127
|
+
: { kind: "local", id: ctx.hostname() };
|
|
1128
|
+
const queuedReason = admission.action === "queue" ? admission.reason : undefined;
|
|
1129
|
+
const lane = createLane(hqRoot, {
|
|
1130
|
+
company,
|
|
1131
|
+
project_id: project,
|
|
1132
|
+
story_id: story,
|
|
1133
|
+
thread_id: threadId,
|
|
1134
|
+
depth: laneDepth,
|
|
1135
|
+
senior,
|
|
1136
|
+
senior_history: [],
|
|
1137
|
+
state: "queued",
|
|
1138
|
+
queued_reason: queuedReason,
|
|
1139
|
+
priority,
|
|
1140
|
+
worker: resolvedWorker?.id ?? opts.worker,
|
|
1141
|
+
provider,
|
|
1142
|
+
account: pinnedAccount,
|
|
1143
|
+
host,
|
|
1144
|
+
attribution_history: [],
|
|
1145
|
+
});
|
|
1146
|
+
return { lane, admission, validation };
|
|
1147
|
+
};
|
|
1148
|
+
let persisted;
|
|
1149
|
+
if (outpostId) {
|
|
1150
|
+
persisted = await persist();
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
persisted = await withAdmissionLock(admissionLockPath(hqRoot, provider, pinnedAccount), persist, ctx.sleep);
|
|
1154
|
+
}
|
|
1155
|
+
const { lane, admission, validation } = persisted;
|
|
1156
|
+
const dest = runDir(hqRoot, lane.lane_id);
|
|
1157
|
+
ensureDir(path.join(dest, "logs"));
|
|
1158
|
+
ensureDir(path.join(dest, "envelopes"));
|
|
1159
|
+
ensureDir(path.join(dest, "dropbox"));
|
|
1160
|
+
ensureDir(path.join(dest, "questions"));
|
|
1161
|
+
const taskBrief = fs.readFileSync(briefAbs, "utf8");
|
|
1162
|
+
const briefText = resolvedWorker
|
|
1163
|
+
? composeWorkerBrief(resolvedWorker, taskBrief)
|
|
1164
|
+
: taskBrief;
|
|
1165
|
+
fs.writeFileSync(path.join(dest, "brief.md"), briefText);
|
|
1166
|
+
if (timeoutSecs !== undefined) {
|
|
1167
|
+
writeJsonFile(timeoutPath(hqRoot, lane.lane_id), {
|
|
1168
|
+
timeout_secs: timeoutSecs,
|
|
1169
|
+
deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
if (admission.action === "start") {
|
|
1173
|
+
await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
|
|
1174
|
+
lane_id: lane.lane_id,
|
|
1175
|
+
project_id: project,
|
|
1176
|
+
story_id: story,
|
|
1177
|
+
});
|
|
1178
|
+
await ctx.mesh.patchStoryStatus(company, project, story, "in_progress");
|
|
1179
|
+
}
|
|
1180
|
+
// spawnLaneWorker is the process-launch seam. Create never calls it: spawn
|
|
1181
|
+
// and the watcher are owned by the reconcile daemon (spec §5.7). Admitted
|
|
1182
|
+
// lanes stay queued until that seam is implemented.
|
|
1183
|
+
printJson({
|
|
1184
|
+
ok: true,
|
|
1185
|
+
action: "create",
|
|
1186
|
+
lane_id: lane.lane_id,
|
|
1187
|
+
state: lane.state,
|
|
1188
|
+
queued_reason: lane.queued_reason ?? null,
|
|
1189
|
+
// An admitted lane still reports why it has not started. Without this a
|
|
1190
|
+
// caller sees state=queued with queued_reason=null and cannot tell an
|
|
1191
|
+
// admitted-but-unspawnable lane from a bug.
|
|
1192
|
+
pending_start: admission.action === "start" ? "spawn_not_implemented" : null,
|
|
1193
|
+
priority: lane.priority,
|
|
1194
|
+
provider: lane.provider,
|
|
1195
|
+
account: lane.account ?? null,
|
|
1196
|
+
host: lane.host,
|
|
1197
|
+
company: lane.company,
|
|
1198
|
+
project_id: lane.project_id,
|
|
1199
|
+
story_id: lane.story_id,
|
|
1200
|
+
thread_id: lane.thread_id ?? null,
|
|
1201
|
+
senior: lane.senior,
|
|
1202
|
+
depth: lane.depth,
|
|
1203
|
+
timeout_secs: timeoutSecs ?? null,
|
|
1204
|
+
...(validation ? { validation } : {}),
|
|
1205
|
+
lane,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
async function runList(ctx, opts) {
|
|
1209
|
+
const hqRoot = ctx.hqRoot();
|
|
1210
|
+
const state = opts.state ? parseLaneState(opts.state) : undefined;
|
|
1211
|
+
const senior = opts.senior ? parseSeniorRef(opts.senior) : undefined;
|
|
1212
|
+
let company;
|
|
1213
|
+
if (opts.company) {
|
|
1214
|
+
company = await ctx.resolveCompany(opts.company);
|
|
1215
|
+
}
|
|
1216
|
+
const now = ctx.now();
|
|
1217
|
+
const lanes = loadAllLanes(hqRoot)
|
|
1218
|
+
.filter((lane) => {
|
|
1219
|
+
if (state && lane.state !== state)
|
|
1220
|
+
return false;
|
|
1221
|
+
if (opts.project && lane.project_id !== opts.project)
|
|
1222
|
+
return false;
|
|
1223
|
+
if (opts.story && lane.story_id !== opts.story)
|
|
1224
|
+
return false;
|
|
1225
|
+
if (company && lane.company.uid !== company.uid && lane.company.slug !== company.slug) {
|
|
1226
|
+
return false;
|
|
1227
|
+
}
|
|
1228
|
+
if (senior && seniorKey(lane.senior) !== seniorKey(senior))
|
|
1229
|
+
return false;
|
|
1230
|
+
return true;
|
|
1231
|
+
})
|
|
1232
|
+
.sort(compareQueueOrder)
|
|
1233
|
+
.map((lane) => ({
|
|
1234
|
+
...lane,
|
|
1235
|
+
overdue: isOverdue(hqRoot, lane.lane_id, now),
|
|
1236
|
+
}));
|
|
1237
|
+
printJson({ ok: true, action: "list", lanes });
|
|
1238
|
+
}
|
|
1239
|
+
async function runShow(ctx, laneId) {
|
|
1240
|
+
const hqRoot = ctx.hqRoot();
|
|
1241
|
+
const lane = readLane(hqRoot, laneId);
|
|
1242
|
+
const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
|
|
1243
|
+
printJson({
|
|
1244
|
+
ok: true,
|
|
1245
|
+
action: "show",
|
|
1246
|
+
lane,
|
|
1247
|
+
overdue: isOverdue(hqRoot, lane.lane_id, ctx.now()),
|
|
1248
|
+
ask_delivery_failed: askDeliveryFailed,
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
async function runMessage(ctx, laneId, opts) {
|
|
1252
|
+
const hqRoot = ctx.hqRoot();
|
|
1253
|
+
const text = opts.text;
|
|
1254
|
+
if (!text) {
|
|
1255
|
+
throw new LanesError("missing_text", "--text is required", {});
|
|
1256
|
+
}
|
|
1257
|
+
const lane = readLane(hqRoot, laneId);
|
|
1258
|
+
const messageId = mintMessageId();
|
|
1259
|
+
const at = ctx.now().toISOString();
|
|
1260
|
+
const drop = path.join(runDir(hqRoot, laneId), "dropbox", "inbox.jsonl");
|
|
1261
|
+
ensureDir(path.dirname(drop));
|
|
1262
|
+
const projectId = opts.project?.trim() || lane.project_id;
|
|
1263
|
+
const storyId = opts.story?.trim() || lane.story_id;
|
|
1264
|
+
fs.appendFileSync(drop, `${JSON.stringify({ at, message_id: messageId, text, project_id: projectId, story_id: storyId })}\n`, "utf8");
|
|
1265
|
+
let next = lane;
|
|
1266
|
+
if (projectId !== lane.project_id || storyId !== lane.story_id) {
|
|
1267
|
+
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1268
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
1269
|
+
if (already)
|
|
1270
|
+
return current;
|
|
1271
|
+
return {
|
|
1272
|
+
...current,
|
|
1273
|
+
project_id: projectId,
|
|
1274
|
+
story_id: storyId,
|
|
1275
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
1276
|
+
project_id: projectId,
|
|
1277
|
+
story_id: storyId,
|
|
1278
|
+
message_id: messageId,
|
|
1279
|
+
at,
|
|
1280
|
+
}),
|
|
1281
|
+
};
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
printJson({
|
|
1285
|
+
ok: true,
|
|
1286
|
+
action: "message",
|
|
1287
|
+
lane_id: laneId,
|
|
1288
|
+
message_id: messageId,
|
|
1289
|
+
project_id: next.project_id,
|
|
1290
|
+
story_id: next.story_id,
|
|
1291
|
+
lane: next,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
async function runReparent(ctx, laneId, opts) {
|
|
1295
|
+
const hqRoot = ctx.hqRoot();
|
|
1296
|
+
if (!opts.senior) {
|
|
1297
|
+
throw new LanesError("missing_senior", "--senior is required", {});
|
|
1298
|
+
}
|
|
1299
|
+
const nextSenior = parseSeniorRef(opts.senior);
|
|
1300
|
+
assertSeniorExists(hqRoot, nextSenior, ctx);
|
|
1301
|
+
const lane = readLane(hqRoot, laneId);
|
|
1302
|
+
if (isTerminalLaneState(lane.state)) {
|
|
1303
|
+
throw new LanesError("lane_terminal", `Cannot re-parent a lane in terminal state ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
1304
|
+
}
|
|
1305
|
+
const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
|
|
1306
|
+
assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
|
|
1307
|
+
if (walkHitsLane(hqRoot, nextSenior, laneId, ctx)) {
|
|
1308
|
+
throw new LanesError("senior_cycle", `Re-parent would create a cycle through ${laneId}`, { lane_id: laneId, senior: nextSenior });
|
|
1309
|
+
}
|
|
1310
|
+
const newDepth = derivedLaneDepth(hqRoot, nextSenior, ctx);
|
|
1311
|
+
if (newDepth > MAX_LANE_DEPTH) {
|
|
1312
|
+
throw new LanesError("max_lane_depth", `Re-parent would put lane at depth ${newDepth} (cap ${MAX_LANE_DEPTH})`, { depth: newDepth, max: MAX_LANE_DEPTH });
|
|
1313
|
+
}
|
|
1314
|
+
const pending = pendingQuestionsForLane(hqRoot, laneId);
|
|
1315
|
+
const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
|
|
1316
|
+
if (newDepth > oldDepth && pending.length > 0) {
|
|
1317
|
+
throw new LanesError("demotion_with_pending_questions", "Cannot increase a lane's depth while it has pending questions", { lane_id: laneId, from_depth: oldDepth, to_depth: newDepth });
|
|
1318
|
+
}
|
|
1319
|
+
const override = { laneId, senior: nextSenior, depth: newDepth };
|
|
1320
|
+
for (const other of loadAllLanes(hqRoot)) {
|
|
1321
|
+
if (other.lane_id === laneId)
|
|
1322
|
+
continue;
|
|
1323
|
+
if (!walkHitsLane(hqRoot, other.senior, laneId, ctx))
|
|
1324
|
+
continue;
|
|
1325
|
+
const d = derivedLaneDepth(hqRoot, other.senior, ctx, override);
|
|
1326
|
+
if (d > MAX_LANE_DEPTH) {
|
|
1327
|
+
throw new LanesError("max_lane_depth", `Re-parent would put descendant ${other.lane_id} at depth ${d}`, { lane_id: other.lane_id, depth: d, max: MAX_LANE_DEPTH });
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const at = ctx.now().toISOString();
|
|
1331
|
+
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1332
|
+
const already = current.senior_history.some((entry) => entry.at === at && entry.reason === (opts.reason ?? "reparent"));
|
|
1333
|
+
if (already && current.depth === newDepth)
|
|
1334
|
+
return current;
|
|
1335
|
+
return {
|
|
1336
|
+
...current,
|
|
1337
|
+
senior: nextSenior,
|
|
1338
|
+
depth: newDepth,
|
|
1339
|
+
senior_history: appendCappedHistory(current.senior_history, {
|
|
1340
|
+
from: current.senior,
|
|
1341
|
+
to: nextSenior,
|
|
1342
|
+
at,
|
|
1343
|
+
by: sessionIdFromEnv(ctx.env) ?? "user",
|
|
1344
|
+
reason: opts.reason ?? "reparent",
|
|
1345
|
+
}),
|
|
1346
|
+
};
|
|
1347
|
+
});
|
|
1348
|
+
for (const question of pending) {
|
|
1349
|
+
casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
1350
|
+
const already = current.routing_history.some((entry) => entry.reason === "reparented" && entry.at === at);
|
|
1351
|
+
if (already)
|
|
1352
|
+
return current;
|
|
1353
|
+
return {
|
|
1354
|
+
...current,
|
|
1355
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
1356
|
+
hop: current.hop,
|
|
1357
|
+
audience: resolveAudience(hqRoot, current, ctx),
|
|
1358
|
+
at,
|
|
1359
|
+
reason: "reparented",
|
|
1360
|
+
}),
|
|
1361
|
+
};
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
printJson({
|
|
1365
|
+
ok: true,
|
|
1366
|
+
action: "reparent",
|
|
1367
|
+
lane: updated,
|
|
1368
|
+
pending_questions: pending.length,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
async function runLogs(ctx, laneId, opts) {
|
|
1372
|
+
const hqRoot = ctx.hqRoot();
|
|
1373
|
+
readLane(hqRoot, laneId);
|
|
1374
|
+
const logsDir = path.join(runDir(hqRoot, laneId), "logs");
|
|
1375
|
+
const tail = parseOptionalInt(opts.tail, "--tail");
|
|
1376
|
+
if (tail === 0) {
|
|
1377
|
+
throw new LanesError("invalid_integer", "--tail must be a positive integer", { flag: "--tail", value: opts.tail });
|
|
1378
|
+
}
|
|
1379
|
+
if (!fs.existsSync(logsDir)) {
|
|
1380
|
+
printJson({ ok: true, action: "logs", lane_id: laneId, lines: [] });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const files = fs
|
|
1384
|
+
.readdirSync(logsDir)
|
|
1385
|
+
.filter((name) => !name.startsWith("."))
|
|
1386
|
+
.sort();
|
|
1387
|
+
const lines = [];
|
|
1388
|
+
for (const name of files) {
|
|
1389
|
+
const text = fs.readFileSync(path.join(logsDir, name), "utf8");
|
|
1390
|
+
for (const line of text.split("\n")) {
|
|
1391
|
+
if (line.length > 0)
|
|
1392
|
+
lines.push(line);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const sliced = tail === undefined ? lines : lines.slice(-tail);
|
|
1396
|
+
printJson({ ok: true, action: "logs", lane_id: laneId, lines: sliced });
|
|
1397
|
+
}
|
|
1398
|
+
async function runStop(ctx, laneId) {
|
|
1399
|
+
const hqRoot = ctx.hqRoot();
|
|
1400
|
+
const lane = readLane(hqRoot, laneId);
|
|
1401
|
+
if (isTerminalLaneState(lane.state)) {
|
|
1402
|
+
throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
1403
|
+
}
|
|
1404
|
+
const nextState = lane.state === "queued" ? "cancelled" : "killed";
|
|
1405
|
+
const at = ctx.now().toISOString();
|
|
1406
|
+
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1407
|
+
if (isTerminalLaneState(current.state)) {
|
|
1408
|
+
if (current.state === nextState)
|
|
1409
|
+
return current;
|
|
1410
|
+
throw new LanesError("already_terminal", `Lane ${laneId} is already ${current.state}`, { lane_id: laneId, state: current.state });
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
...current,
|
|
1414
|
+
state: nextState,
|
|
1415
|
+
queued_reason: undefined,
|
|
1416
|
+
};
|
|
1417
|
+
});
|
|
1418
|
+
const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
|
|
1419
|
+
printJson({
|
|
1420
|
+
ok: true,
|
|
1421
|
+
action: "stop",
|
|
1422
|
+
lane: updated,
|
|
1423
|
+
cancelled_questions: cancelled.map((q) => q.question_id),
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
async function runAsk(ctx, opts) {
|
|
1427
|
+
const hqRoot = ctx.hqRoot();
|
|
1428
|
+
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
1429
|
+
if (!laneId) {
|
|
1430
|
+
throw new LanesError("missing_lane_id", "hq lanes ask requires HQ_LANE_ID in the environment", {});
|
|
1431
|
+
}
|
|
1432
|
+
const questionText = opts.question?.trim();
|
|
1433
|
+
if (!questionText) {
|
|
1434
|
+
throw new LanesError("missing_question", "--question is required", {});
|
|
1435
|
+
}
|
|
1436
|
+
const lane = readLane(hqRoot, laneId);
|
|
1437
|
+
const optionList = Array.isArray(opts.option)
|
|
1438
|
+
? opts.option
|
|
1439
|
+
: opts.option
|
|
1440
|
+
? [opts.option]
|
|
1441
|
+
: [];
|
|
1442
|
+
const body = {
|
|
1443
|
+
question: questionText,
|
|
1444
|
+
context: opts.context ?? "",
|
|
1445
|
+
options: optionList,
|
|
1446
|
+
};
|
|
1447
|
+
if (opts.recommended !== undefined)
|
|
1448
|
+
body.recommended = opts.recommended;
|
|
1449
|
+
const at = ctx.now().toISOString();
|
|
1450
|
+
const store = bodyStoreForLane(lane);
|
|
1451
|
+
let questionId;
|
|
1452
|
+
let lastErr;
|
|
1453
|
+
const attempts = ASK_BACKOFF_MS.length + 1;
|
|
1454
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
1455
|
+
try {
|
|
1456
|
+
if (!questionId) {
|
|
1457
|
+
const created = createQuestion(hqRoot, {
|
|
1458
|
+
lane_id: lane.lane_id,
|
|
1459
|
+
company: lane.company,
|
|
1460
|
+
project_id: lane.project_id,
|
|
1461
|
+
story_id: lane.story_id,
|
|
1462
|
+
thread_id: lane.thread_id,
|
|
1463
|
+
raised_via: "cli",
|
|
1464
|
+
asked_at: at,
|
|
1465
|
+
origin: { kind: "lane", id: lane.lane_id },
|
|
1466
|
+
hop: 1,
|
|
1467
|
+
status: "pending",
|
|
1468
|
+
body: { store, ref: "", digest: "" },
|
|
1469
|
+
routing_history: [],
|
|
1470
|
+
});
|
|
1471
|
+
questionId = created.question_id;
|
|
1472
|
+
}
|
|
1473
|
+
const stored = writeQuestionBody(hqRoot, {
|
|
1474
|
+
company: lane.company,
|
|
1475
|
+
question_id: questionId,
|
|
1476
|
+
body: { store, ref: "", digest: "" },
|
|
1477
|
+
}, body);
|
|
1478
|
+
const withBody = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
1479
|
+
const asked = current.routing_history.some((entry) => entry.reason === "asked");
|
|
1480
|
+
return {
|
|
1481
|
+
...current,
|
|
1482
|
+
body: stored,
|
|
1483
|
+
routing_history: asked
|
|
1484
|
+
? current.routing_history
|
|
1485
|
+
: appendCappedHistory(current.routing_history, {
|
|
1486
|
+
hop: 1,
|
|
1487
|
+
audience: lane.senior,
|
|
1488
|
+
at,
|
|
1489
|
+
reason: "asked",
|
|
1490
|
+
}),
|
|
1491
|
+
};
|
|
1492
|
+
});
|
|
1493
|
+
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question", {
|
|
1494
|
+
lane_id: lane.lane_id,
|
|
1495
|
+
question_id: withBody.question_id,
|
|
1496
|
+
hop: withBody.hop,
|
|
1497
|
+
});
|
|
1498
|
+
const audience = resolveAudience(hqRoot, withBody, ctx);
|
|
1499
|
+
if (audience.kind === "user") {
|
|
1500
|
+
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question_at_user", {
|
|
1501
|
+
lane_id: lane.lane_id,
|
|
1502
|
+
question_id: withBody.question_id,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
printJson({
|
|
1506
|
+
ok: true,
|
|
1507
|
+
action: "ask",
|
|
1508
|
+
question: withBody,
|
|
1509
|
+
lane_id: lane.lane_id,
|
|
1510
|
+
lane_state: lane.state,
|
|
1511
|
+
});
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
lastErr = err;
|
|
1516
|
+
if (i === attempts - 1)
|
|
1517
|
+
break;
|
|
1518
|
+
await ctx.sleep(ASK_BACKOFF_MS[i] ?? 400);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const failure = {
|
|
1522
|
+
schema_version: "hq-lane-ask-delivery-failed.v1",
|
|
1523
|
+
at: ctx.now().toISOString(),
|
|
1524
|
+
lane_id: lane.lane_id,
|
|
1525
|
+
question_id: questionId ?? null,
|
|
1526
|
+
error: lastErr instanceof Error
|
|
1527
|
+
? { name: lastErr.name, message: lastErr.message }
|
|
1528
|
+
: { name: "Error", message: String(lastErr) },
|
|
1529
|
+
};
|
|
1530
|
+
try {
|
|
1531
|
+
writeJsonFile(askDeliveryFailedPath(hqRoot, lane.lane_id), failure);
|
|
1532
|
+
}
|
|
1533
|
+
catch (writeErr) {
|
|
1534
|
+
process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${lane.lane_id}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
|
|
1535
|
+
}
|
|
1536
|
+
throw new LanesError("ask_delivery_failed", `Failed to deliver ask for lane ${lane.lane_id}`, {
|
|
1537
|
+
lane_id: lane.lane_id,
|
|
1538
|
+
lane_state: lane.state,
|
|
1539
|
+
ask_delivery_failed: failure,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
async function runQuestionsList(ctx, opts) {
|
|
1543
|
+
const hqRoot = ctx.hqRoot();
|
|
1544
|
+
const status = opts.status ? parseQuestionStatus(opts.status) : undefined;
|
|
1545
|
+
const forFilter = opts.for ? parseForFilter(opts.for, ctx) : undefined;
|
|
1546
|
+
let company;
|
|
1547
|
+
if (opts.company)
|
|
1548
|
+
company = await ctx.resolveCompany(opts.company);
|
|
1549
|
+
const questions = loadAllQuestions(hqRoot)
|
|
1550
|
+
.filter((q) => {
|
|
1551
|
+
if (status && q.status !== status)
|
|
1552
|
+
return false;
|
|
1553
|
+
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
1554
|
+
return false;
|
|
1555
|
+
if (opts.project && q.project_id !== opts.project)
|
|
1556
|
+
return false;
|
|
1557
|
+
if (opts.story && q.story_id !== opts.story)
|
|
1558
|
+
return false;
|
|
1559
|
+
if (company && q.company.uid !== company.uid && q.company.slug !== company.slug) {
|
|
1560
|
+
return false;
|
|
1561
|
+
}
|
|
1562
|
+
if (forFilter) {
|
|
1563
|
+
const audience = resolveAudience(hqRoot, q, ctx);
|
|
1564
|
+
if (!audienceMatches(audience, forFilter))
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
return true;
|
|
1568
|
+
})
|
|
1569
|
+
.map((q) => ({
|
|
1570
|
+
...q,
|
|
1571
|
+
audience: resolveAudience(hqRoot, q, ctx),
|
|
1572
|
+
}));
|
|
1573
|
+
printJson({ ok: true, action: "questions.list", questions });
|
|
1574
|
+
}
|
|
1575
|
+
async function runQuestionsShow(ctx, questionId) {
|
|
1576
|
+
const hqRoot = ctx.hqRoot();
|
|
1577
|
+
const question = readQuestion(hqRoot, questionId);
|
|
1578
|
+
const body = readQuestionBody(hqRoot, question);
|
|
1579
|
+
printJson({
|
|
1580
|
+
ok: true,
|
|
1581
|
+
action: "questions.show",
|
|
1582
|
+
question,
|
|
1583
|
+
audience: resolveAudience(hqRoot, question, ctx),
|
|
1584
|
+
body,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
1588
|
+
const hqRoot = ctx.hqRoot();
|
|
1589
|
+
if (!opts.choice) {
|
|
1590
|
+
throw new LanesError("missing_choice", "--choice is required", {});
|
|
1591
|
+
}
|
|
1592
|
+
const question = readQuestion(hqRoot, questionId);
|
|
1593
|
+
throwIfQuestionNotPending(question);
|
|
1594
|
+
await yieldEventLoop();
|
|
1595
|
+
const body = requireQuestionBody(hqRoot, question);
|
|
1596
|
+
const choice = resolveAnswerChoice(opts.choice, body.options, Boolean(opts.freeform));
|
|
1597
|
+
const at = ctx.now().toISOString();
|
|
1598
|
+
const answeredBy = sessionIdFromEnv(ctx.env)
|
|
1599
|
+
? `session:${sessionIdFromEnv(ctx.env)}`
|
|
1600
|
+
: "user";
|
|
1601
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
1602
|
+
throwIfQuestionNotPending(current);
|
|
1603
|
+
let notesRef;
|
|
1604
|
+
let nextBody = current.body;
|
|
1605
|
+
if (opts.notes) {
|
|
1606
|
+
const stored = writeQuestionBody(hqRoot, current, {
|
|
1607
|
+
...body,
|
|
1608
|
+
notes: opts.notes,
|
|
1609
|
+
});
|
|
1610
|
+
notesRef = stored.ref;
|
|
1611
|
+
nextBody = stored;
|
|
1612
|
+
}
|
|
1613
|
+
return {
|
|
1614
|
+
...current,
|
|
1615
|
+
body: nextBody,
|
|
1616
|
+
status: "answered",
|
|
1617
|
+
answer: {
|
|
1618
|
+
choice,
|
|
1619
|
+
notes_ref: notesRef,
|
|
1620
|
+
answered_by: answeredBy,
|
|
1621
|
+
answered_at: at,
|
|
1622
|
+
answered_level: current.hop,
|
|
1623
|
+
},
|
|
1624
|
+
};
|
|
1625
|
+
});
|
|
1626
|
+
printJson({ ok: true, action: "questions.answer", question: updated });
|
|
1627
|
+
}
|
|
1628
|
+
async function runQuestionsEscalate(ctx, questionId, opts) {
|
|
1629
|
+
const hqRoot = ctx.hqRoot();
|
|
1630
|
+
const question = readQuestion(hqRoot, questionId);
|
|
1631
|
+
throwIfQuestionNotPending(question);
|
|
1632
|
+
await yieldEventLoop();
|
|
1633
|
+
const originalHop = question.hop;
|
|
1634
|
+
const audience = resolveAudience(hqRoot, question, ctx);
|
|
1635
|
+
if (audience.kind === "user") {
|
|
1636
|
+
throw new LanesError("already_at_top_authority", "Cannot escalate past the user", { question_id: questionId });
|
|
1637
|
+
}
|
|
1638
|
+
const at = ctx.now().toISOString();
|
|
1639
|
+
if (opts.note) {
|
|
1640
|
+
requireQuestionBody(hqRoot, question);
|
|
1641
|
+
}
|
|
1642
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
1643
|
+
throwIfQuestionNotPending(current);
|
|
1644
|
+
if (current.hop !== originalHop) {
|
|
1645
|
+
throw new LanesError("question_already_escalated", `Question ${questionId} hop already advanced`, {
|
|
1646
|
+
question_id: questionId,
|
|
1647
|
+
hop: current.hop,
|
|
1648
|
+
expected_hop: originalHop,
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
let nextBody = current.body;
|
|
1652
|
+
if (opts.note) {
|
|
1653
|
+
const body = requireQuestionBody(hqRoot, current);
|
|
1654
|
+
nextBody = writeQuestionBody(hqRoot, current, {
|
|
1655
|
+
...body,
|
|
1656
|
+
notes: [body.notes, opts.note].filter(Boolean).join("\n"),
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
const nextHop = current.hop + 1;
|
|
1660
|
+
return {
|
|
1661
|
+
...current,
|
|
1662
|
+
body: nextBody,
|
|
1663
|
+
hop: nextHop,
|
|
1664
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
1665
|
+
hop: nextHop,
|
|
1666
|
+
audience: resolveAudience(hqRoot, { ...current, hop: nextHop }, ctx),
|
|
1667
|
+
at,
|
|
1668
|
+
reason: "escalated",
|
|
1669
|
+
}),
|
|
1670
|
+
};
|
|
1671
|
+
});
|
|
1672
|
+
const nextAudience = resolveAudience(hqRoot, updated, ctx);
|
|
1673
|
+
if (nextAudience.kind === "user") {
|
|
1674
|
+
const lane = readLane(hqRoot, updated.lane_id);
|
|
1675
|
+
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question_at_user", { lane_id: lane.lane_id, question_id: updated.question_id });
|
|
1676
|
+
}
|
|
1677
|
+
printJson({ ok: true, action: "questions.escalate", question: updated });
|
|
1678
|
+
}
|
|
1679
|
+
async function runQuestionsCancel(ctx, questionId, opts) {
|
|
1680
|
+
const hqRoot = ctx.hqRoot();
|
|
1681
|
+
const question = readQuestion(hqRoot, questionId);
|
|
1682
|
+
throwIfQuestionNotPending(question);
|
|
1683
|
+
await yieldEventLoop();
|
|
1684
|
+
const at = ctx.now().toISOString();
|
|
1685
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
1686
|
+
throwIfQuestionNotPending(current);
|
|
1687
|
+
const entry = {
|
|
1688
|
+
hop: current.hop,
|
|
1689
|
+
audience: resolveAudience(hqRoot, current, ctx),
|
|
1690
|
+
at,
|
|
1691
|
+
reason: "cancelled",
|
|
1692
|
+
...(opts.reason ? { detail: opts.reason } : {}),
|
|
1693
|
+
};
|
|
1694
|
+
return {
|
|
1695
|
+
...current,
|
|
1696
|
+
status: "cancelled",
|
|
1697
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
1698
|
+
};
|
|
1699
|
+
});
|
|
1700
|
+
printJson({ ok: true, action: "questions.cancel", question: updated });
|
|
1701
|
+
}
|
|
1702
|
+
async function runAccountsList(ctx, opts) {
|
|
1703
|
+
const hqRoot = ctx.hqRoot();
|
|
1704
|
+
const config = loadLanesConfig(hqRoot);
|
|
1705
|
+
const providers = opts.provider
|
|
1706
|
+
? [parseLaneProvider(opts.provider)]
|
|
1707
|
+
: [...LANE_PROVIDERS];
|
|
1708
|
+
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config));
|
|
1709
|
+
printJson({ ok: true, action: "accounts.list", accounts });
|
|
1710
|
+
}
|
|
1711
|
+
async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
1712
|
+
const hqRoot = ctx.hqRoot();
|
|
1713
|
+
const provider = parseLaneProvider(providerRaw);
|
|
1714
|
+
const config = loadLanesConfig(hqRoot);
|
|
1715
|
+
const id = accountId.trim();
|
|
1716
|
+
if (!id) {
|
|
1717
|
+
throw new LanesError("invalid_account", "account-id must be non-empty", {});
|
|
1718
|
+
}
|
|
1719
|
+
const homeRaw = opts.home?.trim() ||
|
|
1720
|
+
(id === "a"
|
|
1721
|
+
? DEFAULT_ACCOUNT_HOMES[provider]
|
|
1722
|
+
: `~/.${provider}-${id}`);
|
|
1723
|
+
const home = canonicalizeHome(homeRaw, {
|
|
1724
|
+
homedir: ctx.homedir,
|
|
1725
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
1726
|
+
existsSync: (p) => fs.existsSync(p),
|
|
1727
|
+
});
|
|
1728
|
+
for (const [existingId, account] of Object.entries(config.providers[provider].accounts)) {
|
|
1729
|
+
if (existingId === id)
|
|
1730
|
+
continue;
|
|
1731
|
+
if (account.home === home) {
|
|
1732
|
+
throw new LanesError("duplicate_account_home", `Home ${home} is already registered as ${provider} account ${existingId}`, { provider, accounts: [existingId, id], home });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
ensureDir(home);
|
|
1736
|
+
const defaultId = config.providers[provider].default_account;
|
|
1737
|
+
const defaultHome = config.providers[provider].accounts[defaultId]?.home ??
|
|
1738
|
+
canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], {
|
|
1739
|
+
homedir: ctx.homedir,
|
|
1740
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
1741
|
+
existsSync: (p) => fs.existsSync(p),
|
|
1742
|
+
});
|
|
1743
|
+
const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
|
|
1744
|
+
const envVar = PROVIDER_HOME_ENV[provider];
|
|
1745
|
+
const login = VENDOR_LOGIN[provider];
|
|
1746
|
+
const result = await ctx.spawnLogin({
|
|
1747
|
+
command: login.command,
|
|
1748
|
+
args: login.args,
|
|
1749
|
+
env: { ...ctx.env, [envVar]: home },
|
|
1750
|
+
cwd: home,
|
|
1751
|
+
});
|
|
1752
|
+
if (result.status !== 0) {
|
|
1753
|
+
throw new LanesError("login_failed", `${login.command} ${login.args.join(" ")} exited ${result.status}`, { provider, account: id, status: result.status });
|
|
1754
|
+
}
|
|
1755
|
+
const grokNeedsRails = provider === "grok";
|
|
1756
|
+
if ((grokNeedsRails || copied) && !railsPresent(home)) {
|
|
1757
|
+
throw new LanesError("rails_missing", `Hook rails are missing under ${home}; account not enabled`, { provider, account: id, home });
|
|
1758
|
+
}
|
|
1759
|
+
config.providers[provider].accounts[id] = {
|
|
1760
|
+
home,
|
|
1761
|
+
enabled: true,
|
|
1762
|
+
};
|
|
1763
|
+
writeLanesConfig(hqRoot, config);
|
|
1764
|
+
const status = accountStatus(provider, id, loadLanesConfig(hqRoot));
|
|
1765
|
+
printJson({ ok: true, action: "accounts.login", account: status });
|
|
1766
|
+
}
|
|
1767
|
+
async function runAccountsEnable(ctx, providerRaw, accountId) {
|
|
1768
|
+
const hqRoot = ctx.hqRoot();
|
|
1769
|
+
const provider = parseLaneProvider(providerRaw);
|
|
1770
|
+
const config = loadLanesConfig(hqRoot);
|
|
1771
|
+
const account = config.providers[provider].accounts[accountId];
|
|
1772
|
+
if (!account) {
|
|
1773
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(accountId)} for ${provider}`, {
|
|
1774
|
+
provider,
|
|
1775
|
+
account: accountId,
|
|
1776
|
+
enabled: Object.entries(config.providers[provider].accounts)
|
|
1777
|
+
.filter(([, a]) => a.enabled)
|
|
1778
|
+
.map(([id]) => id),
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
account.enabled = true;
|
|
1782
|
+
delete account.disabled_reason;
|
|
1783
|
+
writeLanesConfig(hqRoot, config);
|
|
1784
|
+
printJson({
|
|
1785
|
+
ok: true,
|
|
1786
|
+
action: "accounts.enable",
|
|
1787
|
+
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)),
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
|
|
1791
|
+
const hqRoot = ctx.hqRoot();
|
|
1792
|
+
const provider = parseLaneProvider(providerRaw);
|
|
1793
|
+
const config = loadLanesConfig(hqRoot);
|
|
1794
|
+
const account = config.providers[provider].accounts[accountId];
|
|
1795
|
+
if (!account) {
|
|
1796
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(accountId)} for ${provider}`, {
|
|
1797
|
+
provider,
|
|
1798
|
+
account: accountId,
|
|
1799
|
+
enabled: Object.entries(config.providers[provider].accounts)
|
|
1800
|
+
.filter(([, a]) => a.enabled)
|
|
1801
|
+
.map(([id]) => id),
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
account.enabled = false;
|
|
1805
|
+
if (opts.reason)
|
|
1806
|
+
account.disabled_reason = opts.reason;
|
|
1807
|
+
writeLanesConfig(hqRoot, config);
|
|
1808
|
+
printJson({
|
|
1809
|
+
ok: true,
|
|
1810
|
+
action: "accounts.disable",
|
|
1811
|
+
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)),
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
async function runSessionsList(ctx, opts) {
|
|
1815
|
+
const hqRoot = ctx.hqRoot();
|
|
1816
|
+
const state = opts.state ? parseLivenessState(opts.state) : undefined;
|
|
1817
|
+
const rows = listSessionLiveness(hqRoot).filter((row) => {
|
|
1818
|
+
if (state && row.state !== state)
|
|
1819
|
+
return false;
|
|
1820
|
+
if (!opts.company)
|
|
1821
|
+
return true;
|
|
1822
|
+
const meta = readSessionMeta(hqRoot, row.sessionId, ctx);
|
|
1823
|
+
const slug = typeof meta?.company_slug === "string" ? meta.company_slug : "";
|
|
1824
|
+
const uid = typeof meta?.company_uid === "string" ? meta.company_uid : "";
|
|
1825
|
+
return slug === opts.company || uid === opts.company;
|
|
1826
|
+
});
|
|
1827
|
+
printJson({ ok: true, action: "sessions.list", sessions: rows });
|
|
1828
|
+
}
|
|
1829
|
+
async function runSessionsShow(ctx, sessionId) {
|
|
1830
|
+
const hqRoot = ctx.hqRoot();
|
|
1831
|
+
const state = sessionLiveness(hqRoot, sessionId);
|
|
1832
|
+
if (state === null) {
|
|
1833
|
+
throw new LanesError("session_not_found", `Session ${sessionId} is not in the liveness index`, { session_id: sessionId });
|
|
1834
|
+
}
|
|
1835
|
+
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
1836
|
+
printJson({
|
|
1837
|
+
ok: true,
|
|
1838
|
+
action: "sessions.show",
|
|
1839
|
+
session_id: sessionId,
|
|
1840
|
+
state,
|
|
1841
|
+
senior: meta ? seniorOfSession(hqRoot, sessionId, ctx) : { kind: "user" },
|
|
1842
|
+
company_slug: typeof meta?.company_slug === "string" ? meta.company_slug : null,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
export function registerLanesCommand(parent, deps = {}) {
|
|
1846
|
+
const ctx = resolveDeps(deps);
|
|
1847
|
+
const lanes = parent
|
|
1848
|
+
.command("lanes")
|
|
1849
|
+
.description("Create, supervise, and steer worker lanes; routed decision queue");
|
|
1850
|
+
lanes
|
|
1851
|
+
.command("create")
|
|
1852
|
+
.description("Register a lane, run admission, and return immediately")
|
|
1853
|
+
.requiredOption("--company <slug|uid>", "Company slug or cloud uid")
|
|
1854
|
+
.requiredOption("--project <slug>", "HQ project slug")
|
|
1855
|
+
.requiredOption("--story <id>", "Story id from the project's prd.json")
|
|
1856
|
+
.requiredOption("--brief-file <path>", "Path to the lane brief")
|
|
1857
|
+
.option("--worker <name>", "HQ worker id from the registry")
|
|
1858
|
+
.option("--provider <name>", "claude | codex | grok")
|
|
1859
|
+
.option("--account <id>", "Provider account id")
|
|
1860
|
+
.option("--senior <ref>", "session:<id> | lane:<id> | user")
|
|
1861
|
+
.option("--timeout <secs>", "Deadline in seconds (warns, does not kill)")
|
|
1862
|
+
.option("--priority <n>", "Queue priority; higher runs first", "1")
|
|
1863
|
+
.option("--outpost <id>", "Run on this Outpost instead of the local host")
|
|
1864
|
+
.action(wrap(async (opts) => {
|
|
1865
|
+
await runCreate(ctx, opts);
|
|
1866
|
+
}));
|
|
1867
|
+
lanes
|
|
1868
|
+
.command("list")
|
|
1869
|
+
.description("List lanes")
|
|
1870
|
+
.option("--company <slug|uid>", "Filter by company")
|
|
1871
|
+
.option("--project <slug>", "Filter by project")
|
|
1872
|
+
.option("--story <id>", "Filter by story")
|
|
1873
|
+
.option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
|
|
1874
|
+
.option("--senior <ref>", "Filter by senior ref")
|
|
1875
|
+
.action(wrap(async (opts) => {
|
|
1876
|
+
await runList(ctx, opts);
|
|
1877
|
+
}));
|
|
1878
|
+
lanes
|
|
1879
|
+
.command("show")
|
|
1880
|
+
.description("Show one lane")
|
|
1881
|
+
.argument("<lane-id>", "Lane id")
|
|
1882
|
+
.action(wrap(async (laneId) => {
|
|
1883
|
+
await runShow(ctx, laneId);
|
|
1884
|
+
}));
|
|
1885
|
+
lanes
|
|
1886
|
+
.command("message")
|
|
1887
|
+
.description("Send a follow-up message to a lane")
|
|
1888
|
+
.argument("<lane-id>", "Lane id")
|
|
1889
|
+
.requiredOption("--text <msg>", "Message text")
|
|
1890
|
+
.option("--project <slug>", "Re-point project attribution from this message")
|
|
1891
|
+
.option("--story <id>", "Re-point story attribution from this message")
|
|
1892
|
+
.action(wrap(async (laneId, opts) => {
|
|
1893
|
+
await runMessage(ctx, laneId, opts);
|
|
1894
|
+
}));
|
|
1895
|
+
lanes
|
|
1896
|
+
.command("reparent")
|
|
1897
|
+
.description("Rewrite a lane's senior edge")
|
|
1898
|
+
.argument("<lane-id>", "Lane id")
|
|
1899
|
+
.requiredOption("--senior <ref>", "session:<id> | lane:<id> | user")
|
|
1900
|
+
.option("--reason <text>", "Why the senior changed")
|
|
1901
|
+
.action(wrap(async (laneId, opts) => {
|
|
1902
|
+
await runReparent(ctx, laneId, opts);
|
|
1903
|
+
}));
|
|
1904
|
+
lanes
|
|
1905
|
+
.command("logs")
|
|
1906
|
+
.description("Print a lane's local logs")
|
|
1907
|
+
.argument("<lane-id>", "Lane id")
|
|
1908
|
+
.option("--tail <n>", "Last N lines")
|
|
1909
|
+
.action(wrap(async (laneId, opts) => {
|
|
1910
|
+
await runLogs(ctx, laneId, opts);
|
|
1911
|
+
}));
|
|
1912
|
+
lanes
|
|
1913
|
+
.command("stop")
|
|
1914
|
+
.description("Cancel a queued lane or kill a running one")
|
|
1915
|
+
.argument("<lane-id>", "Lane id")
|
|
1916
|
+
.action(wrap(async (laneId) => {
|
|
1917
|
+
await runStop(ctx, laneId);
|
|
1918
|
+
}));
|
|
1919
|
+
lanes
|
|
1920
|
+
.command("ask")
|
|
1921
|
+
.description("Queue a mid-run question without blocking the lane")
|
|
1922
|
+
.requiredOption("--question <text>", "Question text")
|
|
1923
|
+
.option("--context <text>", "Context for the audience")
|
|
1924
|
+
.option("--option <text>", "Answer choice (repeatable)", collectRepeatable)
|
|
1925
|
+
.option("--recommended <text>", "Recommended option")
|
|
1926
|
+
.action(wrap(async (opts) => {
|
|
1927
|
+
await runAsk(ctx, opts);
|
|
1928
|
+
}));
|
|
1929
|
+
const questions = lanes
|
|
1930
|
+
.command("questions")
|
|
1931
|
+
.description("Routed decision queue");
|
|
1932
|
+
questions
|
|
1933
|
+
.command("list")
|
|
1934
|
+
.description("List questions")
|
|
1935
|
+
.option("--for <ref>", "Who must decide (ref | me | user)")
|
|
1936
|
+
.option("--from <lane-id>", "Which lane asked")
|
|
1937
|
+
.option("--company <slug|uid>", "Filter by company")
|
|
1938
|
+
.option("--project <slug>", "Filter by project")
|
|
1939
|
+
.option("--story <id>", "Filter by story")
|
|
1940
|
+
.option("--status <status>", `Filter by status (${QUESTION_STATUSES.join("|")})`)
|
|
1941
|
+
.action(wrap(async (opts) => {
|
|
1942
|
+
await runQuestionsList(ctx, opts);
|
|
1943
|
+
}));
|
|
1944
|
+
questions
|
|
1945
|
+
.command("show")
|
|
1946
|
+
.description("Show one question")
|
|
1947
|
+
.argument("<question-id>", "Question id")
|
|
1948
|
+
.action(wrap(async (questionId) => {
|
|
1949
|
+
await runQuestionsShow(ctx, questionId);
|
|
1950
|
+
}));
|
|
1951
|
+
questions
|
|
1952
|
+
.command("answer")
|
|
1953
|
+
.description("Answer a pending question")
|
|
1954
|
+
.argument("<question-id>", "Question id")
|
|
1955
|
+
.requiredOption("--choice <label|N>", "1-based index or option label")
|
|
1956
|
+
.option("--notes <text>", "Free-text notes stored with the body")
|
|
1957
|
+
.option("--freeform", "Allow a choice that is not on the menu")
|
|
1958
|
+
.action(wrap(async (questionId, opts) => {
|
|
1959
|
+
await runQuestionsAnswer(ctx, questionId, opts);
|
|
1960
|
+
}));
|
|
1961
|
+
questions
|
|
1962
|
+
.command("escalate")
|
|
1963
|
+
.description("Advance a question one hop")
|
|
1964
|
+
.argument("<question-id>", "Question id")
|
|
1965
|
+
.option("--note <text>", "Note recorded with the escalation")
|
|
1966
|
+
.action(wrap(async (questionId, opts) => {
|
|
1967
|
+
await runQuestionsEscalate(ctx, questionId, opts);
|
|
1968
|
+
}));
|
|
1969
|
+
questions
|
|
1970
|
+
.command("cancel")
|
|
1971
|
+
.description("Cancel a pending question")
|
|
1972
|
+
.argument("<question-id>", "Question id")
|
|
1973
|
+
.option("--reason <text>", "Why it was cancelled")
|
|
1974
|
+
.action(wrap(async (questionId, opts) => {
|
|
1975
|
+
await runQuestionsCancel(ctx, questionId, opts);
|
|
1976
|
+
}));
|
|
1977
|
+
const accounts = lanes
|
|
1978
|
+
.command("accounts")
|
|
1979
|
+
.description("Provider account registry (configured vs logged in)");
|
|
1980
|
+
accounts
|
|
1981
|
+
.command("list")
|
|
1982
|
+
.description("List accounts")
|
|
1983
|
+
.option("--provider <name>", "claude | codex | grok")
|
|
1984
|
+
.action(wrap(async (opts) => {
|
|
1985
|
+
await runAccountsList(ctx, opts);
|
|
1986
|
+
}));
|
|
1987
|
+
accounts
|
|
1988
|
+
.command("login")
|
|
1989
|
+
.description("Create a home, run the vendor login, and register the account")
|
|
1990
|
+
.argument("<provider>", "claude | codex | grok")
|
|
1991
|
+
.argument("<account-id>", "Open-ended account id")
|
|
1992
|
+
.option("--home <path>", "Account home directory")
|
|
1993
|
+
.action(wrap(async (provider, accountId, opts) => {
|
|
1994
|
+
await runAccountsLogin(ctx, provider, accountId, opts);
|
|
1995
|
+
}));
|
|
1996
|
+
accounts
|
|
1997
|
+
.command("enable")
|
|
1998
|
+
.description("Enable an account without touching credentials")
|
|
1999
|
+
.argument("<provider>", "claude | codex | grok")
|
|
2000
|
+
.argument("<account-id>", "Account id")
|
|
2001
|
+
.action(wrap(async (provider, accountId) => {
|
|
2002
|
+
await runAccountsEnable(ctx, provider, accountId);
|
|
2003
|
+
}));
|
|
2004
|
+
accounts
|
|
2005
|
+
.command("disable")
|
|
2006
|
+
.description("Disable an account without touching credentials")
|
|
2007
|
+
.argument("<provider>", "claude | codex | grok")
|
|
2008
|
+
.argument("<account-id>", "Account id")
|
|
2009
|
+
.option("--reason <text>", "Why the account is disabled")
|
|
2010
|
+
.action(wrap(async (provider, accountId, opts) => {
|
|
2011
|
+
await runAccountsDisable(ctx, provider, accountId, opts);
|
|
2012
|
+
}));
|
|
2013
|
+
const sessions = lanes
|
|
2014
|
+
.command("sessions")
|
|
2015
|
+
.description("Session liveness from the work-mesh spool");
|
|
2016
|
+
sessions
|
|
2017
|
+
.command("list")
|
|
2018
|
+
.description("List sessions")
|
|
2019
|
+
.option("--state <state>", `live | idle | ended | lost`)
|
|
2020
|
+
.option("--company <slug>", "Filter by session meta company_slug")
|
|
2021
|
+
.action(wrap(async (opts) => {
|
|
2022
|
+
await runSessionsList(ctx, opts);
|
|
2023
|
+
}));
|
|
2024
|
+
sessions
|
|
2025
|
+
.command("show")
|
|
2026
|
+
.description("Show one session's liveness")
|
|
2027
|
+
.argument("<session-id>", "Session id")
|
|
2028
|
+
.action(wrap(async (sessionId) => {
|
|
2029
|
+
await runSessionsShow(ctx, sessionId);
|
|
2030
|
+
}));
|
|
2031
|
+
}
|
|
2032
|
+
//# sourceMappingURL=lanes.js.map
|