@indigoai-us/hq-cli 5.162.0 → 5.163.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 +13 -0
- package/dist/command-catalog.generated.d.ts +188 -0
- package/dist/command-catalog.generated.js +235 -0
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +6 -1
- package/dist/commands/agent-enroll.js +30 -3
- package/dist/commands/agents.js +15 -22
- package/dist/commands/bot.js +56 -6
- package/dist/commands/daemon.d.ts +53 -0
- package/dist/commands/daemon.js +554 -0
- package/dist/commands/lanes.js +7 -0
- package/dist/commands/meetings.js +18 -16
- package/dist/commands/mesh.js +9 -0
- package/dist/lib/daemon/catalog.d.ts +85 -0
- package/dist/lib/daemon/catalog.js +274 -0
- package/dist/lib/daemon/config.d.ts +26 -0
- package/dist/lib/daemon/config.js +85 -0
- package/dist/lib/daemon/handoff.d.ts +53 -0
- package/dist/lib/daemon/handoff.js +136 -0
- package/dist/lib/daemon/install.d.ts +76 -0
- package/dist/lib/daemon/install.js +299 -0
- package/dist/lib/daemon/legacy.d.ts +35 -0
- package/dist/lib/daemon/legacy.js +120 -0
- package/dist/lib/daemon/lock.d.ts +47 -0
- package/dist/lib/daemon/lock.js +138 -0
- package/dist/lib/daemon/machine-type.d.ts +48 -0
- package/dist/lib/daemon/machine-type.js +139 -0
- package/dist/lib/daemon/migrate.d.ts +61 -0
- package/dist/lib/daemon/migrate.js +238 -0
- package/dist/lib/daemon/paths.d.ts +22 -0
- package/dist/lib/daemon/paths.js +42 -0
- package/dist/lib/daemon/remote-control.d.ts +35 -0
- package/dist/lib/daemon/remote-control.js +47 -0
- package/dist/lib/daemon/requests.d.ts +24 -0
- package/dist/lib/daemon/requests.js +63 -0
- package/dist/lib/daemon/run.d.ts +41 -0
- package/dist/lib/daemon/run.js +319 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +523 -0
- package/dist/lib/daemon/updater.d.ts +39 -0
- package/dist/lib/daemon/updater.js +103 -0
- package/dist/lib/flag-registry.d.ts +7 -1
- package/dist/lib/flag-registry.js +8 -0
- package/dist/lib/mesh/live/backoff.d.ts +2 -2
- package/dist/lib/mesh/live/backoff.js +16 -3
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -2
- package/dist/lib/mesh/live/daemon/run.js +5 -2
- package/dist/lib/mesh/live/flush.d.ts +3 -2
- package/dist/lib/mesh/live/flush.js +34 -7
- package/dist/lib/mesh/live/session-events-client.d.ts +2 -0
- package/dist/lib/mesh/live/session-events-client.js +25 -0
- package/dist/lib/service-manager/systemd.d.ts +2 -2
- package/dist/lib/service-manager/systemd.js +1 -1
- package/dist/lib/service-manager/types.d.ts +2 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +53 -13
- package/dist/utils/cognito-session.d.ts +4 -0
- package/dist/utils/cognito-session.js +11 -10
- package/dist/utils/plan-gate-error.d.ts +27 -13
- package/dist/utils/plan-gate-error.js +133 -58
- package/dist/utils/self-update.d.ts +9 -0
- package/dist/utils/self-update.js +15 -2
- package/package.json +1 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One run of the `updater` service (`hq daemon service self-update`), which
|
|
3
|
+
* replaces the boxes' `hq-cli-update.timer` and `*-codex-update.timer`:
|
|
4
|
+
*
|
|
5
|
+
* 1. Update the hq CLI through the shared self-update path (same install
|
|
6
|
+
* routing, lock and convergence check as every other CLI update).
|
|
7
|
+
* 2. Update Codex when it is installed as a global npm package and behind.
|
|
8
|
+
* 3. After a CLI update, ask the daemon to restart itself so the supervisor and
|
|
9
|
+
* every service run the new version.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import semver from "semver";
|
|
13
|
+
export const CODEX_PACKAGE = "@openai/codex";
|
|
14
|
+
export async function runDaemonUpdater(deps) {
|
|
15
|
+
let ok = true;
|
|
16
|
+
const cli = await deps.updateCli();
|
|
17
|
+
// The restart stops this process too, so it is requested only once all the
|
|
18
|
+
// updater's work (including a global npm install of Codex) is done.
|
|
19
|
+
let restart = false;
|
|
20
|
+
switch (cli.action) {
|
|
21
|
+
case "updated":
|
|
22
|
+
deps.log(`hq CLI updated to ${cli.latest ?? "the latest version"}; the daemon restarts onto it once Codex is checked`);
|
|
23
|
+
restart = true;
|
|
24
|
+
break;
|
|
25
|
+
case "update-failed":
|
|
26
|
+
case "update-ineffective":
|
|
27
|
+
deps.log(`hq CLI update to ${cli.latest ?? "latest"} did not take effect (${cli.action})`);
|
|
28
|
+
ok = false;
|
|
29
|
+
break;
|
|
30
|
+
default:
|
|
31
|
+
deps.log(`hq CLI: ${cli.action === "current" ? "up to date" : cli.action}`);
|
|
32
|
+
}
|
|
33
|
+
if (!updateCodex(deps))
|
|
34
|
+
ok = false;
|
|
35
|
+
if (restart)
|
|
36
|
+
deps.requestDaemonRestart();
|
|
37
|
+
return { ok };
|
|
38
|
+
}
|
|
39
|
+
/** Update Codex when it is a global npm install and behind; false when that update failed. */
|
|
40
|
+
function updateCodex(deps) {
|
|
41
|
+
const installed = deps.codex.installedVersion();
|
|
42
|
+
if (installed === null)
|
|
43
|
+
return true;
|
|
44
|
+
const latest = deps.codex.latestVersion();
|
|
45
|
+
if (latest === null || !semver.valid(installed) || !semver.valid(latest) || !semver.gt(latest, installed)) {
|
|
46
|
+
deps.log(`Codex ${installed}: nothing to update`);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const result = deps.codex.install();
|
|
50
|
+
if (result.ok) {
|
|
51
|
+
deps.log(`Codex updated ${installed} -> ${latest}`);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
deps.log(`Codex update ${installed} -> ${latest} failed: ${result.detail ?? "unknown error"}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The variables for the updater's own CLI update. Every supervised service
|
|
59
|
+
* gets HQ_NO_UPDATE_CHECK=1 so it never updates itself; the updater is the one
|
|
60
|
+
* process that must, so it drops that opt-out.
|
|
61
|
+
*/
|
|
62
|
+
export function updaterEnv(vars) {
|
|
63
|
+
const copy = { ...vars };
|
|
64
|
+
delete copy.HQ_NO_UPDATE_CHECK;
|
|
65
|
+
return copy;
|
|
66
|
+
}
|
|
67
|
+
function npm(args) {
|
|
68
|
+
const r = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, {
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
timeout: 10 * 60_000,
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
shell: process.platform === "win32",
|
|
73
|
+
});
|
|
74
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
75
|
+
}
|
|
76
|
+
/** Codex through the global npm install, the way the boxes install it. */
|
|
77
|
+
export function npmCodexUpdater() {
|
|
78
|
+
return {
|
|
79
|
+
installedVersion: () => {
|
|
80
|
+
const r = npm(["ls", "-g", CODEX_PACKAGE, "--depth=0", "--json"]);
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(r.stdout);
|
|
83
|
+
return parsed.dependencies?.[CODEX_PACKAGE]?.version ?? null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
latestVersion: () => {
|
|
90
|
+
const r = npm(["view", CODEX_PACKAGE, "version"]);
|
|
91
|
+
const v = r.status === 0 ? r.stdout.trim() : "";
|
|
92
|
+
return v || null;
|
|
93
|
+
},
|
|
94
|
+
install: () => {
|
|
95
|
+
const r = npm(["install", "-g", `${CODEX_PACKAGE}@latest`]);
|
|
96
|
+
if (r.status === 0)
|
|
97
|
+
return { ok: true };
|
|
98
|
+
const tail = r.stderr.trim().split("\n").slice(-3).join(" ").slice(0, 400);
|
|
99
|
+
return { ok: false, detail: tail || `npm exited ${r.status ?? "by signal"}` };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=updater.js.map
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* keep a short-lived, offline CLI alive until the client's network deadline.
|
|
7
7
|
*/
|
|
8
8
|
import { type WorkerOptions } from "node:worker_threads";
|
|
9
|
-
import { type FlagClient, type FlagLookupOptions } from "@indigoai-us/hq-flags-client";
|
|
9
|
+
import { type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
|
|
10
10
|
/** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
|
|
11
11
|
export declare const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
12
12
|
/** The synchronous capability that command gate call sites need. */
|
|
@@ -38,6 +38,12 @@ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDep
|
|
|
38
38
|
export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
39
39
|
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
40
40
|
export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The registry snapshot held by this process, or null when none has arrived
|
|
43
|
+
* (no endpoint configured, offline, or still fetching). Callers that must
|
|
44
|
+
* distinguish "unreachable" from "off" read this instead of a gate.
|
|
45
|
+
*/
|
|
46
|
+
export declare function currentFlagSnapshot(): FlagSnapshot | null;
|
|
41
47
|
/** Test-only reset for process-global readiness state. */
|
|
42
48
|
export declare function _resetFlagRegistryForTests(): void;
|
|
43
49
|
export {};
|
|
@@ -83,6 +83,14 @@ export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
|
|
|
83
83
|
export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
|
|
84
84
|
return resolveFlagGate(processReader, flagKey, lookup, legacyFallback);
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The registry snapshot held by this process, or null when none has arrived
|
|
88
|
+
* (no endpoint configured, offline, or still fetching). Callers that must
|
|
89
|
+
* distinguish "unreachable" from "off" read this instead of a gate.
|
|
90
|
+
*/
|
|
91
|
+
export function currentFlagSnapshot() {
|
|
92
|
+
return heldSnapshot;
|
|
93
|
+
}
|
|
86
94
|
/** Test-only reset for process-global readiness state. */
|
|
87
95
|
export function _resetFlagRegistryForTests() {
|
|
88
96
|
heldSnapshot = null;
|
|
@@ -6,11 +6,11 @@ export declare const BACKOFF_BASE_MS = 1000;
|
|
|
6
6
|
export declare const BACKOFF_CAP_MS = 60000;
|
|
7
7
|
export declare const FLUSH_MAX_ATTEMPTS = 5;
|
|
8
8
|
export type RandomFn = () => number;
|
|
9
|
-
export type SleepFn = (ms: number) => Promise<void>;
|
|
9
|
+
export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
10
10
|
export declare function fullJitterDelayMs(attempt: number, opts?: {
|
|
11
11
|
baseMs?: number;
|
|
12
12
|
capMs?: number;
|
|
13
13
|
random?: RandomFn;
|
|
14
14
|
}): number;
|
|
15
|
-
export declare function defaultSleep(ms: number): Promise<void>;
|
|
15
|
+
export declare function defaultSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
16
16
|
//# sourceMappingURL=backoff.d.ts.map
|
|
@@ -12,9 +12,22 @@ export function fullJitterDelayMs(attempt, opts = {}) {
|
|
|
12
12
|
const exp = Math.min(cap, base * 2 ** attempt);
|
|
13
13
|
return Math.floor(random() * exp);
|
|
14
14
|
}
|
|
15
|
-
export async function defaultSleep(ms) {
|
|
16
|
-
if (ms <= 0)
|
|
15
|
+
export async function defaultSleep(ms, signal) {
|
|
16
|
+
if (ms <= 0 || signal?.aborted)
|
|
17
17
|
return;
|
|
18
|
-
await new Promise((resolve) =>
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
const onAbort = () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
finish();
|
|
22
|
+
};
|
|
23
|
+
const finish = () => {
|
|
24
|
+
signal?.removeEventListener("abort", onAbort);
|
|
25
|
+
resolve();
|
|
26
|
+
};
|
|
27
|
+
const timer = setTimeout(finish, ms);
|
|
28
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
if (signal?.aborted)
|
|
30
|
+
onAbort();
|
|
31
|
+
});
|
|
19
32
|
}
|
|
20
33
|
//# sourceMappingURL=backoff.js.map
|
|
@@ -45,8 +45,8 @@ export interface DaemonRunDeps {
|
|
|
45
45
|
timers?: TimerHost;
|
|
46
46
|
fetchCredentials?: CredentialsFetcher;
|
|
47
47
|
mqttConnect?: MqttConnectFn;
|
|
48
|
-
/** Injected flush (tests). */
|
|
49
|
-
flush?: () => Promise<FlushSummary>;
|
|
48
|
+
/** Injected flush (tests). Receives the daemon shutdown signal. */
|
|
49
|
+
flush?: (signal?: AbortSignal) => Promise<FlushSummary>;
|
|
50
50
|
/** Injected outbox replay (tests). */
|
|
51
51
|
replayOutbox?: () => Promise<{
|
|
52
52
|
delivered: number;
|
|
@@ -330,8 +330,9 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
330
330
|
};
|
|
331
331
|
/** In-memory held re-attempt throttle (survives watch-triggered flushes). */
|
|
332
332
|
let lastHeldRetryAtMs = 0;
|
|
333
|
+
const shutdownController = new AbortController();
|
|
333
334
|
const flushFn = deps.flush ??
|
|
334
|
-
(async () => {
|
|
335
|
+
(async (signal) => {
|
|
335
336
|
const t = await getToken();
|
|
336
337
|
const poster = deps.poster ?? createSessionEventsPoster({
|
|
337
338
|
token: t,
|
|
@@ -341,6 +342,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
341
342
|
workMeshRoot: meshRoot,
|
|
342
343
|
workContextRoot: ctxRoot,
|
|
343
344
|
poster,
|
|
345
|
+
signal,
|
|
344
346
|
heldRetryIntervalMs: HELD_RETRY_INTERVAL_MS,
|
|
345
347
|
lastHeldRetryAtMs,
|
|
346
348
|
onHeldClaimed: (atMs) => {
|
|
@@ -628,7 +630,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
628
630
|
const at = now().getTime();
|
|
629
631
|
noteHookSessionsFromSpoolFile(workMeshSpoolPath(meshRoot), lastHookEventAt, at);
|
|
630
632
|
noteHookSessionsFromSpoolFile(workMeshHeldPath(meshRoot), lastHookEventAt, at);
|
|
631
|
-
const summary = await flushFn();
|
|
633
|
+
const summary = await flushFn(shutdownController.signal);
|
|
632
634
|
try {
|
|
633
635
|
const replay = await replayFn();
|
|
634
636
|
const delivered = replay.delivered ?? 0;
|
|
@@ -794,6 +796,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
794
796
|
if (stopped)
|
|
795
797
|
return;
|
|
796
798
|
stopped = true;
|
|
799
|
+
shutdownController.abort();
|
|
797
800
|
log(dir, "daemon stopping");
|
|
798
801
|
if (debounceHandle !== null)
|
|
799
802
|
timers.clearTimeout(debounceHandle);
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Flush spool + held session events (US-008).
|
|
3
3
|
*
|
|
4
4
|
* Claim-by-rename → group by sessionId → read work-context state →
|
|
5
|
-
* drop untracked / hold unverified / strip local-only → POST
|
|
6
|
-
*
|
|
5
|
+
* drop untracked / hold unverified / strip local-only → POST at most 25 events
|
|
6
|
+
* per session per flush. Throttles honor Retry-After and preserve the backlog.
|
|
7
7
|
*
|
|
8
8
|
* Orphan `*.claimed` files from a prior crash are recovered first.
|
|
9
9
|
* 2xx responses are accounted per eventId (accepted / duplicates / rejected).
|
|
@@ -32,6 +32,7 @@ export interface FlushDeps {
|
|
|
32
32
|
workMeshRoot: string;
|
|
33
33
|
workContextRoot: string;
|
|
34
34
|
poster: SessionEventsPoster;
|
|
35
|
+
signal?: AbortSignal;
|
|
35
36
|
now?: () => Date;
|
|
36
37
|
sleep?: SleepFn;
|
|
37
38
|
random?: RandomFn;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Flush spool + held session events (US-008).
|
|
3
3
|
*
|
|
4
4
|
* Claim-by-rename → group by sessionId → read work-context state →
|
|
5
|
-
* drop untracked / hold unverified / strip local-only → POST
|
|
6
|
-
*
|
|
5
|
+
* drop untracked / hold unverified / strip local-only → POST at most 25 events
|
|
6
|
+
* per session per flush. Throttles honor Retry-After and preserve the backlog.
|
|
7
7
|
*
|
|
8
8
|
* Orphan `*.claimed` files from a prior crash are recovered first.
|
|
9
9
|
* 2xx responses are accounted per eventId (accepted / duplicates / rejected).
|
|
@@ -21,6 +21,10 @@ import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js"
|
|
|
21
21
|
import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
|
|
22
22
|
import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLine, appendSpoolLine, claimHeld, claimSpool, countJsonlLines, ensureSpoolFile, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
|
|
23
23
|
import { workMeshHeldPath, workMeshRoot } from "./paths.js";
|
|
24
|
+
/** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
|
|
25
|
+
const SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH = 25;
|
|
26
|
+
/** Never keep a flush blocked on a Retry-After value for more than five minutes. */
|
|
27
|
+
const MAX_RETRY_AFTER_MS = 5 * 60 * 1000;
|
|
24
28
|
export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
29
|
/** Re-attempt held events at most this often (spool still flushes every cycle). */
|
|
26
30
|
export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
|
|
@@ -126,18 +130,32 @@ async function postBatchWithRetries(poster, body, deps) {
|
|
|
126
130
|
let lastStatus = 0;
|
|
127
131
|
let lastBody = {};
|
|
128
132
|
for (let attempt = 0; attempt < deps.maxAttempts; attempt += 1) {
|
|
133
|
+
if (deps.signal?.aborted) {
|
|
134
|
+
return { ok: false, retryable: true, status: lastStatus, body: lastBody };
|
|
135
|
+
}
|
|
129
136
|
const res = await poster(body);
|
|
130
137
|
lastStatus = res.status;
|
|
131
138
|
lastBody = res.body;
|
|
132
139
|
if (res.ok)
|
|
133
140
|
return { ok: true, body: res.body };
|
|
141
|
+
if (res.status === 429 || res.status === 503) {
|
|
142
|
+
const retryAfterMs = typeof res.retryAfterMs === "number" &&
|
|
143
|
+
Number.isFinite(res.retryAfterMs) &&
|
|
144
|
+
res.retryAfterMs >= 0
|
|
145
|
+
? Math.min(res.retryAfterMs, MAX_RETRY_AFTER_MS)
|
|
146
|
+
: 0;
|
|
147
|
+
const jitterMs = fullJitterDelayMs(attempt, { random: deps.random });
|
|
148
|
+
await deps.sleep(Math.min(MAX_RETRY_AFTER_MS, Math.max(retryAfterMs, jitterMs)), deps.signal);
|
|
149
|
+
// A throttled batch stays queued for a later flush; do not hot-loop it.
|
|
150
|
+
return { ok: false, retryable: true, status: res.status, body: res.body };
|
|
151
|
+
}
|
|
134
152
|
if (!res.retryable) {
|
|
135
153
|
return { ok: false, retryable: false, status: res.status, body: res.body };
|
|
136
154
|
}
|
|
137
155
|
if (attempt + 1 >= deps.maxAttempts)
|
|
138
156
|
break;
|
|
139
157
|
const delay = fullJitterDelayMs(attempt, { random: deps.random });
|
|
140
|
-
await deps.sleep(delay);
|
|
158
|
+
await deps.sleep(delay, deps.signal);
|
|
141
159
|
}
|
|
142
160
|
return { ok: false, retryable: true, status: lastStatus, body: lastBody };
|
|
143
161
|
}
|
|
@@ -230,7 +248,9 @@ export async function flushSessionEvents(deps) {
|
|
|
230
248
|
pendingRestore.delete(event);
|
|
231
249
|
};
|
|
232
250
|
const loggedProject404 = new Set();
|
|
233
|
-
for (const [sessionId, items] of bySession) {
|
|
251
|
+
sessionLoop: for (const [sessionId, items] of bySession) {
|
|
252
|
+
if (deps.signal?.aborted)
|
|
253
|
+
break sessionLoop;
|
|
234
254
|
let state = readSessionState(sessionId, deps.workContextRoot);
|
|
235
255
|
if (state?.companyConfidence === "device_default" &&
|
|
236
256
|
deps.authorizeDeviceDefaultTenant) {
|
|
@@ -273,11 +293,13 @@ export async function flushSessionEvents(deps) {
|
|
|
273
293
|
}
|
|
274
294
|
continue;
|
|
275
295
|
}
|
|
276
|
-
// Post
|
|
277
|
-
|
|
296
|
+
// Post a bounded portion of this session's backlog. Untouched events
|
|
297
|
+
// remain in pendingRestore and are put back on the spool below.
|
|
298
|
+
const flushItems = items.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
|
|
299
|
+
const stripped = flushItems.map((item) => stripLocalOnlyFields(item.event));
|
|
278
300
|
for (let i = 0; i < stripped.length; i += SESSION_EVENTS_BATCH_MAX) {
|
|
279
301
|
const chunk = stripped.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
280
|
-
const originalChunk =
|
|
302
|
+
const originalChunk = flushItems.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
281
303
|
const body = {
|
|
282
304
|
contractVersion: 1,
|
|
283
305
|
companyUid: decision.companyUid,
|
|
@@ -296,6 +318,7 @@ export async function flushSessionEvents(deps) {
|
|
|
296
318
|
maxAttempts,
|
|
297
319
|
sleep,
|
|
298
320
|
random,
|
|
321
|
+
signal: deps.signal,
|
|
299
322
|
});
|
|
300
323
|
if (result.ok) {
|
|
301
324
|
accountBatchSuccess(result.body, originalChunk, deps.workMeshRoot, now, summary, markDisposed);
|
|
@@ -347,6 +370,10 @@ export async function flushSessionEvents(deps) {
|
|
|
347
370
|
summary.restored += 1;
|
|
348
371
|
markDisposed(item.event);
|
|
349
372
|
}
|
|
373
|
+
if (result.status === 429 || result.status === 503)
|
|
374
|
+
break;
|
|
375
|
+
if (deps.signal?.aborted)
|
|
376
|
+
break sessionLoop;
|
|
350
377
|
}
|
|
351
378
|
}
|
|
352
379
|
}
|
|
@@ -23,6 +23,7 @@ export interface SessionEventsPostResult {
|
|
|
23
23
|
body: unknown;
|
|
24
24
|
ok: boolean;
|
|
25
25
|
retryable: boolean;
|
|
26
|
+
retryAfterMs?: number;
|
|
26
27
|
networkError?: boolean;
|
|
27
28
|
}
|
|
28
29
|
/** Contract §2.3 batch ingest ack (parsed from a 2xx body). */
|
|
@@ -51,6 +52,7 @@ export declare function createSessionEventsPoster(opts: {
|
|
|
51
52
|
post?: (path: string, body: SessionEventsBatchBody) => Promise<{
|
|
52
53
|
status: number;
|
|
53
54
|
body: unknown;
|
|
55
|
+
headers?: Pick<Headers, "get">;
|
|
54
56
|
}>;
|
|
55
57
|
}): SessionEventsPoster;
|
|
56
58
|
/** Resolve API base: HQ_VAULT_API_URL overrides DEFAULT_VAULT_API_URL. */
|
|
@@ -15,6 +15,27 @@ export const WRITABLE_CONTEXT_STATUSES = new Set([
|
|
|
15
15
|
"bound",
|
|
16
16
|
"migration_pending",
|
|
17
17
|
]);
|
|
18
|
+
/** Parse the Retry-After delta-seconds or HTTP-date form into milliseconds. */
|
|
19
|
+
function parseRetryAfterMs(value, nowMs = Date.now()) {
|
|
20
|
+
if (value === null || !value.trim())
|
|
21
|
+
return undefined;
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (/^\d+$/.test(trimmed)) {
|
|
24
|
+
const delayMs = Number(trimmed) * 1_000;
|
|
25
|
+
return Number.isSafeInteger(delayMs) ? delayMs : undefined;
|
|
26
|
+
}
|
|
27
|
+
if (/^[+-]/.test(trimmed))
|
|
28
|
+
return undefined;
|
|
29
|
+
const retryAtMs = Date.parse(trimmed);
|
|
30
|
+
if (!Number.isFinite(retryAtMs))
|
|
31
|
+
return undefined;
|
|
32
|
+
return Math.max(0, retryAtMs - nowMs);
|
|
33
|
+
}
|
|
34
|
+
function retryAfterForStatus(status, headers) {
|
|
35
|
+
if (status !== 429 && status !== 503)
|
|
36
|
+
return undefined;
|
|
37
|
+
return parseRetryAfterMs(headers?.get("retry-after") ?? null);
|
|
38
|
+
}
|
|
18
39
|
/**
|
|
19
40
|
* Parse a batch ingest response body. Returns null when the body is not an
|
|
20
41
|
* object (caller should retain events rather than treating them as posted).
|
|
@@ -65,9 +86,11 @@ export function createSessionEventsPoster(opts) {
|
|
|
65
86
|
try {
|
|
66
87
|
if (opts.post) {
|
|
67
88
|
const res = await opts.post(SESSION_EVENTS_PATH, body);
|
|
89
|
+
const retryAfterMs = retryAfterForStatus(res.status, res.headers);
|
|
68
90
|
return {
|
|
69
91
|
status: res.status,
|
|
70
92
|
body: res.body,
|
|
93
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
71
94
|
...classifySessionEventsResponse(res.status),
|
|
72
95
|
};
|
|
73
96
|
}
|
|
@@ -88,9 +111,11 @@ export function createSessionEventsPoster(opts) {
|
|
|
88
111
|
parsed = { raw: text };
|
|
89
112
|
}
|
|
90
113
|
}
|
|
114
|
+
const retryAfterMs = retryAfterForStatus(res.status, res.headers);
|
|
91
115
|
return {
|
|
92
116
|
status: res.status,
|
|
93
117
|
body: parsed,
|
|
118
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
94
119
|
...classifySessionEventsResponse(res.status),
|
|
95
120
|
};
|
|
96
121
|
}
|
|
@@ -10,8 +10,8 @@ import { type ServiceHostPaths, type ServiceSpec } from "./types.js";
|
|
|
10
10
|
import type { RunCommand } from "./launchd.js";
|
|
11
11
|
export declare const SYSTEMD_PATH_ENV = "/usr/local/bin:/usr/bin:/bin";
|
|
12
12
|
export declare function systemdUserDir(home: string): string;
|
|
13
|
-
export declare function systemdUnitName(spec: Pick<ServiceSpec, "name">): string;
|
|
14
|
-
export declare function systemdUnitPath(home: string, spec: Pick<ServiceSpec, "name">): string;
|
|
13
|
+
export declare function systemdUnitName(spec: Pick<ServiceSpec, "name" | "unitName">): string;
|
|
14
|
+
export declare function systemdUnitPath(home: string, spec: Pick<ServiceSpec, "name" | "unitName">): string;
|
|
15
15
|
export declare function renderSystemdUserUnit(spec: ServiceSpec, host: ServiceHostPaths): string;
|
|
16
16
|
export declare function activateSystemd(unitName: string, run: RunCommand): void;
|
|
17
17
|
export declare function deactivateSystemd(unitName: string, run: RunCommand): void;
|
|
@@ -13,7 +13,7 @@ export function systemdUserDir(home) {
|
|
|
13
13
|
return path.join(home, ".config", "systemd", "user");
|
|
14
14
|
}
|
|
15
15
|
export function systemdUnitName(spec) {
|
|
16
|
-
return `hq-agent-${spec.name}.service`;
|
|
16
|
+
return spec.unitName ?? `hq-agent-${spec.name}.service`;
|
|
17
17
|
}
|
|
18
18
|
export function systemdUnitPath(home, spec) {
|
|
19
19
|
return path.join(systemdUserDir(home), systemdUnitName(spec));
|
|
@@ -19,6 +19,8 @@ export interface ServiceSpec {
|
|
|
19
19
|
env: Record<string, string>;
|
|
20
20
|
/** Seconds to wait before a restart after exit. */
|
|
21
21
|
restartSec: number;
|
|
22
|
+
/** systemd unit file name; defaults to `hq-agent-<name>.service`. */
|
|
23
|
+
unitName?: string;
|
|
22
24
|
}
|
|
23
25
|
export interface ServiceHostPaths {
|
|
24
26
|
home: string;
|
package/dist/main.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import "./node-preflight.js";
|
|
6
6
|
import "./node-network-compat.js";
|
|
7
7
|
import { Sentry } from "./sentry.js";
|
|
8
|
+
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
8
9
|
/**
|
|
9
10
|
* Total time one CLI invocation may await foreground network work. This spans
|
|
10
11
|
* the hard version decision, pre-action observability, and release-health
|
|
@@ -21,6 +22,11 @@ export type TopLevelErrorDependencies = {
|
|
|
21
22
|
sentry: Pick<typeof Sentry, "captureException">;
|
|
22
23
|
stderr: Pick<typeof process.stderr, "write">;
|
|
23
24
|
setExitCode: (code: number) => void;
|
|
25
|
+
interactive?: boolean;
|
|
26
|
+
argv?: readonly string[];
|
|
27
|
+
ask?: (question: string) => Promise<string>;
|
|
28
|
+
openUrl?: (url: string) => Promise<void>;
|
|
29
|
+
upgradeToTeam?: typeof upgradeToTeam;
|
|
24
30
|
};
|
|
25
31
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
26
32
|
export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
|
package/dist/main.js
CHANGED
|
@@ -35,7 +35,7 @@ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js
|
|
|
35
35
|
import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
|
|
36
36
|
import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
|
|
37
37
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
38
|
-
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
|
|
38
|
+
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, offerUpgradeLink, } from "./utils/plan-gate-error.js";
|
|
39
39
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
40
40
|
import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
41
41
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
@@ -379,20 +379,60 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
379
379
|
deps.setExitCode(1);
|
|
380
380
|
}
|
|
381
381
|
else if (isPlanGateError(err)) {
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
deps.
|
|
382
|
+
// Plan denials are expected product limits, never a CLI crash. The
|
|
383
|
+
// server URL is always shown. Company refusals that the Workforce
|
|
384
|
+
// checkout satisfies retain the direct Stripe checkout action.
|
|
385
|
+
const interactive = deps.interactive ?? canOfferTeamUpgrade(deps.argv);
|
|
386
|
+
const message = formatPlanGateError(err, { urlOnly: !interactive });
|
|
387
|
+
deps.stderr.write(interactive ? `hq: ${message}\n` : `${message}\n`);
|
|
386
388
|
deps.setExitCode(1);
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
389
|
+
if (interactive && err.details.upgradeUrl) {
|
|
390
|
+
const companyUid = err.details.companyUid;
|
|
391
|
+
const isCompanyWorkforceCheckout = typeof companyUid === "string" &&
|
|
392
|
+
/^cmp_[A-Za-z0-9_-]+$/.test(companyUid) &&
|
|
393
|
+
(err.code === "PLAN_REQUIRED" ||
|
|
394
|
+
err.code === "MEETING_PLAN_REQUIRED" ||
|
|
395
|
+
err.code === "BILLING_REQUIRED");
|
|
396
|
+
if (isCompanyWorkforceCheckout && companyUid) {
|
|
397
|
+
try {
|
|
398
|
+
await offerTeamUpgrade(async () => {
|
|
399
|
+
await (deps.upgradeToTeam ?? upgradeToTeam)({ companyUid });
|
|
400
|
+
}, {
|
|
401
|
+
interactive: true,
|
|
402
|
+
argv: deps.argv,
|
|
403
|
+
ask: deps.ask,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (upgradeErr) {
|
|
407
|
+
const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
408
|
+
deps.stderr.write(`hq: ${message}\n`);
|
|
409
|
+
try {
|
|
410
|
+
await offerUpgradeLink(err.details.upgradeUrl, {
|
|
411
|
+
interactive: true,
|
|
412
|
+
argv: deps.argv,
|
|
413
|
+
ask: deps.ask,
|
|
414
|
+
openUrl: deps.openUrl,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch (linkErr) {
|
|
418
|
+
const linkMessage = linkErr instanceof Error ? linkErr.message : String(linkErr);
|
|
419
|
+
deps.stderr.write(`hq: ${linkMessage}\n`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
392
422
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
423
|
+
else {
|
|
424
|
+
try {
|
|
425
|
+
await offerUpgradeLink(err.details.upgradeUrl, {
|
|
426
|
+
interactive: true,
|
|
427
|
+
argv: deps.argv,
|
|
428
|
+
ask: deps.ask,
|
|
429
|
+
openUrl: deps.openUrl,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (upgradeErr) {
|
|
433
|
+
const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
434
|
+
deps.stderr.write(`hq: ${message}\n`);
|
|
435
|
+
}
|
|
396
436
|
}
|
|
397
437
|
}
|
|
398
438
|
}
|
|
@@ -81,6 +81,10 @@ export declare const DEFAULT_VAULT_API_URL: string;
|
|
|
81
81
|
*/
|
|
82
82
|
export declare function resolveDefaultHqRoot(opts?: {
|
|
83
83
|
onMissing?: "throw" | "fallback";
|
|
84
|
+
/** Defaults to process.env, process.cwd() and os.homedir(). */
|
|
85
|
+
env?: NodeJS.ProcessEnv;
|
|
86
|
+
cwd?: string;
|
|
87
|
+
home?: string;
|
|
84
88
|
}): string;
|
|
85
89
|
export declare const DEFAULT_HQ_ROOT: string;
|
|
86
90
|
/**
|
|
@@ -102,22 +102,24 @@ export const DEFAULT_VAULT_API_URL = process.env.HQ_VAULT_API_URL ?? "https://hq
|
|
|
102
102
|
* Re-importable as a function for command-time resolution and tests.
|
|
103
103
|
*/
|
|
104
104
|
export function resolveDefaultHqRoot(opts = {}) {
|
|
105
|
+
const env = opts.env ?? process.env;
|
|
106
|
+
const home = opts.home ?? os.homedir();
|
|
105
107
|
// Tier 1: explicit override.
|
|
106
|
-
if (
|
|
107
|
-
return path.resolve(
|
|
108
|
+
if (env.HQ_ROOT)
|
|
109
|
+
return path.resolve(env.HQ_ROOT);
|
|
108
110
|
// Tier 2: working-tree walk-up — operate on the HQ tree you're inside.
|
|
109
|
-
let cur = path.resolve(process.cwd());
|
|
111
|
+
let cur = path.resolve(opts.cwd ?? process.cwd());
|
|
110
112
|
while (cur !== path.dirname(cur)) {
|
|
111
113
|
if (isHqRoot(cur))
|
|
112
114
|
return cur;
|
|
113
115
|
cur = path.dirname(cur);
|
|
114
116
|
}
|
|
115
117
|
// Tiers 3–4: the installer/menubar's canonical "where is HQ" records.
|
|
116
|
-
const fromConfig = readHqRootFromHqConfig(path.join(
|
|
118
|
+
const fromConfig = readHqRootFromHqConfig(path.join(home, ".hq"));
|
|
117
119
|
if (fromConfig)
|
|
118
120
|
return path.resolve(fromConfig);
|
|
119
121
|
// Tier 5: discover a valid HQ root in the well-known locations.
|
|
120
|
-
const discovered = discoverHqRootViaCoreYaml();
|
|
122
|
+
const discovered = discoverHqRootViaCoreYaml(home);
|
|
121
123
|
if (discovered)
|
|
122
124
|
return discovered;
|
|
123
125
|
// Tier 6: nothing resolved.
|
|
@@ -127,7 +129,7 @@ export function resolveDefaultHqRoot(opts = {}) {
|
|
|
127
129
|
"or install HQ via the installer/menubar so ~/.hq/menubar.json records " +
|
|
128
130
|
"its location.");
|
|
129
131
|
}
|
|
130
|
-
return path.join(
|
|
132
|
+
return path.join(home, "hq");
|
|
131
133
|
}
|
|
132
134
|
/**
|
|
133
135
|
* Read the configured HQ root from the installer/menubar records under
|
|
@@ -156,8 +158,7 @@ function readJsonStringField(file, field) {
|
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
/** Well-known parent locations an HQ tree commonly lives in (mirrors hq-sync). */
|
|
159
|
-
function hqDiscoveryCandidates() {
|
|
160
|
-
const home = os.homedir();
|
|
161
|
+
function hqDiscoveryCandidates(home = os.homedir()) {
|
|
161
162
|
return [
|
|
162
163
|
path.join(home, "HQ"),
|
|
163
164
|
path.join(home, "hq"),
|
|
@@ -172,8 +173,8 @@ function hqDiscoveryCandidates() {
|
|
|
172
173
|
* signature. First match wins; returns undefined if none qualify. Cheap — a
|
|
173
174
|
* few stats plus one small YAML parse on a hit.
|
|
174
175
|
*/
|
|
175
|
-
function discoverHqRootViaCoreYaml() {
|
|
176
|
-
return hqDiscoveryCandidates().find(isValidHqRootSignature);
|
|
176
|
+
function discoverHqRootViaCoreYaml(home) {
|
|
177
|
+
return hqDiscoveryCandidates(home).find(isValidHqRootSignature);
|
|
177
178
|
}
|
|
178
179
|
/**
|
|
179
180
|
* True iff `dir` carries a valid hq-core `core.yaml` — canonical
|