@indigoai-us/hq-cli 5.345.160 → 5.345.162
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 +36 -0
- package/assets/scaffold/core/scripts/session-journal.sh +116 -24
- package/dist/command-catalog.generated.d.ts +262 -5
- package/dist/command-catalog.generated.js +322 -5
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-inbox.js +6 -2
- package/dist/commands/auth.js +6 -5
- package/dist/commands/core-session-helper-runtime.d.ts +1 -0
- package/dist/commands/core-session-journal-native.d.ts +1 -0
- package/dist/commands/core-session-journal-native.js +158 -34
- package/dist/commands/feedback.d.ts +12 -0
- package/dist/commands/feedback.js +52 -0
- package/dist/commands/files-browse.d.ts +8 -0
- package/dist/commands/files-browse.js +83 -1
- package/dist/commands/install-global.d.ts +2 -0
- package/dist/commands/install-global.js +24 -1
- package/dist/commands/integrations-connect.js +1 -0
- package/dist/commands/integrations-manage.d.ts +2 -0
- package/dist/commands/integrations-manage.js +6 -4
- package/dist/commands/lanes-link.js +5 -3
- package/dist/commands/lanes-loop.js +7 -7
- package/dist/commands/lanes-questions.js +54 -4
- package/dist/commands/lanes-session-start.d.ts +12 -0
- package/dist/commands/lanes-session-start.js +93 -12
- package/dist/commands/lanes.js +2 -1
- package/dist/commands/meetings.d.ts +11 -0
- package/dist/commands/meetings.js +41 -11
- package/dist/commands/sync-held.d.ts +87 -0
- package/dist/commands/sync-held.js +191 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3830 -2812
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +21 -0
- package/dist/lanes-session-start-argv.d.ts +7 -0
- package/dist/lanes-session-start-argv.js +9 -0
- package/dist/lib/agent-kit/inbox-state.d.ts +5 -4
- package/dist/lib/agent-kit/inbox-state.js +24 -3
- package/dist/lib/bot/log.d.ts +3 -2
- package/dist/lib/bot/progress.d.ts +23 -1
- package/dist/lib/bot/progress.js +43 -5
- package/dist/lib/bot/run.js +2 -0
- package/dist/lib/bot/runtime/claude.d.ts +12 -0
- package/dist/lib/bot/runtime/claude.js +20 -0
- package/dist/lib/bot/runtime/index.d.ts +10 -1
- package/dist/lib/bot/runtime/index.js +14 -2
- package/dist/lib/bot/runtime/types.d.ts +8 -0
- package/dist/lib/doctor/checks/ps1-shims.d.ts +21 -0
- package/dist/lib/doctor/checks/ps1-shims.js +82 -0
- package/dist/lib/doctor/fix/apply.d.ts +13 -1
- package/dist/lib/doctor/fix/apply.js +38 -3
- package/dist/lib/doctor/fix/remediation.d.ts +9 -1
- package/dist/lib/doctor/fix/remediation.js +22 -0
- package/dist/lib/doctor/registry.js +2 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/links.d.ts +2 -1
- package/dist/lib/lanes/links.js +235 -17
- package/dist/lib/lanes/loop.d.ts +17 -0
- package/dist/lib/lanes/loop.js +235 -5
- package/dist/lib/lanes/questions.d.ts +1 -0
- package/dist/lib/lanes/registry-flag.d.ts +24 -0
- package/dist/lib/lanes/registry-flag.js +72 -6
- package/dist/lib/lanes/store.d.ts +4 -2
- package/dist/lib/lanes/store.js +8 -8
- package/dist/lib/windows/ps1-shims.d.ts +60 -0
- package/dist/lib/windows/ps1-shims.js +152 -0
- package/dist/utils/self-update.d.ts +2 -0
- package/dist/utils/self-update.js +8 -0
- package/dist/utils/staged-npm-install.js +13 -2
- package/dist/utils/token-keychain.d.ts +11 -8
- package/dist/utils/token-keychain.js +36 -15
- package/package.json +8 -8
- package/scripts/install-hook-engine.mjs +12 -0
package/dist/lib/lanes/loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Durable envelope queue and runtime primitives for conduct worker lanes. */
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
@@ -14,6 +14,174 @@ export function loopAdmissionPath(state, hasPendingResume, initialAdmission) {
|
|
|
14
14
|
return "deferred";
|
|
15
15
|
return "resume";
|
|
16
16
|
}
|
|
17
|
+
export const HANDOFF_HOLD_CHOICES = [
|
|
18
|
+
"Use the repaired handoff without running the phase again",
|
|
19
|
+
"Retry the phase; I understand external effects may repeat",
|
|
20
|
+
"Keep this phase held",
|
|
21
|
+
];
|
|
22
|
+
function handoffHoldPath(runDir) { return path.join(runDir, "loop", "handoff-hold.json"); }
|
|
23
|
+
function readHandoffHold(runDir) {
|
|
24
|
+
const file = handoffHoldPath(runDir);
|
|
25
|
+
if (!fs.existsSync(file))
|
|
26
|
+
return undefined;
|
|
27
|
+
const hold = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readHandoffBytes(file, LOOP_ENVELOPE_LIMIT * 8)));
|
|
28
|
+
if (!isRecord(hold) || hold.schema !== "hq-loop-handoff-hold.v1" || !nonEmpty(hold.envelope_id) || !nonEmpty(hold.question_id)) {
|
|
29
|
+
throw new Error("Invalid durable handoff hold; operator investigation required");
|
|
30
|
+
}
|
|
31
|
+
return hold;
|
|
32
|
+
}
|
|
33
|
+
async function notifyHandoffHold(options, hold, envelope) {
|
|
34
|
+
if (hold.notified || hold.resolution || !options.raiseQuestion)
|
|
35
|
+
return;
|
|
36
|
+
const record = envelope ? undefined : ["stalled", "active"].flatMap((dir) => envelopeFiles(path.join(options.runDir, "loop", dir)))
|
|
37
|
+
.map(fileEnvelope).find((entry) => entry.envelope_id === hold.envelope_id);
|
|
38
|
+
const heldEnvelope = envelope ?? record?.envelope;
|
|
39
|
+
if (!heldEnvelope)
|
|
40
|
+
throw new Error("Held handoff envelope is missing; cannot deliver its repair question");
|
|
41
|
+
await options.raiseQuestion({ envelope: heldEnvelope, envelopeId: hold.envelope_id, attempt: 1, error: hold.error, questionId: hold.question_id, reason: "handoff_needs_repair" });
|
|
42
|
+
const current = readHandoffHold(options.runDir);
|
|
43
|
+
if (current?.question_id === hold.question_id)
|
|
44
|
+
atomicJson(handoffHoldPath(options.runDir), { ...current, notified: true });
|
|
45
|
+
}
|
|
46
|
+
export function loopHandoffHoldIdentity(runDir) {
|
|
47
|
+
const hold = readHandoffHold(runDir);
|
|
48
|
+
return hold ? { envelope_id: hold.envelope_id, question_id: hold.question_id, unresolved: !hold.resolution } : undefined;
|
|
49
|
+
}
|
|
50
|
+
function readHandoffBytes(file, limit = LOOP_ENVELOPE_LIMIT) {
|
|
51
|
+
const before = fs.lstatSync(file);
|
|
52
|
+
if (!before.isFile())
|
|
53
|
+
throw new Error("Repaired handoff must be a bounded regular file");
|
|
54
|
+
if (!Number.isSafeInteger(before.ino) || before.ino <= 0 || !Number.isSafeInteger(before.dev))
|
|
55
|
+
throw new Error("Cannot establish handoff file identity on this filesystem");
|
|
56
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.fstatSync(fd);
|
|
59
|
+
const current = fs.lstatSync(file);
|
|
60
|
+
if (!current.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || current.dev !== stat.dev || current.ino !== stat.ino)
|
|
61
|
+
throw new Error("Handoff file identity changed while opening it");
|
|
62
|
+
if (!stat.isFile() || stat.size > limit)
|
|
63
|
+
throw new Error("Repaired handoff must be a bounded regular file");
|
|
64
|
+
const bytes = Buffer.alloc(limit + 1);
|
|
65
|
+
let count = 0;
|
|
66
|
+
while (count < bytes.length) {
|
|
67
|
+
const read = fs.readSync(fd, bytes, count, bytes.length - count, null);
|
|
68
|
+
if (read === 0)
|
|
69
|
+
break;
|
|
70
|
+
count += read;
|
|
71
|
+
}
|
|
72
|
+
if (count > limit)
|
|
73
|
+
throw new Error("Repaired handoff exceeds the 1 MiB limit");
|
|
74
|
+
return bytes.subarray(0, count);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function validateAcceptedHandoff(output, envelope) {
|
|
81
|
+
if (Buffer.byteLength(output, "utf8") > LOOP_ENVELOPE_LIMIT)
|
|
82
|
+
throw new Error("Repaired handoff exceeds the 1 MiB limit");
|
|
83
|
+
if (envelope.schema !== PHASE_ENVELOPE_SCHEMA)
|
|
84
|
+
throw new Error("Repair hold requires a section 8 phase");
|
|
85
|
+
const phase = envelope;
|
|
86
|
+
const handoff = validateSection8Handoff(JSON.parse(output), phase);
|
|
87
|
+
if (handoff.worker_id !== phase.worker_id || handoff.status !== "passed") {
|
|
88
|
+
throw new Error("Repaired handoff must match the worker and have passed status");
|
|
89
|
+
}
|
|
90
|
+
const criteria = phase.acceptance_criteria;
|
|
91
|
+
if (!Array.isArray(criteria) || !criteria.every((item) => typeof item === "string"))
|
|
92
|
+
throw new Error("Held phase requires string acceptance criteria");
|
|
93
|
+
if (!Array.isArray(handoff.ac_evidence))
|
|
94
|
+
throw new Error("Repaired handoff requires acceptance-criterion evidence");
|
|
95
|
+
for (let index = 0; index < criteria.length; index += 1) {
|
|
96
|
+
const evidence = handoff.ac_evidence.filter((item) => isRecord(item) && item.index === index);
|
|
97
|
+
if (evidence.length !== 1)
|
|
98
|
+
throw new Error(`Repaired handoff requires exactly one evidence entry for criterion ${index}`);
|
|
99
|
+
const item = evidence[0];
|
|
100
|
+
if (item.criterion !== criteria[index] || item.met !== true || !nonEmpty(item.evidence)) {
|
|
101
|
+
throw new Error(`Repaired handoff must copy criterion ${index} verbatim with met=true and string evidence`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const item of handoff.ac_evidence) {
|
|
105
|
+
if (!isRecord(item) || !Number.isInteger(item.index) || Number(item.index) < 0 || Number(item.index) >= criteria.length) {
|
|
106
|
+
throw new Error("Repaired handoff contains an unknown criterion index");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return handoff;
|
|
110
|
+
}
|
|
111
|
+
function applyHandoffHoldAnswer(input) {
|
|
112
|
+
const release = acquireEnqueueLock(input.runDir);
|
|
113
|
+
try {
|
|
114
|
+
const hold = readHandoffHold(input.runDir);
|
|
115
|
+
if (!hold || hold.question_id !== input.questionId)
|
|
116
|
+
throw new Error("Handoff hold identity changed");
|
|
117
|
+
if (!HANDOFF_HOLD_CHOICES.includes(input.choice))
|
|
118
|
+
throw new Error("Choose an explicit handoff repair disposition");
|
|
119
|
+
if (loopStatus(input.runDir, path.basename(input.runDir)).state === "stopped")
|
|
120
|
+
throw new Error("An intentionally stopped loop cannot resume from a handoff answer");
|
|
121
|
+
if (fs.existsSync(path.join(input.runDir, "loop", "runner.lock")))
|
|
122
|
+
throw new Error("Handoff controller has not released its lease yet; retry the same answer after it exits");
|
|
123
|
+
const resultPath = path.join(resultsDir(input.runDir), resultName(hold.envelope_id));
|
|
124
|
+
const result = fs.existsSync(resultPath) ? readJson(resultPath) : undefined;
|
|
125
|
+
if (input.choice === HANDOFF_HOLD_CHOICES[2]) {
|
|
126
|
+
if (hold.resolution)
|
|
127
|
+
throw new Error("A handoff disposition is already being committed");
|
|
128
|
+
if (isRecord(result))
|
|
129
|
+
atomicJson(resultPath, { ...result, decision: { question_id: input.questionId, choice: input.choice, at: input.at, ...(input.notes ? { notes: input.notes } : {}) } });
|
|
130
|
+
return { lane_id: path.basename(input.runDir), envelope_id: hold.envelope_id, action: "parked" };
|
|
131
|
+
}
|
|
132
|
+
if (hold.resolution && hold.resolution.choice !== input.choice)
|
|
133
|
+
throw new Error("A different handoff disposition is already recorded");
|
|
134
|
+
if (!hold.resolution) {
|
|
135
|
+
if (!isRecord(result) || result.hold_reason !== "handoff_needs_repair" || result.question_id !== input.questionId || result.envelope_id !== hold.envelope_id)
|
|
136
|
+
throw new Error("Handoff result identity changed");
|
|
137
|
+
const held = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => fileEnvelope(file).envelope_id === hold.envelope_id);
|
|
138
|
+
if (!held)
|
|
139
|
+
throw new Error("Held handoff envelope is missing");
|
|
140
|
+
const record = fileEnvelope(held);
|
|
141
|
+
if (input.choice === HANDOFF_HOLD_CHOICES[0]) {
|
|
142
|
+
const externalPath = record.envelope.result_path;
|
|
143
|
+
if (typeof externalPath !== "string" || !externalPath.trim())
|
|
144
|
+
throw new Error("Held phase has no handoff path");
|
|
145
|
+
const output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readHandoffBytes(externalPath));
|
|
146
|
+
validateAcceptedHandoff(output, record.envelope);
|
|
147
|
+
delete record.handoff_retry;
|
|
148
|
+
record.accepted_handoff = { output, sha256: createHash("sha256").update(output).digest("hex") };
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
delete record.accepted_handoff;
|
|
152
|
+
record.handoff_retry = true;
|
|
153
|
+
}
|
|
154
|
+
record.stall_attempts = 0;
|
|
155
|
+
hold.resolution = { choice: input.choice, at: input.at, ...(input.notes ? { notes: input.notes } : {}), pending_name: `0000000000000-answer-${path.basename(held)}`, record };
|
|
156
|
+
// Durable authorization precedes every queue/result move. The hold gates
|
|
157
|
+
// raw controllers until all those moves have completed, including retries.
|
|
158
|
+
atomicJson(handoffHoldPath(input.runDir), hold);
|
|
159
|
+
}
|
|
160
|
+
const resolution = hold.resolution;
|
|
161
|
+
const pending = path.join(pendingDir(input.runDir), resolution.pending_name);
|
|
162
|
+
if (path.basename(resolution.pending_name) !== resolution.pending_name || resolution.record.envelope_id !== hold.envelope_id)
|
|
163
|
+
throw new Error("Invalid handoff disposition record");
|
|
164
|
+
const held = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => fileEnvelope(file).envelope_id === hold.envelope_id);
|
|
165
|
+
if (held) {
|
|
166
|
+
atomicJson(held, resolution.record);
|
|
167
|
+
fs.mkdirSync(pendingDir(input.runDir), { recursive: true });
|
|
168
|
+
fs.renameSync(held, pending);
|
|
169
|
+
}
|
|
170
|
+
else if (!fs.existsSync(pending) || JSON.stringify(fileEnvelope(pending)) !== JSON.stringify(resolution.record)) {
|
|
171
|
+
throw new Error("Disposition envelope changed before it could be resumed");
|
|
172
|
+
}
|
|
173
|
+
if (fs.existsSync(resultPath)) {
|
|
174
|
+
atomicJson(resultPath, { ...readJson(resultPath), decision: { question_id: input.questionId, choice: resolution.choice, at: resolution.at, ...(resolution.notes ? { notes: resolution.notes } : {}) } });
|
|
175
|
+
fs.renameSync(resultPath, asidePath(resultPath, "stalled"));
|
|
176
|
+
}
|
|
177
|
+
persistStatus(input.runDir, path.basename(input.runDir), { state: "waiting" });
|
|
178
|
+
fs.unlinkSync(handoffHoldPath(input.runDir));
|
|
179
|
+
return { lane_id: path.basename(input.runDir), envelope_id: hold.envelope_id, action: "requeued" };
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
release();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
17
185
|
function asidePath(file, marker) {
|
|
18
186
|
const parsed = path.parse(file);
|
|
19
187
|
let suffix = 1;
|
|
@@ -520,6 +688,11 @@ export function findLoopStallResult(runDir, questionId) {
|
|
|
520
688
|
return resultPath ? readJson(resultPath) : undefined;
|
|
521
689
|
}
|
|
522
690
|
export function applyLoopStallAnswer(input) {
|
|
691
|
+
const hold = readHandoffHold(input.runDir);
|
|
692
|
+
if (hold?.question_id === input.questionId)
|
|
693
|
+
return applyHandoffHoldAnswer(input);
|
|
694
|
+
if (hold)
|
|
695
|
+
throw new Error("A different handoff hold must be resolved first");
|
|
523
696
|
const result = findLoopStallResult(input.runDir, input.questionId);
|
|
524
697
|
if (!result) {
|
|
525
698
|
for (const name of stalledResultArchives(input.runDir)) {
|
|
@@ -586,12 +759,14 @@ export function loopStatus(runDir, laneId) {
|
|
|
586
759
|
const stallCounts = isRecord(loop.stall_counts)
|
|
587
760
|
? Object.fromEntries(Object.entries(loop.stall_counts).filter((entry) => typeof entry[1] === "number"))
|
|
588
761
|
: {};
|
|
762
|
+
const hold = readHandoffHold(runDir);
|
|
589
763
|
const processState = loop.state === "running" || loop.state === "parked" || loop.state === "stopped" || loop.state === "failed" ? loop.state : "waiting";
|
|
590
764
|
return {
|
|
591
|
-
mode: "loop", lane_id: laneId, state: processState, queue_depth: queueDepth,
|
|
765
|
+
mode: "loop", lane_id: laneId, state: hold && processState !== "stopped" ? "parked" : processState, queue_depth: queueDepth,
|
|
592
766
|
active_envelope_id: active?.envelope_id ?? null,
|
|
593
767
|
active_phase: active ? phaseName(active.envelope, active.envelope_id) : null,
|
|
594
768
|
stall_counts: stallCounts,
|
|
769
|
+
...(hold ? { handoff_hold: { envelope_id: hold.envelope_id, question_id: hold.question_id } } : {}),
|
|
595
770
|
...(typeof loop.pid === "number" ? { pid: loop.pid } : {}),
|
|
596
771
|
updated_at: typeof loop.updated_at === "string" ? loop.updated_at : new Date(0).toISOString(),
|
|
597
772
|
};
|
|
@@ -746,6 +921,13 @@ function abortAfter(ms, parent) {
|
|
|
746
921
|
/** Run queued phases serially. Sessions are isolated by story and provider. */
|
|
747
922
|
export async function runLoopLane(options) {
|
|
748
923
|
const now = options.now ?? (() => new Date());
|
|
924
|
+
const held = readHandoffHold(options.runDir);
|
|
925
|
+
if (held) {
|
|
926
|
+
if (loopStatus(options.runDir, options.lane.lane_id).state === "stopped")
|
|
927
|
+
return { state: "stopped", completed: 0 };
|
|
928
|
+
await notifyHandoffHold(options, held);
|
|
929
|
+
return { state: "parked", completed: 0 };
|
|
930
|
+
}
|
|
749
931
|
const defaultDeadlineSeconds = options.defaultDeadlineSeconds ?? 3600;
|
|
750
932
|
const sessions = new Map();
|
|
751
933
|
try {
|
|
@@ -766,6 +948,10 @@ export async function runLoopLane(options) {
|
|
|
766
948
|
let completed = 0;
|
|
767
949
|
fs.mkdirSync(options.runDir, { recursive: true });
|
|
768
950
|
let releaseRunner = acquireRunnerLease(options.runDir);
|
|
951
|
+
if (readHandoffHold(options.runDir)) {
|
|
952
|
+
releaseRunner();
|
|
953
|
+
return { state: loopStatus(options.runDir, options.lane.lane_id).state === "stopped" ? "stopped" : "parked", completed: 0 };
|
|
954
|
+
}
|
|
769
955
|
persistStatus(options.runDir, options.lane.lane_id, { pid: process.pid });
|
|
770
956
|
try {
|
|
771
957
|
const previous = readJson(loopPath(options.runDir));
|
|
@@ -821,7 +1007,7 @@ export async function runLoopLane(options) {
|
|
|
821
1007
|
const provider = envelopeProvider(envelope, options.lane.provider);
|
|
822
1008
|
const story = envelopeStory(envelope);
|
|
823
1009
|
const sessionKey = `${story}\0${provider}`;
|
|
824
|
-
const fresh = envelope.fresh === true || envelope.fresh_call === true;
|
|
1010
|
+
const fresh = record.handoff_retry === true || envelope.fresh === true || envelope.fresh_call === true;
|
|
825
1011
|
const previousSession = sessions.get(sessionKey);
|
|
826
1012
|
const startedAt = now().toISOString();
|
|
827
1013
|
const startedAtMs = Date.parse(startedAt);
|
|
@@ -835,14 +1021,29 @@ export async function runLoopLane(options) {
|
|
|
835
1021
|
atomicJson(file, record);
|
|
836
1022
|
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id) });
|
|
837
1023
|
const timer = abortAfter(budget * 1000, options.signal);
|
|
1024
|
+
let malformedHandoff;
|
|
838
1025
|
try {
|
|
839
1026
|
let result;
|
|
840
1027
|
let fallback = false;
|
|
841
1028
|
try {
|
|
842
1029
|
const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
|
|
843
|
-
|
|
1030
|
+
if (record.accepted_handoff) {
|
|
1031
|
+
malformedHandoff = "Accepted handoff changed before continuation";
|
|
1032
|
+
const externalPath = envelope.result_path;
|
|
1033
|
+
if (typeof externalPath !== "string" || createHash("sha256").update(readHandoffBytes(externalPath)).digest("hex") !== record.accepted_handoff.sha256)
|
|
1034
|
+
throw new Error(malformedHandoff);
|
|
1035
|
+
if (createHash("sha256").update(record.accepted_handoff.output).digest("hex") !== record.accepted_handoff.sha256)
|
|
1036
|
+
throw new Error("Accepted handoff hash changed");
|
|
1037
|
+
validateAcceptedHandoff(record.accepted_handoff.output, envelope);
|
|
1038
|
+
result = { output: record.accepted_handoff.output };
|
|
1039
|
+
malformedHandoff = undefined;
|
|
1040
|
+
}
|
|
1041
|
+
else
|
|
1042
|
+
result = await options.execute({ lane: options.lane, envelope, envelopeId: record.envelope_id, signal: timer.signal, fresh: fresh || previousSession === undefined || !resumable, ...(fresh || previousSession === undefined || !resumable ? {} : { sessionId: previousSession }) });
|
|
844
1043
|
}
|
|
845
1044
|
catch (error) {
|
|
1045
|
+
if (record.accepted_handoff)
|
|
1046
|
+
throw error;
|
|
846
1047
|
const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
|
|
847
1048
|
if (!fresh && previousSession && resumable && !timer.signal.aborted) {
|
|
848
1049
|
fallback = true;
|
|
@@ -875,6 +1076,7 @@ export async function runLoopLane(options) {
|
|
|
875
1076
|
}
|
|
876
1077
|
}
|
|
877
1078
|
catch (validationError) {
|
|
1079
|
+
malformedHandoff = validationError instanceof Error ? validationError.message : String(validationError);
|
|
878
1080
|
if (options.repairInvalidHandoff !== false && !timer.signal.aborted) {
|
|
879
1081
|
const repairError = validationError instanceof Error ? validationError.message : String(validationError);
|
|
880
1082
|
const repaired = await options.execute({
|
|
@@ -899,6 +1101,7 @@ export async function runLoopLane(options) {
|
|
|
899
1101
|
if (repairedValidated.worker_id !== envelope.worker_id) {
|
|
900
1102
|
throw new Error(`handoff worker_id must match the queued phase envelope: ${repairError}`);
|
|
901
1103
|
}
|
|
1104
|
+
malformedHandoff = undefined;
|
|
902
1105
|
result = repaired;
|
|
903
1106
|
success = {
|
|
904
1107
|
...success,
|
|
@@ -932,6 +1135,33 @@ export async function runLoopLane(options) {
|
|
|
932
1135
|
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
933
1136
|
return { state: "aborted", completed };
|
|
934
1137
|
}
|
|
1138
|
+
if (malformedHandoff !== undefined) {
|
|
1139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1140
|
+
const questionId = randomUUID();
|
|
1141
|
+
const hold = { schema: "hq-loop-handoff-hold.v1", envelope_id: record.envelope_id, question_id: questionId, error: `${malformedHandoff}; repair failed: ${message}` };
|
|
1142
|
+
try {
|
|
1143
|
+
atomicJson(handoffHoldPath(options.runDir), hold);
|
|
1144
|
+
const externalPath = envelope.result_path;
|
|
1145
|
+
if (typeof externalPath === "string" && fs.existsSync(externalPath)) {
|
|
1146
|
+
const stat = fs.lstatSync(externalPath);
|
|
1147
|
+
if (stat.isFile() && stat.size <= LOOP_ENVELOPE_LIMIT) {
|
|
1148
|
+
const backup = path.join(options.runDir, "loop", "handoff-original", `${resultName(record.envelope_id)}.${questionId}`);
|
|
1149
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
1150
|
+
fs.writeFileSync(backup, readHandoffBytes(externalPath), { flag: "wx", mode: 0o600 });
|
|
1151
|
+
hold.original_copy = backup;
|
|
1152
|
+
atomicJson(handoffHoldPath(options.runDir), hold);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", hold_reason: "handoff_needs_repair", question_id: questionId, started_at: startedAt, finished_at: now().toISOString(), error: hold.error }, options.repairInvalidHandoff !== false);
|
|
1156
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
releaseRunner();
|
|
1160
|
+
clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
|
|
1161
|
+
}
|
|
1162
|
+
await notifyHandoffHold(options, hold, envelope);
|
|
1163
|
+
return { state: "parked", completed };
|
|
1164
|
+
}
|
|
935
1165
|
if (!timer.signal.aborted) {
|
|
936
1166
|
const failure = error instanceof Error ? error.message : String(error);
|
|
937
1167
|
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure }, options.repairInvalidHandoff !== false);
|
|
@@ -1035,7 +1265,7 @@ function writeResultAt(runDir, activeFile, record, result, includeHandoffValidat
|
|
|
1035
1265
|
schema: PHASE_HANDOFF_SCHEMA,
|
|
1036
1266
|
story_id: String(envelope.story_id), phase: String(envelope.phase),
|
|
1037
1267
|
worker_id: String(envelope.worker_id), status: stalled ? "blocked" : "failed",
|
|
1038
|
-
summary: stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
|
|
1268
|
+
summary: result.hold_reason === "handoff_needs_repair" ? `Handoff needs repair: ${result.error ?? "invalid handoff"}` : stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
|
|
1039
1269
|
files_changed: [], commits: [],
|
|
1040
1270
|
back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
|
|
1041
1271
|
context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
|
|
2
2
|
import type { LaneCompanyRef } from "./types.js";
|
|
3
|
+
import { type PersistedPersonFlagSnapshot } from "../flag-snapshot-file.js";
|
|
3
4
|
export declare const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
4
5
|
export declare const LANES_FLAG_READ_TIMEOUT_MS = 5000;
|
|
5
6
|
export interface LanesFlagClient {
|
|
@@ -64,5 +65,28 @@ export declare function readLanesRegistryFlagFailOpen(flagKey: string, options?:
|
|
|
64
65
|
*/
|
|
65
66
|
export declare function readLanesRegistryFlagFailOff(flagKey: string, options?: ReadLanesFlagOptions, reader?: typeof readLanesRegistryFlag): Promise<boolean>;
|
|
66
67
|
export declare function _resetThrownFlagReadWarningsForTests(): void;
|
|
68
|
+
/** Offline value for a company-less lanes flag, with how long ago the registry confirmed it. */
|
|
69
|
+
export interface OfflineLanesFlagRead {
|
|
70
|
+
value: boolean;
|
|
71
|
+
ageMs: number;
|
|
72
|
+
source: "local-override" | "lanes-cache" | "person-snapshot";
|
|
73
|
+
}
|
|
74
|
+
export interface ReadLanesFlagOfflineOptions {
|
|
75
|
+
defaultValue?: boolean;
|
|
76
|
+
env?: NodeJS.ProcessEnv;
|
|
77
|
+
/** Last-known-good cache directory; null disables it. Defaults to the HQ state dir. */
|
|
78
|
+
cacheDir?: string | null;
|
|
79
|
+
now?: number;
|
|
80
|
+
/** Person-scope flag snapshot reader. Defaults to the snapshot under the env's cache home. */
|
|
81
|
+
readPersonSnapshot?: () => PersistedPersonFlagSnapshot | null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read a company-less lanes flag without any network request. Order: local
|
|
85
|
+
* override, then the lanes last-known-good cache, then the person flag
|
|
86
|
+
* snapshot other hq commands keep (default registry endpoint only). Returns
|
|
87
|
+
* null when no value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS; the
|
|
88
|
+
* caller decides the fallback and whether to refresh in the background.
|
|
89
|
+
*/
|
|
90
|
+
export declare function readLanesRegistryFlagOffline(flagKey: string, options?: ReadLanesFlagOfflineOptions): OfflineLanesFlagRead | null;
|
|
67
91
|
export declare function _resetLanesRegistryFlagCacheForTests(): void;
|
|
68
92
|
//# sourceMappingURL=registry-flag.d.ts.map
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens, } from "../../utils/cognito-session.js";
|
|
5
4
|
import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
|
|
5
|
+
import { readPersonFlagSnapshotFile } from "../flag-snapshot-file.js";
|
|
6
6
|
export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
|
|
7
7
|
export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
|
|
8
8
|
export function readLanesFlagLocalOverride(flagKey, env = process.env) {
|
|
@@ -55,7 +55,7 @@ function readFlagCacheFile(dir) {
|
|
|
55
55
|
return {};
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
function
|
|
58
|
+
function readCachedFlagEntry(dir, key, now = Date.now()) {
|
|
59
59
|
const entry = readFlagCacheFile(dir)[key];
|
|
60
60
|
if (!entry || typeof entry.value !== "boolean" || typeof entry.resolvedAt !== "string")
|
|
61
61
|
return null;
|
|
@@ -66,9 +66,15 @@ function readCachedFlagValue(dir, key, now = Date.now()) {
|
|
|
66
66
|
// A future timestamp (clock skew or a hand-edited file) is not trusted either.
|
|
67
67
|
if (age < 0 || age > LANES_FLAG_CACHE_MAX_AGE_MS)
|
|
68
68
|
return null;
|
|
69
|
-
return entry.value;
|
|
69
|
+
return { value: entry.value, missing: entry.missing === true, ageMs: age };
|
|
70
70
|
}
|
|
71
|
-
function
|
|
71
|
+
function readCachedFlagValue(dir, key, defaultValue) {
|
|
72
|
+
const entry = readCachedFlagEntry(dir, key);
|
|
73
|
+
if (!entry)
|
|
74
|
+
return null;
|
|
75
|
+
return entry.missing ? defaultValue : entry.value;
|
|
76
|
+
}
|
|
77
|
+
function writeCachedFlagValue(dir, key, value, missing = false) {
|
|
72
78
|
let tmp;
|
|
73
79
|
try {
|
|
74
80
|
const entries = readFlagCacheFile(dir);
|
|
@@ -76,7 +82,7 @@ function writeCachedFlagValue(dir, key, value) {
|
|
|
76
82
|
// measures time since the registry last confirmed it. Delete first so the
|
|
77
83
|
// key moves to the end and eviction drops the least recently confirmed.
|
|
78
84
|
delete entries[key];
|
|
79
|
-
entries[key] = { value, resolvedAt: new Date().toISOString() };
|
|
85
|
+
entries[key] = { value, resolvedAt: new Date().toISOString(), ...(missing ? { missing: true } : {}) };
|
|
80
86
|
const keys = Object.keys(entries);
|
|
81
87
|
for (const stale of keys.slice(0, Math.max(0, keys.length - FLAG_CACHE_MAX_ENTRIES)))
|
|
82
88
|
delete entries[stale];
|
|
@@ -237,7 +243,13 @@ function classifyAttempt(attempt) {
|
|
|
237
243
|
return "transient";
|
|
238
244
|
return "permanent";
|
|
239
245
|
}
|
|
246
|
+
// Loaded on first token use: the module costs ~350 ms to import, and offline
|
|
247
|
+
// readers such as the SessionStart hook never need a token.
|
|
248
|
+
function loadCognitoSession() {
|
|
249
|
+
return import("../../utils/cognito-session.js");
|
|
250
|
+
}
|
|
240
251
|
async function defaultRefreshToken() {
|
|
252
|
+
const { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens } = await loadCognitoSession();
|
|
241
253
|
if (wantsMachineCognitoTokens()) {
|
|
242
254
|
return ensureCognitoIdToken({ interactive: false });
|
|
243
255
|
}
|
|
@@ -250,6 +262,7 @@ async function defaultRefreshToken() {
|
|
|
250
262
|
function defaultAcquireToken() {
|
|
251
263
|
return async () => {
|
|
252
264
|
try {
|
|
265
|
+
const { ensureCognitoIdToken } = await loadCognitoSession();
|
|
253
266
|
return await ensureCognitoIdToken({ interactive: false });
|
|
254
267
|
}
|
|
255
268
|
catch (error) {
|
|
@@ -388,7 +401,7 @@ async function readRegistryValue(flagKey, endpoint, key, options) {
|
|
|
388
401
|
// 4xx, and a missing token mean the registry (or the session) refused the
|
|
389
402
|
// read, so it fails closed to the caller's default instead.
|
|
390
403
|
const transient = classifyAttempt(attempt) === "transient";
|
|
391
|
-
const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key) : null;
|
|
404
|
+
const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key, options.defaultValue ?? false) : null;
|
|
392
405
|
if (cachedValue !== null) {
|
|
393
406
|
return {
|
|
394
407
|
result: { state: "resolved", value: cachedValue },
|
|
@@ -397,8 +410,61 @@ async function readRegistryValue(flagKey, endpoint, key, options) {
|
|
|
397
410
|
}
|
|
398
411
|
return { result: { state: "unknown" }, failure: { ...attempt.failure, diagnostics } };
|
|
399
412
|
}
|
|
413
|
+
// A clean read that fell back means the registry has no row for this flag.
|
|
414
|
+
// Remember that, so offline readers can apply their default without a request.
|
|
415
|
+
if (attempt.resolution?.source === "fallback" && cacheDir)
|
|
416
|
+
writeCachedFlagValue(cacheDir, key, attempt.resolution.value, true);
|
|
400
417
|
return { result: { state: "resolved", value: attempt.resolution?.value ?? false } };
|
|
401
418
|
}
|
|
419
|
+
function defaultPersonSnapshotReader(env, now) {
|
|
420
|
+
return () => {
|
|
421
|
+
// Use only a cache home the env names, so a hermetic env never reads the real home.
|
|
422
|
+
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
|
|
423
|
+
const cacheHome = env.XDG_CACHE_HOME?.trim() || (home ? path.join(home, ".cache") : "");
|
|
424
|
+
if (!cacheHome)
|
|
425
|
+
return null;
|
|
426
|
+
return readPersonFlagSnapshotFile({ env: { ...env, XDG_CACHE_HOME: cacheHome }, now, maxAgeMs: LANES_FLAG_CACHE_MAX_AGE_MS });
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Read a company-less lanes flag without any network request. Order: local
|
|
431
|
+
* override, then the lanes last-known-good cache, then the person flag
|
|
432
|
+
* snapshot other hq commands keep (default registry endpoint only). Returns
|
|
433
|
+
* null when no value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS; the
|
|
434
|
+
* caller decides the fallback and whether to refresh in the background.
|
|
435
|
+
*/
|
|
436
|
+
export function readLanesRegistryFlagOffline(flagKey, options = {}) {
|
|
437
|
+
const env = options.env ?? process.env;
|
|
438
|
+
const defaultValue = options.defaultValue ?? false;
|
|
439
|
+
const now = options.now ?? Date.now();
|
|
440
|
+
const localOverride = readLanesFlagLocalOverride(flagKey, env);
|
|
441
|
+
if (localOverride !== null)
|
|
442
|
+
return { value: localOverride, ageMs: 0, source: "local-override" };
|
|
443
|
+
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
444
|
+
const cacheDir = flagCacheDir({ env, ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}) });
|
|
445
|
+
const cached = cacheDir ? readCachedFlagEntry(cacheDir, cacheKey(flagKey, null, endpoint), now) : null;
|
|
446
|
+
let snapshot = null;
|
|
447
|
+
if (endpoint === FLAG_REGISTRY_DEFAULT_ENDPOINT) {
|
|
448
|
+
try {
|
|
449
|
+
snapshot = (options.readPersonSnapshot ?? defaultPersonSnapshotReader(env, now))();
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
snapshot = null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const snapshotAge = snapshot ? now - Date.parse(snapshot.fetched_at) : Number.NaN;
|
|
456
|
+
const snapshotUsable = snapshot !== null && Number.isFinite(snapshotAge) && snapshotAge >= 0 &&
|
|
457
|
+
snapshotAge <= LANES_FLAG_CACHE_MAX_AGE_MS;
|
|
458
|
+
// Prefer whichever source the registry confirmed most recently.
|
|
459
|
+
if (cached && (!snapshotUsable || cached.ageMs <= snapshotAge)) {
|
|
460
|
+
return { value: cached.missing ? defaultValue : cached.value, ageMs: cached.ageMs, source: "lanes-cache" };
|
|
461
|
+
}
|
|
462
|
+
if (snapshotUsable) {
|
|
463
|
+
const value = snapshot.flags[flagKey];
|
|
464
|
+
return { value: typeof value === "boolean" ? value : defaultValue, ageMs: snapshotAge, source: "person-snapshot" };
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
402
468
|
function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
|
|
403
469
|
if (!outcome.failure)
|
|
404
470
|
return;
|
|
@@ -40,9 +40,11 @@ export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> &
|
|
|
40
40
|
export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
|
|
41
41
|
export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
|
|
42
42
|
/** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
|
|
43
|
-
export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number): T;
|
|
43
|
+
export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number, reclaimByAge?: boolean): T;
|
|
44
44
|
/** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
|
|
45
|
-
export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T
|
|
45
|
+
export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T, options?: {
|
|
46
|
+
reclaimByAge?: boolean;
|
|
47
|
+
}): T;
|
|
46
48
|
export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
|
|
47
49
|
export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
|
|
48
50
|
/**
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -183,7 +183,7 @@ function sameFile(a, b) {
|
|
|
183
183
|
* (another process reclaimed it and took a fresh lock in between), the fresh
|
|
184
184
|
* lock is linked back into place and nothing is reclaimed.
|
|
185
185
|
*/
|
|
186
|
-
function recordLockIsStale(lockPath, deps) {
|
|
186
|
+
function recordLockIsStale(lockPath, deps, reclaimByAge = true) {
|
|
187
187
|
const statSync = deps.statSync ?? ((p) => fs.statSync(p));
|
|
188
188
|
const linkSync = deps.linkSync ?? fs.linkSync.bind(fs);
|
|
189
189
|
const before = statSync(lockPath);
|
|
@@ -199,7 +199,7 @@ function recordLockIsStale(lockPath, deps) {
|
|
|
199
199
|
const mtimeAge = now - before.mtimeMs;
|
|
200
200
|
const age = Number.isFinite(ts) ? Math.max(now - ts, mtimeAge) : mtimeAge;
|
|
201
201
|
const stale = (pidKnown && sameHost && pid !== process.pid && !lockOwnerAlive(pid)) ||
|
|
202
|
-
age > lockStaleMs();
|
|
202
|
+
(reclaimByAge && age > lockStaleMs());
|
|
203
203
|
if (!stale)
|
|
204
204
|
return false;
|
|
205
205
|
const parked = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
@@ -243,7 +243,7 @@ function recordLockIsStale(lockPath, deps) {
|
|
|
243
243
|
removeParked();
|
|
244
244
|
return reclaimed;
|
|
245
245
|
}
|
|
246
|
-
function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
|
|
246
|
+
function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS, reclaimByAge = true) {
|
|
247
247
|
const lockPath = lockPathFor(recordPath);
|
|
248
248
|
const statSync = deps.statSync ?? ((p) => fs.statSync(p));
|
|
249
249
|
const fstatSync = deps.fstatSync ?? ((fd) => fs.fstatSync(fd));
|
|
@@ -295,7 +295,7 @@ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
|
|
|
295
295
|
throw new LanesError("store_lock_timeout", `Timed out waiting for lock ${lockPath}`, { path: lockPath });
|
|
296
296
|
}
|
|
297
297
|
try {
|
|
298
|
-
if (recordLockIsStale(lockPath, deps))
|
|
298
|
+
if (recordLockIsStale(lockPath, deps, reclaimByAge))
|
|
299
299
|
continue;
|
|
300
300
|
}
|
|
301
301
|
catch (statErr) {
|
|
@@ -309,12 +309,12 @@ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
/** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
|
|
312
|
-
export function withRecordLockForTests(recordPath, overrides, fn, timeoutMs) {
|
|
313
|
-
return withRecordLock(recordPath, { ...defaultStoreDeps(), ...overrides }, fn, timeoutMs);
|
|
312
|
+
export function withRecordLockForTests(recordPath, overrides, fn, timeoutMs, reclaimByAge = true) {
|
|
313
|
+
return withRecordLock(recordPath, { ...defaultStoreDeps(), ...overrides }, fn, timeoutMs, reclaimByAge);
|
|
314
314
|
}
|
|
315
315
|
/** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
|
|
316
|
-
export function withLaneRecordFileLock(recordPath, fn) {
|
|
317
|
-
return withRecordLock(recordPath, defaultStoreDeps(), fn);
|
|
316
|
+
export function withLaneRecordFileLock(recordPath, fn, options = {}) {
|
|
317
|
+
return withRecordLock(recordPath, defaultStoreDeps(), fn, LOCK_TIMEOUT_MS, options.reclaimByAge ?? true);
|
|
318
318
|
}
|
|
319
319
|
function readJsonFile(filePath, deps) {
|
|
320
320
|
let text;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** hq-cli's own bins and the package file each npm launcher runs. */
|
|
2
|
+
export declare const HQ_CLI_BINS: Readonly<Record<string, string>>;
|
|
3
|
+
/**
|
|
4
|
+
* True when `content` is npm's (cmd-shim) PowerShell launcher for the hq-cli
|
|
5
|
+
* bin `bin`. The launcher resolves its own folder, runs node on
|
|
6
|
+
* `$basedir/node_modules/@indigoai-us/hq-cli/<entry>` and exits with node's
|
|
7
|
+
* code. Either slash direction is accepted.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isNpmGeneratedHqCliPs1Shim(content: string, bin: string): boolean;
|
|
10
|
+
export interface Ps1ShimFs {
|
|
11
|
+
readFile: (file: string) => string;
|
|
12
|
+
remove: (file: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/** The npm-generated hq-cli `.ps1` launchers present in `binDir`. */
|
|
15
|
+
export declare function findHqCliPs1Shims(binDir: string, io?: Ps1ShimFs): string[];
|
|
16
|
+
export interface RemovePs1ShimsResult {
|
|
17
|
+
/** Launchers removed by this call. */
|
|
18
|
+
removed: string[];
|
|
19
|
+
/** `.ps1` files with an hq-cli bin name that are not npm's launcher. */
|
|
20
|
+
kept: string[];
|
|
21
|
+
/** Launchers that could not be removed, with the reason. */
|
|
22
|
+
failed: Array<{
|
|
23
|
+
file: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Remove the npm-generated `.ps1` launchers for hq-cli's bins from `binDir`
|
|
29
|
+
* (the npm global prefix on Windows). Safe to run again: a missing file is
|
|
30
|
+
* not an error. Never touches another package's `.ps1` or a hand-written one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function removeHqCliPs1Shims(binDir: string, io?: Ps1ShimFs): RemovePs1ShimsResult;
|
|
33
|
+
/**
|
|
34
|
+
* True when the effective execution policy blocks npm's unsigned launcher.
|
|
35
|
+
* `Undefined` as the effective policy means no scope set one, which Windows
|
|
36
|
+
* client editions treat as `Restricted`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function policyBlocksUnsignedScripts(policy: string): boolean;
|
|
39
|
+
export type ExecutionPolicyRun = () => {
|
|
40
|
+
status: number | null;
|
|
41
|
+
stdout: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Read the current user's effective PowerShell execution policy. Read-only:
|
|
45
|
+
* it runs `Get-ExecutionPolicy` and never sets anything. Null when
|
|
46
|
+
* PowerShell could not be run or printed nothing usable.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readExecutionPolicy(run?: ExecutionPolicyRun): string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Postinstall entry: after a global npm install on Windows, remove hq-cli's
|
|
51
|
+
* `.ps1` launchers from the prefix holding `packageDir`. Acts only when the
|
|
52
|
+
* package sits at `<prefix>/node_modules/@indigoai-us/hq-cli`, npm's Windows
|
|
53
|
+
* global layout. Null when it did nothing. npm 11.16+ may skip lifecycle
|
|
54
|
+
* scripts, so `hq doctor` reports any launcher left behind.
|
|
55
|
+
*/
|
|
56
|
+
export declare function removePs1ShimsAfterNpmInstall(packageDir: string, opts: {
|
|
57
|
+
platform: NodeJS.Platform;
|
|
58
|
+
global: boolean;
|
|
59
|
+
}, io?: Ps1ShimFs): RemovePs1ShimsResult | null;
|
|
60
|
+
//# sourceMappingURL=ps1-shims.d.ts.map
|