@indigoai-us/hq-cli 5.125.0 → 5.127.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 +329 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- 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 +643 -6
- package/dist/command-catalog.generated.js +836 -6
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +11 -1
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +114 -0
- package/dist/commands/lanes.js +2969 -0
- package/dist/commands/mesh.js +287 -24
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/lanes/accounts.d.ts +103 -0
- package/dist/lib/lanes/accounts.js +318 -0
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +71 -0
- package/dist/lib/lanes/config.js +408 -0
- 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 +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- 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 +49 -0
- package/dist/lib/lanes/index.js +27 -0
- package/dist/lib/lanes/liveness.d.ts +95 -0
- package/dist/lib/lanes/liveness.js +332 -0
- 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 +116 -0
- package/dist/lib/lanes/registry.js +521 -0
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +68 -0
- package/dist/lib/lanes/store.js +745 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +535 -0
- package/dist/lib/lanes/types.js +350 -0
- 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/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- 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 +18 -13
- package/dist/lib/work-context/config.d.ts +20 -0
- package/dist/lib/work-context/config.js +26 -5
- package/dist/lib/work-context/reconcile.js +28 -7
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- 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
|
|
@@ -163,34 +163,39 @@ function ensureSymlink(link, info, warn) {
|
|
|
163
163
|
warn(`payload missing: ${link.src} (declared but not shipped)`);
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
166
|
+
const relativeSrc = path.relative(path.dirname(link.dst), link.src);
|
|
166
167
|
fs.mkdirSync(path.dirname(link.dst), { recursive: true });
|
|
167
168
|
try {
|
|
168
169
|
const stat = fs.lstatSync(link.dst);
|
|
169
170
|
if (stat.isSymbolicLink()) {
|
|
170
171
|
const existing = fs.readlinkSync(link.dst);
|
|
171
|
-
|
|
172
|
+
// Correct form is relative to the host directory so a tree can move
|
|
173
|
+
// between machines. An absolute match (this host, pre-relative installer)
|
|
174
|
+
// is ours and converges. A dangling leftover from another HQ root is
|
|
175
|
+
// not host content and is rewritten the same way.
|
|
176
|
+
if (existing === relativeSrc)
|
|
172
177
|
return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
if (existing === link.src) {
|
|
179
|
+
fs.rmSync(link.dst, { force: true });
|
|
180
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
181
|
+
info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing})`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
179
184
|
// Only a lookup that specifically says "not there" earns a relink.
|
|
180
185
|
// fs.existsSync also answers false for EACCES, which would read content
|
|
181
186
|
// this user merely cannot traverse as absent and delete the link to it.
|
|
182
187
|
const lookup = targetLookup(link.dst);
|
|
183
188
|
if (lookup === 'absent') {
|
|
184
189
|
fs.rmSync(link.dst, { force: true });
|
|
185
|
-
fs.symlinkSync(
|
|
186
|
-
info(`relinked ${link.dst} -> ${
|
|
190
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
191
|
+
info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing}, which does not exist here)`);
|
|
187
192
|
return;
|
|
188
193
|
}
|
|
189
194
|
if (lookup !== 'present') {
|
|
190
195
|
warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
|
|
191
196
|
return;
|
|
192
197
|
}
|
|
193
|
-
warn(`collision: ${link.dst} already points at ${existing} (wanted ${
|
|
198
|
+
warn(`collision: ${link.dst} already points at ${existing} (wanted ${relativeSrc}) — skipping`);
|
|
194
199
|
return;
|
|
195
200
|
}
|
|
196
201
|
warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
|
|
@@ -200,8 +205,8 @@ function ensureSymlink(link, info, warn) {
|
|
|
200
205
|
if (error.code !== 'ENOENT')
|
|
201
206
|
throw error;
|
|
202
207
|
}
|
|
203
|
-
fs.symlinkSync(
|
|
204
|
-
info(`linked ${link.dst} -> ${
|
|
208
|
+
fs.symlinkSync(relativeSrc, link.dst);
|
|
209
|
+
info(`linked ${link.dst} -> ${relativeSrc}`);
|
|
205
210
|
}
|
|
206
211
|
/**
|
|
207
212
|
* The link text, when `entry` is a symlink whose target is definitively not
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Device-local default company preference (~/.hq/work-context/config.json).
|
|
3
3
|
* Preference only — never authority. activeCompany is never treated as default.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { type WindowsAclCheckOptions } from "./windows-acl.js";
|
|
5
7
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
6
8
|
export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
|
|
7
9
|
export interface DeviceDefaultCompany {
|
|
@@ -81,6 +83,24 @@ export declare function acquireDeviceConfigLock(root: string, options?: {
|
|
|
81
83
|
timeoutMs?: number;
|
|
82
84
|
staleMs?: number;
|
|
83
85
|
}): () => void;
|
|
86
|
+
/** Injection points for assertSafeConfigPath (tests only). */
|
|
87
|
+
export interface SafeConfigPathIo {
|
|
88
|
+
platform?: NodeJS.Platform;
|
|
89
|
+
existsSync?: (p: string) => boolean;
|
|
90
|
+
lstatSync?: (p: string) => Pick<fs.Stats, "mode" | "isSymbolicLink">;
|
|
91
|
+
windowsAcl?: WindowsAclCheckOptions;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Refuse a config path that is a symlink (every platform) or writable by
|
|
95
|
+
* anyone other than the owner.
|
|
96
|
+
*
|
|
97
|
+
* - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
|
|
98
|
+
* - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
|
|
99
|
+
* file, so the mode test would reject every config (US-018). We read the
|
|
100
|
+
* real DACL instead (see windows-acl.ts). Unreadable DACL → documented
|
|
101
|
+
* reduced-guarantee path: one diagnostic line, no throw.
|
|
102
|
+
*/
|
|
103
|
+
export declare function assertSafeConfigPath(configPath: string, io?: SafeConfigPathIo): void;
|
|
84
104
|
export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
|
|
85
105
|
export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
|
|
86
106
|
/** A clear is a durable opt-out, not an invitation for automatic reactivation. */
|
|
@@ -7,6 +7,7 @@ import * as path from "node:path";
|
|
|
7
7
|
import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
|
|
8
8
|
import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
|
|
9
9
|
import { workContextConfigPath, workContextRoot } from "./paths.js";
|
|
10
|
+
import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
|
|
10
11
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
11
12
|
export const DEVICE_CONFIG_SCHEMA_VERSION = 1;
|
|
12
13
|
const CONFIG_LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -147,20 +148,40 @@ function sanitizeRepoCompanyMap(raw) {
|
|
|
147
148
|
}
|
|
148
149
|
return Object.keys(out).length > 0 ? out : null;
|
|
149
150
|
}
|
|
150
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Refuse a config path that is a symlink (every platform) or writable by
|
|
153
|
+
* anyone other than the owner.
|
|
154
|
+
*
|
|
155
|
+
* - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
|
|
156
|
+
* - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
|
|
157
|
+
* file, so the mode test would reject every config (US-018). We read the
|
|
158
|
+
* real DACL instead (see windows-acl.ts). Unreadable DACL → documented
|
|
159
|
+
* reduced-guarantee path: one diagnostic line, no throw.
|
|
160
|
+
*/
|
|
161
|
+
export function assertSafeConfigPath(configPath, io = {}) {
|
|
162
|
+
const platform = io.platform ?? process.platform;
|
|
163
|
+
const existsSync = io.existsSync ?? fs.existsSync;
|
|
164
|
+
const lstatSync = io.lstatSync ?? fs.lstatSync;
|
|
151
165
|
const dir = path.dirname(configPath);
|
|
152
166
|
try {
|
|
153
|
-
if (
|
|
154
|
-
const lst =
|
|
167
|
+
if (existsSync(dir)) {
|
|
168
|
+
const lst = lstatSync(dir);
|
|
155
169
|
if (lst.isSymbolicLink()) {
|
|
156
170
|
throw new UnsafeConfigPathError(`Config directory is a symlink: ${dir}`);
|
|
157
171
|
}
|
|
172
|
+
if (platform === "win32") {
|
|
173
|
+
assertWindowsOwnerOnlyAcl(dir, "directory", io.windowsAcl);
|
|
174
|
+
}
|
|
158
175
|
}
|
|
159
|
-
if (
|
|
160
|
-
const lst =
|
|
176
|
+
if (existsSync(configPath)) {
|
|
177
|
+
const lst = lstatSync(configPath);
|
|
161
178
|
if (lst.isSymbolicLink()) {
|
|
162
179
|
throw new UnsafeConfigPathError(`Config file is a symlink: ${configPath}`);
|
|
163
180
|
}
|
|
181
|
+
if (platform === "win32") {
|
|
182
|
+
assertWindowsOwnerOnlyAcl(configPath, "file", io.windowsAcl);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
164
185
|
const mode = lst.mode & 0o777;
|
|
165
186
|
// Warn-level only for world-writable; refuse group/other write bits on read for safety.
|
|
166
187
|
if (mode & 0o022) {
|
|
@@ -701,17 +701,37 @@ export async function reconcileObservation(obs, deps) {
|
|
|
701
701
|
if (delivered.ok) {
|
|
702
702
|
markOutboxAcked(outboxOp.operationId, deps.root, delivered.receiptId, nowFn);
|
|
703
703
|
writeAckedRegisterState(outboxOp, deps.root, delivered.receiptId, nowFn);
|
|
704
|
-
//
|
|
705
|
-
|
|
704
|
+
// Company attribution write for EVERY acked register that resolved a
|
|
705
|
+
// company — not only bound sessions. A company-only ack (needs_project /
|
|
706
|
+
// needs_task / unresolved) must still project companyUid/companySlug and
|
|
707
|
+
// the true computed classification into durable session state; otherwise
|
|
708
|
+
// the emitter and mesh presence lose the tenant the server already
|
|
709
|
+
// acknowledged. projectId / taskId are written only when genuinely
|
|
710
|
+
// resolved, and a fresh bindingEpisodeId only when the session is bound —
|
|
711
|
+
// never fabricated on a partial path. (writeAckedRegisterState above may
|
|
712
|
+
// have already recorded a receipt-derived episode for a resolved partial
|
|
713
|
+
// binding; the default merge preserves that when the binding still matches.)
|
|
714
|
+
if (resolvedCompany.uid) {
|
|
715
|
+
const bound = registerClassification === "bound";
|
|
716
|
+
// A server ack of the company makes it authoritative: the durable status
|
|
717
|
+
// is at least needs_project (company known, no project), needs_task
|
|
718
|
+
// (project, no task), or bound (project + task) — mirroring
|
|
719
|
+
// acknowledgedStatus() in state.ts. Never downgrade an acked company to a
|
|
720
|
+
// pre-threshold "unresolved" projection; that is what dropped the tenant.
|
|
721
|
+
const ackedClassification = projectId && taskId
|
|
722
|
+
? "bound"
|
|
723
|
+
: projectId
|
|
724
|
+
? "needs_task"
|
|
725
|
+
: "needs_project";
|
|
706
726
|
writeSessionState(mergeLocalSessionFields({
|
|
707
727
|
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
708
728
|
sessionId,
|
|
709
729
|
companyUid: resolvedCompany.uid,
|
|
710
730
|
companySlug: resolvedCompany.slug,
|
|
711
|
-
contextStatus: "bound",
|
|
712
|
-
projectId,
|
|
713
|
-
taskId,
|
|
714
|
-
bindingEpisodeId: delivered.receiptId,
|
|
731
|
+
contextStatus: bound ? "bound" : ackedClassification,
|
|
732
|
+
...(projectId ? { projectId } : {}),
|
|
733
|
+
...(taskId ? { taskId } : {}),
|
|
734
|
+
...(bound ? { bindingEpisodeId: delivered.receiptId } : {}),
|
|
715
735
|
updatedAt: nowFn().toISOString(),
|
|
716
736
|
startedAt,
|
|
717
737
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
@@ -719,7 +739,8 @@ export async function reconcileObservation(obs, deps) {
|
|
|
719
739
|
...transcriptFields,
|
|
720
740
|
},
|
|
721
741
|
// Merge against the just-written projection rather than the stale
|
|
722
|
-
// snapshot
|
|
742
|
+
// pre-reconcile snapshot, so device confidence and any receipt
|
|
743
|
+
// attribution survive.
|
|
723
744
|
readSessionState(sessionId, deps.root) ?? stateWrite), deps.root);
|
|
724
745
|
}
|
|
725
746
|
const kind = registerClassification === "bound"
|
|
@@ -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.
|