@indigoai-us/hq-cli 5.321.0 → 5.323.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 +10 -0
- package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
- package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
- package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
- package/dist/command-catalog.generated.d.ts +13 -0
- package/dist/command-catalog.generated.js +16 -0
- package/dist/commands/cloud.js +23 -4
- package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
- package/dist/commands/core-derive-trigger-facts-command.js +47 -0
- package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
- package/dist/commands/core-derive-trigger-facts-native.js +492 -0
- package/dist/commands/core.js +6 -0
- package/dist/commands/daemon.js +1 -1
- package/dist/fast-scaffold.js +13 -0
- package/dist/lib/cli-invite-nudge.d.ts +25 -0
- package/dist/lib/cli-invite-nudge.js +152 -0
- package/dist/lib/daemon/requests.d.ts +1 -0
- package/dist/lib/daemon/requests.js +1 -1
- package/dist/lib/daemon/run.js +5 -4
- package/dist/lib/daemon/succession.js +1 -1
- package/dist/lib/daemon/supervisor.d.ts +2 -1
- package/dist/lib/daemon/supervisor.js +134 -7
- package/dist/lib/first-sync-next-step.d.ts +10 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +36 -15
- package/dist/main.js +3 -1
- package/dist/utils/cli-telemetry.d.ts +11 -0
- package/dist/utils/cli-telemetry.js +76 -0
- package/dist/utils/run-bundled-script.d.ts +7 -4
- package/dist/utils/run-bundled-script.js +47 -12
- package/dist/utils/vault-api.js +87 -37
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { isExpiring, isMachineIdentity, loadCachedTokens, } from "../utils/cognito-session.js";
|
|
7
|
+
import { queueCliInviteNudgeEvent } from "../utils/cli-telemetry.js";
|
|
8
|
+
import { resolveCompanyFlagGate } from "./flag-registry.js";
|
|
9
|
+
import { isHumanInteractiveCliSession } from "./first-sync-next-step.js";
|
|
10
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
11
|
+
export const CLI_INVITE_NUDGE_FLAG = "cli.first-sync-invite-teammate-v1";
|
|
12
|
+
function reportFailure(operation, error) {
|
|
13
|
+
const name = error instanceof Error ? error.name : "unknown";
|
|
14
|
+
const code = /^[A-Za-z][A-Za-z0-9]{0,39}$/.test(name) ? name : "unknown";
|
|
15
|
+
console.error(`hq: Could not ${operation} (${code}); invite prompt stays hidden.`);
|
|
16
|
+
}
|
|
17
|
+
/** One eligible, successful work-bearing company sync can show one prompt per company. */
|
|
18
|
+
export async function runCliInviteNudge(input) {
|
|
19
|
+
if (!input.interactive || !input.canPrompt || input.alreadyShown || !/^cmp_[A-Za-z0-9_-]{1,128}$/.test(input.companyUid)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
let enabled;
|
|
23
|
+
try {
|
|
24
|
+
enabled = await input.flagEnabled();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
(input.reportFailure ?? reportFailure)("resolve the invite flag", error);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (!enabled)
|
|
31
|
+
return false;
|
|
32
|
+
let humanMemberCount;
|
|
33
|
+
try {
|
|
34
|
+
humanMemberCount = await input.readHumanMemberCount();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
(input.reportFailure ?? reportFailure)("check company membership", error);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (humanMemberCount !== 1 || !input.claimShown())
|
|
41
|
+
return false;
|
|
42
|
+
input.write("Your company has one member. Invite a teammate to share the workspace.");
|
|
43
|
+
input.record(input.companyUid, "shown");
|
|
44
|
+
let clicked;
|
|
45
|
+
try {
|
|
46
|
+
clicked = await input.ask("Show the existing invite command? [y/N] ");
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
(input.reportFailure ?? reportFailure)("read the invite choice", error);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
input.record(input.companyUid, clicked ? "clicked" : "dismissed");
|
|
53
|
+
if (clicked) {
|
|
54
|
+
input.write(`Run: hq members invite <email> --company ${input.companyRef} --role member`);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
function inviteNudgeMarkerPath(companyUid, home = process.env.HOME?.trim() || os.homedir()) {
|
|
59
|
+
const digest = createHash("sha256").update(companyUid).digest("hex");
|
|
60
|
+
return path.join(home, ".hq", "cli-invite-nudge", `${digest}.shown`);
|
|
61
|
+
}
|
|
62
|
+
function markerExists(companyUid) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.existsSync(inviteNudgeMarkerPath(companyUid));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
reportFailure("read invite prompt state", error);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function claimMarker(companyUid) {
|
|
72
|
+
const marker = inviteNudgeMarkerPath(companyUid);
|
|
73
|
+
const homeHq = path.join(process.env.HOME?.trim() || os.homedir(), ".hq");
|
|
74
|
+
try {
|
|
75
|
+
// Do not create ~/.hq for a machine without a signed-in person profile.
|
|
76
|
+
if (!fs.existsSync(homeHq))
|
|
77
|
+
return false;
|
|
78
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
|
|
79
|
+
fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { flag: "wx", mode: 0o600 });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
reportFailure("write invite prompt state", error);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function readActiveHumanMemberCount(accessToken, companyUid) {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const timeout = setTimeout(() => controller.abort(), 1_200);
|
|
93
|
+
try {
|
|
94
|
+
const response = await vaultApiFetch({
|
|
95
|
+
token: accessToken,
|
|
96
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}`,
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
throw new Error(`http_${response.status}`);
|
|
101
|
+
const body = await response.json();
|
|
102
|
+
if (!Array.isArray(body.members))
|
|
103
|
+
throw new Error("invalid_roster_shape");
|
|
104
|
+
return body.members.filter((member) => typeof member === "object" && member !== null &&
|
|
105
|
+
member.status === "active" &&
|
|
106
|
+
typeof member.personUid === "string" &&
|
|
107
|
+
member.personUid.startsWith("prs_")).length;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function askInviteChoice(prompt) {
|
|
114
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
115
|
+
try {
|
|
116
|
+
const answer = await terminal.question(prompt);
|
|
117
|
+
return answer.trim().toLowerCase() === "y";
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
terminal.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Called after a successful work-bearing company sync; every dependency remains fail-closed. */
|
|
124
|
+
export async function maybeShowCliInviteNudge(input) {
|
|
125
|
+
const companyUid = input.companyUid?.trim() ?? "";
|
|
126
|
+
const companyRef = input.companyRef?.trim() ?? "";
|
|
127
|
+
if (!companyUid || !companyRef)
|
|
128
|
+
return false;
|
|
129
|
+
const cached = loadCachedTokens();
|
|
130
|
+
if (!cached || isExpiring(cached, 120) ||
|
|
131
|
+
!isHumanInteractiveCliSession({
|
|
132
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
133
|
+
argv: process.argv,
|
|
134
|
+
env: process.env,
|
|
135
|
+
machineIdentity: isMachineIdentity(),
|
|
136
|
+
}))
|
|
137
|
+
return false;
|
|
138
|
+
return runCliInviteNudge({
|
|
139
|
+
companyUid,
|
|
140
|
+
companyRef,
|
|
141
|
+
interactive: true,
|
|
142
|
+
canPrompt: process.stdin.isTTY === true,
|
|
143
|
+
alreadyShown: markerExists(companyUid),
|
|
144
|
+
flagEnabled: () => resolveCompanyFlagGate(companyUid, cached.idToken ?? "", CLI_INVITE_NUDGE_FLAG, { fallback: false }, () => false),
|
|
145
|
+
readHumanMemberCount: () => readActiveHumanMemberCount(input.accessToken, companyUid),
|
|
146
|
+
claimShown: () => claimMarker(companyUid),
|
|
147
|
+
ask: askInviteChoice,
|
|
148
|
+
write: (line) => console.log(line),
|
|
149
|
+
record: queueCliInviteNudgeEvent,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=cli-invite-nudge.js.map
|
|
@@ -22,6 +22,7 @@ export type ControlRequest = {
|
|
|
22
22
|
/** Restart the whole daemon, for example after the updater installed a new CLI. */
|
|
23
23
|
| {
|
|
24
24
|
action: "restart-daemon";
|
|
25
|
+
deferUntilSyncIdle?: boolean;
|
|
25
26
|
};
|
|
26
27
|
export declare function writeControlRequest(dir: string, request: ControlRequest, now?: () => number): string;
|
|
27
28
|
export declare function takeControlRequests(dir: string): ControlRequest[];
|
|
@@ -18,7 +18,7 @@ function isControlRequest(value) {
|
|
|
18
18
|
if (v.action === "trigger" || v.action === "stop")
|
|
19
19
|
return typeof v.unit === "string";
|
|
20
20
|
if (v.action === "restart-daemon")
|
|
21
|
-
return
|
|
21
|
+
return v.deferUntilSyncIdle === undefined || typeof v.deferUntilSyncIdle === "boolean";
|
|
22
22
|
if (v.action === "reconcile")
|
|
23
23
|
return true;
|
|
24
24
|
return false;
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -594,7 +594,7 @@ export async function runDaemon(opts) {
|
|
|
594
594
|
}
|
|
595
595
|
};
|
|
596
596
|
// Set once the shutdown path exists below; a restart request before then is dropped.
|
|
597
|
-
let restartDaemon = () =>
|
|
597
|
+
let restartDaemon = () => false;
|
|
598
598
|
const supervisor = createSupervisor({
|
|
599
599
|
machineType,
|
|
600
600
|
host: opts.host,
|
|
@@ -730,12 +730,12 @@ export async function runDaemon(opts) {
|
|
|
730
730
|
restartDaemon = () => {
|
|
731
731
|
if (!restartsForParent(opts)) {
|
|
732
732
|
log("warn", "a restart was requested, but no service manager would start this daemon again; restart it yourself");
|
|
733
|
-
return;
|
|
733
|
+
return false;
|
|
734
734
|
}
|
|
735
735
|
if (stopping || restartPending)
|
|
736
|
-
return;
|
|
736
|
+
return true;
|
|
737
737
|
restartPending = true;
|
|
738
|
-
|
|
738
|
+
return handoffWanted()
|
|
739
739
|
.catch((err) => {
|
|
740
740
|
log("warn", `could not decide on a handoff; restarting every service: ${err.message}`);
|
|
741
741
|
return false;
|
|
@@ -745,6 +745,7 @@ export async function runDaemon(opts) {
|
|
|
745
745
|
handOver();
|
|
746
746
|
else
|
|
747
747
|
shutdown("restarting onto the updated CLI", RESTART_EXIT_CODE);
|
|
748
|
+
return true;
|
|
748
749
|
});
|
|
749
750
|
};
|
|
750
751
|
process.on("SIGTERM", () => shutdown("received SIGTERM"));
|
|
@@ -202,7 +202,7 @@ export async function stopOrphans(orphans, deps) {
|
|
|
202
202
|
remaining = remaining.filter(same);
|
|
203
203
|
}
|
|
204
204
|
for (const r of remaining) {
|
|
205
|
-
deps.log("warn", `${r.id} (pid ${r.pid})
|
|
205
|
+
deps.log("warn", `${r.id} (pid ${r.pid}) did not exit within ${graceMs / 1_000} s of SIGTERM; sending SIGKILL`);
|
|
206
206
|
send(r, "SIGKILL");
|
|
207
207
|
}
|
|
208
208
|
}
|
|
@@ -26,6 +26,7 @@ import type { MachineType } from "./machine-type.js";
|
|
|
26
26
|
import type { ControlRequest } from "./requests.js";
|
|
27
27
|
import { syncPassResult } from "./sync-runner.js";
|
|
28
28
|
export declare const STOP_ESCALATION_MS = 10000;
|
|
29
|
+
export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
|
|
29
30
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
30
31
|
export declare const HANDOFF_STAGGER_MS = 15000;
|
|
31
32
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -94,7 +95,7 @@ export interface SupervisorDeps {
|
|
|
94
95
|
runInternal: (task: string) => Promise<boolean>;
|
|
95
96
|
takeRequests: () => ControlRequest[];
|
|
96
97
|
/** Restart the whole daemon process (a `restart-daemon` request). */
|
|
97
|
-
restartDaemon?: () => void
|
|
98
|
+
restartDaemon?: () => void | boolean | Promise<boolean | void>;
|
|
98
99
|
writeState: (state: DaemonState) => void;
|
|
99
100
|
log: (level: "info" | "warn" | "error", msg: string) => void;
|
|
100
101
|
now?: () => number;
|
|
@@ -27,6 +27,7 @@ const BACKOFF_BASE_MS = 1_000;
|
|
|
27
27
|
const BACKOFF_MAX_MS = 60_000;
|
|
28
28
|
const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
|
+
export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
|
|
30
31
|
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
31
32
|
const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
32
33
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
@@ -61,6 +62,13 @@ export function planKey(plan, cliVersion) {
|
|
|
61
62
|
function errorMessage(err) {
|
|
62
63
|
return err instanceof Error ? err.message : String(err);
|
|
63
64
|
}
|
|
65
|
+
function lastProtocolHint(slot) {
|
|
66
|
+
if (slot.lastProtocolStatus)
|
|
67
|
+
return ` (last status: ${slot.lastProtocolStatus})`;
|
|
68
|
+
if (slot.lastProtocolLine)
|
|
69
|
+
return ` (last protocol line: ${slot.lastProtocolLine})`;
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
64
72
|
export function createSupervisor(deps) {
|
|
65
73
|
const now = deps.now ?? Date.now;
|
|
66
74
|
const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
@@ -81,6 +89,9 @@ export function createSupervisor(deps) {
|
|
|
81
89
|
const live = new Set();
|
|
82
90
|
let drained = null;
|
|
83
91
|
let onDrained = null;
|
|
92
|
+
let deferredCliRestart = false;
|
|
93
|
+
let deferredCliRestartTimer;
|
|
94
|
+
let cliRestartInProgress = false;
|
|
84
95
|
const snapshot = () => ({
|
|
85
96
|
version: 1,
|
|
86
97
|
pid: deps.pid,
|
|
@@ -108,6 +119,71 @@ export function createSupervisor(deps) {
|
|
|
108
119
|
clearT(slot.heartbeatTimer);
|
|
109
120
|
slot.heartbeatTimer = undefined;
|
|
110
121
|
};
|
|
122
|
+
const clearDeferredCliRestart = () => {
|
|
123
|
+
deferredCliRestart = false;
|
|
124
|
+
if (deferredCliRestartTimer !== undefined)
|
|
125
|
+
clearT(deferredCliRestartTimer);
|
|
126
|
+
deferredCliRestartTimer = undefined;
|
|
127
|
+
};
|
|
128
|
+
const runDeferredCliRestart = () => {
|
|
129
|
+
if (!deferredCliRestart)
|
|
130
|
+
return;
|
|
131
|
+
clearDeferredCliRestart();
|
|
132
|
+
cliRestartInProgress = true;
|
|
133
|
+
const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
|
|
134
|
+
if (sync)
|
|
135
|
+
terminate(sync);
|
|
136
|
+
deps.log("info", "sync runner is between passes; restarting daemon onto the updated CLI");
|
|
137
|
+
const releaseHold = () => {
|
|
138
|
+
cliRestartInProgress = false;
|
|
139
|
+
const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
|
|
140
|
+
if (!currentSync)
|
|
141
|
+
return;
|
|
142
|
+
if (currentSync.child)
|
|
143
|
+
terminate(currentSync, () => activate(currentSync));
|
|
144
|
+
else
|
|
145
|
+
activate(currentSync);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const result = deps.restartDaemon?.();
|
|
149
|
+
if (result === false)
|
|
150
|
+
releaseHold();
|
|
151
|
+
else if (result && typeof result === "object" && "then" in result) {
|
|
152
|
+
void Promise.resolve(result).then((accepted) => {
|
|
153
|
+
if (accepted === false)
|
|
154
|
+
releaseHold();
|
|
155
|
+
}).catch((err) => {
|
|
156
|
+
deps.log("error", `CLI-update restart handoff failed: ${errorMessage(err)}`);
|
|
157
|
+
releaseHold();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
deps.log("error", `CLI-update restart handoff failed: ${errorMessage(err)}`);
|
|
163
|
+
releaseHold();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const requestCliUpdateRestart = () => {
|
|
167
|
+
if (!deps.restartDaemon)
|
|
168
|
+
return;
|
|
169
|
+
const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
|
|
170
|
+
if (!sync) {
|
|
171
|
+
void deps.restartDaemon();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (sync.syncPassActive === false) {
|
|
175
|
+
deferredCliRestart = true;
|
|
176
|
+
runDeferredCliRestart();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (deferredCliRestart) {
|
|
180
|
+
deps.log("info", "coalescing CLI-update restart while waiting for sync to become idle");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
deferredCliRestart = true;
|
|
184
|
+
deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
|
|
185
|
+
deferredCliRestartTimer = setT(runDeferredCliRestart, CLI_UPDATE_SYNC_IDLE_MAX_MS);
|
|
186
|
+
};
|
|
111
187
|
const setBlocked = (slot) => {
|
|
112
188
|
const block = slot.plan.blocked;
|
|
113
189
|
slot.state.status = block.state;
|
|
@@ -136,6 +212,12 @@ export function createSupervisor(deps) {
|
|
|
136
212
|
then?.();
|
|
137
213
|
return;
|
|
138
214
|
}
|
|
215
|
+
if (slot.expectingExit) {
|
|
216
|
+
// Keep the original stop deadline and signal; only update the action
|
|
217
|
+
// that runs after exit (for example, start the changed command).
|
|
218
|
+
slot.afterExit = then;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
139
221
|
slot.expectingExit = true;
|
|
140
222
|
slot.afterExit = then;
|
|
141
223
|
if (slot.killTimer !== undefined)
|
|
@@ -145,7 +227,7 @@ export function createSupervisor(deps) {
|
|
|
145
227
|
slot.killTimer = undefined;
|
|
146
228
|
if (slot.child !== child)
|
|
147
229
|
return;
|
|
148
|
-
deps.log("warn", `${slot.plan.id}
|
|
230
|
+
deps.log("warn", `${slot.plan.id} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
|
|
149
231
|
signalChild(slot, child, "SIGKILL");
|
|
150
232
|
}, STOP_ESCALATION_MS);
|
|
151
233
|
};
|
|
@@ -211,6 +293,25 @@ export function createSupervisor(deps) {
|
|
|
211
293
|
}
|
|
212
294
|
if (!event)
|
|
213
295
|
return;
|
|
296
|
+
slot.lastProtocolLine = line.length > 240 ? `${line.slice(0, 237)}...` : line;
|
|
297
|
+
if (slot.plan.service === "sync") {
|
|
298
|
+
if (event.type === "fanout-plan")
|
|
299
|
+
slot.syncPassActive = true;
|
|
300
|
+
else if (event.type === "all-complete")
|
|
301
|
+
slot.syncPassActive = false;
|
|
302
|
+
else if (event.type === "status" && typeof event.state === "string") {
|
|
303
|
+
slot.lastProtocolStatus = event.state;
|
|
304
|
+
if (event.state === "stopped" || event.state === "waiting-for-lock")
|
|
305
|
+
slot.syncPassActive = false;
|
|
306
|
+
else if (slot.syncPassActive === undefined && (event.state === "starting" || event.state === "running")) {
|
|
307
|
+
// Before the first pass boundary, status heartbeats cannot prove
|
|
308
|
+
// that startup work has not already entered a pass.
|
|
309
|
+
slot.syncPassActive = true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (deferredCliRestart && slot.syncPassActive === false)
|
|
313
|
+
runDeferredCliRestart();
|
|
314
|
+
}
|
|
214
315
|
const at = now();
|
|
215
316
|
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
216
317
|
const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
|
|
@@ -358,6 +459,15 @@ export function createSupervisor(deps) {
|
|
|
358
459
|
const startLong = (slot) => {
|
|
359
460
|
if (stopped)
|
|
360
461
|
return;
|
|
462
|
+
if (cliRestartInProgress && slot.plan.service === "sync") {
|
|
463
|
+
slot.state.status = "waiting";
|
|
464
|
+
slot.state.reason = "daemon restart pending";
|
|
465
|
+
delete slot.state.nextRunAt;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
delete slot.lastProtocolLine;
|
|
469
|
+
delete slot.lastProtocolStatus;
|
|
470
|
+
delete slot.syncPassActive;
|
|
361
471
|
let child;
|
|
362
472
|
try {
|
|
363
473
|
child = deps.spawn(slot.plan);
|
|
@@ -550,7 +660,10 @@ export function createSupervisor(deps) {
|
|
|
550
660
|
deps.log("info", `restarting ${plan.id}: its command changed`);
|
|
551
661
|
existing.attempts = 0;
|
|
552
662
|
existing.state.restarts = 0;
|
|
553
|
-
|
|
663
|
+
if (cliRestartInProgress && plan.service === "sync")
|
|
664
|
+
terminate(existing);
|
|
665
|
+
else
|
|
666
|
+
terminate(existing, () => activate(existing));
|
|
554
667
|
}
|
|
555
668
|
}
|
|
556
669
|
slots = next;
|
|
@@ -650,11 +763,19 @@ export function createSupervisor(deps) {
|
|
|
650
763
|
return;
|
|
651
764
|
}
|
|
652
765
|
if (req.action === "restart-daemon") {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
766
|
+
if (req.deferUntilSyncIdle && deps.restartDaemon) {
|
|
767
|
+
deps.log("info", "CLI-update daemon restart requested");
|
|
768
|
+
requestCliUpdateRestart();
|
|
769
|
+
}
|
|
770
|
+
else if (deps.restartDaemon) {
|
|
771
|
+
deps.log("info", "restarting the daemon (requested)");
|
|
772
|
+
clearDeferredCliRestart();
|
|
773
|
+
void deps.restartDaemon();
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
deps.log("info", "restarting the daemon (requested)");
|
|
657
777
|
deps.log("warn", "this daemon cannot restart itself; restart it to load the new version");
|
|
778
|
+
}
|
|
658
779
|
return;
|
|
659
780
|
}
|
|
660
781
|
// A bot created since the last plan read is asked for before the timer
|
|
@@ -756,7 +877,11 @@ export function createSupervisor(deps) {
|
|
|
756
877
|
clearT(requestTimer);
|
|
757
878
|
if (handoffTimer !== undefined)
|
|
758
879
|
clearT(handoffTimer);
|
|
880
|
+
clearDeferredCliRestart();
|
|
881
|
+
const childOwners = new Map();
|
|
759
882
|
for (const slot of slots.values()) {
|
|
883
|
+
if (slot.child)
|
|
884
|
+
childOwners.set(slot.child, slot);
|
|
760
885
|
clearTimer(slot);
|
|
761
886
|
clearHeartbeatTimer(slot);
|
|
762
887
|
slot.child = undefined;
|
|
@@ -784,7 +909,8 @@ export function createSupervisor(deps) {
|
|
|
784
909
|
}
|
|
785
910
|
const escalation = setT(() => {
|
|
786
911
|
for (const child of live) {
|
|
787
|
-
|
|
912
|
+
const owner = childOwners.get(child);
|
|
913
|
+
deps.log("warn", `${owner?.plan.id ?? `pid ${child.pid ?? "?"}`} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${owner ? lastProtocolHint(owner) : ""}`);
|
|
788
914
|
try {
|
|
789
915
|
child.kill("SIGKILL");
|
|
790
916
|
}
|
|
@@ -818,6 +944,7 @@ export function createSupervisor(deps) {
|
|
|
818
944
|
clearT(requestTimer);
|
|
819
945
|
if (handoffTimer !== undefined)
|
|
820
946
|
clearT(handoffTimer);
|
|
947
|
+
clearDeferredCliRestart();
|
|
821
948
|
for (const slot of slots.values()) {
|
|
822
949
|
clearTimer(slot);
|
|
823
950
|
if (slot.killTimer !== undefined)
|
|
@@ -12,7 +12,15 @@ export declare function shouldConfirmFirstWorkBearingSync(input: {
|
|
|
12
12
|
alreadyCompleted: boolean;
|
|
13
13
|
workBearing: boolean;
|
|
14
14
|
}): boolean;
|
|
15
|
-
|
|
15
|
+
interface WorkBearingSyncResult {
|
|
16
|
+
aborted?: unknown;
|
|
17
|
+
filesUploaded?: unknown;
|
|
18
|
+
filesDownloaded?: unknown;
|
|
19
|
+
sessionLogUploadedCount?: unknown;
|
|
20
|
+
filesDeleted?: unknown;
|
|
21
|
+
tombstonedCount?: unknown;
|
|
22
|
+
}
|
|
23
|
+
export declare function isWorkBearingSyncResult(result: WorkBearingSyncResult): boolean;
|
|
16
24
|
export declare function isHumanInteractiveCliSession(input: {
|
|
17
25
|
stdoutIsTTY: boolean;
|
|
18
26
|
argv: readonly string[];
|
|
@@ -31,4 +39,5 @@ export declare function maybeShowFirstSyncNextStep(): boolean;
|
|
|
31
39
|
export declare function recordFirstWorkBearingSync(result: Record<string, unknown>): boolean;
|
|
32
40
|
/** Drain outcomes once so each shown/followed event is sent at most once per run. */
|
|
33
41
|
export declare function takeFirstSyncStepOutcomes(): FirstSyncStepOutcome[];
|
|
42
|
+
export {};
|
|
34
43
|
//# sourceMappingURL=first-sync-next-step.d.ts.map
|
|
@@ -24,6 +24,8 @@ export declare const FLUSH_INTERVAL_MS = 10000;
|
|
|
24
24
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
25
25
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
26
26
|
export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
|
|
27
|
+
/** Three sequential async shutdown steps must finish within six seconds. */
|
|
28
|
+
export declare const SHUTDOWN_STEP_TIMEOUT_MS = 2000;
|
|
27
29
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
28
30
|
export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
29
31
|
/**
|
|
@@ -45,6 +45,8 @@ export const FLUSH_INTERVAL_MS = 10_000;
|
|
|
45
45
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
46
46
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
47
47
|
export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
|
|
48
|
+
/** Three sequential async shutdown steps must finish within six seconds. */
|
|
49
|
+
export const SHUTDOWN_STEP_TIMEOUT_MS = 2_000;
|
|
48
50
|
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
49
51
|
export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
50
52
|
/**
|
|
@@ -871,29 +873,48 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
871
873
|
catch {
|
|
872
874
|
/* ignore */
|
|
873
875
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
876
|
+
const stopStep = async (name, action) => {
|
|
877
|
+
let deadline;
|
|
878
|
+
let timedOut = false;
|
|
879
|
+
const timeout = new Promise((resolve) => {
|
|
880
|
+
deadline = timers.setTimeout(() => {
|
|
881
|
+
timedOut = true;
|
|
882
|
+
resolve();
|
|
883
|
+
}, SHUTDOWN_STEP_TIMEOUT_MS);
|
|
884
|
+
});
|
|
878
885
|
try {
|
|
879
|
-
await
|
|
886
|
+
await Promise.race([Promise.resolve().then(action), timeout]);
|
|
887
|
+
if (timedOut)
|
|
888
|
+
log(dir, `${name} did not finish within ${SHUTDOWN_STEP_TIMEOUT_MS / 1_000} s; continuing shutdown`);
|
|
880
889
|
}
|
|
881
890
|
catch (err) {
|
|
882
891
|
const msg = err instanceof Error ? err.message : String(err);
|
|
883
|
-
log(dir,
|
|
892
|
+
log(dir, `${name} error: ${msg.slice(0, 120)}`);
|
|
893
|
+
}
|
|
894
|
+
finally {
|
|
895
|
+
if (deadline !== undefined)
|
|
896
|
+
timers.clearTimeout(deadline);
|
|
884
897
|
}
|
|
898
|
+
};
|
|
899
|
+
// Final flush, then clean MQTT DISCONNECT so the broker emits the
|
|
900
|
+
// lifecycle event; offline is published server-side only.
|
|
901
|
+
await stopStep("shutdown flush", () => doFlushCycle("shutdown", { force: true }));
|
|
902
|
+
if (workCache) {
|
|
903
|
+
await stopStep("work cache stop", () => workCache.stop());
|
|
885
904
|
}
|
|
886
905
|
if (presence) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
906
|
+
await stopStep("presence stop", () => presence.stop());
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
913
|
+
log(dir, `daemon shutdown state update error: ${msg.slice(0, 120)}`);
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
releasePidLock(dir, lockDeps);
|
|
894
917
|
}
|
|
895
|
-
patchDaemonState(dir, { mqttState: "closed", companiesOnline: [] }, now);
|
|
896
|
-
releasePidLock(dir, lockDeps);
|
|
897
918
|
log(dir, "daemon stopped");
|
|
898
919
|
};
|
|
899
920
|
const onSignal = () => {
|
package/dist/main.js
CHANGED
|
@@ -44,7 +44,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
|
44
44
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
45
45
|
import { CLI_VERSION } from "./cli-version.js";
|
|
46
46
|
import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
|
|
47
|
-
import { emitCliFirstSyncNextStepOutcome, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
|
|
47
|
+
import { emitCliFirstSyncNextStepOutcome, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliInviteNudgeEvent, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takeCliInviteNudgeEvents, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
|
|
48
48
|
import { reportCliClientHealthInvocation } from "./utils/client-health.js";
|
|
49
49
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
50
50
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
@@ -313,6 +313,7 @@ export async function runCli() {
|
|
|
313
313
|
// slice even when the command itself was long-running.
|
|
314
314
|
const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
|
|
315
315
|
const firstSyncOutcomeFlushes = takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs));
|
|
316
|
+
const inviteNudgeOutcomeFlushes = takeCliInviteNudgeEvents().map((event) => emitCliInviteNudgeEvent(event, releaseHealthTimeoutMs));
|
|
316
317
|
const inviteTeammateExposureFlushes = takePlanLimitInviteTeammateExposures().map((companyUid) => emitPlanLimitInviteTeammateExposure(companyUid, releaseHealthTimeoutMs));
|
|
317
318
|
const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
|
|
318
319
|
const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
|
|
@@ -322,6 +323,7 @@ export async function runCli() {
|
|
|
322
323
|
: [refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs })]),
|
|
323
324
|
Sentry.flush(releaseHealthTimeoutMs),
|
|
324
325
|
...firstSyncOutcomeFlushes,
|
|
326
|
+
...inviteNudgeOutcomeFlushes,
|
|
325
327
|
...inviteTeammateExposureFlushes,
|
|
326
328
|
...limitNoticeExposureFlushes,
|
|
327
329
|
...limitNoticeEngagementFlushes,
|
|
@@ -9,8 +9,14 @@ export declare const TELEMETRY_TIMEOUT_MS = 1200;
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
11
11
|
export declare const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; first-sync guidance shown or followed, with the identifier cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
12
|
+
export declare const CLI_INVITE_NUDGE_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when it shows the teammate invite prompt (your company UID and whether you saw, clicked, or dismissed the prompt; no invitee details, command names, file contents, or secrets). Set HQ_TELEMETRY=0 to turn this off.";
|
|
12
13
|
export declare const CLI_FEATURE_USED_TELEMETRY_FLAG = "cli.feature-used-telemetry-v1";
|
|
13
14
|
export declare const CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE = "HQ sends a feature-use event when you run a supported CLI feature (feature kind and outcome, plus your company UID when available; never command names, file contents, or secrets). The hq-flags key cli.feature-used-telemetry-v1 controls these events. Set HQ_TELEMETRY=0 to turn this off.";
|
|
15
|
+
export type CliInviteNudgeOutcome = "shown" | "clicked" | "dismissed";
|
|
16
|
+
export interface CliInviteNudgeEvent {
|
|
17
|
+
companyUid: string;
|
|
18
|
+
outcome: CliInviteNudgeOutcome;
|
|
19
|
+
}
|
|
14
20
|
export declare const CLI_LIMIT_AT_ACTION_PROMPT_FLAG = "billing.limit-at-action-prompt";
|
|
15
21
|
export type CliLimitResourceKind = "users" | "integrations" | "secrets" | "agents" | "deployments" | "storageBytes";
|
|
16
22
|
export type CliLimitPresentationKind = "advisory" | "refusal";
|
|
@@ -33,6 +39,11 @@ export declare function takeCliLimitNoticeEngagements(): CliLimitNoticeEngagemen
|
|
|
33
39
|
export declare function queuePlanLimitInviteTeammateExposure(companyUid: string): void;
|
|
34
40
|
/** Drain invite-suggestion exposures for the bounded command-finalization flush. */
|
|
35
41
|
export declare function takePlanLimitInviteTeammateExposures(): string[];
|
|
42
|
+
/** Queue one company-keyed CLI invite prompt outcome; no person UID is included in the event. */
|
|
43
|
+
export declare function queueCliInviteNudgeEvent(companyUid: string, outcome: CliInviteNudgeOutcome): void;
|
|
44
|
+
export declare function takeCliInviteNudgeEvents(): CliInviteNudgeEvent[];
|
|
45
|
+
/** Emit a bounded CLI session fact through the existing product-telemetry path. */
|
|
46
|
+
export declare function emitCliInviteNudgeEvent(event: CliInviteNudgeEvent, timeoutMs?: number): Promise<void>;
|
|
36
47
|
export type CliFeatureKind = "secret_read" | "lane_create" | "lane_resume";
|
|
37
48
|
export type CliFeatureOutcome = "success" | "failure";
|
|
38
49
|
export interface CliFeatureUsage {
|