@indigoai-us/hq-cli 5.145.0 → 5.147.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 +20 -0
- package/dist/command-catalog.generated.d.ts +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/mesh.js +132 -1
- package/dist/commands/report.d.ts +6 -0
- package/dist/commands/report.js +23 -5
- package/dist/lib/mesh/api.d.ts +1 -1
- package/dist/lib/mesh/api.js +6 -2
- package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/credentials.js +141 -10
- package/dist/lib/mesh/live/daemon/doctor.js +4 -3
- package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/presence.js +69 -1
- package/dist/lib/mesh/live/daemon/run.js +59 -3
- package/dist/lib/mesh/live/flush.d.ts +8 -0
- package/dist/lib/mesh/live/flush.js +71 -9
- package/dist/lib/mesh/live/session-seq.d.ts +4 -0
- package/dist/lib/mesh/live/session-seq.js +33 -0
- package/dist/lib/mesh/project-board.d.ts +14 -0
- package/dist/lib/mesh/project-board.js +32 -1
- package/dist/lib/work-context/bind-best.d.ts +2 -0
- package/dist/lib/work-context/bind-best.js +34 -1
- package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
- package/dist/lib/work-context/bind-calling-session.js +63 -0
- package/dist/lib/work-context/prd-sync.d.ts +34 -0
- package/dist/lib/work-context/prd-sync.js +120 -0
- package/dist/lib/work-context/prompt-rebind.d.ts +25 -0
- package/dist/lib/work-context/prompt-rebind.js +85 -0
- package/dist/lib/work-context/reconcile.d.ts +2 -0
- package/dist/lib/work-context/reconcile.js +31 -0
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +8 -0
- package/package.json +1 -1
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Contract-3 realtime credential vend + 80%-of-lifetime renewal.
|
|
3
3
|
* Never logs or persists credential material.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
5
7
|
import { DEFAULT_VAULT_API_URL } from "../../../../utils/cognito-session.js";
|
|
6
8
|
import { vaultApiFetch } from "../../../../utils/vault-api.js";
|
|
7
9
|
import { readAgentIdentityCompanyUid } from "../../../work-context/company.js";
|
|
10
|
+
import { readDeviceConfig } from "../../../work-context/config.js";
|
|
11
|
+
import { workContextSessionsDir } from "../../../work-context/paths.js";
|
|
8
12
|
export const REALTIME_CREDENTIALS_PATH = "/v1/realtime/credentials";
|
|
9
13
|
export const MQTT_KEEPALIVE_SECONDS = 15;
|
|
10
14
|
/** Renew at 80% of credential lifetime. */
|
|
@@ -20,6 +24,12 @@ export const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
|
|
|
20
24
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
21
25
|
/** Cap any single retry so a bogus Retry-After cannot park the daemon. */
|
|
22
26
|
export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
/** Contract-3 vend accepts at most this many preferred company uids. */
|
|
28
|
+
export const PREFERRED_COMPANY_LIMIT = 5;
|
|
29
|
+
/** Session files older than this do not count as companies in use. */
|
|
30
|
+
export const PREFERRED_SESSION_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
/** Preferred-set changes re-vend at most this often. */
|
|
32
|
+
export const PREFERRED_REVEND_MIN_MS = 60_000;
|
|
23
33
|
/**
|
|
24
34
|
* Typed vend failure. `refused` = server will not vend right now
|
|
25
35
|
* (FEATURE_DISABLED / REALTIME_CONTRACT_UNSUPPORTED / 403); daemon should
|
|
@@ -241,8 +251,14 @@ export function normalizeContract3Bundle(raw) {
|
|
|
241
251
|
const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
|
|
242
252
|
if (!companyUid)
|
|
243
253
|
continue;
|
|
254
|
+
const slug = typeof row.companySlug === "string"
|
|
255
|
+
? row.companySlug.trim()
|
|
256
|
+
: typeof row.slug === "string"
|
|
257
|
+
? row.slug.trim()
|
|
258
|
+
: "";
|
|
244
259
|
droppedCompanies.push({
|
|
245
260
|
companyUid,
|
|
261
|
+
...(slug ? { label: slug } : {}),
|
|
246
262
|
reason: typeof row.reason === "string" && row.reason.trim()
|
|
247
263
|
? row.reason.trim()
|
|
248
264
|
: "not included",
|
|
@@ -282,6 +298,101 @@ export function scopeBundleToAgentIdentity(bundle, env = process.env) {
|
|
|
282
298
|
}
|
|
283
299
|
return { ...bundle, companies: matched };
|
|
284
300
|
}
|
|
301
|
+
function pushDistinct(out, seen, uid) {
|
|
302
|
+
const trimmed = uid?.trim();
|
|
303
|
+
if (!trimmed || seen.has(trimmed) || out.length >= PREFERRED_COMPANY_LIMIT)
|
|
304
|
+
return;
|
|
305
|
+
seen.add(trimmed);
|
|
306
|
+
out.push(trimmed);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Device default first, then distinct company uids from session files updated
|
|
310
|
+
* in the last 24 hours, newest first, max 5. Any read error returns undefined
|
|
311
|
+
* so the vend body omits the field.
|
|
312
|
+
*/
|
|
313
|
+
export function readPreferredCompanyUids(input) {
|
|
314
|
+
try {
|
|
315
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
316
|
+
const config = readDeviceConfig({ root: input.workContextRoot });
|
|
317
|
+
const out = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
if (config.defaultCompany?.enabled && config.defaultCompany.uid) {
|
|
320
|
+
pushDistinct(out, seen, config.defaultCompany.uid);
|
|
321
|
+
}
|
|
322
|
+
const dir = workContextSessionsDir(input.workContextRoot);
|
|
323
|
+
let names = [];
|
|
324
|
+
try {
|
|
325
|
+
names = fs.readdirSync(dir);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
const code = err.code;
|
|
329
|
+
if (code !== "ENOENT")
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
const recent = [];
|
|
333
|
+
for (const name of names) {
|
|
334
|
+
if (!name.endsWith(".json"))
|
|
335
|
+
continue;
|
|
336
|
+
const full = path.join(dir, name);
|
|
337
|
+
const stat = fs.statSync(full);
|
|
338
|
+
if (!stat.isFile())
|
|
339
|
+
continue;
|
|
340
|
+
const raw = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
341
|
+
const parsedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : Number.NaN;
|
|
342
|
+
const at = Number.isFinite(parsedAt) ? parsedAt : stat.mtimeMs;
|
|
343
|
+
if (!Number.isFinite(at) || nowMs - at > PREFERRED_SESSION_WINDOW_MS)
|
|
344
|
+
continue;
|
|
345
|
+
if (nowMs < at)
|
|
346
|
+
continue;
|
|
347
|
+
if (typeof raw.companyUid !== "string")
|
|
348
|
+
continue;
|
|
349
|
+
recent.push({ uid: raw.companyUid, at });
|
|
350
|
+
}
|
|
351
|
+
recent.sort((a, b) => b.at - a.at || a.uid.localeCompare(b.uid));
|
|
352
|
+
for (const row of recent)
|
|
353
|
+
pushDistinct(out, seen, row.uid);
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
export function preferredCompanyKey(uids) {
|
|
361
|
+
return (uids ?? []).join("\n");
|
|
362
|
+
}
|
|
363
|
+
/** True when a new preferred set should force a vend, at most once per minute. */
|
|
364
|
+
export function shouldRevendPreferredCompanies(input) {
|
|
365
|
+
if (!input.next)
|
|
366
|
+
return false;
|
|
367
|
+
if (preferredCompanyKey(input.previous) === preferredCompanyKey(input.next)) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const min = input.minIntervalMs ?? PREFERRED_REVEND_MIN_MS;
|
|
371
|
+
return input.nowMs - input.lastVendMs >= min;
|
|
372
|
+
}
|
|
373
|
+
export function contract3VendBody(preferredCompanyUids) {
|
|
374
|
+
const body = {
|
|
375
|
+
contractVersion: 3,
|
|
376
|
+
};
|
|
377
|
+
if (preferredCompanyUids && preferredCompanyUids.length > 0) {
|
|
378
|
+
body.preferredCompanyUids = preferredCompanyUids.slice(0, PREFERRED_COMPANY_LIMIT);
|
|
379
|
+
}
|
|
380
|
+
return body;
|
|
381
|
+
}
|
|
382
|
+
/** Plain doctor line for one company the vend left out. */
|
|
383
|
+
export function formatDroppedPresenceCompany(company) {
|
|
384
|
+
const name = company.label?.trim() || company.companyUid;
|
|
385
|
+
if (company.reason === "gate_disabled") {
|
|
386
|
+
return `${name}: not included (presence is turned off for this company)`;
|
|
387
|
+
}
|
|
388
|
+
if (company.reason === "policy_budget") {
|
|
389
|
+
return `${name}: not included (too many companies for one credential)`;
|
|
390
|
+
}
|
|
391
|
+
if (!company.reason || company.reason === "unknown" || company.reason === "not included") {
|
|
392
|
+
return `${name}: not included`;
|
|
393
|
+
}
|
|
394
|
+
return `${name}: not included (${company.reason})`;
|
|
395
|
+
}
|
|
285
396
|
function headerGet(headers, name) {
|
|
286
397
|
if (!headers)
|
|
287
398
|
return null;
|
|
@@ -294,11 +405,19 @@ function headerGet(headers, name) {
|
|
|
294
405
|
}
|
|
295
406
|
export function createContract3Fetcher(opts) {
|
|
296
407
|
return async () => {
|
|
408
|
+
let preferred;
|
|
409
|
+
if (opts.readPreferredCompanyUids) {
|
|
410
|
+
try {
|
|
411
|
+
preferred = opts.readPreferredCompanyUids();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
preferred = undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const body = contract3VendBody(preferred);
|
|
297
418
|
let bundle;
|
|
298
419
|
if (opts.post) {
|
|
299
|
-
const res = await opts.post(REALTIME_CREDENTIALS_PATH,
|
|
300
|
-
contractVersion: 3,
|
|
301
|
-
});
|
|
420
|
+
const res = await opts.post(REALTIME_CREDENTIALS_PATH, body);
|
|
302
421
|
if (res.status < 200 || res.status >= 300) {
|
|
303
422
|
throw classifyCredentialVendFailure(res.status, res.body, headerGet(res.headers, "Retry-After"));
|
|
304
423
|
}
|
|
@@ -309,23 +428,23 @@ export function createContract3Fetcher(opts) {
|
|
|
309
428
|
token: opts.token,
|
|
310
429
|
path: REALTIME_CREDENTIALS_PATH,
|
|
311
430
|
method: "POST",
|
|
312
|
-
body
|
|
431
|
+
body,
|
|
313
432
|
baseUrl: opts.baseUrl ?? DEFAULT_VAULT_API_URL,
|
|
314
433
|
});
|
|
315
434
|
const text = await res.text();
|
|
316
|
-
let
|
|
435
|
+
let parsed = {};
|
|
317
436
|
if (text) {
|
|
318
437
|
try {
|
|
319
|
-
|
|
438
|
+
parsed = JSON.parse(text);
|
|
320
439
|
}
|
|
321
440
|
catch {
|
|
322
|
-
|
|
441
|
+
parsed = { raw: text.slice(0, 200) };
|
|
323
442
|
}
|
|
324
443
|
}
|
|
325
444
|
if (!res.ok) {
|
|
326
|
-
throw classifyCredentialVendFailure(res.status,
|
|
445
|
+
throw classifyCredentialVendFailure(res.status, parsed, res.headers.get("Retry-After"));
|
|
327
446
|
}
|
|
328
|
-
bundle = normalizeContract3Bundle(
|
|
447
|
+
bundle = normalizeContract3Bundle(parsed);
|
|
329
448
|
}
|
|
330
449
|
return scopeBundleToAgentIdentity(bundle, opts.env ?? process.env);
|
|
331
450
|
};
|
|
@@ -344,6 +463,7 @@ export class CredentialRenewalManager {
|
|
|
344
463
|
random;
|
|
345
464
|
handle = null;
|
|
346
465
|
stopped = false;
|
|
466
|
+
inflight = false;
|
|
347
467
|
constructor(fetchCredentials, onRenewed, onError = () => { }, timers = realTimerHost, retryDelayMs = 30_000, refusedRetryMs = DEFAULT_REFUSED_RETRY_MS, random = Math.random) {
|
|
348
468
|
this.fetchCredentials = fetchCredentials;
|
|
349
469
|
this.onRenewed = onRenewed;
|
|
@@ -360,9 +480,17 @@ export class CredentialRenewalManager {
|
|
|
360
480
|
const delay = renewalDelayMs(this.timers.now(), bundle.expiresAt);
|
|
361
481
|
this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
|
|
362
482
|
}
|
|
483
|
+
/** Vend now (preferred companies changed). No-op while a vend is in flight. */
|
|
484
|
+
renewNow() {
|
|
485
|
+
if (this.stopped || this.inflight)
|
|
486
|
+
return;
|
|
487
|
+
this.clear();
|
|
488
|
+
void this.renew();
|
|
489
|
+
}
|
|
363
490
|
async renew() {
|
|
364
|
-
if (this.stopped)
|
|
491
|
+
if (this.stopped || this.inflight)
|
|
365
492
|
return;
|
|
493
|
+
this.inflight = true;
|
|
366
494
|
try {
|
|
367
495
|
const bundle = await this.fetchCredentials();
|
|
368
496
|
if (this.stopped)
|
|
@@ -389,6 +517,9 @@ export class CredentialRenewalManager {
|
|
|
389
517
|
return;
|
|
390
518
|
this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
|
|
391
519
|
}
|
|
520
|
+
finally {
|
|
521
|
+
this.inflight = false;
|
|
522
|
+
}
|
|
392
523
|
}
|
|
393
524
|
stop() {
|
|
394
525
|
this.stopped = true;
|
|
@@ -9,6 +9,7 @@ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedToke
|
|
|
9
9
|
import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
|
|
10
10
|
import { readSessionState } from "../../../work-context/state.js";
|
|
11
11
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
12
|
+
import { formatDroppedPresenceCompany } from "./credentials.js";
|
|
12
13
|
import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
13
14
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
14
15
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
@@ -239,9 +240,9 @@ export function formatDaemonDoctor(report) {
|
|
|
239
240
|
if (report.presenceDroppedCompanies.length > 0) {
|
|
240
241
|
lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
|
|
241
242
|
report.presenceDroppedCompanies
|
|
242
|
-
.map((company) =>
|
|
243
|
-
.join("
|
|
244
|
-
`; presence credential holds ${report.companiesOnline.length} companies
|
|
243
|
+
.map((company) => formatDroppedPresenceCompany(company))
|
|
244
|
+
.join("; ") +
|
|
245
|
+
`; presence credential holds ${report.companiesOnline.length} companies`);
|
|
245
246
|
}
|
|
246
247
|
if (report.unhealthy) {
|
|
247
248
|
lines.push(`health: UNHEALTHY`);
|
|
@@ -84,6 +84,13 @@ export interface PresenceClientOptions {
|
|
|
84
84
|
onRefusal?: (refusal: PresenceRefusal | null) => void;
|
|
85
85
|
now?: () => Date;
|
|
86
86
|
env?: NodeJS.ProcessEnv;
|
|
87
|
+
/**
|
|
88
|
+
* Companies the daemon is working in. When the set changes, presence
|
|
89
|
+
* re-vends instead of waiting for credential expiry (at most once a minute).
|
|
90
|
+
* Undefined means a read failed — do not re-vend.
|
|
91
|
+
*/
|
|
92
|
+
readPreferredCompanyUids?: () => string[] | undefined;
|
|
93
|
+
preferredRevendMinMs?: number;
|
|
87
94
|
}
|
|
88
95
|
/**
|
|
89
96
|
* Full-jitter capped exponential backoff (1s base → 60s cap by default),
|
|
@@ -123,6 +130,9 @@ export declare class PresenceClient {
|
|
|
123
130
|
/** Last refusal code we logged via onError (episode dedupe). */
|
|
124
131
|
private loggedRefusalKey;
|
|
125
132
|
private refusal;
|
|
133
|
+
private preferredHandle;
|
|
134
|
+
private preferredSnapshot;
|
|
135
|
+
private lastPreferredVendMs;
|
|
126
136
|
private readonly mqttConnect;
|
|
127
137
|
private readonly timers;
|
|
128
138
|
private readonly random;
|
|
@@ -137,6 +147,11 @@ export declare class PresenceClient {
|
|
|
137
147
|
getRefusal(): PresenceRefusal | null;
|
|
138
148
|
/** Topics this client has published to (for doctor / tests). */
|
|
139
149
|
getPublishedTopics(): readonly string[];
|
|
150
|
+
private preferredMinMs;
|
|
151
|
+
private notePreferredVend;
|
|
152
|
+
private clearPreferredWatch;
|
|
153
|
+
private armPreferredWatch;
|
|
154
|
+
private maybeRevendPreferred;
|
|
140
155
|
private setState;
|
|
141
156
|
start(): Promise<void>;
|
|
142
157
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - Server credential refusals use a long retry (default 10m ±20%)
|
|
13
13
|
*/
|
|
14
14
|
import mqtt from "mqtt";
|
|
15
|
-
import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
|
|
15
|
+
import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, PREFERRED_REVEND_MIN_MS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, shouldRevendPreferredCompanies, } from "./credentials.js";
|
|
16
16
|
import { presignIotWssUrl } from "./presign.js";
|
|
17
17
|
/**
|
|
18
18
|
* A connection that drops before this grace window after `connect` is treated
|
|
@@ -93,6 +93,9 @@ export class PresenceClient {
|
|
|
93
93
|
/** Last refusal code we logged via onError (episode dedupe). */
|
|
94
94
|
loggedRefusalKey = null;
|
|
95
95
|
refusal = null;
|
|
96
|
+
preferredHandle = null;
|
|
97
|
+
preferredSnapshot = [];
|
|
98
|
+
lastPreferredVendMs = 0;
|
|
96
99
|
mqttConnect;
|
|
97
100
|
timers;
|
|
98
101
|
random;
|
|
@@ -128,6 +131,67 @@ export class PresenceClient {
|
|
|
128
131
|
getPublishedTopics() {
|
|
129
132
|
return this.publishedTopics;
|
|
130
133
|
}
|
|
134
|
+
preferredMinMs() {
|
|
135
|
+
return this.options.preferredRevendMinMs ?? PREFERRED_REVEND_MIN_MS;
|
|
136
|
+
}
|
|
137
|
+
notePreferredVend() {
|
|
138
|
+
const read = this.options.readPreferredCompanyUids;
|
|
139
|
+
if (!read)
|
|
140
|
+
return;
|
|
141
|
+
let next;
|
|
142
|
+
try {
|
|
143
|
+
next = read();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!next)
|
|
149
|
+
return;
|
|
150
|
+
this.preferredSnapshot = next;
|
|
151
|
+
this.lastPreferredVendMs = this.timers.now();
|
|
152
|
+
}
|
|
153
|
+
clearPreferredWatch() {
|
|
154
|
+
if (this.preferredHandle) {
|
|
155
|
+
this.preferredHandle.clear();
|
|
156
|
+
this.preferredHandle = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
armPreferredWatch() {
|
|
160
|
+
if (!this.options.readPreferredCompanyUids || this.stopped)
|
|
161
|
+
return;
|
|
162
|
+
this.clearPreferredWatch();
|
|
163
|
+
this.preferredHandle = scheduleBoundedTimeout(this.timers, () => {
|
|
164
|
+
this.preferredHandle = null;
|
|
165
|
+
if (this.stopped)
|
|
166
|
+
return;
|
|
167
|
+
this.maybeRevendPreferred();
|
|
168
|
+
this.armPreferredWatch();
|
|
169
|
+
}, this.preferredMinMs());
|
|
170
|
+
}
|
|
171
|
+
maybeRevendPreferred() {
|
|
172
|
+
const read = this.options.readPreferredCompanyUids;
|
|
173
|
+
if (!read || this.stopped || this.refusal)
|
|
174
|
+
return;
|
|
175
|
+
let next;
|
|
176
|
+
try {
|
|
177
|
+
next = read();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!shouldRevendPreferredCompanies({
|
|
183
|
+
previous: this.preferredSnapshot,
|
|
184
|
+
next,
|
|
185
|
+
lastVendMs: this.lastPreferredVendMs,
|
|
186
|
+
nowMs: this.timers.now(),
|
|
187
|
+
minIntervalMs: this.preferredMinMs(),
|
|
188
|
+
})) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.lastPreferredVendMs = this.timers.now();
|
|
192
|
+
this.preferredSnapshot = next;
|
|
193
|
+
this.renewal.renewNow();
|
|
194
|
+
}
|
|
131
195
|
setState(state) {
|
|
132
196
|
if (this.state === state)
|
|
133
197
|
return;
|
|
@@ -149,6 +213,7 @@ export class PresenceClient {
|
|
|
149
213
|
this.stopped = true;
|
|
150
214
|
this.generation += 1;
|
|
151
215
|
this.renewal.stop();
|
|
216
|
+
this.clearPreferredWatch();
|
|
152
217
|
this.clearReconnect();
|
|
153
218
|
this.clearStableTimer();
|
|
154
219
|
this.connectedAtMs = null;
|
|
@@ -190,6 +255,7 @@ export class PresenceClient {
|
|
|
190
255
|
}
|
|
191
256
|
async onCredentialsRenewed(bundle) {
|
|
192
257
|
this.bundle = bundle;
|
|
258
|
+
this.notePreferredVend();
|
|
193
259
|
// Successful vend ends a refusal episode (renewal skips connectOnce's vend path).
|
|
194
260
|
this.clearRefusal(true);
|
|
195
261
|
if (this.stopped)
|
|
@@ -249,6 +315,8 @@ export class PresenceClient {
|
|
|
249
315
|
// Successful vend ends a refusal episode (even before MQTT connects).
|
|
250
316
|
this.clearRefusal(true);
|
|
251
317
|
this.renewal.schedule(this.bundle);
|
|
318
|
+
this.notePreferredVend();
|
|
319
|
+
this.armPreferredWatch();
|
|
252
320
|
}
|
|
253
321
|
const bundle = this.bundle;
|
|
254
322
|
const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, this.now());
|
|
@@ -26,7 +26,7 @@ import { backfillHeldSessions, } from "../backfill-held.js";
|
|
|
26
26
|
import { reconcileObservation } from "../../../work-context/reconcile.js";
|
|
27
27
|
import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
|
|
28
28
|
import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
|
|
29
|
-
import { createContract3Fetcher, createPersonalRealtimeFetcher, realTimerHost, } from "./credentials.js";
|
|
29
|
+
import { createContract3Fetcher, createPersonalRealtimeFetcher, readPreferredCompanyUids, realTimerHost, } from "./credentials.js";
|
|
30
30
|
import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
|
|
31
31
|
import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
|
|
32
32
|
import { daemonDir } from "./paths.js";
|
|
@@ -165,8 +165,20 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
165
165
|
? readSessionState(input.sessionId, ctxRoot)
|
|
166
166
|
: null);
|
|
167
167
|
const slug = input.companySlug ?? state?.companySlug;
|
|
168
|
+
// A uid alone is enough to ask /membership/me. Missing slug is not a
|
|
169
|
+
// denial. A device-default session whose company was cleared can be
|
|
170
|
+
// checked against the configured default; that is the same company the
|
|
171
|
+
// daemon assigned before the false denial.
|
|
172
|
+
const configuredDefault = input.deviceDefault
|
|
173
|
+
? readDeviceConfig({ root: ctxRoot }).defaultCompany
|
|
174
|
+
: undefined;
|
|
175
|
+
const defaultCompany = configuredDefault?.enabled ? configuredDefault : undefined;
|
|
176
|
+
const usingDefaultFallback = !slug && !state?.companyUid && Boolean(defaultCompany?.slug || defaultCompany?.uid);
|
|
177
|
+
const lookupKey = slug || state?.companyUid || (usingDefaultFallback
|
|
178
|
+
? (defaultCompany?.slug || defaultCompany?.uid)
|
|
179
|
+
: undefined);
|
|
168
180
|
let authorization;
|
|
169
|
-
if (!
|
|
181
|
+
if (!lookupKey) {
|
|
170
182
|
authorization = { status: "denied" };
|
|
171
183
|
}
|
|
172
184
|
else {
|
|
@@ -175,7 +187,14 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
175
187
|
try {
|
|
176
188
|
authorization = await Promise.race([
|
|
177
189
|
(async () => {
|
|
178
|
-
const membership = await resolveMembership(await getToken(),
|
|
190
|
+
const membership = await resolveMembership(await getToken(), lookupKey, controller.signal);
|
|
191
|
+
// Timeout wins even if the probe then returns null.
|
|
192
|
+
if (controller.signal.aborted) {
|
|
193
|
+
return {
|
|
194
|
+
status: "unavailable",
|
|
195
|
+
error: `membership lookup timed out after ${DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS}ms`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
179
198
|
if (membership)
|
|
180
199
|
return { status: "authorized", uid: membership.companyUid };
|
|
181
200
|
// A machine token often cannot read /membership/me (401/403, or an
|
|
@@ -221,8 +240,43 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
221
240
|
if (authorization.status === "authorized" &&
|
|
222
241
|
(!input.companyUid || input.companyUid === authorization.uid) &&
|
|
223
242
|
(!state?.companyUid || state.companyUid === authorization.uid)) {
|
|
243
|
+
// A prior false denial cleared the company and left needs_company.
|
|
244
|
+
// Membership just confirmed the company, so put it back and let flush post.
|
|
245
|
+
if (state &&
|
|
246
|
+
state.companyConfidence === "device_default" &&
|
|
247
|
+
state.contextStatus === "needs_company") {
|
|
248
|
+
const restoredSlug = state.companySlug || slug || defaultCompany?.slug;
|
|
249
|
+
const restoredUid = authorization.uid;
|
|
250
|
+
updateSessionState(state.sessionId, ctxRoot, (current) => {
|
|
251
|
+
if (!current || current.contextStatus !== "needs_company")
|
|
252
|
+
return null;
|
|
253
|
+
if (current.companyUid && current.companyUid !== restoredUid)
|
|
254
|
+
return null;
|
|
255
|
+
if (current.companySlug &&
|
|
256
|
+
restoredSlug &&
|
|
257
|
+
!sameCompanyValue(current.companySlug, restoredSlug)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return mergeLocalSessionFields({
|
|
261
|
+
...current,
|
|
262
|
+
companyUid: restoredUid,
|
|
263
|
+
...(restoredSlug ? { companySlug: restoredSlug } : {}),
|
|
264
|
+
contextStatus: current.projectId
|
|
265
|
+
? (current.taskId ? "bound" : "needs_task")
|
|
266
|
+
: "needs_project",
|
|
267
|
+
updatedAt: now().toISOString(),
|
|
268
|
+
}, current, { preserveCompanyBinding: false });
|
|
269
|
+
});
|
|
270
|
+
}
|
|
224
271
|
return authorization;
|
|
225
272
|
}
|
|
273
|
+
// Already stripped. Do not revoke, count, or log this again on every flush.
|
|
274
|
+
if (state &&
|
|
275
|
+
state.contextStatus === "needs_company" &&
|
|
276
|
+
!state.companyUid &&
|
|
277
|
+
!state.companySlug) {
|
|
278
|
+
return { status: "denied" };
|
|
279
|
+
}
|
|
226
280
|
try {
|
|
227
281
|
invalidateDefaultCompanyMemberships({ root: ctxRoot, now });
|
|
228
282
|
}
|
|
@@ -392,6 +446,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
392
446
|
token: t,
|
|
393
447
|
baseUrl: resolveVaultApiBase(env),
|
|
394
448
|
env,
|
|
449
|
+
readPreferredCompanyUids: () => readPreferredCompanyUids({ workContextRoot: ctxRoot }),
|
|
395
450
|
})();
|
|
396
451
|
});
|
|
397
452
|
const fetchCredentials = async () => {
|
|
@@ -418,6 +473,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
418
473
|
}, now);
|
|
419
474
|
log(dir, `presence online companies=${companies.length}`);
|
|
420
475
|
},
|
|
476
|
+
readPreferredCompanyUids: () => readPreferredCompanyUids({ workContextRoot: ctxRoot }),
|
|
421
477
|
onRefusal: (refusal) => {
|
|
422
478
|
patchDaemonState(dir, {
|
|
423
479
|
presenceRefusal: refusal,
|
|
@@ -16,6 +16,11 @@ export declare const HELD_TTL_MS: number;
|
|
|
16
16
|
export declare const HELD_RETRY_INTERVAL_MS: number;
|
|
17
17
|
/** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
18
18
|
export declare const HELD_MAX_LINES = 20000;
|
|
19
|
+
/**
|
|
20
|
+
* Re-check an unbound session against the project catalog at most this often.
|
|
21
|
+
* The catalog itself is also cached inside bind-best.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PROJECT_REBIND_INTERVAL_MS = 60000;
|
|
19
24
|
export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "NEEDS_PROJECT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
|
|
20
25
|
export interface HeldLineRecord {
|
|
21
26
|
/** Original spool event fields. */
|
|
@@ -46,6 +51,9 @@ export interface FlushDeps {
|
|
|
46
51
|
log?: (message: string) => void;
|
|
47
52
|
/** Authoritative gate for state derived from a device default. */
|
|
48
53
|
authorizeDeviceDefaultTenant?: (state: SessionStateFile) => Promise<boolean>;
|
|
54
|
+
/** Passed through to bind-best (catalog cache root and HQ_WORK_MESH_CACHE). */
|
|
55
|
+
env?: NodeJS.ProcessEnv;
|
|
56
|
+
home?: string;
|
|
49
57
|
}
|
|
50
58
|
export interface FlushSummary {
|
|
51
59
|
claimed: number;
|
|
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { resolveCompany } from "../../work-context/company.js";
|
|
14
14
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
15
|
+
import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
|
|
15
16
|
import { readSessionState, updateSessionState } from "../../work-context/state.js";
|
|
16
17
|
import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
17
18
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
@@ -25,6 +26,12 @@ export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
25
26
|
export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
|
|
26
27
|
/** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
27
28
|
export const HELD_MAX_LINES = 20_000;
|
|
29
|
+
/**
|
|
30
|
+
* Re-check an unbound session against the project catalog at most this often.
|
|
31
|
+
* The catalog itself is also cached inside bind-best.
|
|
32
|
+
*/
|
|
33
|
+
export const PROJECT_REBIND_INTERVAL_MS = 60_000;
|
|
34
|
+
const lastProjectRebindAtMs = new Map();
|
|
28
35
|
function parseLine(line) {
|
|
29
36
|
try {
|
|
30
37
|
const v = JSON.parse(line);
|
|
@@ -224,18 +231,25 @@ export async function flushSessionEvents(deps) {
|
|
|
224
231
|
};
|
|
225
232
|
const loggedProject404 = new Set();
|
|
226
233
|
for (const [sessionId, items] of bySession) {
|
|
227
|
-
|
|
234
|
+
let state = readSessionState(sessionId, deps.workContextRoot);
|
|
228
235
|
if (state?.companyConfidence === "device_default" &&
|
|
229
|
-
deps.authorizeDeviceDefaultTenant
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
deps.authorizeDeviceDefaultTenant) {
|
|
237
|
+
const allowed = await deps.authorizeDeviceDefaultTenant(state);
|
|
238
|
+
// Authorization may restore a company the gate previously cleared.
|
|
239
|
+
state = readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
240
|
+
if (!allowed) {
|
|
241
|
+
const heldAt = now().toISOString();
|
|
242
|
+
for (const item of items) {
|
|
243
|
+
appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
244
|
+
summary.held += 1;
|
|
245
|
+
markDisposed(item.event);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
236
248
|
}
|
|
237
|
-
continue;
|
|
238
249
|
}
|
|
250
|
+
// After the company gate (which may restore a cleared company), try to
|
|
251
|
+
// bind a needs_project session to an existing Board before classifying.
|
|
252
|
+
state = rebindUnboundSession(sessionId, items, state, deps, nowMs);
|
|
239
253
|
const decision = classifySession(state);
|
|
240
254
|
if (decision.action === "drop") {
|
|
241
255
|
appendDroppedReceipt({
|
|
@@ -502,6 +516,54 @@ function canonicalizeForRestore(event) {
|
|
|
502
516
|
}
|
|
503
517
|
return out;
|
|
504
518
|
}
|
|
519
|
+
function eventText(event, key) {
|
|
520
|
+
const value = event[key];
|
|
521
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Before a needs_project (or unresolved-with-company) batch is classified,
|
|
525
|
+
* run the prompt-submit bind-best refinement. A bind updates the session
|
|
526
|
+
* file so the batch posts with that projectId. Ties stay unbound. At most
|
|
527
|
+
* one attempt per session per minute; failures stay on the existing state.
|
|
528
|
+
*/
|
|
529
|
+
function rebindUnboundSession(sessionId, items, state, deps, nowMs) {
|
|
530
|
+
if (!state?.companyUid)
|
|
531
|
+
return state;
|
|
532
|
+
if (state.contextStatus !== "needs_project" && state.contextStatus !== "unresolved") {
|
|
533
|
+
return state;
|
|
534
|
+
}
|
|
535
|
+
const key = `${deps.workContextRoot}\0${sessionId}`;
|
|
536
|
+
const previous = lastProjectRebindAtMs.get(key);
|
|
537
|
+
if (previous !== undefined && nowMs - previous < PROJECT_REBIND_INTERVAL_MS) {
|
|
538
|
+
return state;
|
|
539
|
+
}
|
|
540
|
+
lastProjectRebindAtMs.set(key, nowMs);
|
|
541
|
+
const sample = items[0]?.event ?? {};
|
|
542
|
+
const started = Date.now();
|
|
543
|
+
try {
|
|
544
|
+
rebindNeedsProjectOnPrompt({
|
|
545
|
+
sessionId,
|
|
546
|
+
root: deps.workContextRoot,
|
|
547
|
+
hqRoot: eventText(sample, "hqRoot"),
|
|
548
|
+
cwd: eventText(sample, "cwd"),
|
|
549
|
+
companySlug: state.companySlug ?? eventText(sample, "companySlug"),
|
|
550
|
+
companyUid: state.companyUid,
|
|
551
|
+
hint: eventText(sample, "project"),
|
|
552
|
+
env: deps.env,
|
|
553
|
+
home: deps.home,
|
|
554
|
+
nowMs,
|
|
555
|
+
now: deps.now,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
deps.log?.(`work-mesh: session ${sessionId} project rebind failed: ${err instanceof Error ? err.message : "unknown"}`);
|
|
560
|
+
}
|
|
561
|
+
const elapsed = Date.now() - started;
|
|
562
|
+
if (elapsed > 300) {
|
|
563
|
+
deps.log?.(`work-mesh: session ${sessionId} project rebind took ${elapsed}ms`);
|
|
564
|
+
}
|
|
565
|
+
return readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
566
|
+
}
|
|
505
567
|
function classifySession(state) {
|
|
506
568
|
if (!state) {
|
|
507
569
|
return { action: "hold", heldReason: "STATE_ABSENT" };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function sessionSeqDir(env?: NodeJS.ProcessEnv): string;
|
|
2
|
+
/** Reserve `count` consecutive seq numbers; returns the first one. */
|
|
3
|
+
export declare function reserveSessionSeq(sessionId: string, count: number, env?: NodeJS.ProcessEnv): number;
|
|
4
|
+
//# sourceMappingURL=session-seq.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session event sequence, the same file the core hooks use
|
|
3
|
+
* (`~/.hq/work-mesh/seq/<sid>`, see core/scripts/lib/work-mesh-live-hook.sh).
|
|
4
|
+
* Every emitter for a session must take numbers from here, or the server
|
|
5
|
+
* treats the new events as duplicates of earlier ones and drops them.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
export function sessionSeqDir(env = process.env) {
|
|
11
|
+
return env.WORK_MESH_SEQ_DIR?.trim() || path.join(os.homedir(), ".hq", "work-mesh", "seq");
|
|
12
|
+
}
|
|
13
|
+
/** Reserve `count` consecutive seq numbers; returns the first one. */
|
|
14
|
+
export function reserveSessionSeq(sessionId, count, env = process.env) {
|
|
15
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
|
|
16
|
+
throw new Error("invalid session id");
|
|
17
|
+
const dir = sessionSeqDir(env);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const file = path.join(dir, sessionId);
|
|
20
|
+
let current = 0;
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(file, "utf8").trim();
|
|
23
|
+
if (/^\d+$/.test(raw))
|
|
24
|
+
current = Number(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
current = 0;
|
|
28
|
+
}
|
|
29
|
+
const first = current + 1;
|
|
30
|
+
fs.writeFileSync(file, `${current + Math.max(0, count)}\n`, { mode: 0o600 });
|
|
31
|
+
return first;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=session-seq.js.map
|