@indigoai-us/hq-cli 5.131.0 → 5.133.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 +147 -0
- package/dist/command-catalog.generated.d.ts +12 -0
- package/dist/command-catalog.generated.js +16 -0
- package/dist/commands/lanes.d.ts +15 -4
- package/dist/commands/lanes.js +478 -48
- package/dist/commands/mesh.js +24 -2
- package/dist/commands/update.js +1 -1
- package/dist/lib/bot/paths.d.ts +7 -5
- package/dist/lib/bot/paths.js +8 -1
- package/dist/lib/bot/prompt.d.ts +0 -2
- package/dist/lib/bot/prompt.js +5 -11
- package/dist/lib/bot/room-policy.d.ts +1 -24
- package/dist/lib/bot/room-policy.js +2 -35
- package/dist/lib/bot/run.d.ts +7 -8
- package/dist/lib/bot/run.js +21 -33
- package/dist/lib/bot/session.d.ts +1 -1
- package/dist/lib/bot/session.js +1 -1
- package/dist/lib/lanes/accounts.d.ts +6 -0
- package/dist/lib/lanes/accounts.js +2 -1
- package/dist/lib/lanes/config.d.ts +4 -0
- package/dist/lib/lanes/config.js +9 -0
- package/dist/lib/lanes/constants.d.ts +3 -0
- package/dist/lib/lanes/constants.js +3 -0
- package/dist/lib/lanes/index.d.ts +11 -3
- package/dist/lib/lanes/index.js +6 -1
- package/dist/lib/lanes/mesh-events.js +4 -0
- package/dist/lib/lanes/models.d.ts +78 -0
- package/dist/lib/lanes/models.js +205 -0
- package/dist/lib/lanes/move-account.d.ts +56 -0
- package/dist/lib/lanes/move-account.js +193 -0
- package/dist/lib/lanes/provider-rejection.d.ts +86 -0
- package/dist/lib/lanes/provider-rejection.js +258 -0
- package/dist/lib/lanes/quota.d.ts +23 -3
- package/dist/lib/lanes/quota.js +88 -14
- package/dist/lib/lanes/reconcile-install.d.ts +24 -3
- package/dist/lib/lanes/reconcile-install.js +48 -12
- package/dist/lib/lanes/reconcile.d.ts +5 -2
- package/dist/lib/lanes/reconcile.js +135 -13
- package/dist/lib/lanes/spawn.js +26 -5
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +51 -2
- package/dist/lib/lanes/types.js +5 -3
- package/dist/lib/lanes/watcher.d.ts +27 -0
- package/dist/lib/lanes/watcher.js +68 -5
- package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
- package/dist/lib/mesh/live/backfill-held.js +37 -14
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +16 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +100 -7
- package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
- package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
- package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
- package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
- package/dist/lib/mesh/live/daemon/presence.js +10 -9
- package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/run.js +65 -6
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
- package/dist/lib/mesh/live/emit-evidence.js +20 -2
- package/dist/lib/work-context/company.d.ts +16 -0
- package/dist/lib/work-context/company.js +46 -14
- package/dist/utils/self-update.js +4 -1
- package/package.json +2 -2
|
@@ -34,7 +34,11 @@ export declare const CONNECT_STABLE_GRACE_MS = 5000;
|
|
|
34
34
|
* waits at least this long regardless of the backoff/grace-reset state.
|
|
35
35
|
*/
|
|
36
36
|
export declare const RECONNECT_MIN_DELAY_MS = 1000;
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Close/reconnect info is one line per transition into reconnecting.
|
|
39
|
+
* Repeated `close` events while already reconnecting do not log again.
|
|
40
|
+
* Kept so older imports still resolve; they no longer gate the log.
|
|
41
|
+
*/
|
|
38
42
|
export declare const CLOSE_LOG_ALWAYS_COUNT = 3;
|
|
39
43
|
export declare const CLOSE_LOG_INTERVAL_MS = 60000;
|
|
40
44
|
export interface PresencePayload {
|
|
@@ -111,8 +115,8 @@ export declare class PresenceClient {
|
|
|
111
115
|
private reconnectHandle;
|
|
112
116
|
private stableHandle;
|
|
113
117
|
private connectedAtMs;
|
|
114
|
-
|
|
115
|
-
private
|
|
118
|
+
/** One close log per transition into reconnecting, until the next connect. */
|
|
119
|
+
private reconnectLogged;
|
|
116
120
|
private stopped;
|
|
117
121
|
private generation;
|
|
118
122
|
private publishedTopics;
|
|
@@ -34,7 +34,11 @@ export const CONNECT_STABLE_GRACE_MS = 5_000;
|
|
|
34
34
|
* waits at least this long regardless of the backoff/grace-reset state.
|
|
35
35
|
*/
|
|
36
36
|
export const RECONNECT_MIN_DELAY_MS = 1_000;
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Close/reconnect info is one line per transition into reconnecting.
|
|
39
|
+
* Repeated `close` events while already reconnecting do not log again.
|
|
40
|
+
* Kept so older imports still resolve; they no longer gate the log.
|
|
41
|
+
*/
|
|
38
42
|
export const CLOSE_LOG_ALWAYS_COUNT = 3;
|
|
39
43
|
export const CLOSE_LOG_INTERVAL_MS = 60_000;
|
|
40
44
|
/**
|
|
@@ -81,8 +85,8 @@ export class PresenceClient {
|
|
|
81
85
|
reconnectHandle = null;
|
|
82
86
|
stableHandle = null;
|
|
83
87
|
connectedAtMs = null;
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
/** One close log per transition into reconnecting, until the next connect. */
|
|
89
|
+
reconnectLogged = false;
|
|
86
90
|
stopped = false;
|
|
87
91
|
generation = 0;
|
|
88
92
|
publishedTopics = [];
|
|
@@ -281,6 +285,7 @@ export class PresenceClient {
|
|
|
281
285
|
this.attempt = 0;
|
|
282
286
|
this.lastBackoffMs = 0;
|
|
283
287
|
}, CONNECT_STABLE_GRACE_MS);
|
|
288
|
+
this.reconnectLogged = false;
|
|
284
289
|
this.setState("connected");
|
|
285
290
|
void this.publishAll("online").then(() => {
|
|
286
291
|
this.options.onOnline?.(bundle.companies.map((c) => c.companyUid));
|
|
@@ -383,13 +388,9 @@ export class PresenceClient {
|
|
|
383
388
|
return delay;
|
|
384
389
|
}
|
|
385
390
|
maybeLogClose(elapsedMs, delayMs) {
|
|
386
|
-
|
|
387
|
-
const withinAlways = this.closeLogCount < CLOSE_LOG_ALWAYS_COUNT;
|
|
388
|
-
const intervalElapsed = now - this.lastCloseLogAtMs >= CLOSE_LOG_INTERVAL_MS;
|
|
389
|
-
if (!withinAlways && !intervalElapsed)
|
|
391
|
+
if (this.reconnectLogged)
|
|
390
392
|
return;
|
|
391
|
-
this.
|
|
392
|
-
this.lastCloseLogAtMs = now;
|
|
393
|
+
this.reconnectLogged = true;
|
|
393
394
|
this.options.onInfo?.(`presence mqtt closed after ${elapsedMs}ms; reconnect in ${delayMs}ms`);
|
|
394
395
|
}
|
|
395
396
|
clearReconnect() {
|
|
@@ -18,6 +18,7 @@ import { type PidLockDeps } from "./pid-lock.js";
|
|
|
18
18
|
import { PresenceClient, type MqttConnectFn } from "./presence.js";
|
|
19
19
|
import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
|
|
20
20
|
import { type MeshEmitMode } from "./mode.js";
|
|
21
|
+
import { type CommandRunner, type LegacyListenPlatform } from "./legacy-listen.js";
|
|
21
22
|
export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
22
23
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
23
24
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
@@ -90,6 +91,11 @@ export interface DaemonRunDeps {
|
|
|
90
91
|
resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
|
|
91
92
|
companyUid: string;
|
|
92
93
|
} | null>;
|
|
94
|
+
/**
|
|
95
|
+
* Machine credential (fleet box or local bot). A refused or empty membership
|
|
96
|
+
* probe defers; it does not clear the company. Defaults to isMachineIdentity().
|
|
97
|
+
*/
|
|
98
|
+
machineIdentity?: boolean;
|
|
93
99
|
/** Test seam immediately before a device-default revocation's guarded commit. */
|
|
94
100
|
beforeDeviceDefaultRevocationCommit?: () => void;
|
|
95
101
|
log?: (dir: string, line: string) => void;
|
|
@@ -97,10 +103,22 @@ export interface DaemonRunDeps {
|
|
|
97
103
|
runForMs?: number;
|
|
98
104
|
/** CLI version loaded by this daemon. Defaults to this package's version. */
|
|
99
105
|
daemonVersion?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe to the personal work topic and write live-cache.json.
|
|
108
|
+
* The `hq mesh daemon run` command turns this on. Tests leave it off
|
|
109
|
+
* unless they inject credentials.
|
|
110
|
+
*/
|
|
111
|
+
enableWorkCache?: boolean;
|
|
112
|
+
fetchPersonalCredentials?: import("./credentials.js").PersonalCredentialsFetcher;
|
|
100
113
|
/** Read the currently installed CLI version behind the launch entrypoint. */
|
|
101
114
|
readInstalledCliVersion?: () => string | null;
|
|
102
115
|
random?: () => number;
|
|
103
116
|
now?: () => Date;
|
|
117
|
+
/** Host for legacy listen retirement. Tests inject this; default is process.platform. */
|
|
118
|
+
legacyListenPlatform?: LegacyListenPlatform;
|
|
119
|
+
/** Service-manager runner for legacy listen retirement (tests inject). */
|
|
120
|
+
legacyListenRun?: CommandRunner;
|
|
121
|
+
windowsLegacyTaskPresent?: boolean;
|
|
104
122
|
}
|
|
105
123
|
export interface DaemonHandle {
|
|
106
124
|
stop: () => Promise<void>;
|
|
@@ -14,7 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
15
15
|
import { createWorkSessionDeliverer } from "../../client.js";
|
|
16
16
|
import { listActiveMembershipCompanies, meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
17
|
-
import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
|
|
17
|
+
import { ensureCognitoToken, isMachineIdentity } from "../../../../utils/cognito-session.js";
|
|
18
18
|
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";
|
|
@@ -26,17 +26,19 @@ 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, realTimerHost, } from "./credentials.js";
|
|
29
|
+
import { createContract3Fetcher, createPersonalRealtimeFetcher, 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";
|
|
33
|
-
import { PresenceClient, } from "./presence.js";
|
|
33
|
+
import { PresenceClient, defaultMqttConnect, } from "./presence.js";
|
|
34
34
|
import { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
|
|
35
35
|
import { workMeshHeldPath } from "../paths.js";
|
|
36
36
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
37
37
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
38
38
|
import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
|
|
39
39
|
import { readDaemonConfig } from "./config.js";
|
|
40
|
+
import { retireLegacyListen } from "./legacy-listen.js";
|
|
41
|
+
import { startWorkCacheSubscriber } from "./work-subscribe.js";
|
|
40
42
|
export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
41
43
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
42
44
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
@@ -112,6 +114,15 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
112
114
|
const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
|
|
113
115
|
const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
|
|
114
116
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
const hostPlatform = process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"
|
|
118
|
+
? process.platform
|
|
119
|
+
: "other";
|
|
120
|
+
const retiredListen = retireLegacyListen({
|
|
121
|
+
home,
|
|
122
|
+
platform: deps.legacyListenPlatform ?? hostPlatform,
|
|
123
|
+
run: deps.legacyListenRun,
|
|
124
|
+
windowsTaskPresent: deps.windowsLegacyTaskPresent,
|
|
125
|
+
});
|
|
115
126
|
const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
|
|
116
127
|
const trustedRoots = [...new Set([
|
|
117
128
|
...(deps.hqRoot ? [deps.hqRoot] : []),
|
|
@@ -119,6 +130,9 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
119
130
|
...configuredRoots,
|
|
120
131
|
])];
|
|
121
132
|
ensureDaemonLog(dir);
|
|
133
|
+
if (retiredListen.removed) {
|
|
134
|
+
log(dir, "retired legacy listener ai.getindigo.hq-work-mesh-listen");
|
|
135
|
+
}
|
|
122
136
|
const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
|
|
123
137
|
const lock = acquirePidLock(dir, lockDeps);
|
|
124
138
|
if (!lock.ok) {
|
|
@@ -143,6 +157,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
143
157
|
const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
|
|
144
158
|
const getToken = deps.getToken ?? defaultGetToken;
|
|
145
159
|
const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
|
|
160
|
+
const machineIdentity = deps.machineIdentity ?? isMachineIdentity();
|
|
146
161
|
/** The sole authorization seam for daemon tenant I/O derived from a device default. */
|
|
147
162
|
const tenantGate = {
|
|
148
163
|
authorize: async (input) => {
|
|
@@ -161,9 +176,18 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
161
176
|
authorization = await Promise.race([
|
|
162
177
|
(async () => {
|
|
163
178
|
const membership = await resolveMembership(await getToken(), slug, controller.signal);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
if (membership)
|
|
180
|
+
return { status: "authorized", uid: membership.companyUid };
|
|
181
|
+
// A machine token often cannot read /membership/me (401/403, or an
|
|
182
|
+
// empty person-membership list). That is "cannot tell", not "not
|
|
183
|
+
// a member". Leave the company in place and retry.
|
|
184
|
+
if (machineIdentity) {
|
|
185
|
+
return {
|
|
186
|
+
status: "unavailable",
|
|
187
|
+
error: "membership probe on a machine token did not confirm a company; company left unchanged",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return { status: "denied" };
|
|
167
191
|
})(),
|
|
168
192
|
new Promise((resolve) => {
|
|
169
193
|
controller.signal.addEventListener("abort", () => resolve({
|
|
@@ -359,6 +383,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
359
383
|
});
|
|
360
384
|
});
|
|
361
385
|
let presence = null;
|
|
386
|
+
let workCache = null;
|
|
362
387
|
if (deps.enablePresence !== false) {
|
|
363
388
|
const fetchCredentialsRaw = deps.fetchCredentials ??
|
|
364
389
|
(async () => {
|
|
@@ -420,6 +445,31 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
420
445
|
// Continue draining spool even if MQTT is down.
|
|
421
446
|
}
|
|
422
447
|
}
|
|
448
|
+
if (deps.enableWorkCache) {
|
|
449
|
+
try {
|
|
450
|
+
workCache = await startWorkCacheSubscriber({
|
|
451
|
+
meshRoot,
|
|
452
|
+
fetchCredentials: deps.fetchPersonalCredentials ?? (async () => {
|
|
453
|
+
const t = await getToken();
|
|
454
|
+
return createPersonalRealtimeFetcher({
|
|
455
|
+
token: t,
|
|
456
|
+
baseUrl: resolveVaultApiBase(env),
|
|
457
|
+
})();
|
|
458
|
+
}),
|
|
459
|
+
mqttConnect: (url, options) => {
|
|
460
|
+
const connect = deps.mqttConnect ?? defaultMqttConnect;
|
|
461
|
+
return connect(url, options);
|
|
462
|
+
},
|
|
463
|
+
now,
|
|
464
|
+
onInfo: (message) => log(dir, message.slice(0, 200)),
|
|
465
|
+
random: deps.random,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
470
|
+
log(dir, `work cache subscribe failed: ${msg.slice(0, 200)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
423
473
|
let stopped = false;
|
|
424
474
|
let restartRequested = false;
|
|
425
475
|
let flushInFlight = null;
|
|
@@ -698,6 +748,15 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
698
748
|
// Final flush, then clean MQTT DISCONNECT so the broker emits the
|
|
699
749
|
// lifecycle event; offline is published server-side only.
|
|
700
750
|
await doFlushCycle("shutdown", { force: true });
|
|
751
|
+
if (workCache) {
|
|
752
|
+
try {
|
|
753
|
+
await workCache.stop();
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
757
|
+
log(dir, `work cache stop error: ${msg.slice(0, 120)}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
701
760
|
if (presence) {
|
|
702
761
|
try {
|
|
703
762
|
await presence.stop();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-2 subscriber for the personal work topic.
|
|
3
|
+
* Owns live-cache.json writes. Presence publish stays on PresenceClient.
|
|
4
|
+
*/
|
|
5
|
+
import type { IClientOptions } from "mqtt";
|
|
6
|
+
import { type PersonalCredentialsFetcher } from "./credentials.js";
|
|
7
|
+
export interface WorkCacheMqtt {
|
|
8
|
+
on(event: string, handler: (...args: any[]) => void): unknown;
|
|
9
|
+
subscribe(topic: string, opts: {
|
|
10
|
+
qos: 0 | 1;
|
|
11
|
+
}, cb?: (err?: Error | null) => void): unknown;
|
|
12
|
+
end(force?: boolean, cb?: () => void): unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface WorkCacheSubscriber {
|
|
15
|
+
stop: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function startWorkCacheSubscriber(opts: {
|
|
18
|
+
meshRoot: string;
|
|
19
|
+
fetchCredentials?: PersonalCredentialsFetcher;
|
|
20
|
+
token?: () => Promise<string>;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
mqttConnect: (url: string, options: IClientOptions) => WorkCacheMqtt;
|
|
23
|
+
now?: () => Date;
|
|
24
|
+
timers?: {
|
|
25
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
26
|
+
clearTimeout: (handle: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
onInfo?: (message: string) => void;
|
|
29
|
+
random?: () => number;
|
|
30
|
+
}): Promise<WorkCacheSubscriber>;
|
|
31
|
+
//# sourceMappingURL=work-subscribe.d.ts.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-2 subscriber for the personal work topic.
|
|
3
|
+
* Owns live-cache.json writes. Presence publish stays on PresenceClient.
|
|
4
|
+
*/
|
|
5
|
+
import { createPersonalRealtimeFetcher, } from "./credentials.js";
|
|
6
|
+
import { presignIotWssUrl } from "./presign.js";
|
|
7
|
+
import { applyProjectViewPush, parseProjectViewPush } from "./live-cache.js";
|
|
8
|
+
import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
|
|
9
|
+
export async function startWorkCacheSubscriber(opts) {
|
|
10
|
+
const fetchCredentials = opts.fetchCredentials ??
|
|
11
|
+
(opts.token
|
|
12
|
+
? createPersonalRealtimeFetcher({
|
|
13
|
+
token: await opts.token(),
|
|
14
|
+
baseUrl: opts.baseUrl,
|
|
15
|
+
})
|
|
16
|
+
: null);
|
|
17
|
+
if (!fetchCredentials) {
|
|
18
|
+
throw new Error("work cache subscriber needs credentials");
|
|
19
|
+
}
|
|
20
|
+
const now = opts.now ?? (() => new Date());
|
|
21
|
+
const timers = opts.timers ?? {
|
|
22
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
23
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
24
|
+
};
|
|
25
|
+
const random = opts.random ?? Math.random;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
let client = null;
|
|
28
|
+
let reconnectLogged = false;
|
|
29
|
+
let attempt = 0;
|
|
30
|
+
let timer = null;
|
|
31
|
+
let generation = 0;
|
|
32
|
+
const connect = async () => {
|
|
33
|
+
if (stopped)
|
|
34
|
+
return;
|
|
35
|
+
const gen = ++generation;
|
|
36
|
+
let bundle;
|
|
37
|
+
try {
|
|
38
|
+
bundle = await fetchCredentials();
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
schedule(err);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (stopped || gen !== generation)
|
|
45
|
+
return;
|
|
46
|
+
const topic = bundle.topics.work;
|
|
47
|
+
if (!topic) {
|
|
48
|
+
opts.onInfo?.("work cache subscribe skipped: credentials have no work topic");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
|
|
52
|
+
const next = opts.mqttConnect(url, {
|
|
53
|
+
clientId: `${bundle.clientId}-work`,
|
|
54
|
+
clean: true,
|
|
55
|
+
reconnectPeriod: 0,
|
|
56
|
+
protocolVersion: 4,
|
|
57
|
+
});
|
|
58
|
+
client = next;
|
|
59
|
+
next.on("connect", () => {
|
|
60
|
+
if (stopped || gen !== generation)
|
|
61
|
+
return;
|
|
62
|
+
reconnectLogged = false;
|
|
63
|
+
attempt = 0;
|
|
64
|
+
next.subscribe(topic, { qos: 1 }, () => undefined);
|
|
65
|
+
});
|
|
66
|
+
next.on("message", (_topic, payload) => {
|
|
67
|
+
if (stopped || gen !== generation)
|
|
68
|
+
return;
|
|
69
|
+
if (_topic !== topic)
|
|
70
|
+
return;
|
|
71
|
+
const push = parseProjectViewPush(payload);
|
|
72
|
+
if (!push)
|
|
73
|
+
return;
|
|
74
|
+
const result = applyProjectViewPush(opts.meshRoot, push, now().toISOString());
|
|
75
|
+
if (result.applied) {
|
|
76
|
+
opts.onInfo?.(`project-view cache cursor=${result.cursor} ${push.companyUid}/${push.projectId}`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
next.on("close", () => {
|
|
80
|
+
if (stopped || gen !== generation)
|
|
81
|
+
return;
|
|
82
|
+
schedule();
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const schedule = (err) => {
|
|
86
|
+
if (stopped)
|
|
87
|
+
return;
|
|
88
|
+
if (!reconnectLogged) {
|
|
89
|
+
reconnectLogged = true;
|
|
90
|
+
const detail = err instanceof Error ? `: ${err.message}` : "";
|
|
91
|
+
opts.onInfo?.(`work cache mqtt reconnecting${detail}`.slice(0, 200));
|
|
92
|
+
}
|
|
93
|
+
if (timer !== null)
|
|
94
|
+
timers.clearTimeout(timer);
|
|
95
|
+
const delay = backoffDelayMs(attempt, 1000, 60_000, random, 0, RECONNECT_MIN_DELAY_MS);
|
|
96
|
+
attempt += 1;
|
|
97
|
+
timer = timers.setTimeout(() => {
|
|
98
|
+
timer = null;
|
|
99
|
+
void connect();
|
|
100
|
+
}, delay);
|
|
101
|
+
};
|
|
102
|
+
await connect();
|
|
103
|
+
return {
|
|
104
|
+
stop: async () => {
|
|
105
|
+
stopped = true;
|
|
106
|
+
generation += 1;
|
|
107
|
+
if (timer !== null)
|
|
108
|
+
timers.clearTimeout(timer);
|
|
109
|
+
const current = client;
|
|
110
|
+
client = null;
|
|
111
|
+
if (!current)
|
|
112
|
+
return;
|
|
113
|
+
await new Promise((resolve) => {
|
|
114
|
+
try {
|
|
115
|
+
current.end(true, () => resolve());
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
resolve();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=work-subscribe.js.map
|
|
@@ -37,6 +37,17 @@ export interface BuildEmitEvidenceInput {
|
|
|
37
37
|
readMeta?: (sessionId: string, hqRoot: string | undefined) => string | undefined;
|
|
38
38
|
/** Injectable work-tree finder (tests). */
|
|
39
39
|
findRepo?: (startDir: string) => string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Env for HQ_SPAWN_COMPANY and the identity file. Defaults to empty so a
|
|
42
|
+
* unit test does not inherit the developer shell. Callers that emit pass
|
|
43
|
+
* `process.env`.
|
|
44
|
+
*/
|
|
45
|
+
env?: NodeJS.ProcessEnv;
|
|
46
|
+
/** Injectable machine-company reader (tests). */
|
|
47
|
+
readMachineCompany?: (env: NodeJS.ProcessEnv) => {
|
|
48
|
+
slug?: string;
|
|
49
|
+
uid?: string;
|
|
50
|
+
} | undefined;
|
|
40
51
|
}
|
|
41
52
|
/** Dedupe absolute-ish paths, preserving first-seen order. */
|
|
42
53
|
export declare function normalizeTouchedPaths(paths: Array<string | undefined | null>): string[];
|
|
@@ -46,4 +57,12 @@ export declare function normalizeTouchedPaths(paths: Array<string | undefined |
|
|
|
46
57
|
* first touched file). All fields are omitted when empty.
|
|
47
58
|
*/
|
|
48
59
|
export declare function buildEmitEvidence(input: BuildEmitEvidenceInput): EmitEvidence;
|
|
60
|
+
/**
|
|
61
|
+
* A machine credential with no company must not be posted. The server would
|
|
62
|
+
* file it as company NONE. Hold it instead.
|
|
63
|
+
*/
|
|
64
|
+
export declare function machineEmitDisposition(input: {
|
|
65
|
+
machine: boolean;
|
|
66
|
+
companySlug?: string;
|
|
67
|
+
}): "post" | "hold";
|
|
49
68
|
//# sourceMappingURL=emit-evidence.d.ts.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* work-tree off disk. Never reads prompts/tokens; never hits the network.
|
|
10
10
|
*/
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
-
import { readMetaCompanySlug } from "../../work-context/company.js";
|
|
12
|
+
import { readMachineCompanyBinding, readMetaCompanySlug } from "../../work-context/company.js";
|
|
13
13
|
import { findWorkTreeRoot } from "../../work-context/repo-remote.js";
|
|
14
14
|
function clean(v) {
|
|
15
15
|
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
@@ -41,7 +41,16 @@ export function buildEmitEvidence(input) {
|
|
|
41
41
|
const cwd = clean(input.cwd);
|
|
42
42
|
const hqRoot = clean(input.hqRoot);
|
|
43
43
|
const touchedPaths = normalizeTouchedPaths(input.touchedPaths ?? []);
|
|
44
|
-
const
|
|
44
|
+
const env = input.env ?? {};
|
|
45
|
+
const machine = (input.readMachineCompany ?? readMachineCompanyBinding)(env);
|
|
46
|
+
const spawned = clean(env.HQ_SPAWN_COMPANY);
|
|
47
|
+
// Explicit, spawn, session meta, then the box binding (slug, else uid).
|
|
48
|
+
// A uid with no slug still rides companySlug so the event is not company NONE.
|
|
49
|
+
const companySlug = clean(input.companySlug) ??
|
|
50
|
+
spawned ??
|
|
51
|
+
clean(readMeta(input.sessionId, hqRoot)) ??
|
|
52
|
+
(machine && machine.slug !== spawned ? clean(machine.slug) : undefined) ??
|
|
53
|
+
clean(machine?.uid);
|
|
45
54
|
let repoPath = clean(input.repoPath);
|
|
46
55
|
if (!repoPath) {
|
|
47
56
|
// Search from the cwd (a directory) or the directory of the first touched
|
|
@@ -69,6 +78,15 @@ export function buildEmitEvidence(input) {
|
|
|
69
78
|
evidence.task = task;
|
|
70
79
|
return evidence;
|
|
71
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A machine credential with no company must not be posted. The server would
|
|
83
|
+
* file it as company NONE. Hold it instead.
|
|
84
|
+
*/
|
|
85
|
+
export function machineEmitDisposition(input) {
|
|
86
|
+
if (input.machine && !clean(input.companySlug))
|
|
87
|
+
return "hold";
|
|
88
|
+
return "post";
|
|
89
|
+
}
|
|
72
90
|
/** Directory of the first absolute touched file, for work-tree discovery. */
|
|
73
91
|
function firstFileDir(touchedPaths) {
|
|
74
92
|
const abs = touchedPaths.find((p) => path.isAbsolute(p));
|
|
@@ -77,7 +77,23 @@ export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.j
|
|
|
77
77
|
* Missing/unreadable/malformed → undefined
|
|
78
78
|
* (never throws; never logs file contents).
|
|
79
79
|
*/
|
|
80
|
+
export interface AgentIdentityCompany {
|
|
81
|
+
uid?: string;
|
|
82
|
+
slug?: string;
|
|
83
|
+
}
|
|
84
|
+
/** Company uid and slug from the fleet identity file. Missing file → undefined. */
|
|
85
|
+
export declare function readAgentIdentityCompany(env?: NodeJS.ProcessEnv): AgentIdentityCompany | undefined;
|
|
80
86
|
export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Company named for a machine session.
|
|
89
|
+
* Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
|
|
90
|
+
* Session meta is not included; callers place it between spawn and the file.
|
|
91
|
+
* No company → undefined. Never invents NONE.
|
|
92
|
+
*/
|
|
93
|
+
export declare function readMachineCompanyBinding(env?: NodeJS.ProcessEnv): {
|
|
94
|
+
slug?: string;
|
|
95
|
+
uid?: string;
|
|
96
|
+
} | undefined;
|
|
81
97
|
/**
|
|
82
98
|
* Agent boxes must never infer a human device default. The file itself is the
|
|
83
99
|
* machine marker; legacy identity files do not necessarily contain a company
|
|
@@ -38,13 +38,7 @@ function normalizeSlug(value) {
|
|
|
38
38
|
return undefined;
|
|
39
39
|
return s;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
* Read `companyUid` from the agent identity file when
|
|
43
|
-
* `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
|
|
44
|
-
* Missing/unreadable/malformed → undefined
|
|
45
|
-
* (never throws; never logs file contents).
|
|
46
|
-
*/
|
|
47
|
-
export function readAgentIdentityCompanyUid(env = process.env) {
|
|
41
|
+
function readAgentIdentityRecord(env) {
|
|
48
42
|
// An explicit override (including empty/missing paths) never falls back to
|
|
49
43
|
// another identity. This prevents accidental attribution to a different box.
|
|
50
44
|
const file = (env[HQ_AGENT_IDENTITY_FILE_ENV] ?? DEFAULT_AGENT_IDENTITY_FILE).trim();
|
|
@@ -54,15 +48,49 @@ export function readAgentIdentityCompanyUid(env = process.env) {
|
|
|
54
48
|
if (!fs.existsSync(file))
|
|
55
49
|
return undefined;
|
|
56
50
|
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
: undefined;
|
|
51
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
52
|
+
return undefined;
|
|
53
|
+
return raw;
|
|
61
54
|
}
|
|
62
55
|
catch {
|
|
63
56
|
return undefined;
|
|
64
57
|
}
|
|
65
58
|
}
|
|
59
|
+
/** Company uid and slug from the fleet identity file. Missing file → undefined. */
|
|
60
|
+
export function readAgentIdentityCompany(env = process.env) {
|
|
61
|
+
const raw = readAgentIdentityRecord(env);
|
|
62
|
+
if (!raw)
|
|
63
|
+
return undefined;
|
|
64
|
+
const uid = typeof raw.companyUid === "string" ? raw.companyUid.trim() : "";
|
|
65
|
+
const slug = normalizeSlug(typeof raw.companySlug === "string" ? raw.companySlug : undefined);
|
|
66
|
+
if (!uid && !slug)
|
|
67
|
+
return undefined;
|
|
68
|
+
return {
|
|
69
|
+
...(uid ? { uid } : {}),
|
|
70
|
+
...(slug ? { slug } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function readAgentIdentityCompanyUid(env = process.env) {
|
|
74
|
+
return readAgentIdentityCompany(env)?.uid;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Company named for a machine session.
|
|
78
|
+
* Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
|
|
79
|
+
* Session meta is not included; callers place it between spawn and the file.
|
|
80
|
+
* No company → undefined. Never invents NONE.
|
|
81
|
+
*/
|
|
82
|
+
export function readMachineCompanyBinding(env = process.env) {
|
|
83
|
+
const spawn = normalizeSlug(env.HQ_SPAWN_COMPANY);
|
|
84
|
+
const identity = readAgentIdentityCompany(env);
|
|
85
|
+
if (spawn)
|
|
86
|
+
return { slug: spawn, ...(identity?.uid ? { uid: identity.uid } : {}) };
|
|
87
|
+
if (!identity?.slug && !identity?.uid)
|
|
88
|
+
return undefined;
|
|
89
|
+
return {
|
|
90
|
+
...(identity?.slug ? { slug: identity.slug } : {}),
|
|
91
|
+
...(identity?.uid ? { uid: identity.uid } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
66
94
|
/**
|
|
67
95
|
* Agent boxes must never infer a human device default. The file itself is the
|
|
68
96
|
* machine marker; legacy identity files do not necessarily contain a company
|
|
@@ -273,11 +301,15 @@ export function resolveCompany(input) {
|
|
|
273
301
|
// 4. Agent-box identity file (HQ_AGENT_IDENTITY_FILE). Prefer as the
|
|
274
302
|
// device-default role with source trusted_explicit — never device_default /
|
|
275
303
|
// "default company mode". Below HQ_SPAWN_COMPANY; above device default.
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
304
|
+
const identity = readAgentIdentityCompany(env);
|
|
305
|
+
if (identity?.uid || identity?.slug) {
|
|
278
306
|
return {
|
|
279
307
|
status: "resolved",
|
|
280
|
-
company: {
|
|
308
|
+
company: {
|
|
309
|
+
...(identity.slug ? { slug: identity.slug } : {}),
|
|
310
|
+
...(identity.uid ? { uid: identity.uid } : {}),
|
|
311
|
+
source: "trusted_explicit",
|
|
312
|
+
},
|
|
281
313
|
};
|
|
282
314
|
}
|
|
283
315
|
// 4b. Persisted per-repo company map (gap 4). A person answered once for this
|
|
@@ -282,7 +282,10 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
282
282
|
// A resident mesh daemon keeps the old JS loaded after a global install.
|
|
283
283
|
// Service restart is best-effort and cannot turn a successful update red.
|
|
284
284
|
try {
|
|
285
|
-
const restart = (deps.restartDaemon ?? restartDaemonServiceIfInstalled
|
|
285
|
+
const restart = (deps.restartDaemon ?? (() => restartDaemonServiceIfInstalled({
|
|
286
|
+
env,
|
|
287
|
+
hqRoot: env.HQ_ROOT,
|
|
288
|
+
})))();
|
|
286
289
|
if (restart?.message)
|
|
287
290
|
console.error(chalk.yellow(` ${restart.message}`));
|
|
288
291
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.133.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
41
41
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
42
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
42
|
+
"@indigoai-us/hq-cloud": "~6.16.74",
|
|
43
43
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
44
44
|
"@sentry/node": "^10.49.0",
|
|
45
45
|
"@tobilu/qmd": "2.5.3",
|