@indigoai-us/hq-cli 5.126.0 → 5.128.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 +218 -0
- 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 +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/core-utils/sentry-report.d.ts +2 -0
- package/dist/lib/core-utils/sentry-report.js +47 -7
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- 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 +2 -2
- 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 +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- 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 +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- 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/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 +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push an Outpost-targeted lane through `hq outposts exec` (spec §5.2).
|
|
3
|
+
*
|
|
4
|
+
* The transport is the existing OutpostsClient submit/wait pair — the same
|
|
5
|
+
* path as `hq outposts exec --async`. Create stages a payload, then runs
|
|
6
|
+
* `hq lanes pickup --payload-url` on the box. A submit or wait failure is
|
|
7
|
+
* `outpost_dispatch_failed`, never a queued lane nobody will pick up.
|
|
8
|
+
*/
|
|
9
|
+
import { OutpostsClient } from "@indigoai-us/hq-cloud/outposts";
|
|
10
|
+
import type { LaneRecord } from "./types.js";
|
|
11
|
+
export declare const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
|
|
12
|
+
export declare const PICKUP_WAIT_MS: number;
|
|
13
|
+
export interface OutpostPickupPayload {
|
|
14
|
+
lane: LaneRecord;
|
|
15
|
+
brief: string;
|
|
16
|
+
timeout?: {
|
|
17
|
+
timeout_secs: number;
|
|
18
|
+
deadline_at: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface OutpostDispatchResult {
|
|
22
|
+
commandId: string;
|
|
23
|
+
status: string;
|
|
24
|
+
done?: boolean;
|
|
25
|
+
exitCode: number | null;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
}
|
|
29
|
+
export interface OutpostExecPort {
|
|
30
|
+
stageExecInput: (outpostId?: string) => Promise<{
|
|
31
|
+
putUrl: string;
|
|
32
|
+
getUrl: string;
|
|
33
|
+
}>;
|
|
34
|
+
submitExec: (command: string, outpostId?: string, timeoutSeconds?: number) => Promise<{
|
|
35
|
+
commandId: string;
|
|
36
|
+
}>;
|
|
37
|
+
waitForExecResult: (commandId: string, outpostId?: string, options?: {
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}) => Promise<{
|
|
40
|
+
status: string;
|
|
41
|
+
done: boolean;
|
|
42
|
+
exitCode?: number | null;
|
|
43
|
+
stdout?: string;
|
|
44
|
+
stderr?: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPayload;
|
|
48
|
+
/** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
|
|
49
|
+
export declare function buildOutpostPickupCommand(payloadUrl: string): string;
|
|
50
|
+
export declare function stageOutpostPayload(port: Pick<OutpostExecPort, "stageExecInput">, outpostId: string, bytes: Uint8Array, put?: typeof fetch): Promise<{
|
|
51
|
+
getUrl: string;
|
|
52
|
+
}>;
|
|
53
|
+
export declare function dispatchOutpostPickup(port: OutpostExecPort, input: {
|
|
54
|
+
outpostId: string;
|
|
55
|
+
command: string;
|
|
56
|
+
timeoutSeconds?: number;
|
|
57
|
+
waitMs?: number;
|
|
58
|
+
}): Promise<OutpostDispatchResult>;
|
|
59
|
+
export declare function dispatchErrorMessage(err: unknown): string;
|
|
60
|
+
/** Factory matching `hq outposts exec`'s client wiring. */
|
|
61
|
+
export declare function createOutpostsClient(opts: {
|
|
62
|
+
transport: ConstructorParameters<typeof OutpostsClient>[0]["transport"];
|
|
63
|
+
token: ConstructorParameters<typeof OutpostsClient>[0]["token"];
|
|
64
|
+
}): OutpostsClient;
|
|
65
|
+
//# sourceMappingURL=outpost-dispatch.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push an Outpost-targeted lane through `hq outposts exec` (spec §5.2).
|
|
3
|
+
*
|
|
4
|
+
* The transport is the existing OutpostsClient submit/wait pair — the same
|
|
5
|
+
* path as `hq outposts exec --async`. Create stages a payload, then runs
|
|
6
|
+
* `hq lanes pickup --payload-url` on the box. A submit or wait failure is
|
|
7
|
+
* `outpost_dispatch_failed`, never a queued lane nobody will pick up.
|
|
8
|
+
*/
|
|
9
|
+
import { OutpostsClient, joinCommandParts, withRemoteHqDir } from "@indigoai-us/hq-cloud/outposts";
|
|
10
|
+
import { LanesError } from "./errors.js";
|
|
11
|
+
export const PICKUP_EXEC_TIMEOUT_SECONDS = 120;
|
|
12
|
+
export const PICKUP_WAIT_MS = PICKUP_EXEC_TIMEOUT_SECONDS * 1000;
|
|
13
|
+
export function parseOutpostPickupPayload(raw) {
|
|
14
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
15
|
+
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload must be a JSON object", {});
|
|
16
|
+
}
|
|
17
|
+
const body = raw;
|
|
18
|
+
if (body.lane === null || typeof body.lane !== "object" || Array.isArray(body.lane)) {
|
|
19
|
+
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.lane must be an object", {});
|
|
20
|
+
}
|
|
21
|
+
if (typeof body.brief !== "string") {
|
|
22
|
+
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.brief must be a string", {});
|
|
23
|
+
}
|
|
24
|
+
let timeout;
|
|
25
|
+
if (body.timeout !== undefined && body.timeout !== null) {
|
|
26
|
+
if (typeof body.timeout !== "object" || Array.isArray(body.timeout)) {
|
|
27
|
+
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.timeout must be an object", {});
|
|
28
|
+
}
|
|
29
|
+
const t = body.timeout;
|
|
30
|
+
if (typeof t.timeout_secs !== "number" || typeof t.deadline_at !== "string") {
|
|
31
|
+
throw new LanesError("outpost_payload_invalid", "Outpost pickup payload.timeout needs timeout_secs and deadline_at", {});
|
|
32
|
+
}
|
|
33
|
+
timeout = { timeout_secs: t.timeout_secs, deadline_at: t.deadline_at };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
lane: body.lane,
|
|
37
|
+
brief: body.brief,
|
|
38
|
+
timeout,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Same wrapping `hq outposts exec` applies: run from the box's HQ folder. */
|
|
42
|
+
export function buildOutpostPickupCommand(payloadUrl) {
|
|
43
|
+
return withRemoteHqDir(joinCommandParts(["hq", "lanes", "pickup", "--payload-url", payloadUrl]));
|
|
44
|
+
}
|
|
45
|
+
export async function stageOutpostPayload(port, outpostId, bytes, put = fetch) {
|
|
46
|
+
const staged = await port.stageExecInput(outpostId);
|
|
47
|
+
const upload = await put(staged.putUrl, {
|
|
48
|
+
method: "PUT",
|
|
49
|
+
headers: { "Content-Length": String(bytes.byteLength) },
|
|
50
|
+
body: bytes,
|
|
51
|
+
});
|
|
52
|
+
if (!upload.ok) {
|
|
53
|
+
throw new LanesError("outpost_dispatch_failed", `Could not upload lane payload: HTTP ${upload.status}`, { status: upload.status, outpost_id: outpostId });
|
|
54
|
+
}
|
|
55
|
+
return { getUrl: staged.getUrl };
|
|
56
|
+
}
|
|
57
|
+
export async function dispatchOutpostPickup(port, input) {
|
|
58
|
+
const submitted = await port.submitExec(input.command, input.outpostId, input.timeoutSeconds ?? PICKUP_EXEC_TIMEOUT_SECONDS);
|
|
59
|
+
const ac = new AbortController();
|
|
60
|
+
const waitMs = input.waitMs ?? PICKUP_WAIT_MS;
|
|
61
|
+
const timer = setTimeout(() => ac.abort(), waitMs);
|
|
62
|
+
try {
|
|
63
|
+
const result = await port.waitForExecResult(submitted.commandId, input.outpostId, { signal: ac.signal });
|
|
64
|
+
return {
|
|
65
|
+
commandId: submitted.commandId,
|
|
66
|
+
status: result.status,
|
|
67
|
+
done: result.done,
|
|
68
|
+
exitCode: result.exitCode ?? null,
|
|
69
|
+
stdout: result.stdout ?? "",
|
|
70
|
+
stderr: result.stderr ?? "",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (ac.signal.aborted) {
|
|
75
|
+
throw new LanesError("outpost_dispatch_failed", `Timed out waiting for Outpost pickup (${waitMs}ms)`, { outpost_id: input.outpostId, command_id: submitted.commandId });
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function dispatchErrorMessage(err) {
|
|
84
|
+
return err instanceof Error ? err.message : String(err);
|
|
85
|
+
}
|
|
86
|
+
/** Factory matching `hq outposts exec`'s client wiring. */
|
|
87
|
+
export function createOutpostsClient(opts) {
|
|
88
|
+
return new OutpostsClient(opts);
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=outpost-dispatch.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
|
|
3
|
+
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
4
|
+
* drift on hop, origin, raised_via, body store, and routing_history.
|
|
5
|
+
*/
|
|
6
|
+
import { type LaneEnvelope, type LaneRecord, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
|
|
7
|
+
export interface QuestionBody {
|
|
8
|
+
question: string;
|
|
9
|
+
context: string;
|
|
10
|
+
options: string[];
|
|
11
|
+
recommended?: string;
|
|
12
|
+
notes?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseQuestionBodyStore(value: unknown): QuestionBodyStore;
|
|
15
|
+
export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company">): QuestionBodyStore;
|
|
16
|
+
export declare function questionBodyRef(questionId: string): string;
|
|
17
|
+
export declare function sha256Text(text: string): string;
|
|
18
|
+
/** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
|
|
19
|
+
export declare function questionBodyAbsPath(hqRoot: string, question: Pick<QuestionRecord, "company" | "body" | "question_id">): string;
|
|
20
|
+
export declare function writeQuestionBody(hqRoot: string, question: Pick<QuestionRecord, "company" | "body" | "question_id">, body: QuestionBody): {
|
|
21
|
+
store: QuestionBodyStore;
|
|
22
|
+
ref: string;
|
|
23
|
+
digest: string;
|
|
24
|
+
};
|
|
25
|
+
export interface RaiseLaneQuestionInput {
|
|
26
|
+
hqRoot: string;
|
|
27
|
+
lane: LaneRecord;
|
|
28
|
+
body: QuestionBody;
|
|
29
|
+
raisedVia: RaisedVia;
|
|
30
|
+
at: string;
|
|
31
|
+
questionId?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Persist one question the way `hq lanes ask` does: createQuestion, write the
|
|
35
|
+
* tenant body, then CAS routing_history with reason `asked`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function raiseLaneQuestion(input: RaiseLaneQuestionInput): QuestionRecord;
|
|
38
|
+
/**
|
|
39
|
+
* Options that, trimmed, case-folded and stripped of trailing punctuation,
|
|
40
|
+
* are exactly {continue, stop} in either order. The brief authorises the work
|
|
41
|
+
* it lists, so this question asks the operator nothing the brief did not
|
|
42
|
+
* already settle; it is recorded as `question_kind: "continue"`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function classifyQuestionKind(options: readonly string[]): QuestionKind | undefined;
|
|
45
|
+
/** The store a lane's question bodies live in, derived from the lane and never the reverse. */
|
|
46
|
+
export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company">, questionId: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Free-text notes the operator attached with `questions answer --notes`.
|
|
49
|
+
* Read from the lane's own store (spec §6.5): the company store for a company
|
|
50
|
+
* lane, the personal store otherwise. Returns undefined when the answer has
|
|
51
|
+
* no notes_ref or the body carries no notes; a body the host cannot read is
|
|
52
|
+
* reported and skipped rather than failing the resume.
|
|
53
|
+
*/
|
|
54
|
+
export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company">, question: Pick<QuestionRecord, "question_id" | "answer">): string | undefined;
|
|
55
|
+
export interface AnsweredQuestionEntry {
|
|
56
|
+
question_id: string;
|
|
57
|
+
choice: string | undefined;
|
|
58
|
+
answered_at: string | undefined;
|
|
59
|
+
answered_by: string | undefined;
|
|
60
|
+
notes?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
|
|
64
|
+
* choices and timestamps come from the routing record; the operator's notes
|
|
65
|
+
* come from the lane's own body store and are written only into the run
|
|
66
|
+
* directory on the executing host.
|
|
67
|
+
*/
|
|
68
|
+
export declare function answeredQuestionEntries(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company">, questions: QuestionRecord[]): AnsweredQuestionEntry[];
|
|
69
|
+
export declare function envelopeQuestionId(laneId: string, envelopeRef: string, index: number): string;
|
|
70
|
+
/**
|
|
71
|
+
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
72
|
+
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
73
|
+
*/
|
|
74
|
+
export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string): QuestionRecord[];
|
|
75
|
+
//# sourceMappingURL=questions.d.ts.map
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
|
|
3
|
+
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
4
|
+
* drift on hop, origin, raised_via, body store, and routing_history.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { LanesError } from "./errors.js";
|
|
10
|
+
import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
|
|
11
|
+
import { QUESTION_BODY_STORES, } from "./types.js";
|
|
12
|
+
export function parseQuestionBodyStore(value) {
|
|
13
|
+
if (typeof value === "string" &&
|
|
14
|
+
QUESTION_BODY_STORES.includes(value)) {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
|
|
18
|
+
}
|
|
19
|
+
export function bodyStoreForLane(lane) {
|
|
20
|
+
return lane.company ? "company" : "personal";
|
|
21
|
+
}
|
|
22
|
+
export function questionBodyRef(questionId) {
|
|
23
|
+
return path.posix.join("lanes", "questions", `${questionId}.json`);
|
|
24
|
+
}
|
|
25
|
+
export function sha256Text(text) {
|
|
26
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
27
|
+
}
|
|
28
|
+
/** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
|
|
29
|
+
export function questionBodyAbsPath(hqRoot, question) {
|
|
30
|
+
const store = parseQuestionBodyStore(question.body.store);
|
|
31
|
+
if (store === "company") {
|
|
32
|
+
if (!question.company?.slug) {
|
|
33
|
+
throw new LanesError("question_body_store_unresolved", "Company question body is missing a company slug", { question_id: question.question_id });
|
|
34
|
+
}
|
|
35
|
+
return path.join(hqRoot, "companies", question.company.slug, "lanes", "questions", `${question.question_id}.json`);
|
|
36
|
+
}
|
|
37
|
+
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
38
|
+
}
|
|
39
|
+
export function writeQuestionBody(hqRoot, question, body) {
|
|
40
|
+
const store = parseQuestionBodyStore(question.body.store);
|
|
41
|
+
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
42
|
+
const serialized = `${JSON.stringify(body, null, 2)}\n`;
|
|
43
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
44
|
+
fs.writeFileSync(filePath, serialized, "utf8");
|
|
45
|
+
return {
|
|
46
|
+
store,
|
|
47
|
+
ref: questionBodyRef(question.question_id),
|
|
48
|
+
digest: sha256Text(serialized),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Persist one question the way `hq lanes ask` does: createQuestion, write the
|
|
53
|
+
* tenant body, then CAS routing_history with reason `asked`.
|
|
54
|
+
*/
|
|
55
|
+
export function raiseLaneQuestion(input) {
|
|
56
|
+
const store = bodyStoreForLane(input.lane);
|
|
57
|
+
let questionId = input.questionId;
|
|
58
|
+
if (!questionId) {
|
|
59
|
+
const created = createQuestion(input.hqRoot, {
|
|
60
|
+
lane_id: input.lane.lane_id,
|
|
61
|
+
company: input.lane.company,
|
|
62
|
+
project_id: input.lane.project_id,
|
|
63
|
+
story_id: input.lane.story_id,
|
|
64
|
+
thread_id: input.lane.thread_id,
|
|
65
|
+
raised_via: input.raisedVia,
|
|
66
|
+
asked_at: input.at,
|
|
67
|
+
origin: { kind: "lane", id: input.lane.lane_id },
|
|
68
|
+
hop: 1,
|
|
69
|
+
status: "pending",
|
|
70
|
+
body: { store, ref: "", digest: "" },
|
|
71
|
+
routing_history: [],
|
|
72
|
+
});
|
|
73
|
+
questionId = created.question_id;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
try {
|
|
77
|
+
createQuestion(input.hqRoot, {
|
|
78
|
+
question_id: questionId,
|
|
79
|
+
lane_id: input.lane.lane_id,
|
|
80
|
+
company: input.lane.company,
|
|
81
|
+
project_id: input.lane.project_id,
|
|
82
|
+
story_id: input.lane.story_id,
|
|
83
|
+
thread_id: input.lane.thread_id,
|
|
84
|
+
raised_via: input.raisedVia,
|
|
85
|
+
asked_at: input.at,
|
|
86
|
+
origin: { kind: "lane", id: input.lane.lane_id },
|
|
87
|
+
hop: 1,
|
|
88
|
+
status: "pending",
|
|
89
|
+
body: { store, ref: "", digest: "" },
|
|
90
|
+
routing_history: [],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (!(err instanceof LanesError) || err.error !== "question_exists") {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const stored = writeQuestionBody(input.hqRoot, {
|
|
100
|
+
company: input.lane.company,
|
|
101
|
+
question_id: questionId,
|
|
102
|
+
body: { store, ref: "", digest: "" },
|
|
103
|
+
}, input.body);
|
|
104
|
+
const questionKind = classifyQuestionKind(input.body.options);
|
|
105
|
+
return retryUpdateQuestion(input.hqRoot, questionId, (current) => {
|
|
106
|
+
const asked = current.routing_history.some((entry) => entry.reason === "asked");
|
|
107
|
+
return {
|
|
108
|
+
...current,
|
|
109
|
+
...(questionKind !== undefined ? { question_kind: questionKind } : {}),
|
|
110
|
+
body: stored,
|
|
111
|
+
raised_via: input.raisedVia,
|
|
112
|
+
routing_history: asked
|
|
113
|
+
? current.routing_history
|
|
114
|
+
: appendCappedHistory(current.routing_history, {
|
|
115
|
+
hop: 1,
|
|
116
|
+
audience: input.lane.senior,
|
|
117
|
+
at: input.at,
|
|
118
|
+
reason: "asked",
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Options that, trimmed, case-folded and stripped of trailing punctuation,
|
|
125
|
+
* are exactly {continue, stop} in either order. The brief authorises the work
|
|
126
|
+
* it lists, so this question asks the operator nothing the brief did not
|
|
127
|
+
* already settle; it is recorded as `question_kind: "continue"`.
|
|
128
|
+
*/
|
|
129
|
+
export function classifyQuestionKind(options) {
|
|
130
|
+
if (options.length !== 2)
|
|
131
|
+
return undefined;
|
|
132
|
+
const folded = options.map((option) => option
|
|
133
|
+
.trim()
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
.replace(/[.!?…]+$/u, "")
|
|
136
|
+
.trim());
|
|
137
|
+
const isContinueStop = folded.includes("continue") && folded.includes("stop") && folded[0] !== folded[1];
|
|
138
|
+
return isContinueStop ? "continue" : undefined;
|
|
139
|
+
}
|
|
140
|
+
/** The store a lane's question bodies live in, derived from the lane and never the reverse. */
|
|
141
|
+
export function questionBodyPathForLane(hqRoot, lane, questionId) {
|
|
142
|
+
return questionBodyAbsPath(hqRoot, {
|
|
143
|
+
company: lane.company,
|
|
144
|
+
question_id: questionId,
|
|
145
|
+
body: { store: bodyStoreForLane(lane), ref: "", digest: "" },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Free-text notes the operator attached with `questions answer --notes`.
|
|
150
|
+
* Read from the lane's own store (spec §6.5): the company store for a company
|
|
151
|
+
* lane, the personal store otherwise. Returns undefined when the answer has
|
|
152
|
+
* no notes_ref or the body carries no notes; a body the host cannot read is
|
|
153
|
+
* reported and skipped rather than failing the resume.
|
|
154
|
+
*/
|
|
155
|
+
export function readAnswerNotes(hqRoot, lane, question) {
|
|
156
|
+
if (!question.answer?.notes_ref)
|
|
157
|
+
return undefined;
|
|
158
|
+
const filePath = questionBodyPathForLane(hqRoot, lane, question.question_id);
|
|
159
|
+
let text;
|
|
160
|
+
try {
|
|
161
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const code = err.code;
|
|
165
|
+
process.stderr.write(`hq lanes: answer notes for ${question.question_id} unreadable at ${filePath} (${code ?? (err instanceof Error ? err.message : String(err))})\n`);
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(text);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
process.stderr.write(`hq lanes: answer notes for ${question.question_id} at ${filePath} is not JSON: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
177
|
+
return undefined;
|
|
178
|
+
const notes = parsed.notes;
|
|
179
|
+
return typeof notes === "string" && notes.length > 0 ? notes : undefined;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The `answered-questions.json` a resumed worker is handed (spec §6.5). Ids,
|
|
183
|
+
* choices and timestamps come from the routing record; the operator's notes
|
|
184
|
+
* come from the lane's own body store and are written only into the run
|
|
185
|
+
* directory on the executing host.
|
|
186
|
+
*/
|
|
187
|
+
export function answeredQuestionEntries(hqRoot, lane, questions) {
|
|
188
|
+
return questions
|
|
189
|
+
.filter((q) => q.origin.id === lane.lane_id && q.status === "answered" && q.answer)
|
|
190
|
+
.map((q) => {
|
|
191
|
+
const entry = {
|
|
192
|
+
question_id: q.question_id,
|
|
193
|
+
choice: q.answer?.choice,
|
|
194
|
+
answered_at: q.answer?.answered_at,
|
|
195
|
+
answered_by: q.answer?.answered_by,
|
|
196
|
+
};
|
|
197
|
+
const notes = readAnswerNotes(hqRoot, lane, q);
|
|
198
|
+
if (notes !== undefined)
|
|
199
|
+
entry.notes = notes;
|
|
200
|
+
return entry;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
export function envelopeQuestionId(laneId, envelopeRef, index) {
|
|
204
|
+
const stem = path.basename(envelopeRef, ".json").replace(/[^A-Za-z0-9_-]/g, "");
|
|
205
|
+
const digest = createHash("sha256")
|
|
206
|
+
.update(`${laneId}\0${envelopeRef}\0${index}`)
|
|
207
|
+
.digest("hex")
|
|
208
|
+
.slice(0, 10);
|
|
209
|
+
return `qenv_${stem}_${index}_${digest}`;
|
|
210
|
+
}
|
|
211
|
+
function envelopeQuestionBody(item) {
|
|
212
|
+
const body = {
|
|
213
|
+
question: item.question,
|
|
214
|
+
context: item.context,
|
|
215
|
+
options: item.options,
|
|
216
|
+
};
|
|
217
|
+
if (item.recommended !== undefined)
|
|
218
|
+
body.recommended = item.recommended;
|
|
219
|
+
return body;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
223
|
+
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
224
|
+
*/
|
|
225
|
+
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at) {
|
|
226
|
+
const items = envelope.questions ?? [];
|
|
227
|
+
const raised = [];
|
|
228
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
229
|
+
const item = items[i];
|
|
230
|
+
if (!item)
|
|
231
|
+
continue;
|
|
232
|
+
raised.push(raiseLaneQuestion({
|
|
233
|
+
hqRoot,
|
|
234
|
+
lane,
|
|
235
|
+
body: envelopeQuestionBody(item),
|
|
236
|
+
raisedVia: "envelope",
|
|
237
|
+
at,
|
|
238
|
+
questionId: envelopeQuestionId(lane.lane_id, envelopeRef, i),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
return raised;
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=questions.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-written provider quota, keyed by account and model.
|
|
3
|
+
*
|
|
4
|
+
* `enabled` and `disabled_reason` live in personal/lanes/config.json and are
|
|
5
|
+
* the operator's hand-written switch. `quota_reset_at` and `needs_credits`
|
|
6
|
+
* are written here, in personal/lanes/quota.json, from the provider's own
|
|
7
|
+
* 429. They are separate fields because a quota hold expires on its own:
|
|
8
|
+
* copying it into `disabled_reason` or flipping `enabled` would erase the
|
|
9
|
+
* operator's reason, and would leave the account switched off after the
|
|
10
|
+
* provider reset. The balancer reads this file and does not rewrite the
|
|
11
|
+
* operator fields.
|
|
12
|
+
*
|
|
13
|
+
* A weekly-limit 429 names a reset and applies to every model on that
|
|
14
|
+
* account (measured: account b, every model, until the reset). A credits
|
|
15
|
+
* 429 names one model and no reset (measured: account d refuses
|
|
16
|
+
* claude-fable-5-1 and still runs claude-opus-5). The record is therefore
|
|
17
|
+
* per (account, model), plus an account-wide reset when the text is a
|
|
18
|
+
* usage limit rather than a switch-model refusal.
|
|
19
|
+
*/
|
|
20
|
+
import type { LaneProvider } from "./types.js";
|
|
21
|
+
export declare const QUOTA_STATE_REL: string;
|
|
22
|
+
export declare const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
|
|
23
|
+
export interface ModelQuota {
|
|
24
|
+
quota_reset_at?: string;
|
|
25
|
+
needs_credits?: boolean;
|
|
26
|
+
/** Operator-facing marker, e.g. "needs credits for claude-fable-5-1". */
|
|
27
|
+
marker?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface AccountQuota {
|
|
30
|
+
/** Set when the 429 is an account-wide usage limit with a reset time. */
|
|
31
|
+
all_models_quota_reset_at?: string;
|
|
32
|
+
models: Record<string, ModelQuota>;
|
|
33
|
+
}
|
|
34
|
+
export interface QuotaState {
|
|
35
|
+
schema_version: typeof QUOTA_SCHEMA_VERSION;
|
|
36
|
+
providers: Partial<Record<LaneProvider, Record<string, AccountQuota>>>;
|
|
37
|
+
}
|
|
38
|
+
export interface QuotaSignal {
|
|
39
|
+
reason: "account_quota_exhausted" | "account_model_unavailable";
|
|
40
|
+
quota_reset_at?: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
needs_credits?: boolean;
|
|
43
|
+
marker?: string;
|
|
44
|
+
/** True when the text is a usage limit with a reset, not a per-model credits refusal. */
|
|
45
|
+
account_wide?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare function quotaStatePath(hqRoot: string): string;
|
|
48
|
+
export declare function loadQuotaState(hqRoot: string): QuotaState;
|
|
49
|
+
/**
|
|
50
|
+
* Parse "resets Sep 24, 2am (UTC)" into an ISO timestamp. The year is the
|
|
51
|
+
* current UTC year, rolled forward when that instant is already past.
|
|
52
|
+
* Returns null when the text names no reset.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseProviderResetTime(text: string, now: Date): string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Read only `api_error_status`, `is_error`, and `result` off the last
|
|
57
|
+
* `type: "result"` object. Nothing else from the payload is returned, and
|
|
58
|
+
* this function does not log.
|
|
59
|
+
*/
|
|
60
|
+
export declare function quotaFieldsFromOutput(text: string): {
|
|
61
|
+
api_error_status: unknown;
|
|
62
|
+
is_error: unknown;
|
|
63
|
+
result: unknown;
|
|
64
|
+
} | null;
|
|
65
|
+
export declare function quotaSignalFromFields(fields: {
|
|
66
|
+
api_error_status: unknown;
|
|
67
|
+
is_error: unknown;
|
|
68
|
+
result: unknown;
|
|
69
|
+
}, model: string | undefined, now: Date): QuotaSignal | null;
|
|
70
|
+
export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
|
|
71
|
+
export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
|
|
72
|
+
export interface QuotaBlock {
|
|
73
|
+
blocked: boolean;
|
|
74
|
+
reason?: "account_quota_exhausted" | "account_model_unavailable";
|
|
75
|
+
quota_reset_at?: string;
|
|
76
|
+
marker?: string;
|
|
77
|
+
model?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Skip an account only for the models it cannot run. An account-wide reset
|
|
81
|
+
* blocks every model. A needs-credits marker blocks only that model, with
|
|
82
|
+
* no expiry. A past reset does not block.
|
|
83
|
+
*/
|
|
84
|
+
export declare function quotaBlockFor(state: QuotaState, provider: LaneProvider, accountId: string, model: string | undefined, now: Date): QuotaBlock;
|
|
85
|
+
export interface AccountQuotaView {
|
|
86
|
+
quota_reset_at: string | null;
|
|
87
|
+
model_quota: Array<{
|
|
88
|
+
model: string;
|
|
89
|
+
quota_reset_at?: string;
|
|
90
|
+
needs_credits?: boolean;
|
|
91
|
+
marker?: string;
|
|
92
|
+
}>;
|
|
93
|
+
}
|
|
94
|
+
export declare function accountQuotaView(state: QuotaState, provider: LaneProvider, accountId: string): AccountQuotaView;
|
|
95
|
+
//# sourceMappingURL=quota.d.ts.map
|