@indigoai-us/hq-cli 5.132.0 → 5.134.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 +44 -0
- package/dist/commands/agent-kit.d.ts +2 -0
- package/dist/commands/agent-kit.js +4 -1
- package/dist/commands/lanes.d.ts +2 -2
- package/dist/commands/lanes.js +98 -20
- package/dist/commands/mesh.js +4 -1
- package/dist/lib/agent-kit/run/heartbeat.d.ts +14 -2
- package/dist/lib/agent-kit/run/heartbeat.js +38 -6
- package/dist/lib/bot/api.d.ts +3 -1
- package/dist/lib/bot/api.js +7 -2
- package/dist/lib/bot/run.d.ts +4 -0
- package/dist/lib/bot/run.js +30 -1
- package/dist/lib/bot/status.d.ts +4 -0
- package/dist/lib/doctor/checks/bots.js +4 -1
- package/dist/lib/heartbeat-identity.d.ts +29 -0
- package/dist/lib/heartbeat-identity.js +71 -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 +1 -0
- package/dist/lib/lanes/index.js +1 -0
- package/dist/lib/lanes/quota.d.ts +6 -3
- package/dist/lib/lanes/quota.js +65 -15
- package/dist/lib/lanes/reconcile.d.ts +5 -2
- package/dist/lib/lanes/reconcile.js +94 -2
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/lib/lanes/types.js +2 -1
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +11 -0
- package/dist/lib/mesh/live/daemon/install.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/install.js +16 -6
- 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 +13 -0
- package/dist/lib/mesh/live/daemon/run.js +51 -2
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
- package/package.json +2 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retire the pack-era work-mesh listen unit so only `hq mesh daemon` subscribes.
|
|
3
|
+
*
|
|
4
|
+
* macOS: ~/Library/LaunchAgents/ai.getindigo.hq-work-mesh-listen.plist
|
|
5
|
+
* Linux: ~/.config/systemd/user/hq-work-mesh-listen.service
|
|
6
|
+
* Windows: Scheduled Task ai.getindigo.hq-work-mesh-listen
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
export const LEGACY_LISTEN_LABEL = "ai.getindigo.hq-work-mesh-listen";
|
|
12
|
+
export const LEGACY_LISTEN_SYSTEMD_UNIT = "hq-work-mesh-listen.service";
|
|
13
|
+
export const LEGACY_LISTEN_TASK = LEGACY_LISTEN_LABEL;
|
|
14
|
+
export const LEGACY_LISTENER_DOCTOR_LINE = "legacy listener still installed";
|
|
15
|
+
export function legacyListenPlistPath(home) {
|
|
16
|
+
return path.join(home, "Library", "LaunchAgents", `${LEGACY_LISTEN_LABEL}.plist`);
|
|
17
|
+
}
|
|
18
|
+
export function legacyListenSystemdPath(home) {
|
|
19
|
+
return path.join(home, ".config", "systemd", "user", LEGACY_LISTEN_SYSTEMD_UNIT);
|
|
20
|
+
}
|
|
21
|
+
const defaultRunner = (cmd, args) => {
|
|
22
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
23
|
+
return {
|
|
24
|
+
status: r.status,
|
|
25
|
+
stdout: r.stdout ?? "",
|
|
26
|
+
stderr: r.error ? r.error.message : (r.stderr ?? ""),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export function legacyListenInstalled(opts) {
|
|
30
|
+
const exists = opts.existsSync ?? fs.existsSync;
|
|
31
|
+
if (opts.platform === "darwin")
|
|
32
|
+
return exists(legacyListenPlistPath(opts.home));
|
|
33
|
+
if (opts.platform === "linux")
|
|
34
|
+
return exists(legacyListenSystemdPath(opts.home));
|
|
35
|
+
if (opts.platform === "win32")
|
|
36
|
+
return false;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Unload and delete the legacy listen unit when it is present.
|
|
41
|
+
* On-disk unit files are removed on every host. launchctl, systemctl, and
|
|
42
|
+
* schtasks run only for the matching platform, and only through `run` when
|
|
43
|
+
* the caller injected one (tests never hit a real service manager).
|
|
44
|
+
* Missing unit is a no-op.
|
|
45
|
+
*/
|
|
46
|
+
export function retireLegacyListen(opts) {
|
|
47
|
+
const run = opts.run ?? (opts.platform === "other" ? undefined : defaultRunner);
|
|
48
|
+
let removed = false;
|
|
49
|
+
const plist = legacyListenPlistPath(opts.home);
|
|
50
|
+
if (fs.existsSync(plist)) {
|
|
51
|
+
if (opts.platform === "darwin" && run) {
|
|
52
|
+
const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
|
|
53
|
+
if (uid !== undefined) {
|
|
54
|
+
run("launchctl", ["bootout", `gui/${uid}/${LEGACY_LISTEN_LABEL}`]);
|
|
55
|
+
}
|
|
56
|
+
run("launchctl", ["unload", plist]);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
fs.unlinkSync(plist);
|
|
60
|
+
removed = true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// leave removed false for this file
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const unitPath = legacyListenSystemdPath(opts.home);
|
|
67
|
+
if (fs.existsSync(unitPath)) {
|
|
68
|
+
if (opts.platform === "linux" && run) {
|
|
69
|
+
run("systemctl", ["--user", "disable", "--now", LEGACY_LISTEN_SYSTEMD_UNIT]);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(unitPath);
|
|
73
|
+
removed = true;
|
|
74
|
+
if (opts.platform === "linux" && run) {
|
|
75
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// leave prior removed state
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (opts.platform === "win32" && opts.windowsTaskPresent && run) {
|
|
83
|
+
const result = run("schtasks.exe", ["/Delete", "/TN", LEGACY_LISTEN_TASK, "/F"]);
|
|
84
|
+
if (result.status === 0)
|
|
85
|
+
removed = true;
|
|
86
|
+
}
|
|
87
|
+
return { removed };
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=legacy-listen.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-owned work-mesh live cache.
|
|
3
|
+
*
|
|
4
|
+
* ~/.hq/work-mesh/live-cache.json plus live-cache.json.cursor-v2.json.
|
|
5
|
+
* A project-view push updates the project row and bumps the cursor once.
|
|
6
|
+
* The same company/project/boardVersion (or eventId) does not bump again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function liveCacheFile(meshRoot: string): string;
|
|
9
|
+
export declare function liveCacheCursorFile(meshRoot: string): string;
|
|
10
|
+
export interface ProjectViewPush {
|
|
11
|
+
companyUid: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
boardVersion?: string;
|
|
14
|
+
eventId?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Parse a board wake. Returns null for every other payload. */
|
|
17
|
+
export declare function parseProjectViewPush(payload: unknown): ProjectViewPush | null;
|
|
18
|
+
/**
|
|
19
|
+
* Apply one project-view push. Cursor increases by exactly one on first sight
|
|
20
|
+
* of the key, and is left unchanged when the same push is applied again.
|
|
21
|
+
*/
|
|
22
|
+
export declare function applyProjectViewPush(meshRoot: string, push: ProjectViewPush, nowIso?: string): {
|
|
23
|
+
applied: boolean;
|
|
24
|
+
cursor: number;
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=live-cache.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-owned work-mesh live cache.
|
|
3
|
+
*
|
|
4
|
+
* ~/.hq/work-mesh/live-cache.json plus live-cache.json.cursor-v2.json.
|
|
5
|
+
* A project-view push updates the project row and bumps the cursor once.
|
|
6
|
+
* The same company/project/boardVersion (or eventId) does not bump again.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
export function liveCacheFile(meshRoot) {
|
|
11
|
+
return path.join(meshRoot, "live-cache.json");
|
|
12
|
+
}
|
|
13
|
+
export function liveCacheCursorFile(meshRoot) {
|
|
14
|
+
return path.join(meshRoot, "live-cache.json.cursor-v2.json");
|
|
15
|
+
}
|
|
16
|
+
function readJson(file) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeJson(file, value) {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
26
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
27
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
}
|
|
30
|
+
function pushKey(push) {
|
|
31
|
+
if (push.eventId && push.eventId.trim())
|
|
32
|
+
return `id:${push.eventId.trim()}`;
|
|
33
|
+
return `pv:${push.companyUid}:${push.projectId}:${push.boardVersion ?? ""}`;
|
|
34
|
+
}
|
|
35
|
+
/** Parse a board wake. Returns null for every other payload. */
|
|
36
|
+
export function parseProjectViewPush(payload) {
|
|
37
|
+
let value = payload;
|
|
38
|
+
if (Buffer.isBuffer(value))
|
|
39
|
+
value = value.toString("utf8");
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(value);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
49
|
+
return null;
|
|
50
|
+
const rec = value;
|
|
51
|
+
if (rec.kind !== "board" || rec.mutation !== "project-view")
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof rec.companyUid !== "string" || typeof rec.projectId !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
if (!rec.companyUid || !rec.projectId)
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
companyUid: rec.companyUid,
|
|
59
|
+
projectId: rec.projectId,
|
|
60
|
+
boardVersion: typeof rec.boardVersion === "string" ? rec.boardVersion : undefined,
|
|
61
|
+
eventId: typeof rec.eventId === "string" ? rec.eventId : undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply one project-view push. Cursor increases by exactly one on first sight
|
|
66
|
+
* of the key, and is left unchanged when the same push is applied again.
|
|
67
|
+
*/
|
|
68
|
+
export function applyProjectViewPush(meshRoot, push, nowIso = new Date().toISOString()) {
|
|
69
|
+
const key = pushKey(push);
|
|
70
|
+
const stored = readJson(liveCacheCursorFile(meshRoot));
|
|
71
|
+
const previous = stored &&
|
|
72
|
+
typeof stored === "object" &&
|
|
73
|
+
!Array.isArray(stored) &&
|
|
74
|
+
stored.version === 1 &&
|
|
75
|
+
typeof stored.cursor === "number"
|
|
76
|
+
? {
|
|
77
|
+
version: 1,
|
|
78
|
+
cursor: stored.cursor,
|
|
79
|
+
seen: Array.isArray(stored.seen)
|
|
80
|
+
? stored.seen.filter((s) => typeof s === "string").slice(-200)
|
|
81
|
+
: [],
|
|
82
|
+
}
|
|
83
|
+
: { version: 1, cursor: 0, seen: [] };
|
|
84
|
+
if (previous.seen.includes(key)) {
|
|
85
|
+
return { applied: false, cursor: previous.cursor };
|
|
86
|
+
}
|
|
87
|
+
const cursor = previous.cursor + 1;
|
|
88
|
+
const cacheRaw = readJson(liveCacheFile(meshRoot));
|
|
89
|
+
const cache = cacheRaw && typeof cacheRaw === "object" && !Array.isArray(cacheRaw)
|
|
90
|
+
? cacheRaw
|
|
91
|
+
: {};
|
|
92
|
+
const projects = cache.projects && typeof cache.projects === "object" && !Array.isArray(cache.projects)
|
|
93
|
+
? { ...cache.projects }
|
|
94
|
+
: {};
|
|
95
|
+
const rowKey = `${push.companyUid}/${push.projectId}`;
|
|
96
|
+
projects[rowKey] = {
|
|
97
|
+
companyUid: push.companyUid,
|
|
98
|
+
projectId: push.projectId,
|
|
99
|
+
mutation: "project-view",
|
|
100
|
+
boardVersion: push.boardVersion,
|
|
101
|
+
updatedAt: nowIso,
|
|
102
|
+
};
|
|
103
|
+
writeJson(liveCacheFile(meshRoot), {
|
|
104
|
+
...cache,
|
|
105
|
+
updatedAt: nowIso,
|
|
106
|
+
cursor,
|
|
107
|
+
projects,
|
|
108
|
+
});
|
|
109
|
+
writeJson(liveCacheCursorFile(meshRoot), {
|
|
110
|
+
version: 1,
|
|
111
|
+
cursor,
|
|
112
|
+
seen: [...previous.seen, key].slice(-200),
|
|
113
|
+
});
|
|
114
|
+
return { applied: true, cursor };
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=live-cache.js.map
|
|
@@ -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. */
|
|
@@ -102,10 +103,22 @@ export interface DaemonRunDeps {
|
|
|
102
103
|
runForMs?: number;
|
|
103
104
|
/** CLI version loaded by this daemon. Defaults to this package's version. */
|
|
104
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;
|
|
105
113
|
/** Read the currently installed CLI version behind the launch entrypoint. */
|
|
106
114
|
readInstalledCliVersion?: () => string | null;
|
|
107
115
|
random?: () => number;
|
|
108
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;
|
|
109
122
|
}
|
|
110
123
|
export interface DaemonHandle {
|
|
111
124
|
stop: () => Promise<void>;
|
|
@@ -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) {
|
|
@@ -369,6 +383,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
369
383
|
});
|
|
370
384
|
});
|
|
371
385
|
let presence = null;
|
|
386
|
+
let workCache = null;
|
|
372
387
|
if (deps.enablePresence !== false) {
|
|
373
388
|
const fetchCredentialsRaw = deps.fetchCredentials ??
|
|
374
389
|
(async () => {
|
|
@@ -430,6 +445,31 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
430
445
|
// Continue draining spool even if MQTT is down.
|
|
431
446
|
}
|
|
432
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
|
+
}
|
|
433
473
|
let stopped = false;
|
|
434
474
|
let restartRequested = false;
|
|
435
475
|
let flushInFlight = null;
|
|
@@ -708,6 +748,15 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
708
748
|
// Final flush, then clean MQTT DISCONNECT so the broker emits the
|
|
709
749
|
// lifecycle event; offline is published server-side only.
|
|
710
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
|
+
}
|
|
711
760
|
if (presence) {
|
|
712
761
|
try {
|
|
713
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.134.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",
|