@indigoai-us/hq-cli 5.125.0 → 5.127.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 +329 -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/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +643 -6
- package/dist/command-catalog.generated.js +836 -6
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +11 -1
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +114 -0
- package/dist/commands/lanes.js +2969 -0
- package/dist/commands/mesh.js +287 -24
- 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/lanes/accounts.d.ts +103 -0
- package/dist/lib/lanes/accounts.js +318 -0
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +71 -0
- package/dist/lib/lanes/config.js +408 -0
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +49 -0
- package/dist/lib/lanes/index.js +27 -0
- package/dist/lib/lanes/liveness.d.ts +95 -0
- package/dist/lib/lanes/liveness.js +332 -0
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +116 -0
- package/dist/lib/lanes/registry.js +521 -0
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +68 -0
- package/dist/lib/lanes/store.js +745 -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 +535 -0
- package/dist/lib/lanes/types.js +350 -0
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- 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/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 +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +18 -13
- package/dist/lib/work-context/config.d.ts +20 -0
- package/dist/lib/work-context/config.js +26 -5
- package/dist/lib/work-context/reconcile.js +28 -7
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- 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 +3 -1
|
@@ -0,0 +1,2969 @@
|
|
|
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, spawnSync } from "node:child_process";
|
|
8
|
+
import { 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 { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
13
|
+
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
14
|
+
import { buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.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 { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.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_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
21
|
+
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
22
|
+
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
23
|
+
import { providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
24
|
+
import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
|
|
25
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
26
|
+
import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
|
|
27
|
+
import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, stopLaneProcesses, } from "../lib/lanes/spawn.js";
|
|
28
|
+
export { spawnLaneWorker };
|
|
29
|
+
export { questionBodyAbsPath };
|
|
30
|
+
export const MAX_LANE_DEPTH = 2;
|
|
31
|
+
export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
32
|
+
export const OUTPOST_DIR = "/etc/outpost";
|
|
33
|
+
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
34
|
+
const VENDOR_LOGIN = {
|
|
35
|
+
claude: { command: "claude", args: ["auth", "login"] },
|
|
36
|
+
codex: { command: "codex", args: ["login"] },
|
|
37
|
+
grok: { command: "grok", args: ["login"] },
|
|
38
|
+
};
|
|
39
|
+
export { emitLaneDecisionEvents };
|
|
40
|
+
function printJson(value) {
|
|
41
|
+
console.log(JSON.stringify(value, null, 2));
|
|
42
|
+
}
|
|
43
|
+
function failJson(err) {
|
|
44
|
+
if (isLanesError(err)) {
|
|
45
|
+
printJson(err.toJSON());
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
process.stderr.write(`hq lanes: ${message}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
|
|
50
|
+
printJson({ error: "internal", message });
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
function wrap(action) {
|
|
54
|
+
return async (...args) => {
|
|
55
|
+
try {
|
|
56
|
+
await action(...args);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
failJson(err);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function collectRepeatable(value, previous = []) {
|
|
64
|
+
return previous.concat(value);
|
|
65
|
+
}
|
|
66
|
+
async function defaultResolveCompany(company) {
|
|
67
|
+
const { company: mesh } = await openMeshTransport({ company });
|
|
68
|
+
return { slug: mesh.companySlug, uid: mesh.companyUid };
|
|
69
|
+
}
|
|
70
|
+
function defaultOutpostsClient() {
|
|
71
|
+
return createOutpostsClient({
|
|
72
|
+
transport: vaultApiFetch,
|
|
73
|
+
token: () => ensureCognitoToken({ interactive: false }),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function defaultListOutpostIds() {
|
|
77
|
+
const rows = await defaultOutpostsClient().list();
|
|
78
|
+
return rows.map((row) => row.outpostId);
|
|
79
|
+
}
|
|
80
|
+
async function defaultStageOutpostPayload(outpostId, bytes) {
|
|
81
|
+
return stageOutpostPayload(defaultOutpostsClient(), outpostId, bytes);
|
|
82
|
+
}
|
|
83
|
+
async function defaultDispatchOutpostExec(input) {
|
|
84
|
+
try {
|
|
85
|
+
return await dispatchOutpostPickup(defaultOutpostsClient(), input);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof LanesError)
|
|
89
|
+
throw err;
|
|
90
|
+
throw new LanesError("outpost_dispatch_failed", `Outpost exec failed: ${dispatchErrorMessage(err)}`, { outpost_id: input.outpostId, message: dispatchErrorMessage(err) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function defaultFetchText(url) {
|
|
94
|
+
const res = await fetch(url);
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
throw new LanesError("outpost_payload_invalid", `Failed to download pickup payload: HTTP ${res.status}`, { status: res.status });
|
|
97
|
+
}
|
|
98
|
+
return res.text();
|
|
99
|
+
}
|
|
100
|
+
export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
|
|
101
|
+
const hasMarker = existsSync(OUTPOST_ID_PATH);
|
|
102
|
+
const hasDir = existsSync(OUTPOST_DIR);
|
|
103
|
+
if (hasMarker) {
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = readFileSync(OUTPOST_ID_PATH);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
process.stderr.write(`hq lanes: failed to read ${OUTPOST_ID_PATH}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
const id = text.trim();
|
|
113
|
+
if (!id)
|
|
114
|
+
return hasDir ? { kind: "unknown" } : { kind: "not_outpost" };
|
|
115
|
+
return { kind: "id", id };
|
|
116
|
+
}
|
|
117
|
+
if (hasDir)
|
|
118
|
+
return { kind: "unknown" };
|
|
119
|
+
return { kind: "not_outpost" };
|
|
120
|
+
}
|
|
121
|
+
function defaultSpawnLogin(opts) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const child = spawn(opts.command, opts.args, {
|
|
124
|
+
env: opts.env,
|
|
125
|
+
cwd: opts.cwd,
|
|
126
|
+
stdio: "inherit",
|
|
127
|
+
});
|
|
128
|
+
child.on("error", (err) => {
|
|
129
|
+
reject(err);
|
|
130
|
+
});
|
|
131
|
+
child.on("close", (code) => {
|
|
132
|
+
resolve({ status: code ?? 1 });
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function createDefaultMesh() {
|
|
137
|
+
// Interactive: the command runs in the operator's shell, where a browser
|
|
138
|
+
// login is acceptable. The watcher builds its own non-interactive mesh.
|
|
139
|
+
return createLaneMesh({ interactive: true });
|
|
140
|
+
}
|
|
141
|
+
function defaultSleep(ms) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
setTimeout(resolve, ms);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function resolveCallerPath(callerCwd, filePath) {
|
|
147
|
+
return path.resolve(callerCwd, filePath);
|
|
148
|
+
}
|
|
149
|
+
function resolveDeps(deps = {}) {
|
|
150
|
+
const env = deps.env ?? process.env;
|
|
151
|
+
const cwd = deps.cwd ?? (() => process.cwd());
|
|
152
|
+
let capturedCallerCwd;
|
|
153
|
+
const callerCwd = () => {
|
|
154
|
+
if (capturedCallerCwd === undefined)
|
|
155
|
+
capturedCallerCwd = cwd();
|
|
156
|
+
return capturedCallerCwd;
|
|
157
|
+
};
|
|
158
|
+
const bindProcessCwd = deps.hqRoot === undefined;
|
|
159
|
+
const resolveRoot = deps.hqRoot ?? (() => resolveLanesHqRoot({ env, cwd: cwd() }));
|
|
160
|
+
const hqRoot = () => {
|
|
161
|
+
callerCwd();
|
|
162
|
+
const root = resolveRoot();
|
|
163
|
+
if (bindProcessCwd && process.cwd() !== root) {
|
|
164
|
+
process.chdir(root);
|
|
165
|
+
}
|
|
166
|
+
return root;
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
cwd,
|
|
170
|
+
callerCwd,
|
|
171
|
+
hqRoot,
|
|
172
|
+
env,
|
|
173
|
+
now: deps.now ?? (() => new Date()),
|
|
174
|
+
hostname: deps.hostname ?? (() => os.hostname()),
|
|
175
|
+
resolveCompany: deps.resolveCompany ?? defaultResolveCompany,
|
|
176
|
+
listOutpostIds: deps.listOutpostIds ?? defaultListOutpostIds,
|
|
177
|
+
readOutpostIdentity: deps.readOutpostIdentity ?? (() => readOutpostIdentity()),
|
|
178
|
+
spawnLogin: deps.spawnLogin ?? defaultSpawnLogin,
|
|
179
|
+
throttleDeps: deps.throttleDeps ?? defaultThrottleDeps(env),
|
|
180
|
+
existsSync: deps.existsSync ?? ((p) => fs.existsSync(p)),
|
|
181
|
+
readFileSync: deps.readFileSync ?? ((p, encoding) => fs.readFileSync(p, encoding)),
|
|
182
|
+
homedir: deps.homedir ?? (() => os.homedir()),
|
|
183
|
+
mesh: deps.mesh ?? createDefaultMesh(),
|
|
184
|
+
sleep: deps.sleep ?? defaultSleep,
|
|
185
|
+
spawnProcess: deps.spawnProcess,
|
|
186
|
+
proveDetached: deps.proveDetached,
|
|
187
|
+
killProcess: deps.killProcess,
|
|
188
|
+
isAlive: deps.isAlive,
|
|
189
|
+
stopSleepMs: deps.stopSleepMs,
|
|
190
|
+
livenessDeps: deps.livenessDeps,
|
|
191
|
+
presenceConnected: deps.presenceConnected,
|
|
192
|
+
systemdRoot: deps.systemdRoot,
|
|
193
|
+
runSystemctl: deps.runSystemctl ??
|
|
194
|
+
((command, args) => {
|
|
195
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
196
|
+
return {
|
|
197
|
+
status: result.status ?? 1,
|
|
198
|
+
stdout: result.stdout ?? "",
|
|
199
|
+
stderr: result.stderr ??
|
|
200
|
+
(result.error instanceof Error ? result.error.message : ""),
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
203
|
+
resolveMachineIdentity: deps.resolveMachineIdentity ?? lookupMachineIdentity,
|
|
204
|
+
runCompaction: deps.runCompaction,
|
|
205
|
+
locateTranscript: deps.locateTranscript,
|
|
206
|
+
stageOutpostPayload: deps.stageOutpostPayload ?? defaultStageOutpostPayload,
|
|
207
|
+
dispatchOutpostExec: deps.dispatchOutpostExec ?? defaultDispatchOutpostExec,
|
|
208
|
+
fetchText: deps.fetchText ?? defaultFetchText,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function livenessDepsFromCtx(ctx) {
|
|
212
|
+
const base = ctx.livenessDeps ?? defaultLivenessDeps();
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
now: () => ctx.now().getTime(),
|
|
216
|
+
homedir: ctx.homedir,
|
|
217
|
+
env: ctx.env,
|
|
218
|
+
existsSync: ctx.existsSync,
|
|
219
|
+
readFileSync: ctx.readFileSync,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function reconcileHostFromCtx(ctx) {
|
|
223
|
+
const identity = ctx.readOutpostIdentity();
|
|
224
|
+
return {
|
|
225
|
+
hostname: ctx.hostname(),
|
|
226
|
+
outpostId: identity.kind === "id" ? identity.id : null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export function parseSeniorRef(raw) {
|
|
230
|
+
const trimmed = raw.trim();
|
|
231
|
+
if (trimmed.length === 0) {
|
|
232
|
+
throw new LanesError("unknown_senior_kind", `Unknown senior ref ${JSON.stringify(raw)}. Accepted kinds: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value: raw });
|
|
233
|
+
}
|
|
234
|
+
if (trimmed === "user")
|
|
235
|
+
return { kind: "user" };
|
|
236
|
+
const colon = trimmed.indexOf(":");
|
|
237
|
+
if (colon <= 0) {
|
|
238
|
+
throw new LanesError("unknown_senior_kind", `Unknown senior ref ${JSON.stringify(raw)}. Accepted kinds: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value: raw });
|
|
239
|
+
}
|
|
240
|
+
const kindRaw = trimmed.slice(0, colon);
|
|
241
|
+
const id = trimmed.slice(colon + 1);
|
|
242
|
+
const kind = parseSeniorKind(kindRaw);
|
|
243
|
+
if (kind === "user") {
|
|
244
|
+
if (id.length > 0) {
|
|
245
|
+
throw new LanesError("invalid_senior_ref", "Senior kind user takes no id", { value: raw });
|
|
246
|
+
}
|
|
247
|
+
return { kind: "user" };
|
|
248
|
+
}
|
|
249
|
+
if (id.length === 0) {
|
|
250
|
+
throw new LanesError("invalid_senior_ref", `Senior kind ${kind} requires an id (got ${JSON.stringify(raw)})`, { kind, value: raw });
|
|
251
|
+
}
|
|
252
|
+
return { kind, id };
|
|
253
|
+
}
|
|
254
|
+
function seniorKey(ref) {
|
|
255
|
+
return ref.kind === "user" ? "user" : `${ref.kind}:${ref.id ?? ""}`;
|
|
256
|
+
}
|
|
257
|
+
export function resolveAnswerChoice(choice, options, freeform) {
|
|
258
|
+
const trimmed = choice.trim();
|
|
259
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
260
|
+
const index = Number(trimmed);
|
|
261
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
262
|
+
return options[index - 1];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (options.includes(trimmed) || options.includes(choice)) {
|
|
266
|
+
return options.includes(trimmed) ? trimmed : choice;
|
|
267
|
+
}
|
|
268
|
+
if (freeform)
|
|
269
|
+
return choice;
|
|
270
|
+
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
271
|
+
}
|
|
272
|
+
function parsePriority(raw) {
|
|
273
|
+
if (raw === undefined || raw === null || raw === "")
|
|
274
|
+
return 1;
|
|
275
|
+
const n = typeof raw === "number" ? raw : Number(String(raw));
|
|
276
|
+
if (!Number.isInteger(n)) {
|
|
277
|
+
throw new LanesError("invalid_priority", `--priority must be an integer`, {
|
|
278
|
+
value: raw,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return n;
|
|
282
|
+
}
|
|
283
|
+
function parseOptionalInt(raw, flag) {
|
|
284
|
+
if (raw === undefined || raw === null || raw === "")
|
|
285
|
+
return undefined;
|
|
286
|
+
const n = typeof raw === "number" ? raw : Number(String(raw));
|
|
287
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
288
|
+
throw new LanesError("invalid_integer", `${flag} must be a non-negative integer`, { flag, value: raw });
|
|
289
|
+
}
|
|
290
|
+
return n;
|
|
291
|
+
}
|
|
292
|
+
function runDir(hqRoot, laneId) {
|
|
293
|
+
return path.join(hqRoot, LANES_RUNS_REL, laneId);
|
|
294
|
+
}
|
|
295
|
+
function ensureDir(dir) {
|
|
296
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
297
|
+
}
|
|
298
|
+
function writeJsonFile(filePath, value) {
|
|
299
|
+
ensureDir(path.dirname(filePath));
|
|
300
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
301
|
+
}
|
|
302
|
+
function readJsonFile(filePath) {
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
const code = err.code;
|
|
308
|
+
if (code === "ENOENT")
|
|
309
|
+
return null;
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function parseQuestionBody(raw) {
|
|
314
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
315
|
+
return null;
|
|
316
|
+
const obj = raw;
|
|
317
|
+
const options = Array.isArray(obj.options)
|
|
318
|
+
? obj.options.filter((item) => typeof item === "string")
|
|
319
|
+
: [];
|
|
320
|
+
const body = {
|
|
321
|
+
question: typeof obj.question === "string" ? obj.question : "",
|
|
322
|
+
context: typeof obj.context === "string" ? obj.context : "",
|
|
323
|
+
options,
|
|
324
|
+
};
|
|
325
|
+
if (typeof obj.recommended === "string")
|
|
326
|
+
body.recommended = obj.recommended;
|
|
327
|
+
if (typeof obj.notes === "string")
|
|
328
|
+
body.notes = obj.notes;
|
|
329
|
+
return body;
|
|
330
|
+
}
|
|
331
|
+
function readQuestionBodyFile(hqRoot, question) {
|
|
332
|
+
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
333
|
+
let serialized;
|
|
334
|
+
try {
|
|
335
|
+
serialized = fs.readFileSync(filePath, "utf8");
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
const code = err.code;
|
|
339
|
+
if (code === "ENOENT")
|
|
340
|
+
return null;
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
const digest = sha256Text(serialized);
|
|
344
|
+
let parsed;
|
|
345
|
+
try {
|
|
346
|
+
parsed = JSON.parse(serialized);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
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 });
|
|
350
|
+
}
|
|
351
|
+
const body = parseQuestionBody(parsed);
|
|
352
|
+
if (!body) {
|
|
353
|
+
throw new LanesError("question_body_unreadable", `Question body at ${filePath} is not an object`, { question_id: question.question_id, path: filePath });
|
|
354
|
+
}
|
|
355
|
+
return { body, digest, serialized };
|
|
356
|
+
}
|
|
357
|
+
function readQuestionBody(hqRoot, question) {
|
|
358
|
+
const stored = readQuestionBodyFile(hqRoot, question);
|
|
359
|
+
if (!stored)
|
|
360
|
+
return null;
|
|
361
|
+
if (question.body.digest && stored.digest !== question.body.digest) {
|
|
362
|
+
throw new LanesError("question_body_digest_mismatch", `Question body digest does not match the routing record for ${question.question_id}`, {
|
|
363
|
+
question_id: question.question_id,
|
|
364
|
+
expected: question.body.digest,
|
|
365
|
+
actual: stored.digest,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return stored.body;
|
|
369
|
+
}
|
|
370
|
+
function requireQuestionBody(hqRoot, question) {
|
|
371
|
+
const body = readQuestionBody(hqRoot, question);
|
|
372
|
+
if (!body) {
|
|
373
|
+
throw new LanesError("question_body_unavailable", `Question body is not available on this host for ${question.question_id}`, {
|
|
374
|
+
question_id: question.question_id,
|
|
375
|
+
store: question.body.store,
|
|
376
|
+
ref: question.body.ref,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return body;
|
|
380
|
+
}
|
|
381
|
+
function loadAllLanes(hqRoot) {
|
|
382
|
+
return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
|
|
383
|
+
}
|
|
384
|
+
function loadAllQuestions(hqRoot) {
|
|
385
|
+
return listQuestionIds(hqRoot).map((id) => readQuestion(hqRoot, id));
|
|
386
|
+
}
|
|
387
|
+
function sessionMetaPath(hqRoot, sessionId) {
|
|
388
|
+
return path.join(hqRoot, "workspace", "sessions", sessionId, "meta.yaml");
|
|
389
|
+
}
|
|
390
|
+
function loadSessionMeta(hqRoot, sessionId, ctx) {
|
|
391
|
+
const filePath = sessionMetaPath(hqRoot, sessionId);
|
|
392
|
+
if (!ctx.existsSync(filePath))
|
|
393
|
+
return { status: "missing" };
|
|
394
|
+
let text;
|
|
395
|
+
try {
|
|
396
|
+
text = ctx.readFileSync(filePath, "utf8");
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
process.stderr.write(`hq lanes: failed to read session meta ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
400
|
+
return { status: "unreadable" };
|
|
401
|
+
}
|
|
402
|
+
let doc;
|
|
403
|
+
try {
|
|
404
|
+
doc = parseUserYaml(text, filePath);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
process.stderr.write(`hq lanes: failed to parse session meta ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
408
|
+
return { status: "malformed" };
|
|
409
|
+
}
|
|
410
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
411
|
+
return { status: "malformed" };
|
|
412
|
+
}
|
|
413
|
+
return { status: "ok", doc: doc };
|
|
414
|
+
}
|
|
415
|
+
function readSessionMeta(hqRoot, sessionId, ctx) {
|
|
416
|
+
const loaded = loadSessionMeta(hqRoot, sessionId, ctx);
|
|
417
|
+
if (loaded.status !== "ok")
|
|
418
|
+
return null;
|
|
419
|
+
return loaded.doc;
|
|
420
|
+
}
|
|
421
|
+
function seniorOfSession(hqRoot, sessionId, ctx) {
|
|
422
|
+
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
423
|
+
if (!meta)
|
|
424
|
+
return { kind: "user" };
|
|
425
|
+
const senior = meta.senior;
|
|
426
|
+
if (typeof senior === "string" && senior.trim()) {
|
|
427
|
+
return parseSeniorRef(senior);
|
|
428
|
+
}
|
|
429
|
+
if (senior !== null && typeof senior === "object" && !Array.isArray(senior)) {
|
|
430
|
+
const rec = senior;
|
|
431
|
+
const kind = parseSeniorKind(rec.kind);
|
|
432
|
+
if (kind === "user")
|
|
433
|
+
return { kind: "user" };
|
|
434
|
+
if (typeof rec.id === "string" && rec.id.length > 0) {
|
|
435
|
+
return { kind, id: rec.id };
|
|
436
|
+
}
|
|
437
|
+
throw new LanesError("invalid_senior_ref", `Session ${sessionId} senior is missing an id`, { session_id: sessionId });
|
|
438
|
+
}
|
|
439
|
+
return { kind: "user" };
|
|
440
|
+
}
|
|
441
|
+
function seniorExists(hqRoot, ref, ctx) {
|
|
442
|
+
if (ref.kind === "user")
|
|
443
|
+
return true;
|
|
444
|
+
if (!ref.id)
|
|
445
|
+
return false;
|
|
446
|
+
if (ref.kind === "lane")
|
|
447
|
+
return tryReadLane(hqRoot, ref.id) !== null;
|
|
448
|
+
const dir = path.join(hqRoot, "workspace", "sessions", ref.id);
|
|
449
|
+
if (ctx.existsSync(dir))
|
|
450
|
+
return true;
|
|
451
|
+
try {
|
|
452
|
+
const index = loadLivenessIndex(hqRoot);
|
|
453
|
+
return index.sessions[ref.id] !== undefined;
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
process.stderr.write(`hq lanes: liveness index read failed while checking session ${ref.id}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
457
|
+
throw err;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function assertSeniorExists(hqRoot, ref, ctx) {
|
|
461
|
+
if (seniorExists(hqRoot, ref, ctx))
|
|
462
|
+
return;
|
|
463
|
+
throw new LanesError("unknown_senior", `Senior ${seniorKey(ref)} does not exist`, { senior: ref });
|
|
464
|
+
}
|
|
465
|
+
function depthOfRef(hqRoot, ref, ctx, visited = new Set(), override) {
|
|
466
|
+
if (ref.kind === "user")
|
|
467
|
+
return 0;
|
|
468
|
+
const key = seniorKey(ref);
|
|
469
|
+
if (visited.has(key)) {
|
|
470
|
+
throw new LanesError("senior_cycle", `Cycle in senior chain at ${key}`, {
|
|
471
|
+
at: ref,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
visited.add(key);
|
|
475
|
+
if (ref.kind === "lane" && override && ref.id === override.laneId) {
|
|
476
|
+
return override.depth;
|
|
477
|
+
}
|
|
478
|
+
if (ref.kind === "session") {
|
|
479
|
+
if (!ref.id) {
|
|
480
|
+
throw new LanesError("invalid_senior_ref", "Session senior is missing an id", {
|
|
481
|
+
senior: ref,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return depthOfRef(hqRoot, seniorOfSession(hqRoot, ref.id, ctx), ctx, visited, override) + 1;
|
|
485
|
+
}
|
|
486
|
+
if (!ref.id) {
|
|
487
|
+
throw new LanesError("invalid_senior_ref", "Lane senior is missing an id", {
|
|
488
|
+
senior: ref,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const lane = readLane(hqRoot, ref.id);
|
|
492
|
+
return depthOfRef(hqRoot, lane.senior, ctx, visited, override) + 1;
|
|
493
|
+
}
|
|
494
|
+
function derivedLaneDepth(hqRoot, senior, ctx, override) {
|
|
495
|
+
return depthOfRef(hqRoot, senior, ctx, new Set(), override) + 1;
|
|
496
|
+
}
|
|
497
|
+
function callerDepth(hqRoot, ctx) {
|
|
498
|
+
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
499
|
+
if (laneId) {
|
|
500
|
+
const lane = tryReadLane(hqRoot, laneId);
|
|
501
|
+
if (!lane) {
|
|
502
|
+
throw new LanesError("lane_not_found", `Caller lane ${laneId} not found`, {
|
|
503
|
+
lane_id: laneId,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return derivedLaneDepth(hqRoot, lane.senior, ctx);
|
|
507
|
+
}
|
|
508
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
509
|
+
if (sessionId) {
|
|
510
|
+
return depthOfRef(hqRoot, { kind: "session", id: sessionId }, ctx);
|
|
511
|
+
}
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
function defaultSenior(ctx) {
|
|
515
|
+
const parent = ctx.env.HQ_PARENT_SESSION_ID?.trim();
|
|
516
|
+
if (parent)
|
|
517
|
+
return { kind: "session", id: parent };
|
|
518
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
519
|
+
if (sessionId)
|
|
520
|
+
return { kind: "session", id: sessionId };
|
|
521
|
+
return { kind: "user" };
|
|
522
|
+
}
|
|
523
|
+
function companyOfSenior(hqRoot, ref, ctx) {
|
|
524
|
+
if (ref.kind === "user")
|
|
525
|
+
return { binding: "unknown" };
|
|
526
|
+
if (ref.kind === "lane") {
|
|
527
|
+
if (!ref.id)
|
|
528
|
+
return { binding: "unknown" };
|
|
529
|
+
const lane = readLane(hqRoot, ref.id);
|
|
530
|
+
if (!lane.company)
|
|
531
|
+
return { binding: "none" };
|
|
532
|
+
return {
|
|
533
|
+
binding: "company",
|
|
534
|
+
slug: lane.company.slug,
|
|
535
|
+
uid: lane.company.uid,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (!ref.id)
|
|
539
|
+
return { binding: "unknown" };
|
|
540
|
+
const loaded = loadSessionMeta(hqRoot, ref.id, ctx);
|
|
541
|
+
if (loaded.status !== "ok")
|
|
542
|
+
return { binding: "unknown" };
|
|
543
|
+
return companyBindingFromSessionMeta(loaded.doc);
|
|
544
|
+
}
|
|
545
|
+
function companyBindingFromSessionMeta(meta) {
|
|
546
|
+
const slugRaw = meta.company_slug;
|
|
547
|
+
const uidRaw = meta.company_uid;
|
|
548
|
+
if (slugRaw !== undefined && slugRaw !== null && typeof slugRaw !== "string") {
|
|
549
|
+
return { binding: "unknown" };
|
|
550
|
+
}
|
|
551
|
+
if (uidRaw !== undefined && uidRaw !== null && typeof uidRaw !== "string") {
|
|
552
|
+
return { binding: "unknown" };
|
|
553
|
+
}
|
|
554
|
+
const slug = typeof slugRaw === "string" ? slugRaw : undefined;
|
|
555
|
+
const uid = typeof uidRaw === "string" ? uidRaw : undefined;
|
|
556
|
+
if (!slug && !uid)
|
|
557
|
+
return { binding: "none" };
|
|
558
|
+
return { binding: "company", slug, uid };
|
|
559
|
+
}
|
|
560
|
+
function serializeSeniorCompany(seniorCompany) {
|
|
561
|
+
if (seniorCompany.binding === "unknown")
|
|
562
|
+
return null;
|
|
563
|
+
if (seniorCompany.binding === "none")
|
|
564
|
+
return { kind: "none" };
|
|
565
|
+
const out = {};
|
|
566
|
+
if (seniorCompany.slug)
|
|
567
|
+
out.slug = seniorCompany.slug;
|
|
568
|
+
if (seniorCompany.uid)
|
|
569
|
+
out.uid = seniorCompany.uid;
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
function sameTenant(laneCompany, senior, seniorCompany) {
|
|
573
|
+
if (senior.kind === "user")
|
|
574
|
+
return true;
|
|
575
|
+
// Unknown company stays a refusal for every lane, including no-company.
|
|
576
|
+
if (seniorCompany.binding === "unknown")
|
|
577
|
+
return false;
|
|
578
|
+
if (!laneCompany) {
|
|
579
|
+
return seniorCompany.binding === "none";
|
|
580
|
+
}
|
|
581
|
+
if (seniorCompany.binding !== "company")
|
|
582
|
+
return false;
|
|
583
|
+
if (seniorCompany.uid && seniorCompany.uid === laneCompany.uid)
|
|
584
|
+
return true;
|
|
585
|
+
if (seniorCompany.slug && seniorCompany.slug === laneCompany.slug)
|
|
586
|
+
return true;
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
function assertSameTenant(laneCompany, senior, seniorCompany, extra = {}) {
|
|
590
|
+
if (sameTenant(laneCompany, senior, seniorCompany))
|
|
591
|
+
return;
|
|
592
|
+
throw new LanesError("tenancy_mismatch", seniorCompany.binding === "unknown"
|
|
593
|
+
? "Cannot attach a lane to a senior whose company is unknown"
|
|
594
|
+
: "Cannot attach a lane to a senior in a different company", {
|
|
595
|
+
lane_company: laneCompany,
|
|
596
|
+
senior,
|
|
597
|
+
senior_company: serializeSeniorCompany(seniorCompany),
|
|
598
|
+
...extra,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function recordMatchesCompany(recordCompany, filter) {
|
|
602
|
+
if (!recordCompany)
|
|
603
|
+
return false;
|
|
604
|
+
return recordCompany.uid === filter.uid || recordCompany.slug === filter.slug;
|
|
605
|
+
}
|
|
606
|
+
function walkHitsLane(hqRoot, start, laneId, ctx) {
|
|
607
|
+
const seen = new Set();
|
|
608
|
+
let current = start;
|
|
609
|
+
while (current.kind !== "user") {
|
|
610
|
+
const key = seniorKey(current);
|
|
611
|
+
if (seen.has(key)) {
|
|
612
|
+
throw new LanesError("senior_cycle", `Cycle in senior chain at ${key}`, {
|
|
613
|
+
at: current,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
seen.add(key);
|
|
617
|
+
if (current.kind === "lane" && current.id === laneId)
|
|
618
|
+
return true;
|
|
619
|
+
if (current.kind === "lane") {
|
|
620
|
+
if (!current.id)
|
|
621
|
+
return false;
|
|
622
|
+
const rec = tryReadLane(hqRoot, current.id);
|
|
623
|
+
if (!rec)
|
|
624
|
+
return false;
|
|
625
|
+
current = rec.senior;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (!current.id)
|
|
629
|
+
return false;
|
|
630
|
+
current = seniorOfSession(hqRoot, current.id, ctx);
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
function resolveAudience(hqRoot, question, ctx) {
|
|
635
|
+
let current = { kind: "lane", id: question.origin.id };
|
|
636
|
+
for (let hop = 0; hop < question.hop; hop += 1) {
|
|
637
|
+
if (current.kind === "user")
|
|
638
|
+
return current;
|
|
639
|
+
if (current.kind === "lane") {
|
|
640
|
+
if (!current.id)
|
|
641
|
+
return current;
|
|
642
|
+
const lane = tryReadLane(hqRoot, current.id);
|
|
643
|
+
if (!lane)
|
|
644
|
+
return current;
|
|
645
|
+
current = lane.senior;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (!current.id)
|
|
649
|
+
return current;
|
|
650
|
+
current = seniorOfSession(hqRoot, current.id, ctx);
|
|
651
|
+
}
|
|
652
|
+
return current;
|
|
653
|
+
}
|
|
654
|
+
function audienceMatches(audience, filter) {
|
|
655
|
+
if (filter.kind === "me") {
|
|
656
|
+
return audience.kind === "session" && audience.id === filter.id;
|
|
657
|
+
}
|
|
658
|
+
if (filter.kind === "user")
|
|
659
|
+
return audience.kind === "user";
|
|
660
|
+
return audience.kind === filter.kind && audience.id === filter.id;
|
|
661
|
+
}
|
|
662
|
+
function parseForFilter(raw, ctx) {
|
|
663
|
+
const trimmed = raw.trim();
|
|
664
|
+
if (trimmed === "me") {
|
|
665
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
666
|
+
if (!sessionId) {
|
|
667
|
+
throw new LanesError("missing_session_id", "--for me requires a current session id (HQ_SESSION_ID)", {});
|
|
668
|
+
}
|
|
669
|
+
return { kind: "me", id: sessionId };
|
|
670
|
+
}
|
|
671
|
+
return parseSeniorRef(trimmed);
|
|
672
|
+
}
|
|
673
|
+
function timeoutPath(hqRoot, laneId) {
|
|
674
|
+
return path.join(runDir(hqRoot, laneId), "timeout.json");
|
|
675
|
+
}
|
|
676
|
+
function isOverdue(hqRoot, laneId, now) {
|
|
677
|
+
const raw = readJsonFile(timeoutPath(hqRoot, laneId));
|
|
678
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
679
|
+
return false;
|
|
680
|
+
const deadline = raw.deadline_at;
|
|
681
|
+
if (typeof deadline !== "string")
|
|
682
|
+
return false;
|
|
683
|
+
const at = Date.parse(deadline);
|
|
684
|
+
if (!Number.isFinite(at))
|
|
685
|
+
return false;
|
|
686
|
+
return now.getTime() > at;
|
|
687
|
+
}
|
|
688
|
+
function compareQueueOrder(a, b) {
|
|
689
|
+
if (b.priority !== a.priority)
|
|
690
|
+
return b.priority - a.priority;
|
|
691
|
+
return a.created_at.localeCompare(b.created_at);
|
|
692
|
+
}
|
|
693
|
+
function admitCreate(input) {
|
|
694
|
+
const { hqRoot, provider, pinnedAccount, priority, createdAt, config } = input;
|
|
695
|
+
const providerCfg = config.providers[provider];
|
|
696
|
+
const lanes = loadAllLanes(hqRoot).filter((lane) => lane.lane_id !== input.excludeLaneId);
|
|
697
|
+
const runningProvider = lanes.filter((lane) => lane.provider === provider && lane.state === "running");
|
|
698
|
+
const queuedSame = lanes.filter((lane) => {
|
|
699
|
+
if (lane.provider !== provider || lane.state !== "queued")
|
|
700
|
+
return false;
|
|
701
|
+
if (pinnedAccount)
|
|
702
|
+
return lane.account === pinnedAccount;
|
|
703
|
+
return !lane.account;
|
|
704
|
+
});
|
|
705
|
+
const runningCount = pinnedAccount
|
|
706
|
+
? runningProvider.filter((lane) => lane.account === pinnedAccount).length
|
|
707
|
+
: runningProvider.length;
|
|
708
|
+
const providerRunning = runningProvider.length;
|
|
709
|
+
const queuedCount = queuedSame.length;
|
|
710
|
+
const maxQueued = providerCfg.max_queued;
|
|
711
|
+
const atCap = providerRunning >= providerCfg.max_running ||
|
|
712
|
+
(pinnedAccount !== undefined &&
|
|
713
|
+
runningCount >= accountMaxRunning(providerCfg, pinnedAccount));
|
|
714
|
+
const synthetic = {
|
|
715
|
+
schema_version: "hq-lane.v1",
|
|
716
|
+
lane_id: "__incoming__",
|
|
717
|
+
company: { slug: "", uid: "" },
|
|
718
|
+
project_id: "",
|
|
719
|
+
story_id: "",
|
|
720
|
+
depth: 1,
|
|
721
|
+
senior: { kind: "user" },
|
|
722
|
+
senior_history: [],
|
|
723
|
+
state: "queued",
|
|
724
|
+
priority,
|
|
725
|
+
provider,
|
|
726
|
+
account: pinnedAccount,
|
|
727
|
+
host: { kind: "local", id: "local" },
|
|
728
|
+
version: 0,
|
|
729
|
+
created_at: createdAt,
|
|
730
|
+
updated_at: createdAt,
|
|
731
|
+
attribution_history: [],
|
|
732
|
+
};
|
|
733
|
+
const ranked = [...queuedSame, synthetic].sort(compareQueueOrder);
|
|
734
|
+
const first = ranked[0];
|
|
735
|
+
const isHead = first?.lane_id === "__incoming__";
|
|
736
|
+
const wouldStart = !atCap && isHead;
|
|
737
|
+
if (wouldStart)
|
|
738
|
+
return { action: "start" };
|
|
739
|
+
if (queuedCount >= maxQueued) {
|
|
740
|
+
return {
|
|
741
|
+
action: "refuse",
|
|
742
|
+
error: new LanesError("lane_queue_full", `Lane queue is full for ${pinnedAccount ? `account ${pinnedAccount}` : provider}`, {
|
|
743
|
+
provider,
|
|
744
|
+
...(pinnedAccount ? { account: pinnedAccount } : {}),
|
|
745
|
+
running: pinnedAccount ? runningCount : providerRunning,
|
|
746
|
+
queued: queuedCount,
|
|
747
|
+
}),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (atCap) {
|
|
751
|
+
return { action: "queue", reason: isHead ? "cap" : "queue_position" };
|
|
752
|
+
}
|
|
753
|
+
return { action: "queue", reason: "queue_position" };
|
|
754
|
+
}
|
|
755
|
+
async function applyThrottle(hqRoot, config, ctx, current, provider, pinnedAccount) {
|
|
756
|
+
if (current.action !== "start")
|
|
757
|
+
return current;
|
|
758
|
+
const { decision } = await decideAdmission(hqRoot, config.throttle, ctx.throttleDeps);
|
|
759
|
+
if (decision.admit)
|
|
760
|
+
return current;
|
|
761
|
+
const providerCfg = config.providers[provider];
|
|
762
|
+
const queuedCount = loadAllLanes(hqRoot).filter((lane) => {
|
|
763
|
+
if (lane.provider !== provider || lane.state !== "queued")
|
|
764
|
+
return false;
|
|
765
|
+
if (pinnedAccount)
|
|
766
|
+
return lane.account === pinnedAccount;
|
|
767
|
+
return !lane.account;
|
|
768
|
+
}).length;
|
|
769
|
+
if (queuedCount >= providerCfg.max_queued) {
|
|
770
|
+
const running = loadAllLanes(hqRoot).filter((lane) => lane.provider === provider && lane.state === "running").length;
|
|
771
|
+
return {
|
|
772
|
+
action: "refuse",
|
|
773
|
+
error: new LanesError("lane_queue_full", `Lane queue is full for ${pinnedAccount ? `account ${pinnedAccount}` : provider}`, {
|
|
774
|
+
provider,
|
|
775
|
+
...(pinnedAccount ? { account: pinnedAccount } : {}),
|
|
776
|
+
running,
|
|
777
|
+
queued: queuedCount,
|
|
778
|
+
}),
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return { action: "queue", reason: "throttle" };
|
|
782
|
+
}
|
|
783
|
+
function assertKnownOutpost(requested, known) {
|
|
784
|
+
if (!known.includes(requested)) {
|
|
785
|
+
throw new LanesError("unknown_outpost", `Unknown Outpost ${JSON.stringify(requested)}. Accepted: ${known.join(", ") || "(none)"}`, { accepted: known, value: requested });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
function copyHookRails(fromHome, toHome) {
|
|
789
|
+
const src = path.join(fromHome, "hooks");
|
|
790
|
+
if (!fs.existsSync(src))
|
|
791
|
+
return false;
|
|
792
|
+
fs.cpSync(src, path.join(toHome, "hooks"), { recursive: true });
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
function railsPresent(home) {
|
|
796
|
+
return fs.existsSync(path.join(home, "hooks"));
|
|
797
|
+
}
|
|
798
|
+
const ADMISSION_LOCK_STALE_MS = 30_000;
|
|
799
|
+
const ADMISSION_LOCK_TIMEOUT_MS = 5_000;
|
|
800
|
+
/**
|
|
801
|
+
* Spec §5.1: `hq lanes ask` from inside a lane can hit a binary or dist
|
|
802
|
+
* being replaced mid-call (ENOENT / ETXTBSY / a module that no longer
|
|
803
|
+
* resolves) or a record written by a newer CLI. Three attempts, waits of
|
|
804
|
+
* 5 s then 15 s, 20 s of backoff in total, inside the 30 s bound.
|
|
805
|
+
*/
|
|
806
|
+
export const ASK_BACKOFF_MS = [5_000, 15_000];
|
|
807
|
+
export const ASK_ATTEMPTS = ASK_BACKOFF_MS.length + 1;
|
|
808
|
+
function yieldEventLoop() {
|
|
809
|
+
return new Promise((resolve) => {
|
|
810
|
+
setImmediate(resolve);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function admissionLockPath(hqRoot, provider, pinnedAccount) {
|
|
814
|
+
const name = pinnedAccount
|
|
815
|
+
? `${provider}-${pinnedAccount}`
|
|
816
|
+
: `${provider}-unpinned`;
|
|
817
|
+
return path.join(hqRoot, LANES_RUNS_REL, ".admission-locks", `${name}.lock`);
|
|
818
|
+
}
|
|
819
|
+
async function withAdmissionLock(lockPath, fn, sleep) {
|
|
820
|
+
ensureDir(path.dirname(lockPath));
|
|
821
|
+
const started = Date.now();
|
|
822
|
+
while (true) {
|
|
823
|
+
let fd;
|
|
824
|
+
try {
|
|
825
|
+
fd = fs.openSync(lockPath, "wx");
|
|
826
|
+
try {
|
|
827
|
+
fs.writeSync(fd, Buffer.from(`${process.pid}\n${Date.now()}\n`, "utf8"));
|
|
828
|
+
}
|
|
829
|
+
catch (err) {
|
|
830
|
+
fs.closeSync(fd);
|
|
831
|
+
fd = undefined;
|
|
832
|
+
throw err;
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
await yieldEventLoop();
|
|
836
|
+
return await fn();
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
try {
|
|
840
|
+
fs.closeSync(fd);
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
process.stderr.write(`hq lanes: admission lock close failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
fs.unlinkSync(lockPath);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
const code = err.code;
|
|
850
|
+
if (code !== "ENOENT") {
|
|
851
|
+
process.stderr.write(`hq lanes: admission lock unlink failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
const code = err.code;
|
|
858
|
+
if (code !== "EEXIST")
|
|
859
|
+
throw err;
|
|
860
|
+
if (Date.now() - started > ADMISSION_LOCK_TIMEOUT_MS) {
|
|
861
|
+
throw new LanesError("store_lock_timeout", `Timed out waiting for admission lock ${lockPath}`, { path: lockPath });
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
865
|
+
const tsLine = raw.split("\n")[1];
|
|
866
|
+
const ts = tsLine ? Number(tsLine) : NaN;
|
|
867
|
+
if (Number.isFinite(ts) && Date.now() - ts > ADMISSION_LOCK_STALE_MS) {
|
|
868
|
+
try {
|
|
869
|
+
fs.unlinkSync(lockPath);
|
|
870
|
+
}
|
|
871
|
+
catch (unlinkErr) {
|
|
872
|
+
const unlinkCode = unlinkErr.code;
|
|
873
|
+
if (unlinkCode !== "ENOENT")
|
|
874
|
+
throw unlinkErr;
|
|
875
|
+
}
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch (statErr) {
|
|
880
|
+
const statCode = statErr.code;
|
|
881
|
+
if (statCode !== "ENOENT") {
|
|
882
|
+
process.stderr.write(`hq lanes: stale admission-lock probe failed for ${lockPath}: ${statErr instanceof Error ? statErr.message : String(statErr)}\n`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
await sleep(10);
|
|
886
|
+
await yieldEventLoop();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function withCasRetry(fn, attempts = 5) {
|
|
891
|
+
let last;
|
|
892
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
893
|
+
try {
|
|
894
|
+
return fn();
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
last = err;
|
|
898
|
+
if (!isLaneVersionConflict(err) || i === attempts - 1)
|
|
899
|
+
throw err;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
throw last;
|
|
903
|
+
}
|
|
904
|
+
export function casUpdateQuestion(hqRoot, questionId, updater) {
|
|
905
|
+
return withCasRetry(() => {
|
|
906
|
+
const current = readQuestion(hqRoot, questionId);
|
|
907
|
+
return updateQuestion(hqRoot, questionId, current.version, updater);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
export function casUpdateLane(hqRoot, laneId, updater) {
|
|
911
|
+
return withCasRetry(() => {
|
|
912
|
+
const current = readLane(hqRoot, laneId);
|
|
913
|
+
return updateLane(hqRoot, laneId, current.version, "command", updater);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
function throwIfQuestionNotPending(current) {
|
|
917
|
+
if (current.status === "pending")
|
|
918
|
+
return;
|
|
919
|
+
throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
|
|
920
|
+
}
|
|
921
|
+
function formatUnknownValue(value) {
|
|
922
|
+
if (typeof value === "string")
|
|
923
|
+
return value;
|
|
924
|
+
if (Array.isArray(value)) {
|
|
925
|
+
return value
|
|
926
|
+
.map((item) => (typeof item === "string" ? `- ${item}` : `- ${JSON.stringify(item)}`))
|
|
927
|
+
.join("\n");
|
|
928
|
+
}
|
|
929
|
+
if (value && typeof value === "object") {
|
|
930
|
+
return JSON.stringify(value, null, 2);
|
|
931
|
+
}
|
|
932
|
+
return "";
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Spec §5.3: an approval gate ends the run. The lane stops at each
|
|
936
|
+
* checkpoint with a `decision: ask` envelope whose question names it; the
|
|
937
|
+
* reconcile pass resumes the lane once every question is answered.
|
|
938
|
+
*/
|
|
939
|
+
export function checkpointInstruction(worker) {
|
|
940
|
+
const names = [
|
|
941
|
+
...(worker.approvalRequired ? ["approval_required"] : []),
|
|
942
|
+
...worker.humanCheckpoints,
|
|
943
|
+
];
|
|
944
|
+
if (names.length === 0)
|
|
945
|
+
return "";
|
|
946
|
+
return [
|
|
947
|
+
"Each checkpoint above is an approval gate that ends this run. When you reach a checkpoint, " +
|
|
948
|
+
"stop and write the envelope with decision \"ask\" and story_status \"in_progress\". " +
|
|
949
|
+
"Put exactly one entry in questions for the checkpoint; its question text must start with " +
|
|
950
|
+
"the checkpoint name, followed by what you need approved, and its options must include \"approve\". " +
|
|
951
|
+
"Do not continue past a checkpoint in the same run. When this run is resumed, the answer to " +
|
|
952
|
+
"that question is an operator decision that outranks the brief; continue from the checkpoint.",
|
|
953
|
+
"Checkpoint names, in order:",
|
|
954
|
+
...names.map((name) => `- ${name}`),
|
|
955
|
+
].join("\n");
|
|
956
|
+
}
|
|
957
|
+
export function composeWorkerBrief(worker, taskBrief) {
|
|
958
|
+
const yaml = worker.workerYaml;
|
|
959
|
+
const name = yaml.worker?.name ?? worker.id;
|
|
960
|
+
const description = yaml.worker?.description ?? worker.description ?? "";
|
|
961
|
+
const checkpoints = [
|
|
962
|
+
worker.approvalRequired ? "- approval_required: true" : "",
|
|
963
|
+
...worker.humanCheckpoints.map((item) => `- ${item}`),
|
|
964
|
+
]
|
|
965
|
+
.filter(Boolean)
|
|
966
|
+
.join("\n");
|
|
967
|
+
return [
|
|
968
|
+
`# ${name}`,
|
|
969
|
+
description,
|
|
970
|
+
"",
|
|
971
|
+
"## Standing instructions",
|
|
972
|
+
formatUnknownValue(yaml.instructions) || "(none)",
|
|
973
|
+
"",
|
|
974
|
+
"## Skills",
|
|
975
|
+
worker.skillFiles.map((item) => `- ${item}`).join("\n") || "(none)",
|
|
976
|
+
"",
|
|
977
|
+
"## Context (resolved)",
|
|
978
|
+
worker.resolvedContextBase.map((item) => `- ${item}`).join("\n") || "(none)",
|
|
979
|
+
"",
|
|
980
|
+
"## Knowledge",
|
|
981
|
+
formatUnknownValue(yaml.knowledge) || "(none)",
|
|
982
|
+
"",
|
|
983
|
+
"## Verification checkpoints",
|
|
984
|
+
checkpoints || "(none)",
|
|
985
|
+
...(checkpoints ? ["", checkpointInstruction(worker)] : []),
|
|
986
|
+
"",
|
|
987
|
+
"## Task",
|
|
988
|
+
taskBrief.replace(/\s+$/, ""),
|
|
989
|
+
"",
|
|
990
|
+
"## Standing authorization",
|
|
991
|
+
STANDING_AUTHORIZATION,
|
|
992
|
+
"",
|
|
993
|
+
FOREGROUND_ONLY_RULE,
|
|
994
|
+
"",
|
|
995
|
+
].join("\n");
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* A lane with no worker still carries the standing authorization paragraph
|
|
999
|
+
* and the foreground-only rule.
|
|
1000
|
+
*/
|
|
1001
|
+
export function composeGeneralBrief(taskBrief) {
|
|
1002
|
+
return [
|
|
1003
|
+
taskBrief.replace(/\s+$/, ""),
|
|
1004
|
+
"",
|
|
1005
|
+
"## Standing authorization",
|
|
1006
|
+
STANDING_AUTHORIZATION,
|
|
1007
|
+
"",
|
|
1008
|
+
FOREGROUND_ONLY_RULE,
|
|
1009
|
+
"",
|
|
1010
|
+
].join("\n");
|
|
1011
|
+
}
|
|
1012
|
+
export function parseMaxRuntimeSeconds(raw, workerId) {
|
|
1013
|
+
if (raw === undefined || raw === null || raw === "")
|
|
1014
|
+
return undefined;
|
|
1015
|
+
if (typeof raw === "number") {
|
|
1016
|
+
if (!Number.isFinite(raw) || raw < 0 || !Number.isInteger(raw)) {
|
|
1017
|
+
throw new LanesError("invalid_worker_timeout", `Worker ${workerId} execution.max_runtime is not a non-negative integer number of seconds`, { worker: workerId, value: raw });
|
|
1018
|
+
}
|
|
1019
|
+
return raw;
|
|
1020
|
+
}
|
|
1021
|
+
if (typeof raw !== "string") {
|
|
1022
|
+
throw new LanesError("invalid_worker_timeout", `Worker ${workerId} execution.max_runtime is not a duration`, { worker: workerId, value: raw });
|
|
1023
|
+
}
|
|
1024
|
+
const trimmed = raw.trim();
|
|
1025
|
+
if (/^\d+$/.test(trimmed))
|
|
1026
|
+
return Number(trimmed);
|
|
1027
|
+
const seconds = /^(\d+)\s*s$/i.exec(trimmed);
|
|
1028
|
+
if (seconds)
|
|
1029
|
+
return Number(seconds[1]);
|
|
1030
|
+
const minutes = /^(\d+)\s*m$/i.exec(trimmed);
|
|
1031
|
+
if (minutes)
|
|
1032
|
+
return Number(minutes[1]) * 60;
|
|
1033
|
+
const hours = /^(\d+)\s*h$/i.exec(trimmed);
|
|
1034
|
+
if (hours)
|
|
1035
|
+
return Number(hours[1]) * 3600;
|
|
1036
|
+
throw new LanesError("invalid_worker_timeout", `Worker ${workerId} execution.max_runtime ${JSON.stringify(raw)} is not a recognised duration`, { worker: workerId, value: raw });
|
|
1037
|
+
}
|
|
1038
|
+
function askDeliveryFailedPath(hqRoot, laneId) {
|
|
1039
|
+
return path.join(runDir(hqRoot, laneId), "ask_delivery_failed.json");
|
|
1040
|
+
}
|
|
1041
|
+
async function emitLaneMeshEvent(ctx, company, threadId, event, payload) {
|
|
1042
|
+
if (!threadId || !company)
|
|
1043
|
+
return;
|
|
1044
|
+
await ctx.mesh.appendThreadEvent(company, threadId, event, payload);
|
|
1045
|
+
}
|
|
1046
|
+
export const PICKUP_TERMINAL_REASONS = [
|
|
1047
|
+
"account_not_logged_in",
|
|
1048
|
+
"unknown_account",
|
|
1049
|
+
"account_disabled",
|
|
1050
|
+
];
|
|
1051
|
+
export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
|
|
1052
|
+
function parsePickupTerminalReason(value) {
|
|
1053
|
+
if (PICKUP_TERMINAL_REASONS.includes(value)) {
|
|
1054
|
+
return value;
|
|
1055
|
+
}
|
|
1056
|
+
throw new LanesError("unknown_pickup_reason", `Unknown Outpost pickup reason ${JSON.stringify(value)}. Accepted: ${PICKUP_TERMINAL_REASONS.join(", ")}`, { accepted: [...PICKUP_TERMINAL_REASONS], value });
|
|
1057
|
+
}
|
|
1058
|
+
function markDispatchFailure(hqRoot, laneId, details) {
|
|
1059
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
1060
|
+
if (isTerminalLaneState(current.state) || current.state === "running") {
|
|
1061
|
+
return current;
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
...current,
|
|
1065
|
+
state: "failed",
|
|
1066
|
+
queued_reason: undefined,
|
|
1067
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1068
|
+
terminal_details: details,
|
|
1069
|
+
};
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
|
|
1073
|
+
const reason = parsePickupTerminalReason(reasonRaw);
|
|
1074
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
1075
|
+
const existing = current
|
|
1076
|
+
.terminal_reason;
|
|
1077
|
+
if (isTerminalLaneState(current.state) && existing === reason) {
|
|
1078
|
+
return current;
|
|
1079
|
+
}
|
|
1080
|
+
return {
|
|
1081
|
+
...current,
|
|
1082
|
+
state: "failed",
|
|
1083
|
+
queued_reason: undefined,
|
|
1084
|
+
terminal_reason: reason,
|
|
1085
|
+
terminal_details: details,
|
|
1086
|
+
};
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Outpost-side account bind (spec §5.2). Writes a named terminal reason,
|
|
1091
|
+
* never a plain `failed`.
|
|
1092
|
+
*/
|
|
1093
|
+
export function applyOutpostAccountPickup(hqRoot, laneId) {
|
|
1094
|
+
const lane = readLane(hqRoot, laneId);
|
|
1095
|
+
const config = loadLanesConfig(hqRoot);
|
|
1096
|
+
try {
|
|
1097
|
+
const resolved = resolveAccount(lane.provider, lane.account, config, {
|
|
1098
|
+
hqRoot,
|
|
1099
|
+
});
|
|
1100
|
+
const status = accountStatus(lane.provider, resolved.id, config);
|
|
1101
|
+
if (isLoggedOut(status)) {
|
|
1102
|
+
return markPickupFailure(hqRoot, laneId, "account_not_logged_in", {
|
|
1103
|
+
provider: lane.provider,
|
|
1104
|
+
account: resolved.id,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
if (lane.account === resolved.id)
|
|
1108
|
+
return lane;
|
|
1109
|
+
// The bind is the executing host's pre-spawn account resolution, the same
|
|
1110
|
+
// write spawnLaneWorker makes for a local lane, so it carries the spawn
|
|
1111
|
+
// writer. `account` stays closed to `command`: an operator command never
|
|
1112
|
+
// re-pins the account of a lane it does not execute.
|
|
1113
|
+
return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
|
|
1114
|
+
if (current.account === resolved.id)
|
|
1115
|
+
return current;
|
|
1116
|
+
return { ...current, account: resolved.id };
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
catch (err) {
|
|
1120
|
+
if (err instanceof AccountResolutionError) {
|
|
1121
|
+
if (err.error === "unknown_account") {
|
|
1122
|
+
return markPickupFailure(hqRoot, laneId, "unknown_account", err.details);
|
|
1123
|
+
}
|
|
1124
|
+
if (err.error === "account_disabled") {
|
|
1125
|
+
return markPickupFailure(hqRoot, laneId, "account_disabled", err.details);
|
|
1126
|
+
}
|
|
1127
|
+
if (err.error === "no_enabled_account") {
|
|
1128
|
+
return markPickupFailure(hqRoot, laneId, "account_disabled", err.details);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
throw err;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function pendingQuestionsForLane(hqRoot, laneId) {
|
|
1135
|
+
return loadAllQuestions(hqRoot).filter((q) => q.origin.id === laneId && q.status === "pending");
|
|
1136
|
+
}
|
|
1137
|
+
export function cancelPendingQuestionsForLane(hqRoot, laneId, nowIso, operatorReason) {
|
|
1138
|
+
const cancelled = [];
|
|
1139
|
+
for (const question of pendingQuestionsForLane(hqRoot, laneId)) {
|
|
1140
|
+
const next = casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
1141
|
+
if (current.status !== "pending")
|
|
1142
|
+
return current;
|
|
1143
|
+
const entry = {
|
|
1144
|
+
hop: current.hop,
|
|
1145
|
+
audience: { kind: "user" },
|
|
1146
|
+
at: nowIso,
|
|
1147
|
+
reason: "cancelled",
|
|
1148
|
+
...(operatorReason ? { detail: operatorReason } : {}),
|
|
1149
|
+
};
|
|
1150
|
+
return {
|
|
1151
|
+
...current,
|
|
1152
|
+
status: "cancelled",
|
|
1153
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
1154
|
+
};
|
|
1155
|
+
});
|
|
1156
|
+
cancelled.push(next);
|
|
1157
|
+
}
|
|
1158
|
+
return cancelled;
|
|
1159
|
+
}
|
|
1160
|
+
function mintMessageId() {
|
|
1161
|
+
return `msg_${Date.now().toString(36)}_${randomBytes(3).toString("hex")}`;
|
|
1162
|
+
}
|
|
1163
|
+
async function runCreate(ctx, opts) {
|
|
1164
|
+
const hqRoot = ctx.hqRoot();
|
|
1165
|
+
const companyFlag = opts.company?.trim();
|
|
1166
|
+
const project = opts.project?.trim();
|
|
1167
|
+
const story = opts.story?.trim();
|
|
1168
|
+
const briefFile = opts.briefFile?.trim();
|
|
1169
|
+
if (!project) {
|
|
1170
|
+
throw new LanesError("missing_project", "--project is required", {});
|
|
1171
|
+
}
|
|
1172
|
+
if (!story) {
|
|
1173
|
+
throw new LanesError("missing_story", "--story is required", {});
|
|
1174
|
+
}
|
|
1175
|
+
if (!briefFile) {
|
|
1176
|
+
throw new LanesError("missing_brief_file", "--brief-file is required", {});
|
|
1177
|
+
}
|
|
1178
|
+
const provider = opts.provider
|
|
1179
|
+
? parseLaneProvider(opts.provider)
|
|
1180
|
+
: parseLaneProvider("codex");
|
|
1181
|
+
if (opts.outpost !== undefined && opts.outpost !== "") {
|
|
1182
|
+
const requested = opts.outpost.trim();
|
|
1183
|
+
let known;
|
|
1184
|
+
try {
|
|
1185
|
+
known = await ctx.listOutpostIds();
|
|
1186
|
+
}
|
|
1187
|
+
catch (err) {
|
|
1188
|
+
throw new LanesError("outpost_list_unavailable", `Failed to list Outpost ids: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
1189
|
+
}
|
|
1190
|
+
assertKnownOutpost(requested, known);
|
|
1191
|
+
}
|
|
1192
|
+
const depth = callerDepth(hqRoot, ctx);
|
|
1193
|
+
if (depth >= MAX_LANE_DEPTH) {
|
|
1194
|
+
throw new LanesError("max_lane_depth", `A depth-${depth} caller may not spawn lanes (cap ${MAX_LANE_DEPTH})`, { depth, max: MAX_LANE_DEPTH });
|
|
1195
|
+
}
|
|
1196
|
+
const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
|
|
1197
|
+
assertSeniorExists(hqRoot, senior, ctx);
|
|
1198
|
+
const laneDepth = derivedLaneDepth(hqRoot, senior, ctx);
|
|
1199
|
+
if (laneDepth > MAX_LANE_DEPTH) {
|
|
1200
|
+
throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
|
|
1201
|
+
}
|
|
1202
|
+
// A no-company lane must not call company resolution (openMeshTransport).
|
|
1203
|
+
const company = companyFlag
|
|
1204
|
+
? await ctx.resolveCompany(companyFlag)
|
|
1205
|
+
: null;
|
|
1206
|
+
const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
|
|
1207
|
+
assertSameTenant(company, senior, seniorCompany);
|
|
1208
|
+
let resolvedWorker;
|
|
1209
|
+
if (opts.worker) {
|
|
1210
|
+
resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company?.slug);
|
|
1211
|
+
}
|
|
1212
|
+
const briefAbs = resolveCallerPath(ctx.callerCwd(), briefFile);
|
|
1213
|
+
if (!ctx.existsSync(briefAbs)) {
|
|
1214
|
+
throw new LanesError("brief_not_found", `Brief file not found: ${briefAbs}`, {
|
|
1215
|
+
path: briefAbs,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
const config = loadLanesConfig(hqRoot);
|
|
1219
|
+
const outpostId = opts.outpost?.trim() || undefined;
|
|
1220
|
+
let originHost;
|
|
1221
|
+
let originHostSource;
|
|
1222
|
+
if (outpostId) {
|
|
1223
|
+
const identity = ctx.resolveMachineIdentity();
|
|
1224
|
+
if (identity.status !== "ok") {
|
|
1225
|
+
throw new LanesError("origin_host_unresolvable", "Cannot record origin_host; this machine's identity is unresolvable", { identity });
|
|
1226
|
+
}
|
|
1227
|
+
originHost = identity.id;
|
|
1228
|
+
originHostSource = identity.source;
|
|
1229
|
+
}
|
|
1230
|
+
const cliModel = parseCliModel(opts.model);
|
|
1231
|
+
// Validated here because `claude --effort <unknown>` only warns and runs.
|
|
1232
|
+
const cliEffort = parseCliEffort(provider, opts.effort);
|
|
1233
|
+
let pinnedAccount;
|
|
1234
|
+
const accountWasExplicit = Boolean(opts.account && opts.account.trim() !== "");
|
|
1235
|
+
if (outpostId) {
|
|
1236
|
+
if (accountWasExplicit) {
|
|
1237
|
+
pinnedAccount = opts.account.trim();
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
else if (opts.account !== undefined) {
|
|
1241
|
+
const requested = opts.account.trim();
|
|
1242
|
+
if (!requested) {
|
|
1243
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(opts.account)} for ${provider}`, { provider, account: opts.account, enabled: [] });
|
|
1244
|
+
}
|
|
1245
|
+
const resolved = resolveAccount(provider, requested, config, {
|
|
1246
|
+
hqRoot,
|
|
1247
|
+
cliModel,
|
|
1248
|
+
});
|
|
1249
|
+
pinnedAccount = resolved.id;
|
|
1250
|
+
}
|
|
1251
|
+
const priority = parsePriority(opts.priority);
|
|
1252
|
+
let timeoutSecs = parseOptionalInt(opts.timeout, "--timeout");
|
|
1253
|
+
if (timeoutSecs === undefined && resolvedWorker) {
|
|
1254
|
+
timeoutSecs = parseMaxRuntimeSeconds(resolvedWorker.defaultTimeout, resolvedWorker.id);
|
|
1255
|
+
}
|
|
1256
|
+
const now = ctx.now();
|
|
1257
|
+
const createdAt = now.toISOString();
|
|
1258
|
+
const threadId = company
|
|
1259
|
+
? await ctx.mesh.ensureProjectThread(company, project)
|
|
1260
|
+
: undefined;
|
|
1261
|
+
const persist = async () => {
|
|
1262
|
+
let admission;
|
|
1263
|
+
let validation;
|
|
1264
|
+
if (!outpostId && pinnedAccount === undefined) {
|
|
1265
|
+
pinnedAccount = resolveAccount(provider, undefined, config, {
|
|
1266
|
+
hqRoot,
|
|
1267
|
+
existsSync: ctx.existsSync,
|
|
1268
|
+
cliModel,
|
|
1269
|
+
}).id;
|
|
1270
|
+
}
|
|
1271
|
+
const accountCfg = pinnedAccount !== undefined
|
|
1272
|
+
? config.providers[provider].accounts[pinnedAccount]
|
|
1273
|
+
: undefined;
|
|
1274
|
+
const model = resolveLaneModel({
|
|
1275
|
+
cliModel,
|
|
1276
|
+
accountModel: accountCfg?.model,
|
|
1277
|
+
providerModel: config.providers[provider].model,
|
|
1278
|
+
});
|
|
1279
|
+
// Recorded now so a queued lane already shows it; spawn re-resolves
|
|
1280
|
+
// every source except `cli`.
|
|
1281
|
+
const effort = resolveLaneEffort({
|
|
1282
|
+
cliEffort,
|
|
1283
|
+
accountEffort: accountCfg?.effort,
|
|
1284
|
+
providerEffort: config.providers[provider].effort,
|
|
1285
|
+
model,
|
|
1286
|
+
});
|
|
1287
|
+
if (outpostId) {
|
|
1288
|
+
admission = { action: "queue" };
|
|
1289
|
+
validation = "pending";
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
admission = admitCreate({
|
|
1293
|
+
hqRoot,
|
|
1294
|
+
provider,
|
|
1295
|
+
pinnedAccount,
|
|
1296
|
+
priority,
|
|
1297
|
+
createdAt,
|
|
1298
|
+
config,
|
|
1299
|
+
});
|
|
1300
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1301
|
+
throw admission.error;
|
|
1302
|
+
}
|
|
1303
|
+
admission = await applyThrottle(hqRoot, config, ctx, admission, provider, pinnedAccount);
|
|
1304
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1305
|
+
throw admission.error;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const host = outpostId !== undefined
|
|
1309
|
+
? { kind: "outpost", id: outpostId }
|
|
1310
|
+
: { kind: "local", id: ctx.hostname() };
|
|
1311
|
+
const queuedReason = admission.action === "queue" ? admission.reason : undefined;
|
|
1312
|
+
const lane = createLane(hqRoot, {
|
|
1313
|
+
company,
|
|
1314
|
+
project_id: project,
|
|
1315
|
+
story_id: story,
|
|
1316
|
+
thread_id: threadId,
|
|
1317
|
+
depth: laneDepth,
|
|
1318
|
+
senior,
|
|
1319
|
+
senior_history: [],
|
|
1320
|
+
state: "queued",
|
|
1321
|
+
queued_reason: queuedReason,
|
|
1322
|
+
priority,
|
|
1323
|
+
worker: resolvedWorker?.id ?? opts.worker,
|
|
1324
|
+
...(resolvedWorker ? { worker_path: resolvedWorker.path } : {}),
|
|
1325
|
+
...(resolvedWorker?.pack ? { worker_pack: resolvedWorker.pack } : {}),
|
|
1326
|
+
provider,
|
|
1327
|
+
account: pinnedAccount,
|
|
1328
|
+
...(model !== undefined ? { model } : {}),
|
|
1329
|
+
...(effort.effort !== undefined ? { effort: effort.effort } : {}),
|
|
1330
|
+
effort_source: effort.source,
|
|
1331
|
+
host,
|
|
1332
|
+
...(originHost !== undefined ? { origin_host: originHost } : {}),
|
|
1333
|
+
...(originHostSource !== undefined
|
|
1334
|
+
? { origin_host_source: originHostSource }
|
|
1335
|
+
: {}),
|
|
1336
|
+
attribution_history: [],
|
|
1337
|
+
});
|
|
1338
|
+
return { lane, admission, validation };
|
|
1339
|
+
};
|
|
1340
|
+
let persisted;
|
|
1341
|
+
if (outpostId) {
|
|
1342
|
+
persisted = await persist();
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
persisted = await withAdmissionLock(admissionLockPath(hqRoot, provider, accountWasExplicit ? pinnedAccount : undefined), persist, ctx.sleep);
|
|
1346
|
+
}
|
|
1347
|
+
const { lane, admission, validation } = persisted;
|
|
1348
|
+
const dest = runDir(hqRoot, lane.lane_id);
|
|
1349
|
+
ensureDir(path.join(dest, "logs"));
|
|
1350
|
+
ensureDir(path.join(dest, "envelopes"));
|
|
1351
|
+
ensureDir(path.join(dest, "dropbox"));
|
|
1352
|
+
ensureDir(path.join(dest, "questions"));
|
|
1353
|
+
const taskBrief = fs.readFileSync(briefAbs, "utf8");
|
|
1354
|
+
const briefText = resolvedWorker
|
|
1355
|
+
? composeWorkerBrief(resolvedWorker, taskBrief)
|
|
1356
|
+
: composeGeneralBrief(taskBrief);
|
|
1357
|
+
fs.writeFileSync(path.join(dest, "brief.md"), briefText);
|
|
1358
|
+
if (timeoutSecs !== undefined) {
|
|
1359
|
+
writeJsonFile(timeoutPath(hqRoot, lane.lane_id), {
|
|
1360
|
+
timeout_secs: timeoutSecs,
|
|
1361
|
+
deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
let started = lane;
|
|
1365
|
+
if (admission.action === "start") {
|
|
1366
|
+
started = spawnLaneWorker(hqRoot, lane, {
|
|
1367
|
+
env: ctx.env,
|
|
1368
|
+
existsSync: ctx.existsSync,
|
|
1369
|
+
spawnProcess: ctx.spawnProcess,
|
|
1370
|
+
proveDetached: ctx.proveDetached,
|
|
1371
|
+
killProcess: ctx.killProcess,
|
|
1372
|
+
now: ctx.now,
|
|
1373
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1374
|
+
runCompaction: ctx.runCompaction,
|
|
1375
|
+
locateTranscript: ctx.locateTranscript,
|
|
1376
|
+
});
|
|
1377
|
+
if (company) {
|
|
1378
|
+
await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
|
|
1379
|
+
lane_id: started.lane_id,
|
|
1380
|
+
project_id: project,
|
|
1381
|
+
story_id: story,
|
|
1382
|
+
});
|
|
1383
|
+
await ctx.mesh.patchStoryStatus(company, project, story, "in_progress");
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
else if (outpostId) {
|
|
1387
|
+
started = await dispatchCreatedOutpostLane(ctx, {
|
|
1388
|
+
hqRoot,
|
|
1389
|
+
lane,
|
|
1390
|
+
outpostId,
|
|
1391
|
+
briefText,
|
|
1392
|
+
timeout: timeoutSecs !== undefined
|
|
1393
|
+
? {
|
|
1394
|
+
timeout_secs: timeoutSecs,
|
|
1395
|
+
deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
|
|
1396
|
+
}
|
|
1397
|
+
: undefined,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
printJson({
|
|
1401
|
+
ok: true,
|
|
1402
|
+
action: "create",
|
|
1403
|
+
lane_id: started.lane_id,
|
|
1404
|
+
state: started.state,
|
|
1405
|
+
queued_reason: started.queued_reason ?? null,
|
|
1406
|
+
priority: started.priority,
|
|
1407
|
+
provider: started.provider,
|
|
1408
|
+
account: started.account ?? null,
|
|
1409
|
+
model: started.model ?? null,
|
|
1410
|
+
effort: started.effort ?? null,
|
|
1411
|
+
effort_source: started.effort_source ?? null,
|
|
1412
|
+
host: started.host,
|
|
1413
|
+
company: started.company,
|
|
1414
|
+
project_id: started.project_id,
|
|
1415
|
+
story_id: started.story_id,
|
|
1416
|
+
thread_id: started.thread_id ?? null,
|
|
1417
|
+
senior: started.senior,
|
|
1418
|
+
depth: started.depth,
|
|
1419
|
+
timeout_secs: timeoutSecs ?? null,
|
|
1420
|
+
...(validation ? { validation } : {}),
|
|
1421
|
+
...(started.outpost_command_id
|
|
1422
|
+
? {
|
|
1423
|
+
outpost_command_id: started
|
|
1424
|
+
.outpost_command_id,
|
|
1425
|
+
}
|
|
1426
|
+
: {}),
|
|
1427
|
+
lane: started,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
async function dispatchCreatedOutpostLane(ctx, input) {
|
|
1431
|
+
const { hqRoot, lane, outpostId, briefText, timeout } = input;
|
|
1432
|
+
const payload = {
|
|
1433
|
+
lane,
|
|
1434
|
+
brief: briefText,
|
|
1435
|
+
timeout,
|
|
1436
|
+
};
|
|
1437
|
+
const bytes = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
|
|
1438
|
+
try {
|
|
1439
|
+
const staged = await ctx.stageOutpostPayload(outpostId, bytes);
|
|
1440
|
+
const command = buildOutpostPickupCommand(staged.getUrl);
|
|
1441
|
+
const dispatched = await ctx.dispatchOutpostExec({ outpostId, command });
|
|
1442
|
+
const remote = parseRemotePickupJson(dispatched.stdout);
|
|
1443
|
+
if (remote?.error === "outpost_self_target") {
|
|
1444
|
+
return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
|
|
1445
|
+
...current,
|
|
1446
|
+
state: "cancelled",
|
|
1447
|
+
queued_reason: undefined,
|
|
1448
|
+
terminal_reason: "outpost_self_target",
|
|
1449
|
+
outpost_command_id: dispatched.commandId,
|
|
1450
|
+
}));
|
|
1451
|
+
}
|
|
1452
|
+
const stdoutTrimmed = dispatched.stdout.trim();
|
|
1453
|
+
if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
|
|
1454
|
+
const failed = markDispatchFailure(hqRoot, lane.lane_id, {
|
|
1455
|
+
outpost_id: outpostId,
|
|
1456
|
+
command_id: dispatched.commandId,
|
|
1457
|
+
exit_code: dispatched.exitCode,
|
|
1458
|
+
status: dispatched.status,
|
|
1459
|
+
stderr: dispatched.stderr.slice(0, 2000),
|
|
1460
|
+
parse_error: "unparseable_pickup_stdout",
|
|
1461
|
+
});
|
|
1462
|
+
throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
|
|
1463
|
+
outpost_id: outpostId,
|
|
1464
|
+
lane_id: failed.lane_id,
|
|
1465
|
+
state: failed.state,
|
|
1466
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1467
|
+
command_id: dispatched.commandId,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
const dispatchOk = dispatched.status === "Success" || dispatched.exitCode === 0;
|
|
1471
|
+
if (!dispatchOk) {
|
|
1472
|
+
const failed = markDispatchFailure(hqRoot, lane.lane_id, {
|
|
1473
|
+
outpost_id: outpostId,
|
|
1474
|
+
command_id: dispatched.commandId,
|
|
1475
|
+
exit_code: dispatched.exitCode,
|
|
1476
|
+
status: dispatched.status,
|
|
1477
|
+
stderr: dispatched.stderr.slice(0, 2000),
|
|
1478
|
+
});
|
|
1479
|
+
throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
|
|
1480
|
+
outpost_id: outpostId,
|
|
1481
|
+
lane_id: failed.lane_id,
|
|
1482
|
+
state: failed.state,
|
|
1483
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1484
|
+
command_id: dispatched.commandId,
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
|
|
1488
|
+
...current,
|
|
1489
|
+
outpost_command_id: dispatched.commandId,
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
catch (err) {
|
|
1493
|
+
if (isLanesError(err) &&
|
|
1494
|
+
err.error === DISPATCH_TERMINAL_REASON &&
|
|
1495
|
+
readLane(hqRoot, lane.lane_id).state === "failed") {
|
|
1496
|
+
throw err;
|
|
1497
|
+
}
|
|
1498
|
+
const message = dispatchErrorMessage(err);
|
|
1499
|
+
const failed = markDispatchFailure(hqRoot, lane.lane_id, {
|
|
1500
|
+
outpost_id: outpostId,
|
|
1501
|
+
message,
|
|
1502
|
+
});
|
|
1503
|
+
throw new LanesError(DISPATCH_TERMINAL_REASON, `Failed to dispatch lane to Outpost ${outpostId}: ${message}`, {
|
|
1504
|
+
outpost_id: outpostId,
|
|
1505
|
+
lane_id: failed.lane_id,
|
|
1506
|
+
state: failed.state,
|
|
1507
|
+
terminal_reason: DISPATCH_TERMINAL_REASON,
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function parseRemotePickupJson(stdout) {
|
|
1512
|
+
const trimmed = stdout.trim();
|
|
1513
|
+
if (!trimmed)
|
|
1514
|
+
return null;
|
|
1515
|
+
try {
|
|
1516
|
+
const parsed = JSON.parse(trimmed);
|
|
1517
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1518
|
+
return parsed;
|
|
1519
|
+
}
|
|
1520
|
+
process.stderr.write("hq lanes: Outpost pickup stdout is not a JSON object\n");
|
|
1521
|
+
}
|
|
1522
|
+
catch (err) {
|
|
1523
|
+
process.stderr.write(`hq lanes: failed to parse Outpost pickup JSON: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function ingestPickupPayload(hqRoot, payload) {
|
|
1529
|
+
const laneId = payload.lane.lane_id;
|
|
1530
|
+
if (!laneId) {
|
|
1531
|
+
throw new LanesError("outpost_payload_invalid", "Pickup payload lane.lane_id is required", {});
|
|
1532
|
+
}
|
|
1533
|
+
const existing = tryReadLane(hqRoot, laneId);
|
|
1534
|
+
const lane = existing ?? createLane(hqRoot, payload.lane);
|
|
1535
|
+
const dest = runDir(hqRoot, lane.lane_id);
|
|
1536
|
+
ensureDir(path.join(dest, "logs"));
|
|
1537
|
+
ensureDir(path.join(dest, "envelopes"));
|
|
1538
|
+
ensureDir(path.join(dest, "dropbox"));
|
|
1539
|
+
ensureDir(path.join(dest, "questions"));
|
|
1540
|
+
fs.writeFileSync(path.join(dest, "brief.md"), payload.brief);
|
|
1541
|
+
if (payload.timeout) {
|
|
1542
|
+
writeJsonFile(timeoutPath(hqRoot, lane.lane_id), payload.timeout);
|
|
1543
|
+
}
|
|
1544
|
+
return lane;
|
|
1545
|
+
}
|
|
1546
|
+
async function runPickup(ctx, opts) {
|
|
1547
|
+
const hqRoot = ctx.hqRoot();
|
|
1548
|
+
const payloadFile = opts.payloadFile?.trim();
|
|
1549
|
+
const payloadUrl = opts.payloadUrl?.trim();
|
|
1550
|
+
if (!payloadFile && !payloadUrl) {
|
|
1551
|
+
throw new LanesError("missing_payload", "--payload-file or --payload-url is required", {});
|
|
1552
|
+
}
|
|
1553
|
+
if (payloadFile && payloadUrl) {
|
|
1554
|
+
throw new LanesError("invalid_payload", "Pass only one of --payload-file or --payload-url", {});
|
|
1555
|
+
}
|
|
1556
|
+
let rawText;
|
|
1557
|
+
if (payloadUrl) {
|
|
1558
|
+
rawText = await ctx.fetchText(payloadUrl);
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
const abs = resolveCallerPath(ctx.callerCwd(), payloadFile);
|
|
1562
|
+
if (!ctx.existsSync(abs)) {
|
|
1563
|
+
throw new LanesError("brief_not_found", `Payload file not found: ${abs}`, {
|
|
1564
|
+
path: abs,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
rawText = ctx.readFileSync(abs, "utf8");
|
|
1568
|
+
}
|
|
1569
|
+
let parsedJson;
|
|
1570
|
+
try {
|
|
1571
|
+
parsedJson = JSON.parse(rawText);
|
|
1572
|
+
}
|
|
1573
|
+
catch (err) {
|
|
1574
|
+
throw new LanesError("outpost_payload_invalid", `Pickup payload is not JSON: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
1575
|
+
}
|
|
1576
|
+
const payload = parseOutpostPickupPayload(parsedJson);
|
|
1577
|
+
const ingested = ingestPickupPayload(hqRoot, payload);
|
|
1578
|
+
if (ingested.host.kind === "outpost") {
|
|
1579
|
+
try {
|
|
1580
|
+
refuseOutpostSelfTarget(hqRoot, ingested, {
|
|
1581
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1582
|
+
runCompaction: ctx.runCompaction,
|
|
1583
|
+
locateTranscript: ctx.locateTranscript,
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
if (isLanesError(err) && err.error === "outpost_self_target") {
|
|
1588
|
+
const cancelled = readLane(hqRoot, ingested.lane_id);
|
|
1589
|
+
printJson({
|
|
1590
|
+
ok: true,
|
|
1591
|
+
action: "pickup",
|
|
1592
|
+
lane_id: cancelled.lane_id,
|
|
1593
|
+
state: cancelled.state,
|
|
1594
|
+
error: "outpost_self_target",
|
|
1595
|
+
lane: cancelled,
|
|
1596
|
+
});
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
throw err;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id);
|
|
1603
|
+
if (isTerminalLaneState(afterAccount.state)) {
|
|
1604
|
+
printJson({
|
|
1605
|
+
ok: true,
|
|
1606
|
+
action: "pickup",
|
|
1607
|
+
lane_id: afterAccount.lane_id,
|
|
1608
|
+
state: afterAccount.state,
|
|
1609
|
+
terminal_reason: afterAccount
|
|
1610
|
+
.terminal_reason,
|
|
1611
|
+
lane: afterAccount,
|
|
1612
|
+
});
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const config = loadLanesConfig(hqRoot);
|
|
1616
|
+
let admission = admitCreate({
|
|
1617
|
+
hqRoot,
|
|
1618
|
+
provider: afterAccount.provider,
|
|
1619
|
+
pinnedAccount: afterAccount.account,
|
|
1620
|
+
priority: afterAccount.priority,
|
|
1621
|
+
createdAt: afterAccount.created_at,
|
|
1622
|
+
config,
|
|
1623
|
+
excludeLaneId: afterAccount.lane_id,
|
|
1624
|
+
});
|
|
1625
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1626
|
+
throw admission.error;
|
|
1627
|
+
}
|
|
1628
|
+
admission = await applyThrottle(hqRoot, config, ctx, admission, afterAccount.provider, afterAccount.account);
|
|
1629
|
+
if (admission.action === "refuse" && admission.error) {
|
|
1630
|
+
throw admission.error;
|
|
1631
|
+
}
|
|
1632
|
+
let started;
|
|
1633
|
+
if (admission.action === "queue") {
|
|
1634
|
+
started = casUpdateLane(hqRoot, afterAccount.lane_id, (current) => {
|
|
1635
|
+
if (isTerminalLaneState(current.state))
|
|
1636
|
+
return current;
|
|
1637
|
+
return {
|
|
1638
|
+
...current,
|
|
1639
|
+
state: "queued",
|
|
1640
|
+
queued_reason: admission.reason,
|
|
1641
|
+
};
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
else {
|
|
1645
|
+
try {
|
|
1646
|
+
started = spawnLaneWorker(hqRoot, afterAccount, {
|
|
1647
|
+
env: ctx.env,
|
|
1648
|
+
existsSync: ctx.existsSync,
|
|
1649
|
+
spawnProcess: ctx.spawnProcess,
|
|
1650
|
+
proveDetached: ctx.proveDetached,
|
|
1651
|
+
killProcess: ctx.killProcess,
|
|
1652
|
+
now: ctx.now,
|
|
1653
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1654
|
+
runCompaction: ctx.runCompaction,
|
|
1655
|
+
locateTranscript: ctx.locateTranscript,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
catch (err) {
|
|
1659
|
+
if (isLanesError(err) && err.error === "outpost_self_target") {
|
|
1660
|
+
const cancelled = readLane(hqRoot, afterAccount.lane_id);
|
|
1661
|
+
printJson({
|
|
1662
|
+
ok: true,
|
|
1663
|
+
action: "pickup",
|
|
1664
|
+
lane_id: cancelled.lane_id,
|
|
1665
|
+
state: cancelled.state,
|
|
1666
|
+
error: "outpost_self_target",
|
|
1667
|
+
lane: cancelled,
|
|
1668
|
+
});
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
throw err;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
printJson({
|
|
1675
|
+
ok: true,
|
|
1676
|
+
action: "pickup",
|
|
1677
|
+
lane_id: started.lane_id,
|
|
1678
|
+
state: started.state,
|
|
1679
|
+
queued_reason: started.queued_reason ?? null,
|
|
1680
|
+
lane: started,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
async function runList(ctx, opts) {
|
|
1684
|
+
const hqRoot = ctx.hqRoot();
|
|
1685
|
+
const state = opts.state ? parseLaneState(opts.state) : undefined;
|
|
1686
|
+
const senior = opts.senior ? parseSeniorRef(opts.senior) : undefined;
|
|
1687
|
+
let company;
|
|
1688
|
+
if (opts.company) {
|
|
1689
|
+
company = await ctx.resolveCompany(opts.company);
|
|
1690
|
+
}
|
|
1691
|
+
const now = ctx.now();
|
|
1692
|
+
const lanes = loadAllLanes(hqRoot)
|
|
1693
|
+
.filter((lane) => {
|
|
1694
|
+
if (state && lane.state !== state)
|
|
1695
|
+
return false;
|
|
1696
|
+
if (opts.project && lane.project_id !== opts.project)
|
|
1697
|
+
return false;
|
|
1698
|
+
if (opts.story && lane.story_id !== opts.story)
|
|
1699
|
+
return false;
|
|
1700
|
+
if (company && !recordMatchesCompany(lane.company, company)) {
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
if (senior && seniorKey(lane.senior) !== seniorKey(senior))
|
|
1704
|
+
return false;
|
|
1705
|
+
return true;
|
|
1706
|
+
})
|
|
1707
|
+
.sort(compareQueueOrder)
|
|
1708
|
+
.map((lane) => ({
|
|
1709
|
+
...lane,
|
|
1710
|
+
overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, now),
|
|
1711
|
+
stalled: Boolean(lane.stalled),
|
|
1712
|
+
// Listing is observational. Reconcile owns durable liveness transitions.
|
|
1713
|
+
senior_lost: lane.state === "running" && lane.senior.kind === "session" && lane.senior.id
|
|
1714
|
+
? (() => {
|
|
1715
|
+
const state = sessionLiveness(hqRoot, lane.senior.id, {
|
|
1716
|
+
nowMs: now.getTime(),
|
|
1717
|
+
deps: livenessDepsFromCtx(ctx),
|
|
1718
|
+
presenceConnected: ctx.presenceConnected,
|
|
1719
|
+
});
|
|
1720
|
+
return state === null || state === "ended" || state === "lost";
|
|
1721
|
+
})()
|
|
1722
|
+
: false,
|
|
1723
|
+
}));
|
|
1724
|
+
printJson({ ok: true, action: "list", lanes });
|
|
1725
|
+
}
|
|
1726
|
+
async function runShow(ctx, laneId) {
|
|
1727
|
+
const hqRoot = ctx.hqRoot();
|
|
1728
|
+
const lane = readLane(hqRoot, laneId);
|
|
1729
|
+
const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
|
|
1730
|
+
printJson({
|
|
1731
|
+
ok: true,
|
|
1732
|
+
action: "show",
|
|
1733
|
+
lane,
|
|
1734
|
+
overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, ctx.now()),
|
|
1735
|
+
stalled: Boolean(lane.stalled),
|
|
1736
|
+
stalled_at: lane.stalled_at ?? null,
|
|
1737
|
+
stalled_reason: lane.stalled_reason ?? null,
|
|
1738
|
+
ask_delivery_failed: askDeliveryFailed,
|
|
1739
|
+
delivery: laneDeliveryMechanism(lane),
|
|
1740
|
+
provider_session_id: lane.provider_session_id ?? null,
|
|
1741
|
+
provider_session_id_absent: lane.provider_session_id_absent ?? null,
|
|
1742
|
+
envelope_repair_mode: lane.envelope_repair?.mode ?? null,
|
|
1743
|
+
last_resume: lane.last_resume ?? null,
|
|
1744
|
+
last_run_usage: lane.last_run_usage ?? null,
|
|
1745
|
+
model: lane.model ?? null,
|
|
1746
|
+
effort: lane.effort ?? null,
|
|
1747
|
+
effort_source: lane.effort_source ?? null,
|
|
1748
|
+
last_message_at: lane.last_message_at ?? null,
|
|
1749
|
+
last_compaction: lane.last_compaction
|
|
1750
|
+
? { ...lane.last_compaction, after: compactionAfterUsage(lane) }
|
|
1751
|
+
: null,
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
async function runMessage(ctx, laneId, opts) {
|
|
1755
|
+
const hqRoot = ctx.hqRoot();
|
|
1756
|
+
const text = opts.text;
|
|
1757
|
+
if (!text) {
|
|
1758
|
+
throw new LanesError("missing_text", "--text is required", {});
|
|
1759
|
+
}
|
|
1760
|
+
const lane = readLane(hqRoot, laneId);
|
|
1761
|
+
// One decision point for how a follow-up reaches this lane (delivery.ts).
|
|
1762
|
+
// `none` refuses before anything is written: no inbox append, no spawn.
|
|
1763
|
+
const mechanism = laneDeliveryMechanism(lane);
|
|
1764
|
+
if (mechanism === "none") {
|
|
1765
|
+
const message = deliveryRefusalReason(lane);
|
|
1766
|
+
throw new LanesError("lane_not_accepting_messages", message, {
|
|
1767
|
+
lane_id: laneId,
|
|
1768
|
+
state: lane.state,
|
|
1769
|
+
provider: lane.provider,
|
|
1770
|
+
message,
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
const messageId = mintMessageId();
|
|
1774
|
+
const at = ctx.now().toISOString();
|
|
1775
|
+
const projectId = opts.project?.trim() || lane.project_id;
|
|
1776
|
+
const storyId = opts.story?.trim() || lane.story_id;
|
|
1777
|
+
const dropboxMessage = {
|
|
1778
|
+
at,
|
|
1779
|
+
message_id: messageId,
|
|
1780
|
+
text,
|
|
1781
|
+
project_id: projectId,
|
|
1782
|
+
story_id: storyId,
|
|
1783
|
+
};
|
|
1784
|
+
let resumed = null;
|
|
1785
|
+
if (mechanism === "dropbox") {
|
|
1786
|
+
// Live (or about-to-be-live) process: the PreToolUse drain delivers it.
|
|
1787
|
+
if (!laneAcceptsOperatorMessage(lane)) {
|
|
1788
|
+
throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
|
|
1789
|
+
}
|
|
1790
|
+
appendDropboxMessage(hqRoot, laneId, dropboxMessage);
|
|
1791
|
+
}
|
|
1792
|
+
else {
|
|
1793
|
+
// Exited Claude worker with a recorded session: the message becomes the
|
|
1794
|
+
// next turn of that session. It is archived under delivered/ (never the
|
|
1795
|
+
// inbox, which would hand it to the drain hook a second time) and the
|
|
1796
|
+
// spawn happens before any attribution write so a failed spawn leaves
|
|
1797
|
+
// the record untouched.
|
|
1798
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
1799
|
+
resumed = spawnLaneWorker(hqRoot, lane, {
|
|
1800
|
+
env: ctx.env,
|
|
1801
|
+
existsSync: ctx.existsSync,
|
|
1802
|
+
spawnProcess: ctx.spawnProcess,
|
|
1803
|
+
proveDetached: ctx.proveDetached,
|
|
1804
|
+
killProcess: ctx.killProcess,
|
|
1805
|
+
now: ctx.now,
|
|
1806
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1807
|
+
runCompaction: ctx.runCompaction,
|
|
1808
|
+
locateTranscript: ctx.locateTranscript,
|
|
1809
|
+
}, {
|
|
1810
|
+
trigger: { kind: "message", text, message_id: messageId },
|
|
1811
|
+
compact: opts.compact !== false,
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
// Every accepted message moves the compaction reference forward.
|
|
1815
|
+
let next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
1816
|
+
...current,
|
|
1817
|
+
last_message_at: at,
|
|
1818
|
+
}));
|
|
1819
|
+
if (projectId !== lane.project_id || storyId !== lane.story_id) {
|
|
1820
|
+
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1821
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
1822
|
+
if (already)
|
|
1823
|
+
return current;
|
|
1824
|
+
return {
|
|
1825
|
+
...current,
|
|
1826
|
+
project_id: projectId,
|
|
1827
|
+
story_id: storyId,
|
|
1828
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
1829
|
+
project_id: projectId,
|
|
1830
|
+
story_id: storyId,
|
|
1831
|
+
message_id: messageId,
|
|
1832
|
+
at,
|
|
1833
|
+
}),
|
|
1834
|
+
};
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
// What this command can claim when it returns: the message is durably
|
|
1838
|
+
// written (accepted). Delivery to the model is asynchronous in both
|
|
1839
|
+
// mechanisms. `dropbox` delivers on the worker's next tool call, and only
|
|
1840
|
+
// if the drain hook is registered in the account home the worker was
|
|
1841
|
+
// started from; `resume` has started a new worker with the message as its
|
|
1842
|
+
// prompt. Neither is "delivered", so the response never says so.
|
|
1843
|
+
const drainHook = drainHookStatusFor(ctx, next);
|
|
1844
|
+
const deliveryStatus = mechanism === "dropbox" ? "awaiting_next_tool_call" : "worker_spawned";
|
|
1845
|
+
const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
|
|
1846
|
+
? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
|
|
1847
|
+
: mechanism === "dropbox" && next.stalled === true
|
|
1848
|
+
? `The worker has made no tool call since ${next.stalled_at ?? "an unknown time"} (${next.stalled_reason ?? "stalled"}); delivery waits on its next tool call.`
|
|
1849
|
+
: null;
|
|
1850
|
+
printJson({
|
|
1851
|
+
ok: true,
|
|
1852
|
+
action: "message",
|
|
1853
|
+
lane_id: laneId,
|
|
1854
|
+
message_id: messageId,
|
|
1855
|
+
accepted: true,
|
|
1856
|
+
delivered: false,
|
|
1857
|
+
delivery: mechanism,
|
|
1858
|
+
delivery_status: deliveryStatus,
|
|
1859
|
+
inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
|
|
1860
|
+
drain_hook: drainHook,
|
|
1861
|
+
warning,
|
|
1862
|
+
resumed_session_id: resumed?.last_resume?.session_id ?? null,
|
|
1863
|
+
compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
1864
|
+
? resumed.last_compaction
|
|
1865
|
+
: null,
|
|
1866
|
+
project_id: next.project_id,
|
|
1867
|
+
story_id: next.story_id,
|
|
1868
|
+
lane: next,
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
|
|
1873
|
+
* continues a lane in `interrupted`, `done` or `blocked` on its recorded
|
|
1874
|
+
* provider session. `hq lanes message` keeps refusing `interrupted`; this
|
|
1875
|
+
* command is the path its refusal names. The launch is the same
|
|
1876
|
+
* `spawnLaneWorker` a message resume uses, so the resume-time compaction
|
|
1877
|
+
* rule and the watcher's mesh events are the same. The record gains
|
|
1878
|
+
* `last_resume` with `trigger: "resume"`, `resumed_from` and the text.
|
|
1879
|
+
*/
|
|
1880
|
+
async function runResume(ctx, laneId, opts) {
|
|
1881
|
+
const hqRoot = ctx.hqRoot();
|
|
1882
|
+
const lane = readLane(hqRoot, laneId);
|
|
1883
|
+
const refusal = resumeCommandRefusal(lane);
|
|
1884
|
+
if (refusal !== null) {
|
|
1885
|
+
throw new LanesError(refusal.error, refusal.message, {
|
|
1886
|
+
lane_id: laneId,
|
|
1887
|
+
state: lane.state,
|
|
1888
|
+
provider: lane.provider,
|
|
1889
|
+
message: refusal.message,
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
const resumedFrom = lane.state;
|
|
1893
|
+
const text = opts.text?.trim() ? opts.text : undefined;
|
|
1894
|
+
const at = ctx.now().toISOString();
|
|
1895
|
+
const projectId = opts.project?.trim() || lane.project_id;
|
|
1896
|
+
const storyId = opts.story?.trim() || lane.story_id;
|
|
1897
|
+
const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
|
|
1898
|
+
const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
|
|
1899
|
+
const resumed = spawnLaneWorker(hqRoot, lane, {
|
|
1900
|
+
env: ctx.env,
|
|
1901
|
+
existsSync: ctx.existsSync,
|
|
1902
|
+
spawnProcess: ctx.spawnProcess,
|
|
1903
|
+
proveDetached: ctx.proveDetached,
|
|
1904
|
+
killProcess: ctx.killProcess,
|
|
1905
|
+
now: ctx.now,
|
|
1906
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
1907
|
+
runCompaction: ctx.runCompaction,
|
|
1908
|
+
locateTranscript: ctx.locateTranscript,
|
|
1909
|
+
}, {
|
|
1910
|
+
trigger: {
|
|
1911
|
+
kind: "resume",
|
|
1912
|
+
from: resumedFrom,
|
|
1913
|
+
...(text !== undefined ? { text } : {}),
|
|
1914
|
+
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
1915
|
+
},
|
|
1916
|
+
compact: opts.compact !== false,
|
|
1917
|
+
});
|
|
1918
|
+
if (text !== undefined && messageId !== undefined) {
|
|
1919
|
+
// Same audit copy a message resume leaves; nothing drains it. Archive only
|
|
1920
|
+
// after a successful spawn so a concurrent resume cannot leave a phantom.
|
|
1921
|
+
archiveResumeMessage(hqRoot, laneId, {
|
|
1922
|
+
at,
|
|
1923
|
+
message_id: messageId,
|
|
1924
|
+
text,
|
|
1925
|
+
project_id: projectId,
|
|
1926
|
+
story_id: storyId,
|
|
1927
|
+
resumed_from: resumedFrom,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
let next = resumed;
|
|
1931
|
+
if (text !== undefined) {
|
|
1932
|
+
// An operator text moves the compaction reference forward, as a message does.
|
|
1933
|
+
next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
1934
|
+
...current,
|
|
1935
|
+
last_message_at: at,
|
|
1936
|
+
}));
|
|
1937
|
+
}
|
|
1938
|
+
if (messageId !== undefined && attributionChanged) {
|
|
1939
|
+
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
1940
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
1941
|
+
if (already)
|
|
1942
|
+
return current;
|
|
1943
|
+
return {
|
|
1944
|
+
...current,
|
|
1945
|
+
project_id: projectId,
|
|
1946
|
+
story_id: storyId,
|
|
1947
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
1948
|
+
project_id: projectId,
|
|
1949
|
+
story_id: storyId,
|
|
1950
|
+
message_id: messageId,
|
|
1951
|
+
at,
|
|
1952
|
+
}),
|
|
1953
|
+
};
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
printJson({
|
|
1957
|
+
ok: true,
|
|
1958
|
+
action: "resume",
|
|
1959
|
+
lane_id: laneId,
|
|
1960
|
+
resumed_from: resumedFrom,
|
|
1961
|
+
message_id: messageId ?? null,
|
|
1962
|
+
text: text ?? null,
|
|
1963
|
+
delivery: "resume",
|
|
1964
|
+
delivery_status: "worker_spawned",
|
|
1965
|
+
resumed_session_id: resumed.last_resume?.session_id ?? null,
|
|
1966
|
+
last_resume: next.last_resume ?? null,
|
|
1967
|
+
compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
1968
|
+
? resumed.last_compaction
|
|
1969
|
+
: null,
|
|
1970
|
+
project_id: next.project_id,
|
|
1971
|
+
story_id: next.story_id,
|
|
1972
|
+
lane: next,
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Registration state of the drain hook in the lane's account home, read the
|
|
1977
|
+
* way the provider reads it. Null when the lane has no bound account yet
|
|
1978
|
+
* (queued): spawn installs the hook before the worker starts.
|
|
1979
|
+
*/
|
|
1980
|
+
function drainHookStatusFor(ctx, lane) {
|
|
1981
|
+
if (lane.account === undefined)
|
|
1982
|
+
return null;
|
|
1983
|
+
const hqRoot = ctx.hqRoot();
|
|
1984
|
+
const config = loadLanesConfig(hqRoot);
|
|
1985
|
+
const account = config.providers[lane.provider].accounts[lane.account];
|
|
1986
|
+
if (account === undefined)
|
|
1987
|
+
return null;
|
|
1988
|
+
const home = canonicalizeHome(account.home, {
|
|
1989
|
+
homedir: ctx.homedir,
|
|
1990
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
1991
|
+
existsSync: (p) => fs.existsSync(p),
|
|
1992
|
+
});
|
|
1993
|
+
const probe = probeDrainHookRegistration(lane.provider, home);
|
|
1994
|
+
return { registered: probe.registered, kind: probe.kind, path: probe.path };
|
|
1995
|
+
}
|
|
1996
|
+
/** Audit copy of a message delivered by resume. Not the inbox: nothing drains this. */
|
|
1997
|
+
function archiveResumeMessage(hqRoot, laneId, message) {
|
|
1998
|
+
const dir = path.join(dropboxDir(hqRoot, laneId), "delivered");
|
|
1999
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2000
|
+
fs.writeFileSync(path.join(dir, `${message.message_id}.resume.jsonl`), `${JSON.stringify(message)}\n`, "utf8");
|
|
2001
|
+
}
|
|
2002
|
+
async function runReparent(ctx, laneId, opts) {
|
|
2003
|
+
const hqRoot = ctx.hqRoot();
|
|
2004
|
+
if (!opts.senior) {
|
|
2005
|
+
throw new LanesError("missing_senior", "--senior is required", {});
|
|
2006
|
+
}
|
|
2007
|
+
const nextSenior = parseSeniorRef(opts.senior);
|
|
2008
|
+
assertSeniorExists(hqRoot, nextSenior, ctx);
|
|
2009
|
+
const lane = readLane(hqRoot, laneId);
|
|
2010
|
+
if (isTerminalLaneState(lane.state)) {
|
|
2011
|
+
throw new LanesError("lane_terminal", `Cannot re-parent a lane in terminal state ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
2012
|
+
}
|
|
2013
|
+
const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
|
|
2014
|
+
assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
|
|
2015
|
+
if (walkHitsLane(hqRoot, nextSenior, laneId, ctx)) {
|
|
2016
|
+
throw new LanesError("senior_cycle", `Re-parent would create a cycle through ${laneId}`, { lane_id: laneId, senior: nextSenior });
|
|
2017
|
+
}
|
|
2018
|
+
const newDepth = derivedLaneDepth(hqRoot, nextSenior, ctx);
|
|
2019
|
+
if (newDepth > MAX_LANE_DEPTH) {
|
|
2020
|
+
throw new LanesError("max_lane_depth", `Re-parent would put lane at depth ${newDepth} (cap ${MAX_LANE_DEPTH})`, { depth: newDepth, max: MAX_LANE_DEPTH });
|
|
2021
|
+
}
|
|
2022
|
+
const pending = pendingQuestionsForLane(hqRoot, laneId);
|
|
2023
|
+
const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
|
|
2024
|
+
if (newDepth > oldDepth && pending.length > 0) {
|
|
2025
|
+
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 });
|
|
2026
|
+
}
|
|
2027
|
+
const override = { laneId, senior: nextSenior, depth: newDepth };
|
|
2028
|
+
for (const other of loadAllLanes(hqRoot)) {
|
|
2029
|
+
if (other.lane_id === laneId)
|
|
2030
|
+
continue;
|
|
2031
|
+
if (!walkHitsLane(hqRoot, other.senior, laneId, ctx))
|
|
2032
|
+
continue;
|
|
2033
|
+
const d = derivedLaneDepth(hqRoot, other.senior, ctx, override);
|
|
2034
|
+
if (d > MAX_LANE_DEPTH) {
|
|
2035
|
+
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 });
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const at = ctx.now().toISOString();
|
|
2039
|
+
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
2040
|
+
const already = current.senior_history.some((entry) => entry.at === at && entry.reason === (opts.reason ?? "reparent"));
|
|
2041
|
+
if (already && current.depth === newDepth)
|
|
2042
|
+
return current;
|
|
2043
|
+
return {
|
|
2044
|
+
...current,
|
|
2045
|
+
senior: nextSenior,
|
|
2046
|
+
depth: newDepth,
|
|
2047
|
+
senior_history: appendCappedHistory(current.senior_history, {
|
|
2048
|
+
from: current.senior,
|
|
2049
|
+
to: nextSenior,
|
|
2050
|
+
at,
|
|
2051
|
+
by: sessionIdFromEnv(ctx.env) ?? "user",
|
|
2052
|
+
reason: opts.reason ?? "reparent",
|
|
2053
|
+
}),
|
|
2054
|
+
};
|
|
2055
|
+
});
|
|
2056
|
+
for (const question of pending) {
|
|
2057
|
+
casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
2058
|
+
const already = current.routing_history.some((entry) => entry.reason === "reparented" && entry.at === at);
|
|
2059
|
+
if (already)
|
|
2060
|
+
return current;
|
|
2061
|
+
return {
|
|
2062
|
+
...current,
|
|
2063
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
2064
|
+
hop: current.hop,
|
|
2065
|
+
audience: resolveAudience(hqRoot, current, ctx),
|
|
2066
|
+
at,
|
|
2067
|
+
reason: "reparented",
|
|
2068
|
+
}),
|
|
2069
|
+
};
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
printJson({
|
|
2073
|
+
ok: true,
|
|
2074
|
+
action: "reparent",
|
|
2075
|
+
lane: updated,
|
|
2076
|
+
pending_questions: pending.length,
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
const CANONICAL_LOG_STREAMS = [
|
|
2080
|
+
"watcher.err",
|
|
2081
|
+
"watcher.out",
|
|
2082
|
+
"worker.err",
|
|
2083
|
+
"worker.out",
|
|
2084
|
+
];
|
|
2085
|
+
async function runLogs(ctx, laneId, opts) {
|
|
2086
|
+
const hqRoot = ctx.hqRoot();
|
|
2087
|
+
readLane(hqRoot, laneId);
|
|
2088
|
+
const logsDir = path.join(runDir(hqRoot, laneId), "logs");
|
|
2089
|
+
const tail = parseOptionalInt(opts.tail, "--tail");
|
|
2090
|
+
if (tail === 0) {
|
|
2091
|
+
throw new LanesError("invalid_integer", "--tail must be a positive integer", { flag: "--tail", value: opts.tail });
|
|
2092
|
+
}
|
|
2093
|
+
const names = new Set(CANONICAL_LOG_STREAMS);
|
|
2094
|
+
if (fs.existsSync(logsDir)) {
|
|
2095
|
+
for (const name of fs.readdirSync(logsDir)) {
|
|
2096
|
+
if (!name.startsWith("."))
|
|
2097
|
+
names.add(name);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
const streams = [...names].sort().map((name) => {
|
|
2101
|
+
const filePath = path.join(logsDir, name);
|
|
2102
|
+
let bytes = 0;
|
|
2103
|
+
let text = "";
|
|
2104
|
+
try {
|
|
2105
|
+
const st = fs.statSync(filePath);
|
|
2106
|
+
if (st.isFile()) {
|
|
2107
|
+
bytes = st.size;
|
|
2108
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
catch (err) {
|
|
2112
|
+
const code = err.code;
|
|
2113
|
+
if (code !== "ENOENT")
|
|
2114
|
+
throw err;
|
|
2115
|
+
}
|
|
2116
|
+
const lines = text.split("\n").filter((line) => line.length > 0);
|
|
2117
|
+
const sliced = tail === undefined ? lines : lines.slice(-tail);
|
|
2118
|
+
return { name, path: filePath, bytes, lines: sliced };
|
|
2119
|
+
});
|
|
2120
|
+
printJson({ ok: true, action: "logs", lane_id: laneId, streams });
|
|
2121
|
+
}
|
|
2122
|
+
export function stopCommittedState(state) {
|
|
2123
|
+
return state === "queued" ? "cancelled" : "killed";
|
|
2124
|
+
}
|
|
2125
|
+
async function runStop(ctx, laneId) {
|
|
2126
|
+
const hqRoot = ctx.hqRoot();
|
|
2127
|
+
const lane = readLane(hqRoot, laneId);
|
|
2128
|
+
if (isTerminalLaneState(lane.state)) {
|
|
2129
|
+
throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
2130
|
+
}
|
|
2131
|
+
const at = ctx.now().toISOString();
|
|
2132
|
+
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
2133
|
+
if (isTerminalLaneState(current.state)) {
|
|
2134
|
+
throw new LanesError("already_terminal", `Lane ${laneId} is already ${current.state}`, { lane_id: laneId, state: current.state });
|
|
2135
|
+
}
|
|
2136
|
+
return {
|
|
2137
|
+
...current,
|
|
2138
|
+
state: stopCommittedState(current.state),
|
|
2139
|
+
queued_reason: undefined,
|
|
2140
|
+
};
|
|
2141
|
+
});
|
|
2142
|
+
if (updated.state === "killed") {
|
|
2143
|
+
stopLaneProcesses(hqRoot, laneId, {
|
|
2144
|
+
killProcess: ctx.killProcess,
|
|
2145
|
+
isAlive: ctx.isAlive,
|
|
2146
|
+
sleepMs: ctx.stopSleepMs,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
|
|
2150
|
+
printJson({
|
|
2151
|
+
ok: true,
|
|
2152
|
+
action: "stop",
|
|
2153
|
+
lane: updated,
|
|
2154
|
+
cancelled_questions: cancelled.map((q) => q.question_id),
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
async function runAsk(ctx, opts) {
|
|
2158
|
+
const hqRoot = ctx.hqRoot();
|
|
2159
|
+
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
2160
|
+
if (!laneId) {
|
|
2161
|
+
throw new LanesError("missing_lane_id", "hq lanes ask requires HQ_LANE_ID in the environment", {});
|
|
2162
|
+
}
|
|
2163
|
+
const questionText = opts.question?.trim();
|
|
2164
|
+
if (!questionText) {
|
|
2165
|
+
throw new LanesError("missing_question", "--question is required", {});
|
|
2166
|
+
}
|
|
2167
|
+
const lane = readLane(hqRoot, laneId);
|
|
2168
|
+
const optionList = Array.isArray(opts.option)
|
|
2169
|
+
? opts.option
|
|
2170
|
+
: opts.option
|
|
2171
|
+
? [opts.option]
|
|
2172
|
+
: [];
|
|
2173
|
+
const body = {
|
|
2174
|
+
question: questionText,
|
|
2175
|
+
context: opts.context ?? "",
|
|
2176
|
+
options: optionList,
|
|
2177
|
+
};
|
|
2178
|
+
if (opts.recommended !== undefined)
|
|
2179
|
+
body.recommended = opts.recommended;
|
|
2180
|
+
const at = ctx.now().toISOString();
|
|
2181
|
+
let questionId;
|
|
2182
|
+
let lastErr;
|
|
2183
|
+
const attempts = ASK_ATTEMPTS;
|
|
2184
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
2185
|
+
try {
|
|
2186
|
+
const withBody = raiseLaneQuestion({
|
|
2187
|
+
hqRoot,
|
|
2188
|
+
lane,
|
|
2189
|
+
body,
|
|
2190
|
+
raisedVia: "cli",
|
|
2191
|
+
at,
|
|
2192
|
+
questionId,
|
|
2193
|
+
});
|
|
2194
|
+
questionId = withBody.question_id;
|
|
2195
|
+
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question", {
|
|
2196
|
+
lane_id: lane.lane_id,
|
|
2197
|
+
question_id: withBody.question_id,
|
|
2198
|
+
hop: withBody.hop,
|
|
2199
|
+
});
|
|
2200
|
+
const audience = resolveAudience(hqRoot, withBody, ctx);
|
|
2201
|
+
if (audience.kind === "user") {
|
|
2202
|
+
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question_at_user", {
|
|
2203
|
+
lane_id: lane.lane_id,
|
|
2204
|
+
question_id: withBody.question_id,
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
printJson({
|
|
2208
|
+
ok: true,
|
|
2209
|
+
action: "ask",
|
|
2210
|
+
question: withBody,
|
|
2211
|
+
lane_id: lane.lane_id,
|
|
2212
|
+
lane_state: lane.state,
|
|
2213
|
+
});
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
catch (err) {
|
|
2217
|
+
lastErr = err;
|
|
2218
|
+
process.stderr.write(`hq lanes ask: attempt ${i + 1}/${attempts} failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2219
|
+
if (i === attempts - 1)
|
|
2220
|
+
break;
|
|
2221
|
+
await ctx.sleep(ASK_BACKOFF_MS[i] ?? ASK_BACKOFF_MS[ASK_BACKOFF_MS.length - 1]);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const failure = {
|
|
2225
|
+
schema_version: "hq-lane-ask-delivery-failed.v1",
|
|
2226
|
+
at: ctx.now().toISOString(),
|
|
2227
|
+
lane_id: lane.lane_id,
|
|
2228
|
+
question_id: questionId ?? null,
|
|
2229
|
+
error: lastErr instanceof Error
|
|
2230
|
+
? { name: lastErr.name, message: lastErr.message }
|
|
2231
|
+
: { name: "Error", message: String(lastErr) },
|
|
2232
|
+
};
|
|
2233
|
+
try {
|
|
2234
|
+
writeJsonFile(askDeliveryFailedPath(hqRoot, lane.lane_id), failure);
|
|
2235
|
+
}
|
|
2236
|
+
catch (writeErr) {
|
|
2237
|
+
process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${lane.lane_id}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
|
|
2238
|
+
}
|
|
2239
|
+
throw new LanesError("ask_delivery_failed", `Failed to deliver ask for lane ${lane.lane_id}`, {
|
|
2240
|
+
lane_id: lane.lane_id,
|
|
2241
|
+
lane_state: lane.state,
|
|
2242
|
+
ask_delivery_failed: failure,
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
async function runQuestionsList(ctx, opts) {
|
|
2246
|
+
const hqRoot = ctx.hqRoot();
|
|
2247
|
+
const status = opts.status ? parseQuestionStatus(opts.status) : undefined;
|
|
2248
|
+
const forFilter = opts.for ? parseForFilter(opts.for, ctx) : undefined;
|
|
2249
|
+
let company;
|
|
2250
|
+
if (opts.company)
|
|
2251
|
+
company = await ctx.resolveCompany(opts.company);
|
|
2252
|
+
const questions = loadAllQuestions(hqRoot)
|
|
2253
|
+
.filter((q) => {
|
|
2254
|
+
if (status && q.status !== status)
|
|
2255
|
+
return false;
|
|
2256
|
+
if (opts.from && q.lane_id !== opts.from && q.origin.id !== opts.from)
|
|
2257
|
+
return false;
|
|
2258
|
+
if (opts.project && q.project_id !== opts.project)
|
|
2259
|
+
return false;
|
|
2260
|
+
if (opts.story && q.story_id !== opts.story)
|
|
2261
|
+
return false;
|
|
2262
|
+
if (company && !recordMatchesCompany(q.company, company)) {
|
|
2263
|
+
return false;
|
|
2264
|
+
}
|
|
2265
|
+
if (forFilter) {
|
|
2266
|
+
const audience = resolveAudience(hqRoot, q, ctx);
|
|
2267
|
+
if (!audienceMatches(audience, forFilter))
|
|
2268
|
+
return false;
|
|
2269
|
+
}
|
|
2270
|
+
return true;
|
|
2271
|
+
})
|
|
2272
|
+
.map((q) => ({
|
|
2273
|
+
...q,
|
|
2274
|
+
audience: resolveAudience(hqRoot, q, ctx),
|
|
2275
|
+
...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
|
|
2276
|
+
}));
|
|
2277
|
+
printJson({ ok: true, action: "questions.list", questions });
|
|
2278
|
+
}
|
|
2279
|
+
async function runQuestionsShow(ctx, questionId) {
|
|
2280
|
+
const hqRoot = ctx.hqRoot();
|
|
2281
|
+
const question = readQuestion(hqRoot, questionId);
|
|
2282
|
+
const body = readQuestionBody(hqRoot, question);
|
|
2283
|
+
printJson({
|
|
2284
|
+
ok: true,
|
|
2285
|
+
action: "questions.show",
|
|
2286
|
+
question,
|
|
2287
|
+
audience: resolveAudience(hqRoot, question, ctx),
|
|
2288
|
+
body,
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
2292
|
+
const hqRoot = ctx.hqRoot();
|
|
2293
|
+
const question = readQuestion(hqRoot, questionId);
|
|
2294
|
+
throwIfQuestionNotPending(question);
|
|
2295
|
+
await yieldEventLoop();
|
|
2296
|
+
const body = requireQuestionBody(hqRoot, question);
|
|
2297
|
+
// A trimmed-empty --freeform is absent: it must not win over --choice and
|
|
2298
|
+
// must not become a freeform answer of "".
|
|
2299
|
+
const freeform = opts.freeform?.trim() || undefined;
|
|
2300
|
+
if (freeform !== undefined && body.options.length === 0) {
|
|
2301
|
+
throw new LanesError("freeform_not_allowed", "--freeform is only allowed for a question with options", { question_id: questionId });
|
|
2302
|
+
}
|
|
2303
|
+
if (!freeform && !opts.choice) {
|
|
2304
|
+
throw new LanesError("missing_choice", "--choice or --freeform is required", {});
|
|
2305
|
+
}
|
|
2306
|
+
const choice = freeform ?? resolveAnswerChoice(opts.choice, body.options, false);
|
|
2307
|
+
const at = ctx.now().toISOString();
|
|
2308
|
+
const answeredBy = sessionIdFromEnv(ctx.env)
|
|
2309
|
+
? `session:${sessionIdFromEnv(ctx.env)}`
|
|
2310
|
+
: "user";
|
|
2311
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
2312
|
+
throwIfQuestionNotPending(current);
|
|
2313
|
+
let notesRef;
|
|
2314
|
+
let nextBody = current.body;
|
|
2315
|
+
if (opts.notes) {
|
|
2316
|
+
const stored = writeQuestionBody(hqRoot, current, {
|
|
2317
|
+
...body,
|
|
2318
|
+
notes: opts.notes,
|
|
2319
|
+
});
|
|
2320
|
+
notesRef = stored.ref;
|
|
2321
|
+
nextBody = stored;
|
|
2322
|
+
}
|
|
2323
|
+
return {
|
|
2324
|
+
...current,
|
|
2325
|
+
body: nextBody,
|
|
2326
|
+
status: "answered",
|
|
2327
|
+
answer: {
|
|
2328
|
+
choice,
|
|
2329
|
+
answer_kind: freeform ? "freeform" : "option",
|
|
2330
|
+
notes_ref: notesRef,
|
|
2331
|
+
answered_by: answeredBy,
|
|
2332
|
+
answered_at: at,
|
|
2333
|
+
answered_level: current.hop,
|
|
2334
|
+
},
|
|
2335
|
+
};
|
|
2336
|
+
});
|
|
2337
|
+
const delivery = deliverAnswerToAskingLane(hqRoot, updated, choice, at);
|
|
2338
|
+
printJson({
|
|
2339
|
+
ok: true,
|
|
2340
|
+
action: "questions.answer",
|
|
2341
|
+
question: updated,
|
|
2342
|
+
delivery: delivery.status,
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
function writeAnswerDeliveryFailed(hqRoot, laneId, questionId, err, at) {
|
|
2346
|
+
const failure = {
|
|
2347
|
+
schema_version: "hq-lane-ask-delivery-failed.v1",
|
|
2348
|
+
at,
|
|
2349
|
+
lane_id: laneId,
|
|
2350
|
+
question_id: questionId,
|
|
2351
|
+
error: err instanceof Error
|
|
2352
|
+
? { name: err.name, message: err.message }
|
|
2353
|
+
: { name: "Error", message: String(err) },
|
|
2354
|
+
};
|
|
2355
|
+
try {
|
|
2356
|
+
writeJsonFile(askDeliveryFailedPath(hqRoot, laneId), failure);
|
|
2357
|
+
}
|
|
2358
|
+
catch (writeErr) {
|
|
2359
|
+
process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${laneId}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Record is already durable. Delivery is best-effort onto the asking lane's
|
|
2364
|
+
* drop box (spec §6.5). Target is question.lane_id, never a project/story lookup.
|
|
2365
|
+
*/
|
|
2366
|
+
function deliverAnswerToAskingLane(hqRoot, question, choice, at) {
|
|
2367
|
+
const laneId = laneIdForAnswerDelivery(question);
|
|
2368
|
+
try {
|
|
2369
|
+
const lane = readLane(hqRoot, laneId);
|
|
2370
|
+
if (!laneAcceptsDropboxDelivery(lane)) {
|
|
2371
|
+
return { status: "recorded_only" };
|
|
2372
|
+
}
|
|
2373
|
+
appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
|
|
2374
|
+
return { status: "dropped" };
|
|
2375
|
+
}
|
|
2376
|
+
catch (err) {
|
|
2377
|
+
writeAnswerDeliveryFailed(hqRoot, laneId, question.question_id, err, at);
|
|
2378
|
+
return { status: "failed" };
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
async function runQuestionsEscalate(ctx, questionId, opts) {
|
|
2382
|
+
const hqRoot = ctx.hqRoot();
|
|
2383
|
+
const question = readQuestion(hqRoot, questionId);
|
|
2384
|
+
throwIfQuestionNotPending(question);
|
|
2385
|
+
await yieldEventLoop();
|
|
2386
|
+
const originalHop = question.hop;
|
|
2387
|
+
const audience = resolveAudience(hqRoot, question, ctx);
|
|
2388
|
+
if (audience.kind === "user") {
|
|
2389
|
+
throw new LanesError("already_at_top_authority", "Cannot escalate past the user", { question_id: questionId });
|
|
2390
|
+
}
|
|
2391
|
+
const at = ctx.now().toISOString();
|
|
2392
|
+
if (opts.note) {
|
|
2393
|
+
requireQuestionBody(hqRoot, question);
|
|
2394
|
+
}
|
|
2395
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
2396
|
+
throwIfQuestionNotPending(current);
|
|
2397
|
+
if (current.hop !== originalHop) {
|
|
2398
|
+
throw new LanesError("question_already_escalated", `Question ${questionId} hop already advanced`, {
|
|
2399
|
+
question_id: questionId,
|
|
2400
|
+
hop: current.hop,
|
|
2401
|
+
expected_hop: originalHop,
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
let nextBody = current.body;
|
|
2405
|
+
if (opts.note) {
|
|
2406
|
+
const body = requireQuestionBody(hqRoot, current);
|
|
2407
|
+
nextBody = writeQuestionBody(hqRoot, current, {
|
|
2408
|
+
...body,
|
|
2409
|
+
notes: [body.notes, opts.note].filter(Boolean).join("\n"),
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
const nextHop = current.hop + 1;
|
|
2413
|
+
return {
|
|
2414
|
+
...current,
|
|
2415
|
+
body: nextBody,
|
|
2416
|
+
hop: nextHop,
|
|
2417
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
2418
|
+
hop: nextHop,
|
|
2419
|
+
audience: resolveAudience(hqRoot, { ...current, hop: nextHop }, ctx),
|
|
2420
|
+
at,
|
|
2421
|
+
reason: "escalated",
|
|
2422
|
+
}),
|
|
2423
|
+
};
|
|
2424
|
+
});
|
|
2425
|
+
const nextAudience = resolveAudience(hqRoot, updated, ctx);
|
|
2426
|
+
if (nextAudience.kind === "user") {
|
|
2427
|
+
const lane = readLane(hqRoot, updated.lane_id);
|
|
2428
|
+
await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question_at_user", { lane_id: lane.lane_id, question_id: updated.question_id });
|
|
2429
|
+
}
|
|
2430
|
+
printJson({ ok: true, action: "questions.escalate", question: updated });
|
|
2431
|
+
}
|
|
2432
|
+
async function runQuestionsCancel(ctx, questionId, opts) {
|
|
2433
|
+
const hqRoot = ctx.hqRoot();
|
|
2434
|
+
const question = readQuestion(hqRoot, questionId);
|
|
2435
|
+
throwIfQuestionNotPending(question);
|
|
2436
|
+
await yieldEventLoop();
|
|
2437
|
+
const at = ctx.now().toISOString();
|
|
2438
|
+
const updated = casUpdateQuestion(hqRoot, questionId, (current) => {
|
|
2439
|
+
throwIfQuestionNotPending(current);
|
|
2440
|
+
const entry = {
|
|
2441
|
+
hop: current.hop,
|
|
2442
|
+
audience: resolveAudience(hqRoot, current, ctx),
|
|
2443
|
+
at,
|
|
2444
|
+
reason: "cancelled",
|
|
2445
|
+
...(opts.reason ? { detail: opts.reason } : {}),
|
|
2446
|
+
};
|
|
2447
|
+
return {
|
|
2448
|
+
...current,
|
|
2449
|
+
status: "cancelled",
|
|
2450
|
+
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
2451
|
+
};
|
|
2452
|
+
});
|
|
2453
|
+
printJson({ ok: true, action: "questions.cancel", question: updated });
|
|
2454
|
+
}
|
|
2455
|
+
async function runAccountsList(ctx, opts) {
|
|
2456
|
+
const hqRoot = ctx.hqRoot();
|
|
2457
|
+
const config = loadLanesConfig(hqRoot);
|
|
2458
|
+
const providers = opts.provider
|
|
2459
|
+
? [parseLaneProvider(opts.provider)]
|
|
2460
|
+
: [...LANE_PROVIDERS];
|
|
2461
|
+
// model and effort are what a spawn on each account would start from
|
|
2462
|
+
// (spec: --model/--effort, then the account, then the provider).
|
|
2463
|
+
const quota = loadQuotaState(hqRoot);
|
|
2464
|
+
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config).map((status) => {
|
|
2465
|
+
const accountCfg = config.providers[provider].accounts[status.id];
|
|
2466
|
+
const model = resolveLaneModel({
|
|
2467
|
+
accountModel: accountCfg?.model,
|
|
2468
|
+
providerModel: config.providers[provider].model,
|
|
2469
|
+
});
|
|
2470
|
+
const effort = resolveLaneEffort({
|
|
2471
|
+
accountEffort: accountCfg?.effort,
|
|
2472
|
+
providerEffort: config.providers[provider].effort,
|
|
2473
|
+
model,
|
|
2474
|
+
});
|
|
2475
|
+
return {
|
|
2476
|
+
...status,
|
|
2477
|
+
model: accountCfg?.model ?? null,
|
|
2478
|
+
effort: accountCfg?.effort ?? null,
|
|
2479
|
+
effective_model: model ?? null,
|
|
2480
|
+
effective_effort: effort.effort ?? null,
|
|
2481
|
+
effective_effort_source: effort.source,
|
|
2482
|
+
...accountQuotaView(quota, provider, status.id),
|
|
2483
|
+
};
|
|
2484
|
+
}));
|
|
2485
|
+
const providerDefaults = providers.map((provider) => ({
|
|
2486
|
+
provider,
|
|
2487
|
+
model: config.providers[provider].model ?? null,
|
|
2488
|
+
effort: config.providers[provider].effort ?? null,
|
|
2489
|
+
compact_after_ms: providerCompactAfterMs(config.providers[provider]),
|
|
2490
|
+
}));
|
|
2491
|
+
printJson({
|
|
2492
|
+
ok: true,
|
|
2493
|
+
action: "accounts.list",
|
|
2494
|
+
providers: providerDefaults,
|
|
2495
|
+
accounts,
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
2499
|
+
const callerCwd = ctx.callerCwd();
|
|
2500
|
+
const hqRoot = ctx.hqRoot();
|
|
2501
|
+
const provider = parseLaneProvider(providerRaw);
|
|
2502
|
+
const config = loadLanesConfig(hqRoot);
|
|
2503
|
+
const id = accountId.trim();
|
|
2504
|
+
if (!id) {
|
|
2505
|
+
throw new LanesError("invalid_account", "account-id must be non-empty", {});
|
|
2506
|
+
}
|
|
2507
|
+
const homeRaw = opts.home?.trim() ||
|
|
2508
|
+
(id === "a"
|
|
2509
|
+
? DEFAULT_ACCOUNT_HOMES[provider]
|
|
2510
|
+
: `~/.${provider}-${id}`);
|
|
2511
|
+
const homeResolved = homeRaw.startsWith("~") || path.isAbsolute(homeRaw)
|
|
2512
|
+
? homeRaw
|
|
2513
|
+
: resolveCallerPath(callerCwd, homeRaw);
|
|
2514
|
+
const home = canonicalizeHome(homeResolved, {
|
|
2515
|
+
homedir: ctx.homedir,
|
|
2516
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
2517
|
+
existsSync: (p) => fs.existsSync(p),
|
|
2518
|
+
});
|
|
2519
|
+
for (const [existingId, account] of Object.entries(config.providers[provider].accounts)) {
|
|
2520
|
+
if (existingId === id)
|
|
2521
|
+
continue;
|
|
2522
|
+
if (account.home === home) {
|
|
2523
|
+
throw new LanesError("duplicate_account_home", `Home ${home} is already registered as ${provider} account ${existingId}`, { provider, accounts: [existingId, id], home });
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
ensureDir(home);
|
|
2527
|
+
const defaultId = config.providers[provider].default_account;
|
|
2528
|
+
const defaultHome = (defaultId !== undefined
|
|
2529
|
+
? config.providers[provider].accounts[defaultId]?.home
|
|
2530
|
+
: undefined) ??
|
|
2531
|
+
Object.values(config.providers[provider].accounts).find((account) => account.enabled)
|
|
2532
|
+
?.home ??
|
|
2533
|
+
canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], {
|
|
2534
|
+
homedir: ctx.homedir,
|
|
2535
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
2536
|
+
existsSync: (p) => fs.existsSync(p),
|
|
2537
|
+
});
|
|
2538
|
+
const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
|
|
2539
|
+
installDrainHook(provider, home);
|
|
2540
|
+
const envVar = PROVIDER_HOME_ENV[provider];
|
|
2541
|
+
const login = VENDOR_LOGIN[provider];
|
|
2542
|
+
const result = await ctx.spawnLogin({
|
|
2543
|
+
command: login.command,
|
|
2544
|
+
args: login.args,
|
|
2545
|
+
env: { ...ctx.env, [envVar]: home },
|
|
2546
|
+
cwd: home,
|
|
2547
|
+
});
|
|
2548
|
+
if (result.status !== 0) {
|
|
2549
|
+
throw new LanesError("login_failed", `${login.command} ${login.args.join(" ")} exited ${result.status}`, { provider, account: id, status: result.status });
|
|
2550
|
+
}
|
|
2551
|
+
const grokNeedsRails = provider === "grok";
|
|
2552
|
+
if ((grokNeedsRails || copied) && !railsPresent(home)) {
|
|
2553
|
+
throw new LanesError("rails_missing", `Hook rails are missing under ${home}; account not enabled`, { provider, account: id, home });
|
|
2554
|
+
}
|
|
2555
|
+
config.providers[provider].accounts[id] = {
|
|
2556
|
+
home,
|
|
2557
|
+
enabled: true,
|
|
2558
|
+
};
|
|
2559
|
+
writeLanesConfig(hqRoot, config);
|
|
2560
|
+
const status = accountStatus(provider, id, loadLanesConfig(hqRoot));
|
|
2561
|
+
printJson({ ok: true, action: "accounts.login", account: status });
|
|
2562
|
+
}
|
|
2563
|
+
async function runAccountsEnable(ctx, providerRaw, accountId) {
|
|
2564
|
+
const hqRoot = ctx.hqRoot();
|
|
2565
|
+
const provider = parseLaneProvider(providerRaw);
|
|
2566
|
+
const config = loadLanesConfig(hqRoot);
|
|
2567
|
+
const account = config.providers[provider].accounts[accountId];
|
|
2568
|
+
if (!account) {
|
|
2569
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(accountId)} for ${provider}`, {
|
|
2570
|
+
provider,
|
|
2571
|
+
account: accountId,
|
|
2572
|
+
enabled: Object.entries(config.providers[provider].accounts)
|
|
2573
|
+
.filter(([, a]) => a.enabled)
|
|
2574
|
+
.map(([id]) => id),
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
account.enabled = true;
|
|
2578
|
+
delete account.disabled_reason;
|
|
2579
|
+
writeLanesConfig(hqRoot, config);
|
|
2580
|
+
printJson({
|
|
2581
|
+
ok: true,
|
|
2582
|
+
action: "accounts.enable",
|
|
2583
|
+
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)),
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
|
|
2587
|
+
const hqRoot = ctx.hqRoot();
|
|
2588
|
+
const provider = parseLaneProvider(providerRaw);
|
|
2589
|
+
const config = loadLanesConfig(hqRoot);
|
|
2590
|
+
const account = config.providers[provider].accounts[accountId];
|
|
2591
|
+
if (!account) {
|
|
2592
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(accountId)} for ${provider}`, {
|
|
2593
|
+
provider,
|
|
2594
|
+
account: accountId,
|
|
2595
|
+
enabled: Object.entries(config.providers[provider].accounts)
|
|
2596
|
+
.filter(([, a]) => a.enabled)
|
|
2597
|
+
.map(([id]) => id),
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
account.enabled = false;
|
|
2601
|
+
if (opts.reason)
|
|
2602
|
+
account.disabled_reason = opts.reason;
|
|
2603
|
+
writeLanesConfig(hqRoot, config);
|
|
2604
|
+
printJson({
|
|
2605
|
+
ok: true,
|
|
2606
|
+
action: "accounts.disable",
|
|
2607
|
+
account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)),
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
async function runSessionsList(ctx, opts) {
|
|
2611
|
+
const hqRoot = ctx.hqRoot();
|
|
2612
|
+
const state = opts.state ? parseLivenessState(opts.state) : undefined;
|
|
2613
|
+
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
2614
|
+
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
2615
|
+
const rows = listSessionLiveness(hqRoot, {
|
|
2616
|
+
nowMs: ctx.now().getTime(),
|
|
2617
|
+
presenceConnected,
|
|
2618
|
+
deps: livenessDeps,
|
|
2619
|
+
}).filter((row) => {
|
|
2620
|
+
if (state && row.state !== state)
|
|
2621
|
+
return false;
|
|
2622
|
+
if (!opts.company)
|
|
2623
|
+
return true;
|
|
2624
|
+
const meta = readSessionMeta(hqRoot, row.sessionId, ctx);
|
|
2625
|
+
const slug = typeof meta?.company_slug === "string" ? meta.company_slug : "";
|
|
2626
|
+
const uid = typeof meta?.company_uid === "string" ? meta.company_uid : "";
|
|
2627
|
+
return slug === opts.company || uid === opts.company;
|
|
2628
|
+
});
|
|
2629
|
+
printJson({ ok: true, action: "sessions.list", sessions: rows });
|
|
2630
|
+
}
|
|
2631
|
+
async function runSessionsShow(ctx, sessionId) {
|
|
2632
|
+
const hqRoot = ctx.hqRoot();
|
|
2633
|
+
const livenessDeps = livenessDepsFromCtx(ctx);
|
|
2634
|
+
const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
|
|
2635
|
+
const state = sessionLiveness(hqRoot, sessionId, {
|
|
2636
|
+
nowMs: ctx.now().getTime(),
|
|
2637
|
+
presenceConnected,
|
|
2638
|
+
deps: livenessDeps,
|
|
2639
|
+
});
|
|
2640
|
+
if (state === null) {
|
|
2641
|
+
throw new LanesError("session_not_found", `Session ${sessionId} is not in the liveness index`, { session_id: sessionId });
|
|
2642
|
+
}
|
|
2643
|
+
const meta = readSessionMeta(hqRoot, sessionId, ctx);
|
|
2644
|
+
printJson({
|
|
2645
|
+
ok: true,
|
|
2646
|
+
action: "sessions.show",
|
|
2647
|
+
session_id: sessionId,
|
|
2648
|
+
state,
|
|
2649
|
+
senior: meta ? seniorOfSession(hqRoot, sessionId, ctx) : { kind: "user" },
|
|
2650
|
+
company_slug: typeof meta?.company_slug === "string" ? meta.company_slug : null,
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
async function runReconcile(ctx, opts) {
|
|
2654
|
+
const fromEnv = ctx.env.HQ_ROOT?.trim();
|
|
2655
|
+
const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
|
|
2656
|
+
const summary = await runReconcilePass(hqRoot, {
|
|
2657
|
+
now: ctx.now,
|
|
2658
|
+
host: reconcileHostFromCtx(ctx),
|
|
2659
|
+
dryRun: Boolean(opts.dryRun),
|
|
2660
|
+
spawnLaneDeps: {
|
|
2661
|
+
env: ctx.env,
|
|
2662
|
+
existsSync: ctx.existsSync,
|
|
2663
|
+
spawnProcess: ctx.spawnProcess,
|
|
2664
|
+
proveDetached: ctx.proveDetached,
|
|
2665
|
+
killProcess: ctx.killProcess,
|
|
2666
|
+
now: ctx.now,
|
|
2667
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
2668
|
+
runCompaction: ctx.runCompaction,
|
|
2669
|
+
locateTranscript: ctx.locateTranscript,
|
|
2670
|
+
},
|
|
2671
|
+
throttleDeps: ctx.throttleDeps,
|
|
2672
|
+
livenessDeps: livenessDepsFromCtx(ctx),
|
|
2673
|
+
presenceConnected: ctx.presenceConnected,
|
|
2674
|
+
env: ctx.env,
|
|
2675
|
+
});
|
|
2676
|
+
printJson(summary);
|
|
2677
|
+
if (!summary.ok) {
|
|
2678
|
+
process.exit(1);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
async function runReconcileInstall(ctx, opts) {
|
|
2682
|
+
const callerCwd = ctx.callerCwd();
|
|
2683
|
+
const systemdRootRaw = opts.systemdRoot ?? ctx.systemdRoot ?? defaultSystemdUserDir(ctx.homedir());
|
|
2684
|
+
const systemdRoot = opts.systemdRoot !== undefined
|
|
2685
|
+
? resolveCallerPath(callerCwd, opts.systemdRoot)
|
|
2686
|
+
: systemdRootRaw;
|
|
2687
|
+
const config = loadLanesConfig(ctx.hqRoot());
|
|
2688
|
+
const activeProviders = LANE_PROVIDERS.filter((provider) => Object.values(config.providers[provider].accounts).some((account) => account.enabled));
|
|
2689
|
+
const providerPath = providerPathEnv(activeProviders, ctx.env.PATH);
|
|
2690
|
+
const result = installReconcileTimer({
|
|
2691
|
+
systemdRoot,
|
|
2692
|
+
hqRoot: ctx.hqRoot(),
|
|
2693
|
+
enable: Boolean(opts.enable),
|
|
2694
|
+
runCommand: opts.enable ? ctx.runSystemctl : undefined,
|
|
2695
|
+
pathEnv: providerPath.path,
|
|
2696
|
+
});
|
|
2697
|
+
printJson({
|
|
2698
|
+
...result,
|
|
2699
|
+
providers_resolved: providerPath.resolved,
|
|
2700
|
+
providers_unresolved: providerPath.unresolved,
|
|
2701
|
+
...(providerPath.unresolved.length > 0
|
|
2702
|
+
? {
|
|
2703
|
+
warnings: providerPath.unresolved.map((provider) => `provider binary ${provider} is not on PATH; lanes for ${provider} stay queued with provider_binary_missing until it is installed and the timer is reinstalled`),
|
|
2704
|
+
}
|
|
2705
|
+
: {}),
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
async function runReconcileUninstall(ctx, opts) {
|
|
2709
|
+
const callerCwd = ctx.callerCwd();
|
|
2710
|
+
const systemdRootRaw = opts.systemdRoot ?? ctx.systemdRoot ?? defaultSystemdUserDir(ctx.homedir());
|
|
2711
|
+
const systemdRoot = opts.systemdRoot !== undefined
|
|
2712
|
+
? resolveCallerPath(callerCwd, opts.systemdRoot)
|
|
2713
|
+
: systemdRootRaw;
|
|
2714
|
+
const result = uninstallReconcileTimer({
|
|
2715
|
+
systemdRoot,
|
|
2716
|
+
disable: Boolean(opts.disable),
|
|
2717
|
+
runCommand: opts.disable ? ctx.runSystemctl : undefined,
|
|
2718
|
+
});
|
|
2719
|
+
printJson(result);
|
|
2720
|
+
}
|
|
2721
|
+
function commandHasAction(cmd) {
|
|
2722
|
+
return (typeof cmd._actionHandler ===
|
|
2723
|
+
"function");
|
|
2724
|
+
}
|
|
2725
|
+
/** Spec §7: every command prints JSON. Accept `--json` as a no-op everywhere. */
|
|
2726
|
+
function attachJsonNoop(cmd) {
|
|
2727
|
+
const hasJson = (cmd.options ?? []).some((opt) => opt.long === "--json");
|
|
2728
|
+
if (commandHasAction(cmd) && !hasJson) {
|
|
2729
|
+
cmd.option("--json", "JSON on stdout (always)");
|
|
2730
|
+
}
|
|
2731
|
+
for (const child of cmd.commands)
|
|
2732
|
+
attachJsonNoop(child);
|
|
2733
|
+
}
|
|
2734
|
+
export function registerLanesCommand(parent, deps = {}) {
|
|
2735
|
+
const ctx = resolveDeps(deps);
|
|
2736
|
+
const lanes = parent
|
|
2737
|
+
.command("lanes", { hidden: true })
|
|
2738
|
+
.description("Create, supervise, and steer worker lanes; routed decision queue");
|
|
2739
|
+
lanes
|
|
2740
|
+
.command("create")
|
|
2741
|
+
.description("Register a lane, run admission, and return immediately")
|
|
2742
|
+
.option("--company <slug|uid>", "Company slug or cloud uid; omit for a core/personal lane")
|
|
2743
|
+
.requiredOption("--project <slug>", "HQ project slug")
|
|
2744
|
+
.requiredOption("--story <id>", "Story id from the project's prd.json")
|
|
2745
|
+
.requiredOption("--brief-file <path>", "Path to the lane brief")
|
|
2746
|
+
.option("--worker <name>", "HQ worker id from the registry")
|
|
2747
|
+
.option("--provider <name>", "claude | codex | grok")
|
|
2748
|
+
.option("--account <id>", "Provider account id")
|
|
2749
|
+
.option("--model <id>", "Model id for the provider CLI")
|
|
2750
|
+
.option("--effort <level>", "Effort level; claude: low|medium|high|xhigh|max, codex: minimal|low|medium|high|xhigh|max|ultra, grok: low|medium|high|xhigh")
|
|
2751
|
+
.option("--senior <ref>", "session:<id> | lane:<id> | user")
|
|
2752
|
+
.option("--timeout <secs>", "Deadline in seconds (warns, does not kill)")
|
|
2753
|
+
.option("--priority <n>", "Queue priority; higher runs first", "1")
|
|
2754
|
+
.option("--outpost <id>", "Run on this Outpost instead of the local host")
|
|
2755
|
+
.action(wrap(async (opts) => {
|
|
2756
|
+
await runCreate(ctx, opts);
|
|
2757
|
+
}));
|
|
2758
|
+
lanes
|
|
2759
|
+
.command("list")
|
|
2760
|
+
.description("List lanes")
|
|
2761
|
+
.option("--company <slug|uid>", "Filter by company")
|
|
2762
|
+
.option("--project <slug>", "Filter by project")
|
|
2763
|
+
.option("--story <id>", "Filter by story")
|
|
2764
|
+
.option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
|
|
2765
|
+
.option("--senior <ref>", "Filter by senior ref")
|
|
2766
|
+
.option("--json", "JSON on stdout (always)")
|
|
2767
|
+
.action(wrap(async (opts) => {
|
|
2768
|
+
await runList(ctx, opts);
|
|
2769
|
+
}));
|
|
2770
|
+
lanes
|
|
2771
|
+
.command("show")
|
|
2772
|
+
.description("Show one lane")
|
|
2773
|
+
.argument("<lane-id>", "Lane id")
|
|
2774
|
+
.action(wrap(async (laneId) => {
|
|
2775
|
+
await runShow(ctx, laneId);
|
|
2776
|
+
}));
|
|
2777
|
+
lanes
|
|
2778
|
+
.command("message")
|
|
2779
|
+
.description("Send a follow-up message to a lane")
|
|
2780
|
+
.argument("<lane-id>", "Lane id")
|
|
2781
|
+
.requiredOption("--text <msg>", "Message text")
|
|
2782
|
+
.option("--project <slug>", "Re-point project attribution from this message")
|
|
2783
|
+
.option("--story <id>", "Re-point story attribution from this message")
|
|
2784
|
+
.option("--no-compact", "Skip the resume-time compaction check for this message")
|
|
2785
|
+
.action(wrap(async (laneId, opts) => {
|
|
2786
|
+
await runMessage(ctx, laneId, opts);
|
|
2787
|
+
}));
|
|
2788
|
+
lanes
|
|
2789
|
+
.command("resume")
|
|
2790
|
+
.description("Resume an interrupted, done or blocked lane on its recorded provider session")
|
|
2791
|
+
.argument("<lane-id>", "Lane id")
|
|
2792
|
+
.option("--text <msg>", "Operator message for the resumed turn")
|
|
2793
|
+
.option("--project <slug>", "Re-point project attribution from this resume")
|
|
2794
|
+
.option("--story <id>", "Re-point story attribution from this resume")
|
|
2795
|
+
.option("--no-compact", "Skip the resume-time compaction check for this resume")
|
|
2796
|
+
.action(wrap(async (laneId, opts) => {
|
|
2797
|
+
await runResume(ctx, laneId, opts);
|
|
2798
|
+
}));
|
|
2799
|
+
lanes
|
|
2800
|
+
.command("reparent")
|
|
2801
|
+
.description("Rewrite a lane's senior edge")
|
|
2802
|
+
.argument("<lane-id>", "Lane id")
|
|
2803
|
+
.requiredOption("--senior <ref>", "session:<id> | lane:<id> | user")
|
|
2804
|
+
.option("--reason <text>", "Why the senior changed")
|
|
2805
|
+
.action(wrap(async (laneId, opts) => {
|
|
2806
|
+
await runReparent(ctx, laneId, opts);
|
|
2807
|
+
}));
|
|
2808
|
+
lanes
|
|
2809
|
+
.command("logs")
|
|
2810
|
+
.description("Print a lane's local logs")
|
|
2811
|
+
.argument("<lane-id>", "Lane id")
|
|
2812
|
+
.option("--tail <n>", "Last N lines")
|
|
2813
|
+
.action(wrap(async (laneId, opts) => {
|
|
2814
|
+
await runLogs(ctx, laneId, opts);
|
|
2815
|
+
}));
|
|
2816
|
+
lanes
|
|
2817
|
+
.command("stop")
|
|
2818
|
+
.description("Cancel a queued lane or kill a running one")
|
|
2819
|
+
.argument("<lane-id>", "Lane id")
|
|
2820
|
+
.action(wrap(async (laneId) => {
|
|
2821
|
+
await runStop(ctx, laneId);
|
|
2822
|
+
}));
|
|
2823
|
+
lanes
|
|
2824
|
+
.command("ask")
|
|
2825
|
+
.description("Queue a mid-run question without blocking the lane")
|
|
2826
|
+
.requiredOption("--question <text>", "Question text")
|
|
2827
|
+
.option("--context <text>", "Context for the audience")
|
|
2828
|
+
.option("--option <text>", "Answer choice (repeatable)", collectRepeatable)
|
|
2829
|
+
.option("--recommended <text>", "Recommended option")
|
|
2830
|
+
.action(wrap(async (opts) => {
|
|
2831
|
+
await runAsk(ctx, opts);
|
|
2832
|
+
}));
|
|
2833
|
+
const questions = lanes
|
|
2834
|
+
.command("questions")
|
|
2835
|
+
.description("Routed decision queue");
|
|
2836
|
+
questions
|
|
2837
|
+
.command("list")
|
|
2838
|
+
.description("List questions")
|
|
2839
|
+
.option("--for <ref>", "Who must decide (ref | me | user)")
|
|
2840
|
+
.option("--from <lane-id>", "Which lane asked")
|
|
2841
|
+
.option("--company <slug|uid>", "Filter by company")
|
|
2842
|
+
.option("--project <slug>", "Filter by project")
|
|
2843
|
+
.option("--story <id>", "Filter by story")
|
|
2844
|
+
.option("--status <status>", `Filter by status (${QUESTION_STATUSES.join("|")})`)
|
|
2845
|
+
.action(wrap(async (opts) => {
|
|
2846
|
+
await runQuestionsList(ctx, opts);
|
|
2847
|
+
}));
|
|
2848
|
+
questions
|
|
2849
|
+
.command("show")
|
|
2850
|
+
.description("Show one question")
|
|
2851
|
+
.argument("<question-id>", "Question id")
|
|
2852
|
+
.action(wrap(async (questionId) => {
|
|
2853
|
+
await runQuestionsShow(ctx, questionId);
|
|
2854
|
+
}));
|
|
2855
|
+
questions
|
|
2856
|
+
.command("answer")
|
|
2857
|
+
.description("Answer a pending question")
|
|
2858
|
+
.argument("<question-id>", "Question id")
|
|
2859
|
+
.option("--choice <label|N>", "1-based index or option label")
|
|
2860
|
+
.option("--notes <text>", "Free-text notes stored with the body")
|
|
2861
|
+
.option("--freeform <text>", "Supply a free-form answer for a question with options")
|
|
2862
|
+
.action(wrap(async (questionId, opts) => {
|
|
2863
|
+
await runQuestionsAnswer(ctx, questionId, opts);
|
|
2864
|
+
}));
|
|
2865
|
+
questions
|
|
2866
|
+
.command("escalate")
|
|
2867
|
+
.description("Advance a question one hop")
|
|
2868
|
+
.argument("<question-id>", "Question id")
|
|
2869
|
+
.option("--note <text>", "Note recorded with the escalation")
|
|
2870
|
+
.action(wrap(async (questionId, opts) => {
|
|
2871
|
+
await runQuestionsEscalate(ctx, questionId, opts);
|
|
2872
|
+
}));
|
|
2873
|
+
questions
|
|
2874
|
+
.command("cancel")
|
|
2875
|
+
.description("Cancel a pending question")
|
|
2876
|
+
.argument("<question-id>", "Question id")
|
|
2877
|
+
.option("--reason <text>", "Why it was cancelled")
|
|
2878
|
+
.action(wrap(async (questionId, opts) => {
|
|
2879
|
+
await runQuestionsCancel(ctx, questionId, opts);
|
|
2880
|
+
}));
|
|
2881
|
+
const accounts = lanes
|
|
2882
|
+
.command("accounts")
|
|
2883
|
+
.description("Provider account registry (configured vs logged in)");
|
|
2884
|
+
accounts
|
|
2885
|
+
.command("list")
|
|
2886
|
+
.description("List accounts")
|
|
2887
|
+
.option("--provider <name>", "claude | codex | grok")
|
|
2888
|
+
.action(wrap(async (opts) => {
|
|
2889
|
+
await runAccountsList(ctx, opts);
|
|
2890
|
+
}));
|
|
2891
|
+
accounts
|
|
2892
|
+
.command("login")
|
|
2893
|
+
.description("Create a home, run the vendor login, and register the account")
|
|
2894
|
+
.argument("<provider>", "claude | codex | grok")
|
|
2895
|
+
.argument("<account-id>", "Open-ended account id")
|
|
2896
|
+
.option("--home <path>", "Account home directory")
|
|
2897
|
+
.action(wrap(async (provider, accountId, opts) => {
|
|
2898
|
+
await runAccountsLogin(ctx, provider, accountId, opts);
|
|
2899
|
+
}));
|
|
2900
|
+
accounts
|
|
2901
|
+
.command("enable")
|
|
2902
|
+
.description("Enable an account without touching credentials")
|
|
2903
|
+
.argument("<provider>", "claude | codex | grok")
|
|
2904
|
+
.argument("<account-id>", "Account id")
|
|
2905
|
+
.action(wrap(async (provider, accountId) => {
|
|
2906
|
+
await runAccountsEnable(ctx, provider, accountId);
|
|
2907
|
+
}));
|
|
2908
|
+
accounts
|
|
2909
|
+
.command("disable")
|
|
2910
|
+
.description("Disable an account without touching credentials")
|
|
2911
|
+
.argument("<provider>", "claude | codex | grok")
|
|
2912
|
+
.argument("<account-id>", "Account id")
|
|
2913
|
+
.option("--reason <text>", "Why the account is disabled")
|
|
2914
|
+
.action(wrap(async (provider, accountId, opts) => {
|
|
2915
|
+
await runAccountsDisable(ctx, provider, accountId, opts);
|
|
2916
|
+
}));
|
|
2917
|
+
const sessions = lanes
|
|
2918
|
+
.command("sessions")
|
|
2919
|
+
.description("Session liveness from the work-mesh spool");
|
|
2920
|
+
sessions
|
|
2921
|
+
.command("list")
|
|
2922
|
+
.description("List sessions")
|
|
2923
|
+
.option("--state <state>", `live | idle | ended | lost`)
|
|
2924
|
+
.option("--company <slug>", "Filter by session meta company_slug")
|
|
2925
|
+
.action(wrap(async (opts) => {
|
|
2926
|
+
await runSessionsList(ctx, opts);
|
|
2927
|
+
}));
|
|
2928
|
+
sessions
|
|
2929
|
+
.command("show")
|
|
2930
|
+
.description("Show one session's liveness")
|
|
2931
|
+
.argument("<session-id>", "Session id")
|
|
2932
|
+
.action(wrap(async (sessionId) => {
|
|
2933
|
+
await runSessionsShow(ctx, sessionId);
|
|
2934
|
+
}));
|
|
2935
|
+
lanes
|
|
2936
|
+
.command("pickup", { hidden: true })
|
|
2937
|
+
.description("Ingest an Outpost-dispatched lane payload and start it on this host")
|
|
2938
|
+
.option("--payload-file <path>", "Local pickup payload JSON")
|
|
2939
|
+
.option("--payload-url <url>", "HTTPS URL of a staged pickup payload")
|
|
2940
|
+
.action(wrap(async (opts) => {
|
|
2941
|
+
await runPickup(ctx, opts);
|
|
2942
|
+
}));
|
|
2943
|
+
const reconcile = lanes
|
|
2944
|
+
.command("reconcile", { hidden: true })
|
|
2945
|
+
.description("Run one machine-owned reconcile pass and exit")
|
|
2946
|
+
.option("--dry-run", "Report what would change and write nothing")
|
|
2947
|
+
.action(wrap(async (opts) => {
|
|
2948
|
+
await runReconcile(ctx, opts);
|
|
2949
|
+
}));
|
|
2950
|
+
reconcile
|
|
2951
|
+
.command("install")
|
|
2952
|
+
.description("Write systemd user unit and timer files (idempotent)")
|
|
2953
|
+
.option("--systemd-root <dir>", "Directory for unit files (default ~/.config/systemd/user)")
|
|
2954
|
+
.option("--enable", "Enable the timer via systemctl --user")
|
|
2955
|
+
.action(wrap(async (opts) => {
|
|
2956
|
+
await runReconcileInstall(ctx, opts);
|
|
2957
|
+
}));
|
|
2958
|
+
reconcile
|
|
2959
|
+
.command("uninstall")
|
|
2960
|
+
.description("Remove systemd user unit and timer files (idempotent)")
|
|
2961
|
+
.option("--systemd-root <dir>", "Directory for unit files")
|
|
2962
|
+
.option("--disable", "Disable the timer via systemctl --user")
|
|
2963
|
+
.action(wrap(async (opts) => {
|
|
2964
|
+
await runReconcileUninstall(ctx, opts);
|
|
2965
|
+
}));
|
|
2966
|
+
attachJsonNoop(lanes);
|
|
2967
|
+
return lanes;
|
|
2968
|
+
}
|
|
2969
|
+
//# sourceMappingURL=lanes.js.map
|