@indigoai-us/hq-cli 5.132.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 +36 -0
- 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/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
package/dist/lib/lanes/types.js
CHANGED
|
@@ -198,7 +198,8 @@ export const LANE_FIELD_WRITERS = {
|
|
|
198
198
|
project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"],
|
|
199
199
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
200
200
|
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
|
|
201
|
-
pickup_validation_pending: ["creator", "command", "spawn"],
|
|
201
|
+
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
202
|
+
pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
|
|
202
203
|
priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"],
|
|
203
204
|
provider: ["creator"], account: ["creator", "spawn", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
204
205
|
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
|
|
6
6
|
import { type UnattributedEventCounts } from "../spool.js";
|
|
7
7
|
import { type DaemonStateFile } from "./state.js";
|
|
8
|
+
import { type LegacyListenPlatform } from "./legacy-listen.js";
|
|
8
9
|
export declare const UNHEALTHY_SPOOL_AGE_MS = 60000;
|
|
9
10
|
export interface DaemonDoctorReport {
|
|
10
11
|
running: boolean;
|
|
@@ -54,6 +55,8 @@ export interface DaemonDoctorReport {
|
|
|
54
55
|
};
|
|
55
56
|
/** Work held or quarantined after default-derived membership was denied. */
|
|
56
57
|
deviceDefaultAuthorizationDenials: number;
|
|
58
|
+
/** True when the pack-era listen unit is still on disk. */
|
|
59
|
+
legacyListenerInstalled: boolean;
|
|
57
60
|
}
|
|
58
61
|
export interface DaemonDoctorDeps {
|
|
59
62
|
home?: string;
|
|
@@ -64,6 +67,9 @@ export interface DaemonDoctorDeps {
|
|
|
64
67
|
env?: NodeJS.ProcessEnv;
|
|
65
68
|
isProcessAlive?: (pid: number) => boolean;
|
|
66
69
|
now?: () => number;
|
|
70
|
+
/** Override the legacy-listen probe (tests). */
|
|
71
|
+
legacyListenerInstalled?: boolean;
|
|
72
|
+
platform?: LegacyListenPlatform;
|
|
67
73
|
}
|
|
68
74
|
export declare function collectDaemonDoctor(deps?: DaemonDoctorDeps): DaemonDoctorReport;
|
|
69
75
|
export declare function formatDaemonDoctor(report: DaemonDoctorReport): string[];
|
|
@@ -18,6 +18,7 @@ import { hasCurrentAcknowledgedRegistration, readHeldSessions, refWithLocalCompa
|
|
|
18
18
|
import { readDaemonConfig } from "./config.js";
|
|
19
19
|
import { pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
20
20
|
import { readDaemonState } from "./state.js";
|
|
21
|
+
import { LEGACY_LISTENER_DOCTOR_LINE, legacyListenInstalled, } from "./legacy-listen.js";
|
|
21
22
|
export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
|
|
22
23
|
function oldestJsonlAgeMs(filePath, nowMs) {
|
|
23
24
|
if (!fs.existsSync(filePath))
|
|
@@ -144,6 +145,14 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
144
145
|
if (presenceRefusal) {
|
|
145
146
|
unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
|
|
146
147
|
}
|
|
148
|
+
const platform = deps.platform ?? process.platform;
|
|
149
|
+
const legacyListener = deps.legacyListenerInstalled ??
|
|
150
|
+
(platform === "darwin" || platform === "linux"
|
|
151
|
+
? legacyListenInstalled({ home, platform })
|
|
152
|
+
: false);
|
|
153
|
+
if (legacyListener) {
|
|
154
|
+
unhealthyReasons.push(LEGACY_LISTENER_DOCTOR_LINE);
|
|
155
|
+
}
|
|
147
156
|
const auth = describeCognitoTokenSource({ home, env });
|
|
148
157
|
const whoamiTokens = readPersonTokenFile(home, env);
|
|
149
158
|
const daemonTokens = auth.tokenSource === "machine"
|
|
@@ -192,6 +201,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
192
201
|
}
|
|
193
202
|
: undefined,
|
|
194
203
|
deviceDefaultAuthorizationDenials,
|
|
204
|
+
legacyListenerInstalled: legacyListener,
|
|
195
205
|
};
|
|
196
206
|
}
|
|
197
207
|
export function formatDaemonDoctor(report) {
|
|
@@ -200,6 +210,7 @@ export function formatDaemonDoctor(report) {
|
|
|
200
210
|
`mode: ${report.emitMode}`,
|
|
201
211
|
`mqtt: ${report.mqttState}`,
|
|
202
212
|
`subscription: ${report.subscribed ? "connected" : "disconnected"}`,
|
|
213
|
+
...(report.legacyListenerInstalled ? [LEGACY_LISTENER_DOCTOR_LINE] : []),
|
|
203
214
|
`companies online: ${report.companiesOnline.length ? report.companiesOnline.join(", ") : "(none)"}`,
|
|
204
215
|
];
|
|
205
216
|
if (report.emitMode === "legacy") {
|
|
@@ -235,6 +235,13 @@ export declare function installDaemonService(opts: {
|
|
|
235
235
|
schtasks?: SchtasksRunner;
|
|
236
236
|
/** Windows: terminate the recorded daemon pid when replacing a task (tests inject). */
|
|
237
237
|
kill?: (pid: number) => void;
|
|
238
|
+
/** Service-manager runner for retiring the legacy listen unit (tests inject). */
|
|
239
|
+
runCommand?: (cmd: string, args: string[]) => {
|
|
240
|
+
status: number | null;
|
|
241
|
+
stdout: string;
|
|
242
|
+
stderr: string;
|
|
243
|
+
};
|
|
244
|
+
windowsLegacyTaskPresent?: boolean;
|
|
238
245
|
}): InstallResult;
|
|
239
246
|
export declare function uninstallDaemonService(opts: {
|
|
240
247
|
platform?: DaemonInstallPlatform;
|
|
@@ -19,6 +19,7 @@ import { resolveDaemonAssetDir } from "./log.js";
|
|
|
19
19
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
20
|
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
21
21
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
|
+
import { retireLegacyListen } from "./legacy-listen.js";
|
|
22
23
|
/**
|
|
23
24
|
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
24
25
|
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
@@ -651,6 +652,15 @@ export function installDaemonService(opts) {
|
|
|
651
652
|
const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
|
|
652
653
|
mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
|
|
653
654
|
registerTrustedHqRoot(paths.daemonDir, paths.hqRoot);
|
|
655
|
+
const retired = retireLegacyListen({
|
|
656
|
+
home: paths.home,
|
|
657
|
+
platform,
|
|
658
|
+
run: opts.runCommand,
|
|
659
|
+
windowsTaskPresent: opts.windowsLegacyTaskPresent,
|
|
660
|
+
});
|
|
661
|
+
const legacyNote = retired.removed
|
|
662
|
+
? " Retired legacy listener ai.getindigo.hq-work-mesh-listen."
|
|
663
|
+
: "";
|
|
654
664
|
if (platform === "darwin") {
|
|
655
665
|
const rendered = renderLaunchdPlist(paths);
|
|
656
666
|
mkdirSync(paths.launchAgentsDir, { recursive: true });
|
|
@@ -662,7 +672,7 @@ export function installDaemonService(opts) {
|
|
|
662
672
|
paths,
|
|
663
673
|
rendered,
|
|
664
674
|
dest: paths.plistPath,
|
|
665
|
-
message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}`,
|
|
675
|
+
message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}${legacyNote}`,
|
|
666
676
|
};
|
|
667
677
|
}
|
|
668
678
|
if (platform === "linux") {
|
|
@@ -682,7 +692,7 @@ export function installDaemonService(opts) {
|
|
|
682
692
|
dest,
|
|
683
693
|
message: `Wrote system unit template for user ${opts.systemUser ?? paths.systemUser} at ${dest}. ` +
|
|
684
694
|
`Install with: sudo cp ${dest} /etc/systemd/system/${SYSTEMD_UNIT_NAME} && ` +
|
|
685
|
-
`sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}`,
|
|
695
|
+
`sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}${legacyNote}`,
|
|
686
696
|
};
|
|
687
697
|
}
|
|
688
698
|
const rendered = renderSystemdUserUnit(paths);
|
|
@@ -696,7 +706,7 @@ export function installDaemonService(opts) {
|
|
|
696
706
|
paths,
|
|
697
707
|
rendered,
|
|
698
708
|
dest: paths.systemdUnitPath,
|
|
699
|
-
message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
|
|
709
|
+
message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}${legacyNote}`,
|
|
700
710
|
};
|
|
701
711
|
}
|
|
702
712
|
if (platform === "win32") {
|
|
@@ -728,9 +738,9 @@ export function installDaemonService(opts) {
|
|
|
728
738
|
dest: paths.windowsTaskXmlPath,
|
|
729
739
|
running,
|
|
730
740
|
message: running
|
|
731
|
-
? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
|
|
741
|
+
? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
|
|
732
742
|
: `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
|
|
733
|
-
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon
|
|
743
|
+
`start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.${legacyNote}`,
|
|
734
744
|
notice: windowsLogonNotice(paths),
|
|
735
745
|
};
|
|
736
746
|
}
|
|
@@ -738,7 +748,7 @@ export function installDaemonService(opts) {
|
|
|
738
748
|
platform,
|
|
739
749
|
action: "install",
|
|
740
750
|
paths,
|
|
741
|
-
message: `Unsupported platform for service install; run: ${paths.nodeBinary} ${paths.hqBinary} mesh daemon run`,
|
|
751
|
+
message: `Unsupported platform for service install; run: ${paths.nodeBinary} ${paths.hqBinary} mesh daemon run${legacyNote}`,
|
|
742
752
|
};
|
|
743
753
|
}
|
|
744
754
|
export function uninstallDaemonService(opts) {
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
export type LegacyListenPlatform = "darwin" | "linux" | "win32" | "other";
|
|
9
|
+
export declare const LEGACY_LISTEN_LABEL = "ai.getindigo.hq-work-mesh-listen";
|
|
10
|
+
export declare const LEGACY_LISTEN_SYSTEMD_UNIT = "hq-work-mesh-listen.service";
|
|
11
|
+
export declare const LEGACY_LISTEN_TASK = "ai.getindigo.hq-work-mesh-listen";
|
|
12
|
+
export declare const LEGACY_LISTENER_DOCTOR_LINE = "legacy listener still installed";
|
|
13
|
+
export interface CommandResult {
|
|
14
|
+
status: number | null;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}
|
|
18
|
+
export type CommandRunner = (cmd: string, args: string[]) => CommandResult;
|
|
19
|
+
export declare function legacyListenPlistPath(home: string): string;
|
|
20
|
+
export declare function legacyListenSystemdPath(home: string): string;
|
|
21
|
+
export declare function legacyListenInstalled(opts: {
|
|
22
|
+
home: string;
|
|
23
|
+
platform: LegacyListenPlatform;
|
|
24
|
+
existsSync?: (p: string) => boolean;
|
|
25
|
+
}): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Unload and delete the legacy listen unit when it is present.
|
|
28
|
+
* On-disk unit files are removed on every host. launchctl, systemctl, and
|
|
29
|
+
* schtasks run only for the matching platform, and only through `run` when
|
|
30
|
+
* the caller injected one (tests never hit a real service manager).
|
|
31
|
+
* Missing unit is a no-op.
|
|
32
|
+
*/
|
|
33
|
+
export declare function retireLegacyListen(opts: {
|
|
34
|
+
home: string;
|
|
35
|
+
platform: LegacyListenPlatform;
|
|
36
|
+
run?: CommandRunner;
|
|
37
|
+
uid?: number;
|
|
38
|
+
/** Windows task probe: true when the scheduled task is registered. */
|
|
39
|
+
windowsTaskPresent?: boolean;
|
|
40
|
+
}): {
|
|
41
|
+
removed: boolean;
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=legacy-listen.d.ts.map
|
|
@@ -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();
|