@indigoai-us/hq-cli 5.282.0 → 5.284.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 +32 -0
- package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
- package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
- package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
- package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
- package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
- package/dist/command-catalog.generated.d.ts +67 -0
- package/dist/command-catalog.generated.js +83 -0
- package/dist/commands/core-knowledge-prefs.d.ts +10 -0
- package/dist/commands/core-knowledge-prefs.js +125 -0
- package/dist/commands/core-native-policy-flag.d.ts +9 -0
- package/dist/commands/core-native-policy-flag.js +12 -0
- package/dist/commands/core-ontology-native.d.ts +0 -1
- package/dist/commands/core-ontology-native.js +2 -30
- package/dist/commands/core-ontology.d.ts +4 -0
- package/dist/commands/core-ontology.js +8 -3
- package/dist/commands/core-policy-frontmatter.d.ts +8 -0
- package/dist/commands/core-policy-frontmatter.js +52 -0
- package/dist/commands/core-policy.d.ts +15 -0
- package/dist/commands/core-policy.js +982 -0
- package/dist/commands/core.js +14 -1
- package/dist/commands/lanes-shared.d.ts +4 -1
- package/dist/commands/lanes-shared.js +2 -1
- package/dist/commands/lanes.d.ts +8 -0
- package/dist/commands/lanes.js +303 -91
- package/dist/fast-lanes-list-route.d.ts +8 -0
- package/dist/fast-lanes-list-route.js +30 -0
- package/dist/fast-lanes-list.d.ts +3 -0
- package/dist/fast-lanes-list.js +252 -0
- package/dist/fast-scaffold.d.ts +5 -0
- package/dist/fast-scaffold.js +10 -0
- package/dist/index.js +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
- package/dist/lib/lanes/flag-keys.d.ts +3 -0
- package/dist/lib/lanes/flag-keys.js +3 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
- package/dist/lib/lanes/outpost-dispatch.js +14 -2
- package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
- package/dist/lib/lanes/outpost-hire-cache.js +11 -3
- package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
- package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
- package/dist/lib/lanes/store.d.ts +4 -0
- package/dist/lib/lanes/store.js +39 -0
- package/dist/lib/mesh/live/daemon/credentials.js +8 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +143 -5
- package/dist/lib/policy-validator.d.ts +2 -0
- package/dist/lib/policy-validator.js +136 -0
- package/dist/native-fast-route.d.ts +2 -0
- package/dist/native-fast-route.js +6 -2
- package/dist/utils/contribution-table.d.ts +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
export const OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS = 60_000;
|
|
5
|
+
export const LANE_OUTPOST_SNAPSHOT_CACHE_FLAG = "lanes.outpost-snapshot-cache";
|
|
5
6
|
const CACHE_RELATIVE_PATH = path.join("workspace", "lanes-runs", ".cache", "outpost-hire-summaries.json");
|
|
6
7
|
export function outpostHireSummaryCachePath(hqRoot) {
|
|
7
8
|
return path.join(hqRoot, CACHE_RELATIVE_PATH);
|
|
@@ -10,6 +11,13 @@ export function outpostHireSummaryCacheKey(outpostId, laneId) {
|
|
|
10
11
|
return JSON.stringify([outpostId, laneId]);
|
|
11
12
|
}
|
|
12
13
|
export function readOutpostHireSummaryCache(hqRoot, nowMs) {
|
|
14
|
+
return readCache(hqRoot, nowMs, false);
|
|
15
|
+
}
|
|
16
|
+
/** Reads the last validated summaries even after their freshness window expires. */
|
|
17
|
+
export function readOutpostHireSummaryCacheIncludingStale(hqRoot, nowMs) {
|
|
18
|
+
return readCache(hqRoot, nowMs, true);
|
|
19
|
+
}
|
|
20
|
+
function readCache(hqRoot, nowMs, includeStale) {
|
|
13
21
|
const filePath = outpostHireSummaryCachePath(hqRoot);
|
|
14
22
|
let contents;
|
|
15
23
|
try {
|
|
@@ -36,7 +44,7 @@ export function readOutpostHireSummaryCache(hqRoot, nowMs) {
|
|
|
36
44
|
const entries = new Map();
|
|
37
45
|
const duplicates = new Set();
|
|
38
46
|
for (const value of parsed) {
|
|
39
|
-
const entry = parseCacheEntry(value, nowMs);
|
|
47
|
+
const entry = parseCacheEntry(value, nowMs, includeStale);
|
|
40
48
|
if (!entry)
|
|
41
49
|
continue;
|
|
42
50
|
const key = outpostHireSummaryCacheKey(entry.outpost_id, entry.lane_id);
|
|
@@ -94,7 +102,7 @@ export function writeOutpostHireSummaryCache(hqRoot, updates, nowMs, existingEnt
|
|
|
94
102
|
console.warn(`Could not write Outpost hire summary cache at ${filePath}: ${errorText(err)}`);
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
|
-
function parseCacheEntry(value, nowMs) {
|
|
105
|
+
function parseCacheEntry(value, nowMs, includeStale = false) {
|
|
98
106
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
99
107
|
return undefined;
|
|
100
108
|
const entry = value;
|
|
@@ -120,7 +128,7 @@ function parseCacheEntry(value, nowMs) {
|
|
|
120
128
|
const cachedAt = Date.parse(entry.cached_at);
|
|
121
129
|
if (!Number.isFinite(cachedAt) ||
|
|
122
130
|
cachedAt > nowMs ||
|
|
123
|
-
nowMs - cachedAt > OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS) {
|
|
131
|
+
(!includeStale && nowMs - cachedAt > OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS)) {
|
|
124
132
|
return undefined;
|
|
125
133
|
}
|
|
126
134
|
const hire = `${entry.hire_alive_children}/${entry.hire_grant}`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3000;
|
|
2
|
+
export declare const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45000;
|
|
3
|
+
export interface OutpostSnapshotCachePolicy {
|
|
4
|
+
ttlMs?: number;
|
|
5
|
+
staleLockMs?: number;
|
|
6
|
+
lockWaitMs?: number;
|
|
7
|
+
pollMs?: number;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
acquireLock?: (lockPath: string, token: string, nowMs: number) => void;
|
|
11
|
+
}
|
|
12
|
+
/** Deduplicates identical remote snapshot queries within one command invocation. */
|
|
13
|
+
export declare class OutpostSnapshotInvocationMemo {
|
|
14
|
+
private readonly requests;
|
|
15
|
+
getOrCreate<T>(outpostId: string, query: unknown, fetch: () => Promise<T>): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
export declare function outpostLaneListSnapshotKey(outpostId: string, normalizedQuery: unknown): string;
|
|
18
|
+
export declare function outpostSnapshotCachePath(hqRoot: string, key: string): string;
|
|
19
|
+
export declare function getOrFetchOutpostSnapshot<T>(input: {
|
|
20
|
+
hqRoot: string;
|
|
21
|
+
key: string;
|
|
22
|
+
isValue: (value: unknown) => value is T;
|
|
23
|
+
fetch: () => Promise<T>;
|
|
24
|
+
policy?: OutpostSnapshotCachePolicy;
|
|
25
|
+
}): Promise<T>;
|
|
26
|
+
//# sourceMappingURL=outpost-snapshot-cache.d.ts.map
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
export const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3_000;
|
|
6
|
+
export const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45_000;
|
|
7
|
+
const CACHE_RELATIVE_PATH = path.join("workspace", "lanes-runs", ".cache", "outpost-snapshots");
|
|
8
|
+
const CACHE_SCHEMA_VERSION = "hq-lanes-outpost-snapshot-cache.v1";
|
|
9
|
+
const LOCK_POLL_MS = 20;
|
|
10
|
+
const LOCK_WAIT_MS = 60_000;
|
|
11
|
+
/** Deduplicates identical remote snapshot queries within one command invocation. */
|
|
12
|
+
export class OutpostSnapshotInvocationMemo {
|
|
13
|
+
requests = new Map();
|
|
14
|
+
getOrCreate(outpostId, query, fetch) {
|
|
15
|
+
const key = JSON.stringify([outpostId, query]);
|
|
16
|
+
const existing = this.requests.get(key);
|
|
17
|
+
if (existing)
|
|
18
|
+
return existing;
|
|
19
|
+
const request = fetch();
|
|
20
|
+
this.requests.set(key, request);
|
|
21
|
+
return request;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function outpostLaneListSnapshotKey(outpostId, normalizedQuery) {
|
|
25
|
+
return JSON.stringify(["lane-list", outpostId, normalizedQuery]);
|
|
26
|
+
}
|
|
27
|
+
export function outpostSnapshotCachePath(hqRoot, key) {
|
|
28
|
+
const digest = createKeyDigest(key);
|
|
29
|
+
return path.join(hqRoot, CACHE_RELATIVE_PATH, `${digest}.json`);
|
|
30
|
+
}
|
|
31
|
+
export async function getOrFetchOutpostSnapshot(input) {
|
|
32
|
+
const policy = input.policy ?? {};
|
|
33
|
+
const now = policy.now ?? Date.now;
|
|
34
|
+
const ttlMs = policy.ttlMs ?? OUTPOST_SNAPSHOT_CACHE_TTL_MS;
|
|
35
|
+
const staleLockMs = policy.staleLockMs ?? OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS;
|
|
36
|
+
const lockWaitMs = policy.lockWaitMs ?? LOCK_WAIT_MS;
|
|
37
|
+
const pollMs = policy.pollMs ?? LOCK_POLL_MS;
|
|
38
|
+
const sleep = policy.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
39
|
+
const filePath = outpostSnapshotCachePath(input.hqRoot, input.key);
|
|
40
|
+
const cached = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
41
|
+
if (cached !== undefined)
|
|
42
|
+
return cached;
|
|
43
|
+
const lockPath = `${filePath}.lock`;
|
|
44
|
+
const token = `${process.pid}:${randomBytes(12).toString("hex")}`;
|
|
45
|
+
const startedAt = now();
|
|
46
|
+
const acquireLock = policy.acquireLock ?? createCacheLock;
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
acquireLock(lockPath, token, now());
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const code = err.code;
|
|
54
|
+
if (code !== "EEXIST") {
|
|
55
|
+
process.stderr.write(`${JSON.stringify({
|
|
56
|
+
event: "lanes.outpost_snapshot_cache_lock_unavailable",
|
|
57
|
+
error: typeof code === "string" ? code : "UnknownError",
|
|
58
|
+
fallback: "uncached",
|
|
59
|
+
})}\n`);
|
|
60
|
+
const value = await input.fetch();
|
|
61
|
+
if (!input.isValue(value)) {
|
|
62
|
+
throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
if (recoverStaleLock(lockPath, staleLockMs, now()))
|
|
67
|
+
continue;
|
|
68
|
+
const afterWait = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
69
|
+
if (afterWait !== undefined)
|
|
70
|
+
return afterWait;
|
|
71
|
+
if (now() - startedAt >= lockWaitMs) {
|
|
72
|
+
throw new LanesError("outpost_snapshot_cache_lock_timeout", "Timed out waiting for an Outpost snapshot refresh", { path: lockPath });
|
|
73
|
+
}
|
|
74
|
+
await sleep(pollMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const afterLock = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
79
|
+
if (afterLock !== undefined)
|
|
80
|
+
return afterLock;
|
|
81
|
+
const value = await input.fetch();
|
|
82
|
+
if (!input.isValue(value)) {
|
|
83
|
+
throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
|
|
84
|
+
}
|
|
85
|
+
writeCache(filePath, input.key, value, now());
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
releaseLock(lockPath, token);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function createCacheLock(lockPath, token, nowMs) {
|
|
93
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
94
|
+
const lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
95
|
+
try {
|
|
96
|
+
fs.writeSync(lockFd, `${token}\n${nowMs}\n`);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
fs.closeSync(lockFd);
|
|
101
|
+
}
|
|
102
|
+
catch (closeErr) {
|
|
103
|
+
process.stderr.write(`lanes snapshot cache: lock close failed: ${errorText(closeErr)}\n`);
|
|
104
|
+
}
|
|
105
|
+
releaseLock(lockPath, token);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
fs.closeSync(lockFd);
|
|
109
|
+
}
|
|
110
|
+
function createKeyDigest(key) {
|
|
111
|
+
// A lane-list query can contain lane ids. Keep raw ids out of cache filenames.
|
|
112
|
+
return createHash("sha256").update(key).digest("hex");
|
|
113
|
+
}
|
|
114
|
+
function readFreshCache(filePath, key, nowMs, ttlMs, isValue) {
|
|
115
|
+
let contents;
|
|
116
|
+
try {
|
|
117
|
+
contents = fs.readFileSync(filePath, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.code === "ENOENT")
|
|
121
|
+
return undefined;
|
|
122
|
+
process.stderr.write(`lanes snapshot cache: read failed: ${errorText(err)}\n`);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = JSON.parse(contents);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
process.stderr.write(`lanes snapshot cache: ignoring malformed cache: ${errorText(err)}\n`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
134
|
+
return undefined;
|
|
135
|
+
const entry = raw;
|
|
136
|
+
if (Object.keys(entry).length !== 4 ||
|
|
137
|
+
entry.schema_version !== CACHE_SCHEMA_VERSION ||
|
|
138
|
+
entry.key !== key ||
|
|
139
|
+
!Number.isSafeInteger(entry.cached_at) ||
|
|
140
|
+
entry.cached_at > nowMs ||
|
|
141
|
+
nowMs - entry.cached_at >= ttlMs ||
|
|
142
|
+
!isValue(entry.value))
|
|
143
|
+
return undefined;
|
|
144
|
+
return entry.value;
|
|
145
|
+
}
|
|
146
|
+
function writeCache(filePath, key, value, nowMs) {
|
|
147
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
const fd = fs.openSync(temporaryPath, "wx", 0o600);
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(fd, `${JSON.stringify({
|
|
152
|
+
schema_version: CACHE_SCHEMA_VERSION,
|
|
153
|
+
key,
|
|
154
|
+
cached_at: nowMs,
|
|
155
|
+
value,
|
|
156
|
+
})}\n`);
|
|
157
|
+
fs.fsyncSync(fd);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
fs.closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
fs.renameSync(temporaryPath, filePath);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(temporaryPath);
|
|
167
|
+
}
|
|
168
|
+
catch (cleanupErr) {
|
|
169
|
+
if (cleanupErr.code !== "ENOENT") {
|
|
170
|
+
process.stderr.write(`lanes snapshot cache: temp cleanup failed: ${errorText(cleanupErr)}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// The cache is an optimization; a successful, validated remote result stays usable.
|
|
174
|
+
process.stderr.write(`lanes snapshot cache: write failed: ${errorText(err)}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function recoverStaleLock(lockPath, staleLockMs, nowMs) {
|
|
178
|
+
let stat;
|
|
179
|
+
try {
|
|
180
|
+
stat = fs.statSync(lockPath);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return err.code === "ENOENT";
|
|
184
|
+
}
|
|
185
|
+
if (nowMs - stat.mtimeMs <= staleLockMs)
|
|
186
|
+
return false;
|
|
187
|
+
const stalePath = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
188
|
+
try {
|
|
189
|
+
fs.renameSync(lockPath, stalePath);
|
|
190
|
+
fs.unlinkSync(stalePath);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const code = err.code;
|
|
195
|
+
if (code === "ENOENT")
|
|
196
|
+
return true;
|
|
197
|
+
throw new LanesError("outpost_snapshot_cache_lock_recovery_failed", `Could not recover a stale Outpost snapshot lock: ${errorText(err)}`, { path: lockPath });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function releaseLock(lockPath, token) {
|
|
201
|
+
try {
|
|
202
|
+
const contents = fs.readFileSync(lockPath, "utf8");
|
|
203
|
+
if (contents.split("\n", 1)[0] === token)
|
|
204
|
+
fs.unlinkSync(lockPath);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err.code !== "ENOENT") {
|
|
208
|
+
process.stderr.write(`lanes snapshot cache: lock release failed: ${errorText(err)}\n`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function errorText(err) {
|
|
213
|
+
return err instanceof Error ? err.message : String(err);
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=outpost-snapshot-cache.js.map
|
|
@@ -22,6 +22,8 @@ export interface StoreDeps {
|
|
|
22
22
|
unlinkSync: typeof fs.unlinkSync;
|
|
23
23
|
existsSync: typeof fs.existsSync;
|
|
24
24
|
readdirSync: typeof fs.readdirSync;
|
|
25
|
+
/** Bulk readers may provide a pre-indexed conflict-sidecar lookup. */
|
|
26
|
+
conflictSidecarPathsFor?: (recordPath: string) => readonly string[];
|
|
25
27
|
openSync: typeof fs.openSync;
|
|
26
28
|
closeSync: typeof fs.closeSync;
|
|
27
29
|
fsyncSync: typeof fs.fsyncSync;
|
|
@@ -50,6 +52,8 @@ export declare function mergeLaneRecords(live: LaneRecord, sidecars: readonly La
|
|
|
50
52
|
export declare function mergeQuestionRecords(live: QuestionRecord, sidecars: readonly QuestionRecord[]): QuestionRecord;
|
|
51
53
|
export declare function tryReadLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord | null;
|
|
52
54
|
export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
|
|
55
|
+
/** Reads every lane while scanning the lane directory for conflict sidecars once. */
|
|
56
|
+
export declare function readAllLanes(hqRoot: string, deps?: StoreDeps): LaneRecord[];
|
|
53
57
|
export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
|
|
54
58
|
export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
|
|
55
59
|
/** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -954,6 +954,9 @@ export function isConflictSidecarName(recordBasename, name) {
|
|
|
954
954
|
return name.startsWith(`${recordBasename}.conflict-`);
|
|
955
955
|
}
|
|
956
956
|
function sidecarPathsFor(recordPath, deps) {
|
|
957
|
+
if (deps.conflictSidecarPathsFor) {
|
|
958
|
+
return [...deps.conflictSidecarPathsFor(recordPath)];
|
|
959
|
+
}
|
|
957
960
|
const dir = path.dirname(recordPath);
|
|
958
961
|
const base = path.basename(recordPath);
|
|
959
962
|
if (!deps.existsSync(dir))
|
|
@@ -1157,6 +1160,42 @@ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
|
|
|
1157
1160
|
throw new LaneNotFoundError("lane", laneId);
|
|
1158
1161
|
return record;
|
|
1159
1162
|
}
|
|
1163
|
+
/** Reads every lane while scanning the lane directory for conflict sidecars once. */
|
|
1164
|
+
export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
|
|
1165
|
+
const laneIds = listLaneIds(hqRoot, deps);
|
|
1166
|
+
if (laneIds.length === 0)
|
|
1167
|
+
return [];
|
|
1168
|
+
const laneDir = path.join(lanesStoreRoot(hqRoot), LANE_RECORDS_DIR);
|
|
1169
|
+
let names;
|
|
1170
|
+
try {
|
|
1171
|
+
names = deps.readdirSync(laneDir);
|
|
1172
|
+
}
|
|
1173
|
+
catch (err) {
|
|
1174
|
+
if (err.code === "ENOENT")
|
|
1175
|
+
names = [];
|
|
1176
|
+
else
|
|
1177
|
+
throw err;
|
|
1178
|
+
}
|
|
1179
|
+
const sidecarsByRecord = new Map();
|
|
1180
|
+
for (const name of names) {
|
|
1181
|
+
if (typeof name !== "string")
|
|
1182
|
+
continue;
|
|
1183
|
+
const marker = name.indexOf(".json.conflict-");
|
|
1184
|
+
if (marker < 0 || name.startsWith("."))
|
|
1185
|
+
continue;
|
|
1186
|
+
const recordBasename = name.slice(0, marker + ".json".length);
|
|
1187
|
+
if (!isConflictSidecarName(recordBasename, name))
|
|
1188
|
+
continue;
|
|
1189
|
+
const sidecars = sidecarsByRecord.get(recordBasename) ?? [];
|
|
1190
|
+
sidecars.push(path.join(laneDir, name));
|
|
1191
|
+
sidecarsByRecord.set(recordBasename, sidecars);
|
|
1192
|
+
}
|
|
1193
|
+
const indexedDeps = {
|
|
1194
|
+
...deps,
|
|
1195
|
+
conflictSidecarPathsFor: (recordPath) => sidecarsByRecord.get(path.basename(recordPath)) ?? [],
|
|
1196
|
+
};
|
|
1197
|
+
return laneIds.map((laneId) => readLane(hqRoot, laneId, indexedDeps));
|
|
1198
|
+
}
|
|
1160
1199
|
export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
|
|
1161
1200
|
const filePath = questionRecordPath(hqRoot, questionId);
|
|
1162
1201
|
return readMergedRecord(filePath, parseQuestionRecord, mergeQuestionRecords, deps);
|
|
@@ -562,13 +562,21 @@ export function normalizePersonalRealtimeBundle(raw) {
|
|
|
562
562
|
}
|
|
563
563
|
if (!topics.dm && typeof r.topic === "string")
|
|
564
564
|
topics.dm = r.topic;
|
|
565
|
+
const agentInboxTopic = typeof r.agentInboxTopic === "string" ? r.agentInboxTopic.trim() : "";
|
|
565
566
|
const actorUid = (typeof r.principalUid === "string" && /^(?:prs|agt)_[A-Za-z0-9]+$/.test(r.principalUid)
|
|
566
567
|
? r.principalUid
|
|
567
568
|
: undefined) ||
|
|
568
569
|
actorUidFromPersonalTopic(topics.dm) ||
|
|
570
|
+
actorUidFromPersonalTopic(agentInboxTopic) ||
|
|
569
571
|
"";
|
|
570
572
|
if (!actorUid)
|
|
571
573
|
throw new Error("contract-2 vend could not derive actorUid");
|
|
574
|
+
// hq-pro advertises the agent's own inbox topic as a top-level capability.
|
|
575
|
+
// Keep it only for the exact actor-owned topic; it reuses this personal MQTT
|
|
576
|
+
// session and never grants the subscriber a topic supplied by another actor.
|
|
577
|
+
if (actorUid.startsWith("agt_") && agentInboxTopic === `hq/${actorUid}/inbox`) {
|
|
578
|
+
topics.inbox = agentInboxTopic;
|
|
579
|
+
}
|
|
572
580
|
// Only keep topics that belong to the vended principal.
|
|
573
581
|
for (const [k, v] of Object.entries(topics)) {
|
|
574
582
|
if (actorUidFromPersonalTopic(v) !== actorUid)
|
|
@@ -14,6 +14,7 @@ export interface WorkCacheMqtt {
|
|
|
14
14
|
export interface WorkCacheSubscriber {
|
|
15
15
|
stop: () => Promise<void>;
|
|
16
16
|
}
|
|
17
|
+
export declare const AGENT_INBOX_PUSH_STATE_BASENAME = "agent-inbox-push.json";
|
|
17
18
|
export declare function startWorkCacheSubscriber(opts: {
|
|
18
19
|
meshRoot: string;
|
|
19
20
|
fetchCredentials?: PersonalCredentialsFetcher;
|
|
@@ -2,10 +2,57 @@
|
|
|
2
2
|
* Contract-2 subscriber for the personal work topic.
|
|
3
3
|
* Owns live-cache.json writes. Presence publish stays on PresenceClient.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
5
7
|
import { createPersonalRealtimeFetcher, } from "./credentials.js";
|
|
6
8
|
import { presignIotWssUrl } from "./presign.js";
|
|
7
9
|
import { applyProjectViewPush, parseProjectViewPush } from "./live-cache.js";
|
|
8
10
|
import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
|
|
11
|
+
export const AGENT_INBOX_PUSH_STATE_BASENAME = "agent-inbox-push.json";
|
|
12
|
+
const INBOX_PUSH_HEARTBEAT_MS = 5_000;
|
|
13
|
+
function readInboxWakeSequence(meshRoot, onInfo) {
|
|
14
|
+
const target = path.join(meshRoot, AGENT_INBOX_PUSH_STATE_BASENAME);
|
|
15
|
+
try {
|
|
16
|
+
const info = fs.lstatSync(target);
|
|
17
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
18
|
+
onInfo?.("agent inbox push state read ignored (not a regular file)");
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
22
|
+
if (Number.isSafeInteger(parsed.wakeSequence) && Number(parsed.wakeSequence) >= 0) {
|
|
23
|
+
return Number(parsed.wakeSequence);
|
|
24
|
+
}
|
|
25
|
+
onInfo?.("agent inbox push state read ignored (invalid wake sequence)");
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== "ENOENT") {
|
|
30
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
31
|
+
onInfo?.(`agent inbox push state read failed (${name})`);
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function writeInboxPushState(meshRoot, state) {
|
|
37
|
+
fs.mkdirSync(meshRoot, { recursive: true, mode: 0o700 });
|
|
38
|
+
const target = path.join(meshRoot, AGENT_INBOX_PUSH_STATE_BASENAME);
|
|
39
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
42
|
+
fs.chmodSync(temporary, 0o600);
|
|
43
|
+
fs.renameSync(temporary, target);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(temporary);
|
|
48
|
+
}
|
|
49
|
+
catch (cleanupError) {
|
|
50
|
+
if (cleanupError.code !== "ENOENT")
|
|
51
|
+
throw cleanupError;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
9
56
|
export async function startWorkCacheSubscriber(opts) {
|
|
10
57
|
const fetchCredentials = opts.fetchCredentials ??
|
|
11
58
|
(opts.token
|
|
@@ -28,10 +75,48 @@ export async function startWorkCacheSubscriber(opts) {
|
|
|
28
75
|
let reconnectLogged = false;
|
|
29
76
|
let attempt = 0;
|
|
30
77
|
let timer = null;
|
|
78
|
+
let heartbeatTimer = null;
|
|
31
79
|
let generation = 0;
|
|
80
|
+
let inboxSubscribed = false;
|
|
81
|
+
// Carry the sequence forward from the last state marker so a daemon restart
|
|
82
|
+
// cannot reuse a value the watcher has already consumed.
|
|
83
|
+
let wakeSequence = readInboxWakeSequence(opts.meshRoot, opts.onInfo);
|
|
84
|
+
const clearInboxHeartbeat = () => {
|
|
85
|
+
if (heartbeatTimer !== null)
|
|
86
|
+
timers.clearTimeout(heartbeatTimer);
|
|
87
|
+
heartbeatTimer = null;
|
|
88
|
+
};
|
|
89
|
+
const persistInboxState = (connected) => {
|
|
90
|
+
try {
|
|
91
|
+
writeInboxPushState(opts.meshRoot, {
|
|
92
|
+
connected,
|
|
93
|
+
heartbeatAtMs: now().getTime(),
|
|
94
|
+
wakeSequence,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
99
|
+
opts.onInfo?.(`agent inbox push state write failed (${name})`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const scheduleInboxHeartbeat = () => {
|
|
103
|
+
if (stopped || !inboxSubscribed)
|
|
104
|
+
return;
|
|
105
|
+
clearInboxHeartbeat();
|
|
106
|
+
heartbeatTimer = timers.setTimeout(() => {
|
|
107
|
+
heartbeatTimer = null;
|
|
108
|
+
if (!stopped && inboxSubscribed) {
|
|
109
|
+
persistInboxState(true);
|
|
110
|
+
scheduleInboxHeartbeat();
|
|
111
|
+
}
|
|
112
|
+
}, INBOX_PUSH_HEARTBEAT_MS);
|
|
113
|
+
};
|
|
32
114
|
const connect = async () => {
|
|
33
115
|
if (stopped)
|
|
34
116
|
return;
|
|
117
|
+
inboxSubscribed = false;
|
|
118
|
+
clearInboxHeartbeat();
|
|
119
|
+
persistInboxState(false);
|
|
35
120
|
const gen = ++generation;
|
|
36
121
|
let bundle;
|
|
37
122
|
try {
|
|
@@ -44,8 +129,12 @@ export async function startWorkCacheSubscriber(opts) {
|
|
|
44
129
|
if (stopped || gen !== generation)
|
|
45
130
|
return;
|
|
46
131
|
const topic = bundle.topics.work;
|
|
47
|
-
|
|
48
|
-
|
|
132
|
+
const inboxTopic = bundle.actorUid.startsWith("agt_") &&
|
|
133
|
+
bundle.topics.inbox === `hq/${bundle.actorUid}/inbox`
|
|
134
|
+
? bundle.topics.inbox
|
|
135
|
+
: undefined;
|
|
136
|
+
if (!topic && !inboxTopic) {
|
|
137
|
+
opts.onInfo?.("work cache subscribe skipped: credentials have no supported topic");
|
|
49
138
|
return;
|
|
50
139
|
}
|
|
51
140
|
const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
|
|
@@ -60,12 +149,55 @@ export async function startWorkCacheSubscriber(opts) {
|
|
|
60
149
|
if (stopped || gen !== generation)
|
|
61
150
|
return;
|
|
62
151
|
reconnectLogged = false;
|
|
63
|
-
|
|
64
|
-
|
|
152
|
+
let pendingSubscriptions = Number(Boolean(topic)) + Number(Boolean(inboxTopic));
|
|
153
|
+
let subscriptionFailed = false;
|
|
154
|
+
const finishSubscription = (err) => {
|
|
155
|
+
if (err) {
|
|
156
|
+
if (subscriptionFailed)
|
|
157
|
+
return;
|
|
158
|
+
subscriptionFailed = true;
|
|
159
|
+
schedule(err);
|
|
160
|
+
next.end(true);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (subscriptionFailed)
|
|
164
|
+
return;
|
|
165
|
+
pendingSubscriptions -= 1;
|
|
166
|
+
if (pendingSubscriptions === 0)
|
|
167
|
+
attempt = 0;
|
|
168
|
+
};
|
|
169
|
+
if (topic) {
|
|
170
|
+
next.subscribe(topic, { qos: 1 }, (err) => finishSubscription(err));
|
|
171
|
+
}
|
|
172
|
+
if (inboxTopic) {
|
|
173
|
+
next.subscribe(inboxTopic, { qos: 1 }, (err) => {
|
|
174
|
+
if (stopped || gen !== generation)
|
|
175
|
+
return;
|
|
176
|
+
if (err) {
|
|
177
|
+
inboxSubscribed = false;
|
|
178
|
+
clearInboxHeartbeat();
|
|
179
|
+
persistInboxState(false);
|
|
180
|
+
opts.onInfo?.("agent inbox push subscribe failed");
|
|
181
|
+
finishSubscription(err);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
inboxSubscribed = true;
|
|
185
|
+
persistInboxState(true);
|
|
186
|
+
scheduleInboxHeartbeat();
|
|
187
|
+
finishSubscription();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
65
190
|
});
|
|
66
191
|
next.on("message", (_topic, payload) => {
|
|
67
192
|
if (stopped || gen !== generation)
|
|
68
193
|
return;
|
|
194
|
+
if (inboxTopic && _topic === inboxTopic && inboxSubscribed) {
|
|
195
|
+
// A topic message is a doorbell only. Never parse, persist, or log its
|
|
196
|
+
// content; the authenticated inbox GET remains the source of truth.
|
|
197
|
+
wakeSequence += 1;
|
|
198
|
+
persistInboxState(true);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
69
201
|
if (_topic !== topic)
|
|
70
202
|
return;
|
|
71
203
|
const push = parseProjectViewPush(payload);
|
|
@@ -79,11 +211,14 @@ export async function startWorkCacheSubscriber(opts) {
|
|
|
79
211
|
next.on("close", () => {
|
|
80
212
|
if (stopped || gen !== generation)
|
|
81
213
|
return;
|
|
214
|
+
inboxSubscribed = false;
|
|
215
|
+
clearInboxHeartbeat();
|
|
216
|
+
persistInboxState(false);
|
|
82
217
|
schedule();
|
|
83
218
|
});
|
|
84
219
|
};
|
|
85
220
|
const schedule = (err) => {
|
|
86
|
-
if (stopped)
|
|
221
|
+
if (stopped || timer !== null)
|
|
87
222
|
return;
|
|
88
223
|
if (!reconnectLogged) {
|
|
89
224
|
reconnectLogged = true;
|
|
@@ -106,6 +241,9 @@ export async function startWorkCacheSubscriber(opts) {
|
|
|
106
241
|
generation += 1;
|
|
107
242
|
if (timer !== null)
|
|
108
243
|
timers.clearTimeout(timer);
|
|
244
|
+
clearInboxHeartbeat();
|
|
245
|
+
inboxSubscribed = false;
|
|
246
|
+
persistInboxState(false);
|
|
109
247
|
const current = client;
|
|
110
248
|
client = null;
|
|
111
249
|
if (!current)
|