@indigoai-us/hq-cli 5.126.0 → 5.128.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/core-utils/sentry-report.d.ts +2 -0
- package/dist/lib/core-utils/sentry-report.js +47 -7
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
|
@@ -19,7 +19,7 @@ import { replayOutbox } from "../../../work-context/outbox.js";
|
|
|
19
19
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
20
20
|
import { ensureDefaultCompany } from "../../../work-context/default-company.js";
|
|
21
21
|
import { invalidateDefaultCompanyMemberships, readDeviceConfig, } from "../../../work-context/config.js";
|
|
22
|
-
import { readSessionState,
|
|
22
|
+
import { readSessionState, mergeLocalSessionFields, updateSessionState, } from "../../../work-context/state.js";
|
|
23
23
|
import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
|
|
24
24
|
import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
|
|
25
25
|
import { backfillHeldSessions, } from "../backfill-held.js";
|
|
@@ -58,6 +58,30 @@ export function shouldFlushOnSpoolDirEvent(filename) {
|
|
|
58
58
|
return true;
|
|
59
59
|
return String(filename) === "spool.jsonl";
|
|
60
60
|
}
|
|
61
|
+
function sameCompanyValue(a, b) {
|
|
62
|
+
return Boolean(a && b && a.toLowerCase() === b.toLowerCase());
|
|
63
|
+
}
|
|
64
|
+
function stillMatchesDeniedCompany(current, input, slug) {
|
|
65
|
+
const deniedUid = input.companyUid ?? input.state?.companyUid;
|
|
66
|
+
const deniedSlug = input.companySlug ?? input.state?.companySlug ?? slug;
|
|
67
|
+
if (deniedUid && current.companyUid && deniedUid !== current.companyUid)
|
|
68
|
+
return false;
|
|
69
|
+
if (deniedSlug && current.companySlug && !sameCompanyValue(deniedSlug, current.companySlug))
|
|
70
|
+
return false;
|
|
71
|
+
return Boolean(current.companyUid || current.companySlug);
|
|
72
|
+
}
|
|
73
|
+
function stillMatchesDeniedDeviceDefaultBinding(current, original, input, slug) {
|
|
74
|
+
return stillMatchesDeniedCompany(current, input, slug) &&
|
|
75
|
+
current.companyUid === original.companyUid &&
|
|
76
|
+
current.companySlug === original.companySlug &&
|
|
77
|
+
current.contextStatus === original.contextStatus &&
|
|
78
|
+
current.projectId === original.projectId &&
|
|
79
|
+
current.taskId === original.taskId &&
|
|
80
|
+
current.bindingEpisodeId === original.bindingEpisodeId &&
|
|
81
|
+
current.source === original.source &&
|
|
82
|
+
current.version === original.version &&
|
|
83
|
+
current.updatedAt === original.updatedAt;
|
|
84
|
+
}
|
|
61
85
|
function alive(pid) {
|
|
62
86
|
try {
|
|
63
87
|
process.kill(pid, 0);
|
|
@@ -123,33 +147,54 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
123
147
|
? readSessionState(input.sessionId, ctxRoot)
|
|
124
148
|
: null);
|
|
125
149
|
const slug = input.companySlug ?? state?.companySlug;
|
|
126
|
-
|
|
150
|
+
let authorization;
|
|
151
|
+
if (!slug) {
|
|
152
|
+
authorization = { status: "denied" };
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timeout = setTimeout(() => controller.abort(), DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS);
|
|
127
157
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
authorization = await Promise.race([
|
|
159
|
+
(async () => {
|
|
160
|
+
const membership = await resolveMembership(await getToken(), slug, controller.signal);
|
|
161
|
+
return membership
|
|
162
|
+
? { status: "authorized", uid: membership.companyUid }
|
|
163
|
+
: { status: "denied" };
|
|
164
|
+
})(),
|
|
165
|
+
new Promise((resolve) => {
|
|
166
|
+
controller.signal.addEventListener("abort", () => resolve({
|
|
167
|
+
status: "unavailable",
|
|
168
|
+
error: `membership lookup timed out after ${DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS}ms`,
|
|
169
|
+
}), { once: true });
|
|
170
|
+
}),
|
|
171
|
+
]);
|
|
141
172
|
}
|
|
142
|
-
catch {
|
|
143
|
-
|
|
173
|
+
catch (err) {
|
|
174
|
+
authorization = {
|
|
175
|
+
status: "unavailable",
|
|
176
|
+
error: err instanceof Error ? err.message : String(err),
|
|
177
|
+
};
|
|
144
178
|
}
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
finally {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (authorization.status === "unavailable") {
|
|
184
|
+
const prior = readDaemonState(dir)?.deviceDefaultMembershipLookupFailures ?? 0;
|
|
185
|
+
patchDaemonState(dir, {
|
|
186
|
+
lastErrorCode: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED",
|
|
187
|
+
deviceDefaultMembershipLookupFailures: prior + 1,
|
|
188
|
+
}, now);
|
|
189
|
+
log(dir, `device-default membership lookup unavailable${slug ? ` company=${slug}` : ""}: ${authorization.error.slice(0, 160)}`);
|
|
190
|
+
return authorization;
|
|
191
|
+
}
|
|
147
192
|
if (!input.deviceDefault)
|
|
148
|
-
return
|
|
149
|
-
if (
|
|
150
|
-
(!input.companyUid || input.companyUid === uid) &&
|
|
151
|
-
(!state?.companyUid || state.companyUid === uid)) {
|
|
152
|
-
return
|
|
193
|
+
return authorization;
|
|
194
|
+
if (authorization.status === "authorized" &&
|
|
195
|
+
(!input.companyUid || input.companyUid === authorization.uid) &&
|
|
196
|
+
(!state?.companyUid || state.companyUid === authorization.uid)) {
|
|
197
|
+
return authorization;
|
|
153
198
|
}
|
|
154
199
|
try {
|
|
155
200
|
invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
|
|
@@ -158,20 +203,39 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
158
203
|
log(dir, `default membership cache invalidation failed: ${String(err).slice(0, 160)}`);
|
|
159
204
|
}
|
|
160
205
|
if (state) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
206
|
+
// Commit only if the device-default binding that started this lookup is
|
|
207
|
+
// still current. The shared session-state lock also makes a hook writer
|
|
208
|
+
// that arrives at this boundary commit after us rather than get lost.
|
|
209
|
+
deps.beforeDeviceDefaultRevocationCommit?.();
|
|
210
|
+
const revoked = updateSessionState(state.sessionId, ctxRoot, (current) => {
|
|
211
|
+
if (!current || !stillMatchesDeniedDeviceDefaultBinding(current, state, input, slug)) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return mergeLocalSessionFields({
|
|
215
|
+
...current,
|
|
216
|
+
companyUid: undefined,
|
|
217
|
+
companySlug: undefined,
|
|
218
|
+
contextStatus: "needs_company",
|
|
219
|
+
updatedAt: now().toISOString(),
|
|
220
|
+
}, current, { preserveCompanyBinding: false });
|
|
221
|
+
});
|
|
222
|
+
if (!revoked) {
|
|
223
|
+
log(dir, `device-default membership denial left newer session binding intact session=${state.sessionId}`);
|
|
224
|
+
}
|
|
168
225
|
}
|
|
169
226
|
const prior = readDaemonState(dir)?.deviceDefaultAuthorizationDenials ?? 0;
|
|
170
227
|
patchDaemonState(dir, { deviceDefaultAuthorizationDenials: prior + 1 }, now);
|
|
171
228
|
log(dir, `device-default membership denied${slug ? ` company=${slug}` : ""}; work held`);
|
|
172
|
-
return
|
|
229
|
+
return { status: "denied" };
|
|
173
230
|
},
|
|
174
231
|
};
|
|
232
|
+
const validateDaemonMembership = async (slug, deviceDefault) => {
|
|
233
|
+
const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault });
|
|
234
|
+
if (authorization.status === "unavailable") {
|
|
235
|
+
throw new Error(`device-default membership lookup unavailable: ${authorization.error}`);
|
|
236
|
+
}
|
|
237
|
+
return authorization.status === "authorized" ? { uid: authorization.uid } : false;
|
|
238
|
+
};
|
|
175
239
|
/** In-memory held re-attempt throttle (survives watch-triggered flushes). */
|
|
176
240
|
let lastHeldRetryAtMs = 0;
|
|
177
241
|
const flushFn = deps.flush ??
|
|
@@ -191,12 +255,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
191
255
|
lastHeldRetryAtMs = atMs;
|
|
192
256
|
},
|
|
193
257
|
log: (message) => log(dir, message),
|
|
194
|
-
authorizeDeviceDefaultTenant: async (state) =>
|
|
258
|
+
authorizeDeviceDefaultTenant: async (state) => (await tenantGate.authorize({
|
|
195
259
|
state,
|
|
196
260
|
companySlug: state.companySlug,
|
|
197
261
|
companyUid: state.companyUid,
|
|
198
262
|
deviceDefault: true,
|
|
199
|
-
})),
|
|
263
|
+
})).status === "authorized",
|
|
200
264
|
});
|
|
201
265
|
});
|
|
202
266
|
const replayFn = deps.replayOutbox ??
|
|
@@ -215,13 +279,16 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
215
279
|
// only authority that may release them.
|
|
216
280
|
!op.companyConfidence;
|
|
217
281
|
if (requiresMembershipCheck) {
|
|
218
|
-
const
|
|
282
|
+
const authorization = await tenantGate.authorize({
|
|
219
283
|
state: state ?? undefined,
|
|
220
284
|
companySlug: op.companySlug ?? state?.companySlug,
|
|
221
285
|
companyUid: op.companyUid ?? state?.companyUid,
|
|
222
286
|
deviceDefault: true,
|
|
223
287
|
});
|
|
224
|
-
if (
|
|
288
|
+
if (authorization.status === "unavailable") {
|
|
289
|
+
return { ok: false, retryable: true, code: "DEVICE_DEFAULT_MEMBERSHIP_LOOKUP_FAILED" };
|
|
290
|
+
}
|
|
291
|
+
if (authorization.status !== "authorized") {
|
|
225
292
|
return { ok: false, retryable: false, code: "DEVICE_DEFAULT_MEMBERSHIP_DENIED" };
|
|
226
293
|
}
|
|
227
294
|
}
|
|
@@ -260,13 +327,11 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
260
327
|
root: ctxRoot,
|
|
261
328
|
env,
|
|
262
329
|
deliver,
|
|
263
|
-
validateMembership:
|
|
264
|
-
companySlug: slug,
|
|
265
|
-
deviceDefault: useDeviceDefault,
|
|
266
|
-
}),
|
|
330
|
+
validateMembership: (slug) => validateDaemonMembership(slug, useDeviceDefault),
|
|
267
331
|
allowDeviceDefault: useDeviceDefault,
|
|
268
332
|
deviceDefaultConfidence: useDeviceDefault,
|
|
269
333
|
}),
|
|
334
|
+
validateMembership: (slug) => validateDaemonMembership(slug, useDeviceDefault),
|
|
270
335
|
});
|
|
271
336
|
});
|
|
272
337
|
const boardLast = new Map();
|
|
@@ -282,12 +347,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
282
347
|
lastRefresh: boardLast,
|
|
283
348
|
intervalMs: BOARD_REFRESH_INTERVAL_MS,
|
|
284
349
|
now: () => timers.now(),
|
|
285
|
-
authorizeDeviceDefaultTenant: async (_sessionId, state) =>
|
|
350
|
+
authorizeDeviceDefaultTenant: async (_sessionId, state) => (await tenantGate.authorize({
|
|
286
351
|
state,
|
|
287
352
|
companySlug: state.companySlug,
|
|
288
353
|
companyUid: state.companyUid,
|
|
289
354
|
deviceDefault: true,
|
|
290
|
-
})),
|
|
355
|
+
})).status === "authorized",
|
|
291
356
|
});
|
|
292
357
|
});
|
|
293
358
|
let presence = null;
|
|
@@ -387,18 +452,10 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
387
452
|
fs: deps.transcriptFs,
|
|
388
453
|
now: () => now().getTime(),
|
|
389
454
|
lastHookEventAt,
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (!membership)
|
|
395
|
-
return false;
|
|
396
|
-
return { uid: membership.companyUid };
|
|
397
|
-
}
|
|
398
|
-
catch {
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
401
|
-
},
|
|
455
|
+
// Transcript watch has no direct state writer: reconcile owns its write
|
|
456
|
+
// and merges local fields. Surface transient lookup failures as throws so
|
|
457
|
+
// reconcile defers without turning them into an authoritative denial.
|
|
458
|
+
validateMembership: (slug) => validateDaemonMembership(slug, true),
|
|
402
459
|
deliver: async (op) => {
|
|
403
460
|
try {
|
|
404
461
|
const token = await getToken();
|
|
@@ -29,6 +29,8 @@ export interface DaemonStateFile {
|
|
|
29
29
|
emitMode?: "legacy" | "direct";
|
|
30
30
|
/** Authoritative membership denials for device-default-derived tenant I/O. */
|
|
31
31
|
deviceDefaultAuthorizationDenials?: number;
|
|
32
|
+
/** Transient device-default membership refresh failures; the next cycle retries. */
|
|
33
|
+
deviceDefaultMembershipLookupFailures?: number;
|
|
32
34
|
updatedAt: string;
|
|
33
35
|
}
|
|
34
36
|
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
@@ -49,6 +49,9 @@ export declare const TRANSCRIPT_WATCH_REVALIDATE_MS: number;
|
|
|
49
49
|
export declare const TRANSCRIPT_TURN_QUIET_MS = 5000;
|
|
50
50
|
export declare const TRANSCRIPT_SESSION_END_MS: number;
|
|
51
51
|
export declare const TRANSCRIPT_HOOK_COVER_MS = 60000;
|
|
52
|
+
/** Retry unavailable transcript-start reconciliation without hot-looping. */
|
|
53
|
+
export declare const TRANSCRIPT_START_RETRY_BASE_MS = 30000;
|
|
54
|
+
export declare const TRANSCRIPT_START_RETRY_MAX_MS = 120000;
|
|
52
55
|
/** Ignore cold transcripts whose mtime is older than this on first sight. */
|
|
53
56
|
export declare const TRANSCRIPT_FRESH_MS: number;
|
|
54
57
|
export declare const TRANSCRIPT_MARKER: "from transcript";
|
|
@@ -54,6 +54,9 @@ export const TRANSCRIPT_WATCH_REVALIDATE_MS = 5 * 60_000;
|
|
|
54
54
|
export const TRANSCRIPT_TURN_QUIET_MS = 5_000;
|
|
55
55
|
export const TRANSCRIPT_SESSION_END_MS = 10 * 60_000;
|
|
56
56
|
export const TRANSCRIPT_HOOK_COVER_MS = 60_000;
|
|
57
|
+
/** Retry unavailable transcript-start reconciliation without hot-looping. */
|
|
58
|
+
export const TRANSCRIPT_START_RETRY_BASE_MS = 30_000;
|
|
59
|
+
export const TRANSCRIPT_START_RETRY_MAX_MS = 120_000;
|
|
57
60
|
/** Ignore cold transcripts whose mtime is older than this on first sight. */
|
|
58
61
|
export const TRANSCRIPT_FRESH_MS = TRANSCRIPT_SESSION_END_MS;
|
|
59
62
|
export const TRANSCRIPT_MARKER = "from transcript";
|
|
@@ -652,6 +655,8 @@ export class TranscriptWatcher {
|
|
|
652
655
|
ended: false,
|
|
653
656
|
pendingTurn: false,
|
|
654
657
|
seq: 0,
|
|
658
|
+
startRetryAttempts: 0,
|
|
659
|
+
nextStartRetryAtMs: 0,
|
|
655
660
|
};
|
|
656
661
|
this.tracked.set(d.path, track);
|
|
657
662
|
const started = await this.emitStart(track, nowMs);
|
|
@@ -661,6 +666,14 @@ export class TranscriptWatcher {
|
|
|
661
666
|
}
|
|
662
667
|
if (track.ended)
|
|
663
668
|
continue;
|
|
669
|
+
if (!track.started) {
|
|
670
|
+
if (nowMs < track.nextStartRetryAtMs)
|
|
671
|
+
continue;
|
|
672
|
+
const started = await this.emitStart(track, nowMs);
|
|
673
|
+
if (started)
|
|
674
|
+
result.started += 1;
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
664
677
|
if (d.mtimeMs > track.lastMtimeMs) {
|
|
665
678
|
track.lastMtimeMs = d.mtimeMs;
|
|
666
679
|
track.lastChangeAtMs = nowMs;
|
|
@@ -737,14 +750,22 @@ export class TranscriptWatcher {
|
|
|
737
750
|
hqRoot: this.deps.hqRoot,
|
|
738
751
|
source: "transcript",
|
|
739
752
|
};
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
753
|
+
try {
|
|
754
|
+
await this.reconcileFn(observation, {
|
|
755
|
+
root: this.deps.workContextRoot,
|
|
756
|
+
env: this.deps.env,
|
|
757
|
+
now: () => new Date(nowMs),
|
|
758
|
+
validateMembership: this.deps.validateMembership,
|
|
759
|
+
deliver: this.deps.deliver,
|
|
760
|
+
offline: !this.deps.deliver,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
track.startRetryAttempts += 1;
|
|
765
|
+
const delayMs = Math.min(TRANSCRIPT_START_RETRY_BASE_MS * 2 ** (track.startRetryAttempts - 1), TRANSCRIPT_START_RETRY_MAX_MS);
|
|
766
|
+
track.nextStartRetryAtMs = nowMs + delayMs;
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
748
769
|
// Re-check: a hook may have raced us during reconcile.
|
|
749
770
|
const after = this.readState(track.sessionId, this.deps.workContextRoot);
|
|
750
771
|
if (isHookWrittenSessionState(after)) {
|
|
@@ -753,6 +774,8 @@ export class TranscriptWatcher {
|
|
|
753
774
|
}
|
|
754
775
|
this.emitKind(track, "session_start", nowMs);
|
|
755
776
|
track.started = true;
|
|
777
|
+
track.startRetryAttempts = 0;
|
|
778
|
+
track.nextStartRetryAtMs = 0;
|
|
756
779
|
return true;
|
|
757
780
|
}
|
|
758
781
|
emitKind(track, kind, nowMs) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PackContributeKey } from '../../types.js';
|
|
1
2
|
type Logger = (message: string) => void;
|
|
2
3
|
export interface ScanPackagesOptions {
|
|
3
4
|
quiet?: boolean;
|
|
@@ -7,6 +8,7 @@ export interface ScanPackagesOptions {
|
|
|
7
8
|
export interface ScanPackagesResult {
|
|
8
9
|
status: 0 | 1;
|
|
9
10
|
}
|
|
11
|
+
export declare function readContributes(manifest: string): Partial<Record<PackContributeKey, string[]>>;
|
|
10
12
|
/** Wire installed pack contributions into their table-declared host locations. */
|
|
11
13
|
export declare function scanPackages(hqRoot: string, options?: ScanPackagesOptions): ScanPackagesResult;
|
|
12
14
|
export {};
|
|
@@ -18,7 +18,7 @@ catch {
|
|
|
18
18
|
return false;
|
|
19
19
|
} }
|
|
20
20
|
function normalized(relative) { return relative.replaceAll(path.sep, '/'); }
|
|
21
|
-
function readContributes(manifest) {
|
|
21
|
+
export function readContributes(manifest) {
|
|
22
22
|
// This deliberately mirrors the staging script's shallow awk grammar rather
|
|
23
23
|
// than accepting YAML forms the legacy ABI never recognized (notably a
|
|
24
24
|
// non-empty inline list). Mapping remains single-sourced in
|
|
@@ -109,8 +109,19 @@ export interface AcknowledgedRegisterBinding {
|
|
|
109
109
|
}
|
|
110
110
|
/** Allowlisted keys that may appear in the durable state projection. */
|
|
111
111
|
export declare const SESSION_STATE_ALLOWLIST: Set<string>;
|
|
112
|
+
/**
|
|
113
|
+
* Serialize a session-state read/modify/write with every state writer. Atomic
|
|
114
|
+
* rename protects readers from torn files, but only this shared lock protects
|
|
115
|
+
* a writer that has to verify the current binding before changing it.
|
|
116
|
+
*/
|
|
117
|
+
export declare function withSessionStateLock<T>(sessionId: string, root: string, fn: () => T): T;
|
|
112
118
|
export declare function readSessionState(sessionId: string, root: string): SessionStateFile | null;
|
|
113
119
|
export declare function writeSessionState(state: SessionStateFile, root: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* Commit a state change only after inspecting the latest value under the same
|
|
122
|
+
* lock used by ordinary session writers. Returning null declines the write.
|
|
123
|
+
*/
|
|
124
|
+
export declare function updateSessionState(sessionId: string, root: string, update: (current: SessionStateFile | null) => SessionStateFile | null): SessionStateFile | null;
|
|
114
125
|
/** Strip any non-allowlisted fields before durable write. */
|
|
115
126
|
export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
|
|
116
127
|
/**
|
|
@@ -119,6 +130,7 @@ export declare function projectSessionState(state: SessionStateFile): SessionSta
|
|
|
119
130
|
*/
|
|
120
131
|
export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null, options?: {
|
|
121
132
|
preserveBindingEpisode?: boolean;
|
|
133
|
+
preserveCompanyBinding?: boolean;
|
|
122
134
|
}): SessionStateFile;
|
|
123
135
|
/**
|
|
124
136
|
* Upgrade the local projection after the server acknowledges a register.
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* Written only by hq mesh context. Owner 0600. Metadata-only — never prompts/tokens.
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
6
7
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
|
-
import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
|
|
8
|
-
import { NotTrackingError } from "./errors.js";
|
|
8
|
+
import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
|
|
9
|
+
import { NotTrackingError, UnsafeConfigPathError } from "./errors.js";
|
|
9
10
|
import { isSafeWorkContextSegment, workContextSessionPath, workContextSessionsDir, } from "./paths.js";
|
|
10
11
|
/** Allowlisted keys that may appear in the durable state projection. */
|
|
11
12
|
export const SESSION_STATE_ALLOWLIST = new Set([
|
|
@@ -30,6 +31,113 @@ export const SESSION_STATE_ALLOWLIST = new Set([
|
|
|
30
31
|
"companyConfidence",
|
|
31
32
|
"updatedAt",
|
|
32
33
|
]);
|
|
34
|
+
const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
|
|
35
|
+
const SESSION_STATE_LOCK_RETRY_MS = 10;
|
|
36
|
+
function waitForSessionStateLock(ms) {
|
|
37
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
38
|
+
}
|
|
39
|
+
function asPersistenceFailure(err) {
|
|
40
|
+
if (err instanceof NotTrackingError || err instanceof UnsafeConfigPathError)
|
|
41
|
+
throw err;
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
throw new NotTrackingError(`Failed to persist session state: ${message}`, "NOT_TRACKING");
|
|
44
|
+
}
|
|
45
|
+
function reclaimStaleSessionStateLock(lockPath) {
|
|
46
|
+
const claim = `${lockPath}.reclaim.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
47
|
+
try {
|
|
48
|
+
fs.renameSync(lockPath, claim);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "ENOENT")
|
|
52
|
+
return false;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
try {
|
|
60
|
+
fs.unlinkSync(claim);
|
|
61
|
+
}
|
|
62
|
+
catch { /* private claim cannot block writers */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Serialize a session-state read/modify/write with every state writer. Atomic
|
|
67
|
+
* rename protects readers from torn files, but only this shared lock protects
|
|
68
|
+
* a writer that has to verify the current binding before changing it.
|
|
69
|
+
*/
|
|
70
|
+
export function withSessionStateLock(sessionId, root, fn) {
|
|
71
|
+
if (!isSafeWorkContextSegment(sessionId)) {
|
|
72
|
+
throw new NotTrackingError(`Unsafe session id for state file: ${sessionId}`, "NOT_TRACKING");
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
ensureOwnerDir(workContextSessionsDir(root));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
asPersistenceFailure(err);
|
|
79
|
+
}
|
|
80
|
+
const statePath = resolveRealTarget(workContextSessionPath(sessionId, root));
|
|
81
|
+
const lockPath = `${statePath}.lock`;
|
|
82
|
+
const token = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
|
|
83
|
+
const deadline = Date.now() + SESSION_STATE_LOCK_TIMEOUT_MS;
|
|
84
|
+
let fd;
|
|
85
|
+
while (fd === undefined) {
|
|
86
|
+
try {
|
|
87
|
+
fd = fs.openSync(lockPath, "wx", 0o600);
|
|
88
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, token }));
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.code !== "EEXIST")
|
|
92
|
+
asPersistenceFailure(err);
|
|
93
|
+
let ownerPid = 0;
|
|
94
|
+
try {
|
|
95
|
+
const raw = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
96
|
+
ownerPid = typeof raw.pid === "number" && Number.isInteger(raw.pid) ? raw.pid : 0;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// A malformed lock cannot safely protect the state.
|
|
100
|
+
}
|
|
101
|
+
let ownerAlive = false;
|
|
102
|
+
if (ownerPid > 0) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(ownerPid, 0);
|
|
105
|
+
ownerAlive = true;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Dead owners do not retain the lock.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!ownerAlive) {
|
|
112
|
+
try {
|
|
113
|
+
reclaimStaleSessionStateLock(lockPath);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
asPersistenceFailure(err);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
throw new NotTrackingError(`Timed out waiting for session state lock: ${path.basename(statePath)}`, "NOT_TRACKING");
|
|
122
|
+
}
|
|
123
|
+
waitForSessionStateLock(SESSION_STATE_LOCK_RETRY_MS);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return fn();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
fs.closeSync(fd);
|
|
131
|
+
try {
|
|
132
|
+
const current = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
133
|
+
if (current.token === token)
|
|
134
|
+
fs.unlinkSync(lockPath);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// A stale-lock reclaimer or replacement owner has already retired it.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
33
141
|
export function readSessionState(sessionId, root) {
|
|
34
142
|
if (!isSafeWorkContextSegment(sessionId))
|
|
35
143
|
return null;
|
|
@@ -50,6 +158,9 @@ export function writeSessionState(state, root) {
|
|
|
50
158
|
if (!isSafeWorkContextSegment(state.sessionId)) {
|
|
51
159
|
throw new NotTrackingError(`Unsafe session id for state file: ${state.sessionId}`, "NOT_TRACKING");
|
|
52
160
|
}
|
|
161
|
+
return withSessionStateLock(state.sessionId, root, () => writeSessionStateUnlocked(state, root));
|
|
162
|
+
}
|
|
163
|
+
function writeSessionStateUnlocked(state, root) {
|
|
53
164
|
const projected = projectSessionState(state);
|
|
54
165
|
try {
|
|
55
166
|
ensureOwnerDir(workContextSessionsDir(root));
|
|
@@ -61,6 +172,20 @@ export function writeSessionState(state, root) {
|
|
|
61
172
|
throw new NotTrackingError(`Failed to persist session state: ${err instanceof Error ? err.message : String(err)}`, "NOT_TRACKING");
|
|
62
173
|
}
|
|
63
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Commit a state change only after inspecting the latest value under the same
|
|
177
|
+
* lock used by ordinary session writers. Returning null declines the write.
|
|
178
|
+
*/
|
|
179
|
+
export function updateSessionState(sessionId, root, update) {
|
|
180
|
+
return withSessionStateLock(sessionId, root, () => {
|
|
181
|
+
const current = readSessionState(sessionId, root);
|
|
182
|
+
const next = update(current);
|
|
183
|
+
if (!next)
|
|
184
|
+
return null;
|
|
185
|
+
writeSessionStateUnlocked(next, root);
|
|
186
|
+
return next;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
64
189
|
/** Strip any non-allowlisted fields before durable write. */
|
|
65
190
|
export function projectSessionState(state) {
|
|
66
191
|
const out = {
|
|
@@ -118,7 +243,10 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
|
|
|
118
243
|
// projected locally; losing it causes held events and repeat registrations.
|
|
119
244
|
// A caller that carries either company field is intentionally changing the
|
|
120
245
|
// binding and remains authoritative.
|
|
121
|
-
if (
|
|
246
|
+
if (options.preserveCompanyBinding !== false &&
|
|
247
|
+
!out.companyUid &&
|
|
248
|
+
!out.companySlug &&
|
|
249
|
+
(prior.companyUid || prior.companySlug)) {
|
|
122
250
|
out.companyUid = prior.companyUid;
|
|
123
251
|
out.companySlug = prior.companySlug;
|
|
124
252
|
out.contextStatus = prior.contextStatus;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.128.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"assets/scaffold",
|
|
14
14
|
"assets/bot-workers",
|
|
15
15
|
"assets/mesh-daemon",
|
|
16
|
+
"assets/lanes-reconcile",
|
|
17
|
+
"assets/lanes-hooks",
|
|
16
18
|
"CHANGELOG.md",
|
|
17
19
|
"README.md"
|
|
18
20
|
],
|