@indigoai-us/hq-cli 5.345.159 → 5.345.161
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 +48 -0
- package/assets/scaffold/core/scripts/session-journal.sh +116 -24
- package/dist/command-catalog.generated.d.ts +222 -9
- package/dist/command-catalog.generated.js +273 -9
- package/dist/commands/access.d.ts +6 -0
- package/dist/commands/access.js +18 -1
- package/dist/commands/acl-lock.d.ts +41 -0
- package/dist/commands/acl-lock.js +28 -0
- package/dist/commands/agent-enroll.d.ts +2 -0
- package/dist/commands/agent-enroll.js +9 -4
- package/dist/commands/agents.js +5 -11
- package/dist/commands/auth.js +6 -5
- package/dist/commands/billing-sponsor.d.ts +4 -4
- package/dist/commands/billing-sponsor.js +11 -11
- package/dist/commands/billing-usage.d.ts +22 -8
- package/dist/commands/billing-usage.js +57 -22
- package/dist/commands/billing.d.ts +7 -7
- package/dist/commands/billing.js +13 -13
- package/dist/commands/cloud-provision.js +2 -2
- package/dist/commands/cloud.js +7 -7
- 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/db-remote-deps.js +12 -1
- 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/files.js +19 -7
- 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-session-start.d.ts +12 -0
- package/dist/commands/lanes-session-start.js +93 -12
- package/dist/commands/meetings.d.ts +15 -4
- package/dist/commands/meetings.js +58 -19
- package/dist/commands/skill.js +6 -6
- package/dist/commands/whoami.js +1 -1
- package/dist/hook-engine-path.js +13 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3236 -1988
- 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/billing/plan-limit-refusals.d.ts +13 -3
- package/dist/lib/billing/plan-limit-refusals.js +26 -3
- package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
- package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
- package/dist/lib/billing/plan-lock.d.ts +22 -6
- package/dist/lib/billing/plan-lock.js +58 -30
- package/dist/lib/billing/usage-cost.d.ts +24 -2
- package/dist/lib/billing/usage-cost.js +103 -3
- package/dist/lib/billing/usage-only.d.ts +37 -1
- package/dist/lib/billing/usage-only.js +89 -4
- package/dist/lib/billing/usage-pause.d.ts +27 -0
- package/dist/lib/billing/usage-pause.js +50 -0
- 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/db/control-plane.d.ts +5 -0
- package/dist/lib/db/control-plane.js +10 -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/hook-engine/resolve.d.ts +14 -0
- package/dist/lib/hook-engine/resolve.js +55 -1
- package/dist/lib/lanes/links.d.ts +2 -1
- package/dist/lib/lanes/links.js +235 -17
- 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/plan-limit-nag.d.ts +0 -16
- package/dist/lib/plan-limit-nag.js +17 -18
- package/dist/lib/windows/ps1-shims.d.ts +60 -0
- package/dist/lib/windows/ps1-shims.js +152 -0
- package/dist/main.d.ts +0 -2
- package/dist/main.js +19 -57
- package/dist/utils/plan-gate-error.d.ts +30 -14
- package/dist/utils/plan-gate-error.js +60 -46
- 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/team-upgrade.d.ts +19 -6
- package/dist/utils/team-upgrade.js +37 -39
- 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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SpawnSyncReturns } from 'node:child_process';
|
|
1
2
|
export declare const HOOK_ENGINE_REPAIR = "npm install -g @indigoai-us/hq-cli --include=optional";
|
|
2
3
|
export declare const HOOK_ENGINE_REPAIR_PIN = "hq core hook-engine-path --repair";
|
|
3
4
|
export interface EngineOptions {
|
|
@@ -23,6 +24,19 @@ export declare function pinnedHookEngineMatches(engine: EnginePackage): boolean;
|
|
|
23
24
|
export declare function findHookEngine(options?: EngineOptions): EnginePackage | null;
|
|
24
25
|
/** Never truncate or replace a running Windows executable. */
|
|
25
26
|
export declare function installHookEngine(options?: EngineOptions): string | null;
|
|
27
|
+
/** master-hook.sh reads this one-line file with shell builtins to find the
|
|
28
|
+
* pinned engine without starting Node (US-021 bridge). It always names the
|
|
29
|
+
* engine of the CLI that wrote it, so a CLI update retargets it. */
|
|
30
|
+
export declare function hookEnginePointerPath(options?: EngineOptions): string;
|
|
31
|
+
/** Best effort and atomic: a missing pointer only keeps the bridge off. */
|
|
32
|
+
export declare function writeHookEnginePointer(pinned: string, options?: EngineOptions): void;
|
|
33
|
+
export declare function removeHookEnginePointer(options?: EngineOptions): void;
|
|
34
|
+
export interface VerifyOptions {
|
|
35
|
+
spawn?: (command: string, args: readonly string[]) => Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'error'>;
|
|
36
|
+
}
|
|
37
|
+
/** Run the engine's own `--self-check` and read its report. Returns the
|
|
38
|
+
* problem as text, or null when the binary answers as an hq-hook engine. */
|
|
39
|
+
export declare function verifyHookEngine(binary: string, options?: VerifyOptions): string | null;
|
|
26
40
|
/** Call only after settings no longer reference these old versions. Windows
|
|
27
41
|
* refuses unlink of a mapped executable; a busy version stays for a later pass.
|
|
28
42
|
* POSIX permits unlink while running, so retain those versions conservatively.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
6
|
const require = createRequire(import.meta.url);
|
|
@@ -104,8 +105,61 @@ export function installHookEngine(options = {}) {
|
|
|
104
105
|
if (!pinnedHookEngineMatches(engine)) {
|
|
105
106
|
throw new Error(`Hook engine ${engine.version} differs from its pinned copy; close HQ sessions and repair with: ${HOOK_ENGINE_REPAIR_PIN}`);
|
|
106
107
|
}
|
|
108
|
+
writeHookEnginePointer(engine.pinned, options);
|
|
107
109
|
return engine.pinned;
|
|
108
110
|
}
|
|
111
|
+
/** master-hook.sh reads this one-line file with shell builtins to find the
|
|
112
|
+
* pinned engine without starting Node (US-021 bridge). It always names the
|
|
113
|
+
* engine of the CLI that wrote it, so a CLI update retargets it. */
|
|
114
|
+
export function hookEnginePointerPath(options = {}) {
|
|
115
|
+
return join(options.home ?? homedir(), '.hq', 'bin', 'hq-hook', 'current');
|
|
116
|
+
}
|
|
117
|
+
/** Best effort and atomic: a missing pointer only keeps the bridge off. */
|
|
118
|
+
export function writeHookEnginePointer(pinned, options = {}) {
|
|
119
|
+
const pointer = hookEnginePointerPath(options);
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(dirname(pointer), { recursive: true });
|
|
122
|
+
const stage = `${pointer}.${process.pid}.tmp`;
|
|
123
|
+
writeFileSync(stage, `${pinned}\n`);
|
|
124
|
+
renameSync(stage, pointer);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// The hook path falls back to bash when the pointer is absent.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function removeHookEnginePointer(options = {}) {
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(hookEnginePointerPath(options));
|
|
133
|
+
}
|
|
134
|
+
catch { /* already absent */ }
|
|
135
|
+
}
|
|
136
|
+
/** Run the engine's own `--self-check` and read its report. Returns the
|
|
137
|
+
* problem as text, or null when the binary answers as an hq-hook engine. */
|
|
138
|
+
export function verifyHookEngine(binary, options = {}) {
|
|
139
|
+
const spawn = options.spawn ?? ((command, args) => spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }));
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = spawn(binary, ['--self-check']);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return `hook engine self-check could not start: ${error instanceof Error ? error.message : String(error)}`;
|
|
146
|
+
}
|
|
147
|
+
if (result.error)
|
|
148
|
+
return `hook engine self-check could not start: ${result.error.message}`;
|
|
149
|
+
let report;
|
|
150
|
+
try {
|
|
151
|
+
report = JSON.parse(String(result.stdout ?? '').trim().split('\n').pop() ?? '');
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return `hook engine self-check printed no report (exit ${result.status ?? 'signal'})`;
|
|
155
|
+
}
|
|
156
|
+
if (report.engine !== 'hq-hook' || typeof report.version !== 'string' || typeof report.bridge_schema !== 'number') {
|
|
157
|
+
return 'hook engine self-check report is not from hq-hook';
|
|
158
|
+
}
|
|
159
|
+
if (result.status !== 0 || report.ok !== true)
|
|
160
|
+
return `hook engine self-check failed: ${typeof report.error === 'string' ? report.error : `exit ${result.status ?? 'signal'}`}`;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
109
163
|
/** Call only after settings no longer reference these old versions. Windows
|
|
110
164
|
* refuses unlink of a mapped executable; a busy version stays for a later pass.
|
|
111
165
|
* POSIX permits unlink while running, so retain those versions conservatively.
|
|
@@ -20,6 +20,8 @@ export interface LinkOptions {
|
|
|
20
20
|
timeout?: number;
|
|
21
21
|
event?: string;
|
|
22
22
|
input?: string;
|
|
23
|
+
ackMode?: string;
|
|
24
|
+
claims?: string;
|
|
23
25
|
now?: () => Date;
|
|
24
26
|
sleep?: (milliseconds: number) => Promise<void>;
|
|
25
27
|
}
|
|
@@ -31,7 +33,6 @@ export declare function resolveLinkSessionId(root: string, explicit: string | un
|
|
|
31
33
|
/** Cheap hook guard used before remote flag lookup or queue access. */
|
|
32
34
|
export declare function linkedSessionIdFromHookInput(input: string): string;
|
|
33
35
|
export declare function isLinkedSession(root: string, sid: string): boolean;
|
|
34
|
-
/** Claims pending files by renaming the whole queue, matching conduct-inbox's no-loss drain. */
|
|
35
36
|
export declare function drainLinkQueue(inbox: string): string;
|
|
36
37
|
export declare function runLaneLink(subcommand: string, options: LinkOptions): Promise<LinkResult>;
|
|
37
38
|
/** Apply the default-on feature gate contract at the command boundary. */
|
package/dist/lib/lanes/links.js
CHANGED
|
@@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { appendDropboxMessageAtPath } from "./dropbox.js";
|
|
5
|
+
import { withLaneRecordFileLock } from "./store.js";
|
|
6
|
+
import { LanesError } from "./errors.js";
|
|
5
7
|
export const LANE_SESSION_LINKS_FLAG = "lanes.session-links";
|
|
6
8
|
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
7
9
|
export class LaneLinkError extends Error {
|
|
@@ -132,6 +134,17 @@ function resolvePointer(root, sid, role, options) {
|
|
|
132
134
|
function queueDir(root, link, child) {
|
|
133
135
|
return path.join(rootDir(root), link, child ? "children" : "up", ...(child ? [child] : []), "inbox");
|
|
134
136
|
}
|
|
137
|
+
function withLinkQueueLock(inbox, fn) {
|
|
138
|
+
try {
|
|
139
|
+
return withLaneRecordFileLock(path.join(inbox, "queue"), fn, { reclaimByAge: false });
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof LanesError && error.error === "store_lock_timeout") {
|
|
143
|
+
throw new LaneLinkError("queue_busy", "Conductor queue busy; instructions retained");
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
135
148
|
function sendQueue(root, link, child, text) {
|
|
136
149
|
const inbox = queueDir(root, link, child);
|
|
137
150
|
const pending = path.join(inbox, "pending");
|
|
@@ -139,10 +152,12 @@ function sendQueue(root, link, child, text) {
|
|
|
139
152
|
// Reuse hq monitor's session-inbox append primitive; the pending directory is
|
|
140
153
|
// still claimed as a whole below, keeping conduct-inbox's no-loss handoff.
|
|
141
154
|
const cleanText = text.replaceAll("\0", "").replaceAll("\u000b", "").replaceAll("\u000c", "").replaceAll("\r", "");
|
|
142
|
-
|
|
155
|
+
withLinkQueueLock(inbox, () => {
|
|
156
|
+
appendDropboxMessageAtPath(pending, { at: new Date().toISOString(), text: cleanText });
|
|
157
|
+
});
|
|
143
158
|
}
|
|
144
159
|
/** Claims pending files by renaming the whole queue, matching conduct-inbox's no-loss drain. */
|
|
145
|
-
|
|
160
|
+
function drainLinkQueueUnlocked(inbox) {
|
|
146
161
|
const pending = path.join(inbox, "pending");
|
|
147
162
|
const claimedRoot = path.join(inbox, "claimed");
|
|
148
163
|
// Recover claims interrupted before the delivery result was committed. This is
|
|
@@ -190,16 +205,197 @@ export function drainLinkQueue(inbox) {
|
|
|
190
205
|
fs.writeFileSync(path.join(claim, ".consumed"), "delivered\n", { mode: 0o600 });
|
|
191
206
|
return messages;
|
|
192
207
|
}
|
|
208
|
+
export function drainLinkQueue(inbox) {
|
|
209
|
+
return withLinkQueueLock(inbox, () => drainLinkQueueUnlocked(inbox));
|
|
210
|
+
}
|
|
211
|
+
/** Claims remain replayable until the recipient explicitly accepts instructions.
|
|
212
|
+
* This is at-least-once transport, not proof of a native turn or execution.
|
|
213
|
+
*/
|
|
214
|
+
function claimLinkQueueUnlocked(inbox) {
|
|
215
|
+
const pending = path.join(inbox, "pending");
|
|
216
|
+
const claimed = path.join(inbox, "unaccepted");
|
|
217
|
+
fs.mkdirSync(claimed, { recursive: true });
|
|
218
|
+
if (fs.existsSync(path.join(pending, "inbox.jsonl"))) {
|
|
219
|
+
// Allocate while holding the queue lock. Wall time alone can repeat or
|
|
220
|
+
// move backward, and pid/random suffixes do not preserve claim order.
|
|
221
|
+
let serial = 0n;
|
|
222
|
+
for (const previous of fs.readdirSync(claimed)) {
|
|
223
|
+
const prefix = /^(\d{1,16})-/.exec(previous)?.[1];
|
|
224
|
+
if (prefix && BigInt(prefix) > serial)
|
|
225
|
+
serial = BigInt(prefix);
|
|
226
|
+
}
|
|
227
|
+
const wall = Date.now();
|
|
228
|
+
if (!Number.isSafeInteger(wall) || wall < 0)
|
|
229
|
+
throw new LaneLinkError("claim_clock_invalid", "Cannot allocate a safe receipt serial");
|
|
230
|
+
serial = BigInt(wall) > serial ? BigInt(wall) : serial + 1n;
|
|
231
|
+
if (serial > 9999999999999999n)
|
|
232
|
+
throw new LaneLinkError("claim_serial_exhausted", "Receipt serial exhausted; instructions retained");
|
|
233
|
+
const id = `${serial.toString().padStart(16, "0")}-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
234
|
+
const dir = path.join(claimed, id);
|
|
235
|
+
fs.mkdirSync(dir);
|
|
236
|
+
// The shared queue lock joins the producer's open/write/close before this
|
|
237
|
+
// immutable snapshot is read. Keep the producer directory stable too.
|
|
238
|
+
try {
|
|
239
|
+
fs.renameSync(path.join(pending, "inbox.jsonl"), path.join(dir, "inbox.jsonl"));
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error.code !== "ENOENT")
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const claims = [];
|
|
247
|
+
const messages = [];
|
|
248
|
+
const ordered = fs.readdirSync(claimed).sort((a, b) => {
|
|
249
|
+
const left = /^(\d{1,16})-/.exec(a)?.[1];
|
|
250
|
+
const right = /^(\d{1,16})-/.exec(b)?.[1];
|
|
251
|
+
if (left && right && BigInt(left) !== BigInt(right))
|
|
252
|
+
return BigInt(left) < BigInt(right) ? -1 : 1;
|
|
253
|
+
return a.localeCompare(b);
|
|
254
|
+
});
|
|
255
|
+
for (const id of ordered) {
|
|
256
|
+
if (!safeId(id))
|
|
257
|
+
continue;
|
|
258
|
+
const dir = path.join(claimed, id);
|
|
259
|
+
if (fs.existsSync(path.join(dir, ".consumed")))
|
|
260
|
+
continue;
|
|
261
|
+
let text;
|
|
262
|
+
try {
|
|
263
|
+
text = fs.readFileSync(path.join(dir, "inbox.jsonl"), "utf8");
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error.code === "ENOENT")
|
|
267
|
+
continue;
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
const rows = [];
|
|
271
|
+
const invalidRows = [];
|
|
272
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
273
|
+
if (!line)
|
|
274
|
+
return;
|
|
275
|
+
try {
|
|
276
|
+
const row = JSON.parse(line);
|
|
277
|
+
if (!row || typeof row !== "object" || typeof row.text !== "string")
|
|
278
|
+
throw new Error("invalid row");
|
|
279
|
+
rows.push(row.text);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
invalidRows.push(index + 1);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
if (invalidRows.length) {
|
|
286
|
+
// The immutable original is the quarantine evidence. Record positions,
|
|
287
|
+
// never malformed contents, and retain both even after valid rows' ACK.
|
|
288
|
+
const audit = path.join(dir, "invalid-rows.json");
|
|
289
|
+
const expectedAudit = JSON.stringify({ version: 1, rows: invalidRows }) + "\n";
|
|
290
|
+
let previousAudit;
|
|
291
|
+
try {
|
|
292
|
+
previousAudit = fs.readFileSync(audit, "utf8");
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error.code !== "ENOENT")
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
if (previousAudit !== expectedAudit) {
|
|
299
|
+
const temporary = `${audit}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
300
|
+
try {
|
|
301
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
302
|
+
try {
|
|
303
|
+
fs.writeFileSync(fd, expectedAudit);
|
|
304
|
+
fs.fsyncSync(fd);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
fs.closeSync(fd);
|
|
308
|
+
}
|
|
309
|
+
fs.renameSync(temporary, audit);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
fs.rmSync(temporary, { force: true });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Retry all durability barriers even if a previous attempt wrote the
|
|
316
|
+
// audit but failed before publishing its directory entry durably.
|
|
317
|
+
for (const file of [path.join(dir, "inbox.jsonl"), audit]) {
|
|
318
|
+
const fd = fs.openSync(file, "r");
|
|
319
|
+
try {
|
|
320
|
+
fs.fsyncSync(fd);
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
fs.closeSync(fd);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const directory of [dir, claimed]) {
|
|
327
|
+
try {
|
|
328
|
+
const fd = fs.openSync(directory, "r");
|
|
329
|
+
try {
|
|
330
|
+
fs.fsyncSync(fd);
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
fs.closeSync(fd);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
// Same known unsupported directory-flush codes as lanes store.
|
|
338
|
+
// File flush errors and unknown directory errors remain fatal.
|
|
339
|
+
if (!["EISDIR", "EINVAL", "ENOTSUP"].includes(error.code ?? ""))
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!rows.length)
|
|
345
|
+
continue;
|
|
346
|
+
claims.push(id);
|
|
347
|
+
messages.push(...rows);
|
|
348
|
+
}
|
|
349
|
+
return { messages: messages.join("\n"), claims };
|
|
350
|
+
}
|
|
351
|
+
function claimLinkQueue(inbox) {
|
|
352
|
+
return withLinkQueueLock(inbox, () => claimLinkQueueUnlocked(inbox));
|
|
353
|
+
}
|
|
354
|
+
function acknowledgeLinkClaims(inbox, claims) {
|
|
355
|
+
return withLinkQueueLock(inbox, () => acknowledgeLinkClaimsUnlocked(inbox, claims));
|
|
356
|
+
}
|
|
357
|
+
function acknowledgeLinkClaimsUnlocked(inbox, claims) {
|
|
358
|
+
if (!Array.isArray(claims) || !claims.length || claims.some(id => typeof id !== "string" || !safeId(id))) {
|
|
359
|
+
throw new LaneLinkError("claim_invalid", "An exact nonempty claim receipt is required");
|
|
360
|
+
}
|
|
361
|
+
// Validate the entire receipt before changing any claim. Never resolve paths
|
|
362
|
+
// outside the current session's authoritative queue.
|
|
363
|
+
for (const id of claims) {
|
|
364
|
+
if (!fs.existsSync(path.join(inbox, "unaccepted", id, "inbox.jsonl"))) {
|
|
365
|
+
throw new LaneLinkError("claim_not_found", "Claim does not belong to this queue");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const id of claims) {
|
|
369
|
+
fs.writeFileSync(path.join(inbox, "unaccepted", id, ".consumed"), "recipient-accepted\n", { mode: 0o600 });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function countQuarantined(inbox) {
|
|
373
|
+
const claims = path.join(inbox, "unaccepted");
|
|
374
|
+
if (!fs.existsSync(claims))
|
|
375
|
+
return 0;
|
|
376
|
+
return fs.readdirSync(claims).filter(id => safeId(id) && fs.existsSync(path.join(claims, id, "invalid-rows.json"))).length;
|
|
377
|
+
}
|
|
193
378
|
function countPending(inbox) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
379
|
+
const paths = [path.join(inbox, "pending", "inbox.jsonl")];
|
|
380
|
+
for (const name of ["claimed", "unaccepted"]) {
|
|
381
|
+
const claimed = path.join(inbox, name);
|
|
382
|
+
if (fs.existsSync(claimed))
|
|
383
|
+
for (const id of fs.readdirSync(claimed)) {
|
|
384
|
+
if (!fs.existsSync(path.join(claimed, id, ".consumed")))
|
|
385
|
+
paths.push(path.join(claimed, id, "inbox.jsonl"));
|
|
386
|
+
}
|
|
197
387
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
388
|
+
let count = 0;
|
|
389
|
+
for (const file of paths) {
|
|
390
|
+
try {
|
|
391
|
+
count += fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).length;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (error.code !== "ENOENT")
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
202
397
|
}
|
|
398
|
+
return count;
|
|
203
399
|
}
|
|
204
400
|
function readText(options) {
|
|
205
401
|
if (options.textFile) {
|
|
@@ -244,7 +440,7 @@ function listChildren(root, dir) {
|
|
|
244
440
|
if (!fs.existsSync(metaPath))
|
|
245
441
|
return [];
|
|
246
442
|
const unread = countPending(path.join(cdir, "inbox"));
|
|
247
|
-
return [{ ...readJson(metaPath), undelivered: unread }];
|
|
443
|
+
return [{ ...readJson(metaPath), undelivered: unread, quarantined_claims: countQuarantined(path.join(cdir, "inbox")) }];
|
|
248
444
|
});
|
|
249
445
|
}
|
|
250
446
|
async function waitForReport(dir, timeoutSeconds, sleep) {
|
|
@@ -288,6 +484,7 @@ export async function runLaneLink(subcommand, options) {
|
|
|
288
484
|
const root = options.hqRoot;
|
|
289
485
|
let sid = "";
|
|
290
486
|
let childContext = false;
|
|
487
|
+
let stopHookActive = false;
|
|
291
488
|
if (subcommand === "_deliver") {
|
|
292
489
|
let payload = {};
|
|
293
490
|
try {
|
|
@@ -299,6 +496,7 @@ export async function runLaneLink(subcommand, options) {
|
|
|
299
496
|
const value = payload.session_id ?? payload.sessionId;
|
|
300
497
|
sid = typeof value === "string" && safeId(value) ? value : "";
|
|
301
498
|
childContext = isChildHookPayload(payload);
|
|
499
|
+
stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
|
|
302
500
|
}
|
|
303
501
|
const requireSid = () => sid || (sid = resolveLinkSessionId(root, options.sessionId, env));
|
|
304
502
|
const links = rootDir(root);
|
|
@@ -307,21 +505,41 @@ export async function runLaneLink(subcommand, options) {
|
|
|
307
505
|
// drain the root's queue into the subagent, so leave the message queued.
|
|
308
506
|
if (!sid || childContext || !["PostToolUse", "Stop"].includes(options.event ?? ""))
|
|
309
507
|
return {};
|
|
310
|
-
if (options.event === "Stop" && env.HQ_STOP_DECISION_DELIVERABLE === "0")
|
|
508
|
+
if (options.event === "Stop" && (stopHookActive || env.HQ_STOP_DECISION_DELIVERABLE === "0"))
|
|
311
509
|
return {};
|
|
312
510
|
const pointer = readPointer(root, sid);
|
|
313
511
|
if (!pointer)
|
|
314
512
|
return {};
|
|
315
513
|
const target = hookMessage(root, pointer);
|
|
316
|
-
|
|
514
|
+
if (options.ackMode !== undefined && options.ackMode !== "explicit")
|
|
515
|
+
throw new LaneLinkError("ack_mode_invalid", "Unsupported acknowledgement mode");
|
|
516
|
+
const claim = options.ackMode === "explicit" ? claimLinkQueue(target.inbox) : undefined;
|
|
517
|
+
const messages = claim ? claim.messages : drainLinkQueue(target.inbox);
|
|
317
518
|
if (!messages)
|
|
318
519
|
return {};
|
|
319
|
-
const
|
|
520
|
+
const receipt = claim ? { _hqLinkDelivery: { version: 1, sessionId: sid, link: pointer.link, child: pointer.name ?? null, claims: claim.claims } } : {};
|
|
521
|
+
const acceptance = claim ? `\n\n[conduct receipt] Stable instruction receipt IDs: ${claim.claims.join(",")}. Treat replayed IDs as the same instruction, not new execution authority. After accepting these instructions, acknowledge receipt once with: hq lanes link ack --session-id ${sid} --link ${pointer.link}${pointer.name ? ` --child ${pointer.name}` : ""} --claims ${claim.claims.join(",")}. Receipt acceptance does not approve actions, answer owner pickers, or mean work is completed.` : "";
|
|
522
|
+
const body = (target.role === "conductor"
|
|
320
523
|
? `[conduct] Sessions you are conducting reported while you were working.\nEach report names the session it came from. Relay what the operator needs to know, and answer a session with: hq lanes link send --child <name> --text '...'\n\n${messages}`
|
|
321
|
-
: `[conduct] The session conducting you sent this while you were working.\nTreat it as a new instruction from the operator, taking precedence over your current task where the two conflict. When you have something to tell the conductor - a result, a blocker, a question - send it with: hq lanes link report --state <working|blocked|idle|done> --text '...'\n\n${messages}
|
|
524
|
+
: `[conduct] The session conducting you sent this while you were working.\nTreat it as a new instruction from the operator, taking precedence over your current task where the two conflict. When you have something to tell the conductor - a result, a blocker, a question - send it with: hq lanes link report --state <working|blocked|idle|done> --text '...'\n\n${messages}`) + acceptance;
|
|
322
525
|
return options.event === "PostToolUse"
|
|
323
|
-
? { output: { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: body } } }
|
|
324
|
-
: { output: { decision: "block", reason: body } };
|
|
526
|
+
? { output: { ...receipt, hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: body } } }
|
|
527
|
+
: { output: { ...receipt, decision: "block", reason: body } };
|
|
528
|
+
}
|
|
529
|
+
if (subcommand === "capabilities") {
|
|
530
|
+
return { output: { protocol: "conduct-recipient-acceptance/1", explicitClaim: true, recipientAck: true, idleWake: false } };
|
|
531
|
+
}
|
|
532
|
+
if (subcommand === "ack") {
|
|
533
|
+
const currentSid = requireSid();
|
|
534
|
+
const pointer = readPointer(root, currentSid);
|
|
535
|
+
if (!pointer)
|
|
536
|
+
throw new LaneLinkError("session_unlinked", "Session is not linked");
|
|
537
|
+
if (!options.link || options.link !== pointer.link || (options.child ?? null) !== (pointer.name ?? null)) {
|
|
538
|
+
throw new LaneLinkError("claim_owner_invalid", "Receipt does not match the current link/child binding");
|
|
539
|
+
}
|
|
540
|
+
const claims = options.claims?.split(",");
|
|
541
|
+
acknowledgeLinkClaims(hookMessage(root, pointer).inbox, claims);
|
|
542
|
+
return { output: { acknowledged: true, acceptance: "recipient_instruction_only" } };
|
|
325
543
|
}
|
|
326
544
|
if (subcommand === "open") {
|
|
327
545
|
const sessionId = requireSid();
|
|
@@ -438,7 +656,7 @@ export async function runLaneLink(subcommand, options) {
|
|
|
438
656
|
const dir = linkPath(root, link);
|
|
439
657
|
const meta = readJson(path.join(dir, "meta.json"));
|
|
440
658
|
const unread = countPending(path.join(dir, "up", "inbox"));
|
|
441
|
-
return { output: { ...meta, unread_reports: unread, children: listChildren(root, dir) } };
|
|
659
|
+
return { output: { ...meta, unread_reports: unread, quarantined_reports: countQuarantined(path.join(dir, "up", "inbox")), children: listChildren(root, dir) } };
|
|
442
660
|
}
|
|
443
661
|
if (subcommand === "leave") {
|
|
444
662
|
const sessionId = requireSid();
|
|
@@ -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
|
/**
|