@indigoai-us/hq-cli 5.345.201 → 5.345.203
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 +16 -0
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +26 -0
- package/dist/commands/capacity.js +10 -2
- package/dist/commands/daemon.d.ts +2 -1
- package/dist/commands/daemon.js +225 -4
- package/dist/commands/integrations-core.d.ts +3 -0
- package/dist/commands/integrations-core.js +11 -0
- package/dist/commands/integrations-manage.js +2 -2
- package/dist/commands/integrations.js +5 -5
- package/dist/commands/sync-core.js +2 -1
- package/dist/lib/bot/executive-settings.d.ts +1 -1
- package/dist/lib/bot/executive-settings.js +16 -6
- package/dist/lib/bot/jobs/meeting-detect.js +2 -2
- package/dist/lib/bot/jobs/meeting-rider.js +2 -2
- package/dist/lib/bot/jobs/signal-to-lane.d.ts +3 -2
- package/dist/lib/bot/jobs/signal-to-lane.js +31 -8
- package/dist/lib/bot/run.js +3 -3
- package/dist/lib/capacity/codex-availability.d.ts +6 -0
- package/dist/lib/capacity/codex-availability.js +33 -0
- package/dist/lib/capacity/ledger.d.ts +0 -11
- package/dist/lib/capacity/ledger.js +30 -3
- package/dist/lib/capacity/route.js +11 -5
- package/dist/lib/daemon/catalog.d.ts +3 -0
- package/dist/lib/daemon/catalog.js +15 -2
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +9 -2
- package/dist/lib/daemon/install.d.ts +99 -0
- package/dist/lib/daemon/install.js +316 -7
- package/dist/lib/daemon/migrate.d.ts +3 -1
- package/dist/lib/daemon/migrate.js +14 -2
- package/dist/lib/daemon/plist-refresh.d.ts +34 -0
- package/dist/lib/daemon/plist-refresh.js +62 -0
- package/dist/lib/daemon/run.js +25 -0
- package/dist/lib/daemon/selfcheck.d.ts +71 -0
- package/dist/lib/daemon/selfcheck.js +150 -0
- package/dist/lib/daemon/supervisor.d.ts +31 -0
- package/dist/lib/daemon/supervisor.js +130 -6
- package/dist/lib/daemon/unit-holds.js +2 -1
- package/dist/lib/service-manager/launchd.d.ts +25 -0
- package/dist/lib/service-manager/launchd.js +51 -3
- package/dist/lib/service-manager/migration.d.ts +14 -0
- package/dist/lib/service-manager/migration.js +34 -0
- package/dist/lib/service-manager/systemd.d.ts +13 -0
- package/dist/lib/service-manager/systemd.js +19 -0
- package/dist/lib/service-manager/types.d.ts +8 -0
- package/dist/lib/sync-core/reconcile.d.ts +4 -1
- package/dist/lib/sync-core/reconcile.js +12 -2
- package/dist/lib/sync-core/release.d.ts +23 -0
- package/dist/lib/sync-core/release.js +39 -0
- package/dist/main.js +3 -3
- package/dist/utils/cli-network-lifecycle.d.ts +9 -0
- package/dist/utils/cli-network-lifecycle.js +25 -1
- package/package.json +9 -8
|
@@ -12,7 +12,7 @@ import { meetingSignalSourceRef, cursorPath } from "../../signals/incremental.js
|
|
|
12
12
|
import { readAllLanes } from "../../lanes/store.js";
|
|
13
13
|
import { runSignals } from "../../signals/engine.js";
|
|
14
14
|
import { emitBotEvent, EVENT_PAYLOAD_BYTES } from "../events.js";
|
|
15
|
-
import {
|
|
15
|
+
import { executiveEnabled } from "../executive-settings.js";
|
|
16
16
|
export { isExecutiveBot } from "../executive-settings.js";
|
|
17
17
|
import { readLocalJobsStrict, withJobsLock, writeLocalJobs } from "../local-jobs.js";
|
|
18
18
|
import { readBuiltinJobsMarker, writeBuiltinJobsMarker } from "./hot-signal-sync.js";
|
|
@@ -56,7 +56,7 @@ export function ensureMeetingRiderJob(dir) {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
export function meetingRiderSettings(hqRoot, home = os.homedir()) {
|
|
59
|
-
const enabled =
|
|
59
|
+
const enabled = executiveEnabled(home);
|
|
60
60
|
let interval = 20;
|
|
61
61
|
try {
|
|
62
62
|
const settings = yaml.load(fs.readFileSync(path.join(hqRoot, "core/settings/signals.yaml"), "utf8"));
|
|
@@ -7,9 +7,10 @@ export declare function ensureSignalToLaneJobs(dir: string, config: BotConfig, c
|
|
|
7
7
|
/** Only new locally extracted signals reach here; cloud upload/garden is separate. */
|
|
8
8
|
export declare function emitExecutiveSignalWritten(hqRoot: string, company: string, payload: Record<string, string>, root?: string, home?: string): boolean;
|
|
9
9
|
/** Persist intent before publishing a new signal file; ordinary redo creates none. */
|
|
10
|
-
|
|
10
|
+
/** Records nothing while no opted-in Executive serves the company, so no backlog builds up. */
|
|
11
|
+
export declare function prepareSignalPublication(hqRoot: string, company: string, payload: Record<string, string>, root?: string, home?: string): void;
|
|
11
12
|
/** A pre-append crash retries; a post-append crash uses the event journal's dedupe. */
|
|
12
|
-
export declare function flushSignalPublications(hqRoot: string, company: string, emit?: typeof emitExecutiveSignalWritten): void;
|
|
13
|
+
export declare function flushSignalPublications(hqRoot: string, company: string, emit?: typeof emitExecutiveSignalWritten, root?: string, home?: string): void;
|
|
13
14
|
type Create = (input: {
|
|
14
15
|
hqRoot: string;
|
|
15
16
|
company: string;
|
|
@@ -41,10 +41,10 @@ export function ensureSignalToLaneJobs(dir, config, companies = signalLaneCompan
|
|
|
41
41
|
return jobs.filter(job => job.context?.jobKind === SIGNAL_TO_LANE_JOB_KIND);
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/** The opted-in Executive bot dir serving this HQ root and company, if any. */
|
|
45
|
+
function executiveFor(hqRoot, company, root = botsRoot(), home) {
|
|
46
46
|
if (!fs.existsSync(root))
|
|
47
|
-
return
|
|
47
|
+
return null;
|
|
48
48
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
49
49
|
if (!entry.isDirectory())
|
|
50
50
|
continue;
|
|
@@ -54,10 +54,17 @@ export function emitExecutiveSignalWritten(hqRoot, company, payload, root = bots
|
|
|
54
54
|
continue;
|
|
55
55
|
if (!signalLaneCompanies(config).includes(company))
|
|
56
56
|
continue;
|
|
57
|
-
|
|
58
|
-
return true;
|
|
57
|
+
return dir;
|
|
59
58
|
}
|
|
60
|
-
return
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** Only new locally extracted signals reach here; cloud upload/garden is separate. */
|
|
62
|
+
export function emitExecutiveSignalWritten(hqRoot, company, payload, root = botsRoot(), home) {
|
|
63
|
+
const dir = executiveFor(hqRoot, company, root, home);
|
|
64
|
+
if (!dir)
|
|
65
|
+
return false;
|
|
66
|
+
emitBotEvent(dir, "signal.written", { ...payload, company }, new Date(), prior => prior.event === "signal.written" && prior.payload.company === company && prior.payload.signal_id === payload.signal_id);
|
|
67
|
+
return true;
|
|
61
68
|
}
|
|
62
69
|
function publicationDir(hqRoot, company) {
|
|
63
70
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(company))
|
|
@@ -65,8 +72,11 @@ function publicationDir(hqRoot, company) {
|
|
|
65
72
|
return path.join(hqRoot, "workspace", "signal-publications", company);
|
|
66
73
|
}
|
|
67
74
|
/** Persist intent before publishing a new signal file; ordinary redo creates none. */
|
|
68
|
-
|
|
75
|
+
/** Records nothing while no opted-in Executive serves the company, so no backlog builds up. */
|
|
76
|
+
export function prepareSignalPublication(hqRoot, company, payload, root = botsRoot(), home) {
|
|
69
77
|
const dir = publicationDir(hqRoot, company);
|
|
78
|
+
if (!executiveFor(hqRoot, company, root, home))
|
|
79
|
+
return;
|
|
70
80
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
81
|
const file = path.join(dir, createHash("sha256").update(payload.signal_id).digest("hex") + ".json");
|
|
72
82
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
@@ -74,12 +84,25 @@ export function prepareSignalPublication(hqRoot, company, payload) {
|
|
|
74
84
|
fs.renameSync(tmp, file);
|
|
75
85
|
}
|
|
76
86
|
/** A pre-append crash retries; a post-append crash uses the event journal's dedupe. */
|
|
77
|
-
export function flushSignalPublications(hqRoot, company, emit = emitExecutiveSignalWritten) {
|
|
87
|
+
export function flushSignalPublications(hqRoot, company, emit = emitExecutiveSignalWritten, root = botsRoot(), home) {
|
|
78
88
|
const dir = publicationDir(hqRoot, company);
|
|
79
89
|
if (!fs.existsSync(dir))
|
|
80
90
|
return;
|
|
91
|
+
// With the Executive off, drop stale intents so a later opt-in does not
|
|
92
|
+
// turn past action items into a burst of lanes.
|
|
93
|
+
const optedIn = executiveFor(hqRoot, company, root, home) !== null;
|
|
81
94
|
for (const name of fs.readdirSync(dir).filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
|
|
82
95
|
const file = path.join(dir, name);
|
|
96
|
+
if (!optedIn) {
|
|
97
|
+
try {
|
|
98
|
+
fs.unlinkSync(file);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code !== "ENOENT")
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
83
106
|
let payload;
|
|
84
107
|
try {
|
|
85
108
|
payload = JSON.parse(fs.readFileSync(file, "utf8"));
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -50,7 +50,7 @@ import { ensureSignalToLaneJobs, runSignalToLane, SIGNAL_TO_LANE_JOB_KIND } from
|
|
|
50
50
|
* Everything with a side effect is injectable so the loop is unit-testable.
|
|
51
51
|
*/
|
|
52
52
|
import { createMeetingRider, defaultMeetingRiderDeps, ensureMeetingRiderJob, MEETING_RIDER_JOB_KIND } from "./jobs/meeting-rider.js";
|
|
53
|
-
import {
|
|
53
|
+
import { executiveEnabled, isExecutiveBot } from "./executive-settings.js";
|
|
54
54
|
import { createMeetingDetect, defaultMeetingDetectDeps, resolveDetectedMeeting } from "./jobs/meeting-detect.js";
|
|
55
55
|
import { botAuthEnv, describeBotAuth, formatBotAuth } from "./runtime-auth.js";
|
|
56
56
|
import { ownerContextBlock } from "./owner-context.js";
|
|
@@ -435,7 +435,7 @@ export async function runBot(deps) {
|
|
|
435
435
|
eventHandlers[MEETING_RIDER_JOB_KIND] = async ({ item }) => {
|
|
436
436
|
if (!isExecutiveBot(config))
|
|
437
437
|
return { status: "skipped-precondition", output: "Not the Executive" };
|
|
438
|
-
if (!
|
|
438
|
+
if (!executiveEnabled())
|
|
439
439
|
return { status: "skipped-precondition", output: "rider disabled" };
|
|
440
440
|
meetingRider.start(await resolveDetectedMeeting(config.hqRoot, typeof item.context === "object" ? item.context?.payload : undefined, meetingDetectDeps.list));
|
|
441
441
|
return { status: "succeeded", output: "Meeting rider accepted" };
|
|
@@ -2905,7 +2905,7 @@ export async function runBot(deps) {
|
|
|
2905
2905
|
}
|
|
2906
2906
|
if (isExecutiveBot(config)) {
|
|
2907
2907
|
try {
|
|
2908
|
-
if (ensureMeetingRiderJob(dir) &&
|
|
2908
|
+
if (ensureMeetingRiderJob(dir) && executiveEnabled())
|
|
2909
2909
|
meetingRider.resume();
|
|
2910
2910
|
}
|
|
2911
2911
|
catch {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { NowLimit } from "./ledger.js";
|
|
2
|
+
/** A newer snapshot supersedes omitted windows within the same account. */
|
|
3
|
+
export declare function codexSnapshotStale(limit: NowLimit, limits: readonly NowLimit[]): boolean;
|
|
4
|
+
/** Credits can keep Codex serving after a plan window runs out. */
|
|
5
|
+
export declare function hasCodexCreditRoom(limits: readonly NowLimit[], nowMs: number): boolean;
|
|
6
|
+
//# sourceMappingURL=codex-availability.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const MAX_CREDIT_AGE_MS = 24 * 36e5;
|
|
2
|
+
/** A newer snapshot supersedes omitted windows within the same account. */
|
|
3
|
+
export function codexSnapshotStale(limit, limits) {
|
|
4
|
+
if (limit.source !== "codex-jsonl" && limit.source !== "codex-app-server" && limit.source !== "refusal")
|
|
5
|
+
return false;
|
|
6
|
+
const at = Date.parse(limit.observedAt);
|
|
7
|
+
return limits.some((other) => other.accountId === limit.accountId
|
|
8
|
+
&& (other.source === "codex-jsonl" || other.source === "codex-app-server")
|
|
9
|
+
&& Date.parse(other.observedAt) > at);
|
|
10
|
+
}
|
|
11
|
+
/** Credits can keep Codex serving after a plan window runs out. */
|
|
12
|
+
export function hasCodexCreditRoom(limits, nowMs) {
|
|
13
|
+
return limits.some((credit) => {
|
|
14
|
+
if (credit.kind !== "credits" || !credit.limitId.endsWith(":credits"))
|
|
15
|
+
return false;
|
|
16
|
+
const at = Date.parse(credit.observedAt);
|
|
17
|
+
if (!Number.isFinite(at) || at > nowMs || nowMs - at > MAX_CREDIT_AGE_MS)
|
|
18
|
+
return false;
|
|
19
|
+
if (codexSnapshotStale(credit, limits) || credit.state !== "ok")
|
|
20
|
+
return false;
|
|
21
|
+
if (credit.resetsAt && Date.parse(credit.resetsAt) <= nowMs)
|
|
22
|
+
return false;
|
|
23
|
+
if (credit.hasCredits !== true || credit.spendControlReached === true)
|
|
24
|
+
return false;
|
|
25
|
+
const sameAccount = limits.filter((limit) => limit.accountId === credit.accountId);
|
|
26
|
+
if (sameAccount.some((limit) => !codexSnapshotStale(limit, limits)
|
|
27
|
+
&& limit.spendControlReached === true))
|
|
28
|
+
return false;
|
|
29
|
+
const balance = credit.balance === null ? NaN : Number(credit.balance);
|
|
30
|
+
return credit.unlimited === true || (Number.isFinite(balance) && balance > 0);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=codex-availability.js.map
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Personal capacity ledger under ~/.hq/capacity/.
|
|
3
|
-
*
|
|
4
|
-
* Daily files events-YYYY-MM-DD.jsonl are the append-only event log (one
|
|
5
|
-
* O_APPEND write under 4 KB). rebuildNow() replays those files, plus a
|
|
6
|
-
* legacy events.jsonl if a fixture dropped one, into now.json. The now.json
|
|
7
|
-
* rewrite holds a TTL lock reclaimed after 30 seconds.
|
|
8
|
-
*
|
|
9
|
-
* Root is HQ_CAPACITY_ROOT, or an explicit option, so tests never touch the
|
|
10
|
-
* real home directory. No network, child process, or provider call.
|
|
11
|
-
*/
|
|
12
1
|
export declare const CAPACITY_DIRNAME = "capacity";
|
|
13
2
|
export declare const CAPACITY_LOCK_TTL_MS = 30000;
|
|
14
3
|
export declare const CAPACITY_LOCK_NAME = "now.lock";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexSnapshotStale, hasCodexCreditRoom } from "./codex-availability.js";
|
|
1
2
|
/**
|
|
2
3
|
* Personal capacity ledger under ~/.hq/capacity/.
|
|
3
4
|
*
|
|
@@ -857,11 +858,26 @@ function materialize(events, ctx) {
|
|
|
857
858
|
return a.reading.limit.limitId.localeCompare(b.reading.limit.limitId);
|
|
858
859
|
})
|
|
859
860
|
.map((limit) => toNowLimit(limit, ctx.nowMs));
|
|
861
|
+
if (provider === "codex") {
|
|
862
|
+
for (const limit of limits) {
|
|
863
|
+
if (codexSnapshotStale(limit, limits)) {
|
|
864
|
+
limit.state = "unknown";
|
|
865
|
+
limit.usedPercent = null;
|
|
866
|
+
limit.burnRatePercentPerHour = null;
|
|
867
|
+
limit.lastError = "superseded by a newer Codex snapshot";
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
860
871
|
const failed = bucket.failure !== null && bucket.failure.at >= bucket.latestReadingAt;
|
|
861
872
|
let state = "ok";
|
|
862
873
|
for (const limit of limits) {
|
|
863
|
-
if (
|
|
864
|
-
|
|
874
|
+
if (provider === "codex" && codexSnapshotStale(limit, limits))
|
|
875
|
+
continue;
|
|
876
|
+
const availableState = provider === "codex" && limit.state === "exhausted"
|
|
877
|
+
&& hasCodexCreditRoom(limits.filter((row) => row.accountId === limit.accountId), ctx.nowMs)
|
|
878
|
+
? "ok" : limit.state;
|
|
879
|
+
if (STATE_RANK[availableState] > STATE_RANK[state])
|
|
880
|
+
state = availableState;
|
|
865
881
|
}
|
|
866
882
|
if (failed)
|
|
867
883
|
state = "unknown";
|
|
@@ -878,6 +894,15 @@ function materialize(events, ctx) {
|
|
|
878
894
|
}
|
|
879
895
|
const unscopedFailure = bucket.failure !== null && bucket.failure.at >= bucket.latestReadingAt;
|
|
880
896
|
attachAccounts(entry, events, unscopedFailure);
|
|
897
|
+
if (provider === "codex" && !failed && entry.accounts?.length) {
|
|
898
|
+
for (const account of entry.accounts) {
|
|
899
|
+
if (account.authState === "ok" && account.state === "exhausted"
|
|
900
|
+
&& hasCodexCreditRoom(account.limits, ctx.nowMs))
|
|
901
|
+
account.state = "ok";
|
|
902
|
+
}
|
|
903
|
+
const live = entry.accounts.filter((account) => account.authState !== "expired" && account.authState !== "signed_out" && !account.signedOut);
|
|
904
|
+
entry.state = live.length === 0 ? "unknown" : live.reduce((state, account) => STATE_RANK[account.state] > STATE_RANK[state] ? account.state : state, "ok");
|
|
905
|
+
}
|
|
881
906
|
return entry;
|
|
882
907
|
});
|
|
883
908
|
return {
|
|
@@ -1039,6 +1064,8 @@ function clusterAccounts(provider, events) {
|
|
|
1039
1064
|
if (!blocked) {
|
|
1040
1065
|
state = "ok";
|
|
1041
1066
|
for (const limit of limits) {
|
|
1067
|
+
if (provider.provider === "codex" && codexSnapshotStale(limit, limits))
|
|
1068
|
+
continue;
|
|
1042
1069
|
if (STATE_RANK[limit.state] > STATE_RANK[state])
|
|
1043
1070
|
state = limit.state;
|
|
1044
1071
|
}
|
|
@@ -1182,7 +1209,7 @@ function deriveLimit(reading, nowMs) {
|
|
|
1182
1209
|
// A passed reset beats both a refusal and a 100% reading. The newest
|
|
1183
1210
|
// observedAt wins in materialize, so a later fetched or observed percent
|
|
1184
1211
|
// below 100 replaces this refusal before the reset.
|
|
1185
|
-
if (!Number.isNaN(resetsAtMs) && resetsAtMs
|
|
1212
|
+
if (!Number.isNaN(resetsAtMs) && resetsAtMs <= nowMs) {
|
|
1186
1213
|
return { state: "reset-pending", usedPercent: null, secondsToReset: null, lastError: null };
|
|
1187
1214
|
}
|
|
1188
1215
|
const percent = reading.limit.usedPercent;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexSnapshotStale, hasCodexCreditRoom } from "./codex-availability.js";
|
|
1
2
|
/**
|
|
2
3
|
* Pure router for `hq capacity route` and `hq lanes create --account auto`.
|
|
3
4
|
*
|
|
@@ -368,7 +369,7 @@ export function pickAccount(input) {
|
|
|
368
369
|
if (resetPassed) {
|
|
369
370
|
return { limit, used: 0, readAge, resetPassed: true };
|
|
370
371
|
}
|
|
371
|
-
if (readAge > LIMIT_STALE_MS) {
|
|
372
|
+
if (readAge > LIMIT_STALE_MS || (providerId === "codex" && codexSnapshotStale(limit, account.limits ?? []))) {
|
|
372
373
|
return { limit, used: null, readAge, resetPassed: false };
|
|
373
374
|
}
|
|
374
375
|
const used = typeof limit.usedPercent === "number" ? limit.usedPercent : null;
|
|
@@ -393,14 +394,18 @@ export function pickAccount(input) {
|
|
|
393
394
|
}
|
|
394
395
|
const exhausted = viewRows.find((row) => !row.resetPassed
|
|
395
396
|
&& row.readAge <= LIMIT_STALE_MS
|
|
397
|
+
&& !(providerId === "codex" && codexSnapshotStale(row.limit, account.limits ?? []))
|
|
396
398
|
&& (row.limit.state === "exhausted" || (typeof row.used === "number" && row.used >= 100)));
|
|
397
399
|
if (exhausted) {
|
|
400
|
+
const codexCredits = providerId === "codex" && hasCodexCreditRoom(account.limits ?? [], nowMs);
|
|
398
401
|
const extraRoom = providerId === "claude" && hasExtraUsageRoom(account);
|
|
399
|
-
if (extraRoom && input.allowExtraUsage === true) {
|
|
402
|
+
if (codexCredits || (extraRoom && input.allowExtraUsage === true)) {
|
|
400
403
|
const live = liveLaneCount(lanes, providerId, laneMatch.id);
|
|
401
404
|
const weekly = applicable.find((l) => l.kind === "weekly_all");
|
|
402
405
|
const whyParts = [
|
|
403
|
-
|
|
406
|
+
codexCredits
|
|
407
|
+
? `${labelLimit(exhausted.limit)} used up; runs on available Codex credits`
|
|
408
|
+
: `${labelLimit(exhausted.limit)} used up; runs on paid extra usage (--allow-extra-usage)`,
|
|
404
409
|
];
|
|
405
410
|
if (live)
|
|
406
411
|
whyParts.push(`${live} live lane${live === 1 ? "" : "s"} already on it`);
|
|
@@ -515,7 +520,8 @@ export function pickAccount(input) {
|
|
|
515
520
|
});
|
|
516
521
|
}
|
|
517
522
|
}
|
|
518
|
-
ranked.sort((a, b) =>
|
|
523
|
+
ranked.sort((a, b) => Number(a.extra_usage === true) - Number(b.extra_usage === true)
|
|
524
|
+
|| b.score - a.score
|
|
519
525
|
|| b.headroom - a.headroom
|
|
520
526
|
|| a.reading_age_h - b.reading_age_h
|
|
521
527
|
|| a.provider.localeCompare(b.provider));
|
|
@@ -524,7 +530,7 @@ export function pickAccount(input) {
|
|
|
524
530
|
if (!requested.includes(p))
|
|
525
531
|
continue;
|
|
526
532
|
const best = ranked.find((r) => r.provider === p);
|
|
527
|
-
if (best && best.headroom >= floor) {
|
|
533
|
+
if (best && !best.extra_usage && best.headroom >= floor) {
|
|
528
534
|
pick = best;
|
|
529
535
|
break;
|
|
530
536
|
}
|
|
@@ -98,6 +98,9 @@ export declare const FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC = 120;
|
|
|
98
98
|
export declare const SYNC_CORE_RECONCILE_UNIT = "sync-core";
|
|
99
99
|
export declare const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
|
|
100
100
|
export declare const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
|
|
101
|
+
export declare const SYNC_CORE_SELFCHECK_UNIT = "sync-core-selfcheck";
|
|
102
|
+
/** The self-check looks hourly; it DMs the owner at most once a day for the same units (selfcheck.ts). */
|
|
103
|
+
export declare const SYNC_CORE_SELFCHECK_INTERVAL_SEC = 3600;
|
|
101
104
|
export declare const SYNC_CORE_MANIFEST_INTERVAL_SEC: number;
|
|
102
105
|
export declare const SYNC_CORE_LEGACY_SETTLE_MS: number;
|
|
103
106
|
export interface PlanContext {
|
|
@@ -98,6 +98,9 @@ export const FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC = 120;
|
|
|
98
98
|
export const SYNC_CORE_RECONCILE_UNIT = "sync-core";
|
|
99
99
|
export const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
|
|
100
100
|
export const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
|
|
101
|
+
export const SYNC_CORE_SELFCHECK_UNIT = "sync-core-selfcheck";
|
|
102
|
+
/** The self-check looks hourly; it DMs the owner at most once a day for the same units (selfcheck.ts). */
|
|
103
|
+
export const SYNC_CORE_SELFCHECK_INTERVAL_SEC = 3600;
|
|
101
104
|
export const SYNC_CORE_MANIFEST_INTERVAL_SEC = 24 * 3600;
|
|
102
105
|
export const SYNC_CORE_LEGACY_SETTLE_MS = 10 * 60 * 1000;
|
|
103
106
|
function base(service, over = {}) {
|
|
@@ -265,9 +268,19 @@ function planService(service, ctx) {
|
|
|
265
268
|
]),
|
|
266
269
|
});
|
|
267
270
|
});
|
|
271
|
+
const selfcheck = engines.length === 0 ? [] : [base(service, {
|
|
272
|
+
id: SYNC_CORE_SELFCHECK_UNIT,
|
|
273
|
+
description: "tells the signed-in person by DM when a sync engine unit has been held or failing for over 30 minutes",
|
|
274
|
+
kind: "scheduled",
|
|
275
|
+
intervalSec: SYNC_CORE_SELFCHECK_INTERVAL_SEC,
|
|
276
|
+
initialDelaySec: 35 * 60,
|
|
277
|
+
command: { type: "hq", args: ["daemon", "service", "sync-core-selfcheck"] },
|
|
278
|
+
env: { HQ_NO_UPDATE_CHECK: "1" },
|
|
279
|
+
blocked: firstBlock([[!ctx.signedIn, NOT_SIGNED_IN]]),
|
|
280
|
+
})];
|
|
268
281
|
const manifestScopes = engineOwnedManifestScopes(ctx.vaultSync, ctx.syncCoreVaults);
|
|
269
282
|
if (manifestScopes.length === 0)
|
|
270
|
-
return [reconcile, ...engines];
|
|
283
|
+
return [reconcile, ...engines, ...selfcheck];
|
|
271
284
|
const manifest = base(service, {
|
|
272
285
|
id: SYNC_CORE_MANIFEST_UNIT,
|
|
273
286
|
description: `uploads a daily sync-audit manifest for ${manifestScopes.map((scope) => scope.kind === "personal" ? "the personal vault" : `the ${scope.slug} vault`).join(", ")}`,
|
|
@@ -286,7 +299,7 @@ function planService(service, ctx) {
|
|
|
286
299
|
[!ctx.signedIn, NOT_SIGNED_IN],
|
|
287
300
|
]),
|
|
288
301
|
});
|
|
289
|
-
return [reconcile, ...engines, manifest];
|
|
302
|
+
return [reconcile, ...engines, manifest, ...selfcheck];
|
|
290
303
|
}
|
|
291
304
|
case "mesh": {
|
|
292
305
|
const args = ["mesh", "daemon", "run", ...(ctx.hqRoot ? ["--hq-root", ctx.hqRoot] : [])];
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { type ServiceName } from "./catalog.js";
|
|
8
8
|
import { type MachineType } from "./machine-type.js";
|
|
9
9
|
import { type DaemonPaths } from "./paths.js";
|
|
10
|
+
/** The updater's postinstall may run in the short gap before the desktop app relaunches its daemon. */
|
|
11
|
+
export declare function recentlyHostedDaemon(paths: DaemonPaths, alive?: (pid: number) => boolean): boolean;
|
|
10
12
|
/** A live daemon runs `unitId` (or held it after it was stopped from outside). */
|
|
11
13
|
export declare function daemonSupervises(paths: DaemonPaths, unitId: string, alive?: (pid: number) => boolean): boolean;
|
|
12
14
|
/** Current supervisor hold, including its explicit recovery command; never infer one from stale state. */
|
|
@@ -20,10 +20,14 @@ import { writeControlRequest } from "./requests.js";
|
|
|
20
20
|
const OWNED = new Set(["running", "backoff", "exited", "idle", "stopped", "held"]);
|
|
21
21
|
const RECENT_HOSTED_DAEMON_MS = 5 * 60_000;
|
|
22
22
|
/** The updater's postinstall may run in the short gap before the desktop app relaunches its daemon. */
|
|
23
|
-
function recentlyHostedDaemon(paths) {
|
|
23
|
+
export function recentlyHostedDaemon(paths, alive = (pid) => isDaemonProcess(pid)) {
|
|
24
24
|
if (!fs.existsSync(paths.statePath))
|
|
25
25
|
return false;
|
|
26
26
|
const state = JSON.parse(fs.readFileSync(paths.statePath, "utf8"));
|
|
27
|
+
// A different live daemon holds the lock: the hosted daemon that wrote this state is gone, not restarting.
|
|
28
|
+
const lockPid = runningDaemonPid(paths.lockPath, alive);
|
|
29
|
+
if (lockPid !== null && state.pid !== lockPid)
|
|
30
|
+
return false;
|
|
27
31
|
const age = Date.now() - (state.updatedAt ?? 0);
|
|
28
32
|
return state.host === "desktop" && age >= 0 && age <= RECENT_HOSTED_DAEMON_MS;
|
|
29
33
|
}
|
|
@@ -85,7 +89,10 @@ export function askDaemonToStop(paths, unitId) {
|
|
|
85
89
|
/** A live daemon runs as the desktop app's child, which counts as installed even though no OS unit exists. */
|
|
86
90
|
export function hostedDaemonRunning(paths, alive = (pid) => isDaemonProcess(pid)) {
|
|
87
91
|
try {
|
|
88
|
-
|
|
92
|
+
// state.json outlives the daemon that wrote it: count it only when it is the lock holder's.
|
|
93
|
+
const lockPid = runningDaemonPid(paths.lockPath, alive);
|
|
94
|
+
const state = liveState(paths, alive);
|
|
95
|
+
return state?.host === "desktop" && state.pid === lockPid;
|
|
89
96
|
}
|
|
90
97
|
catch {
|
|
91
98
|
return false;
|
|
@@ -163,6 +163,105 @@ export declare function classifyWindowsTask(run: RunCommand, task: string, host:
|
|
|
163
163
|
/** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
|
|
164
164
|
export declare function oldDaemonWarning(pid: number, reason: string): string;
|
|
165
165
|
export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
|
|
166
|
+
/** Install-level bootstrap retries after launchd left the job unloaded. */
|
|
167
|
+
export declare const DAEMON_BOOTSTRAP_RETRIES = 3;
|
|
168
|
+
export declare const DAEMON_BOOTSTRAP_RETRY_MS = 2000;
|
|
169
|
+
/**
|
|
170
|
+
* Bootstrap the daemon agent again when an install left it unloaded, up to
|
|
171
|
+
* DAEMON_BOOTSTRAP_RETRIES times, then kickstart it. Returns the error that
|
|
172
|
+
* says the agent is not loaded when every retry failed.
|
|
173
|
+
*/
|
|
174
|
+
export declare function retryLaunchdBootstrap(plistPath: string, run: RunCommand, uid: number, sleep?: SleepFn): {
|
|
175
|
+
ok: boolean;
|
|
176
|
+
error?: string;
|
|
177
|
+
};
|
|
178
|
+
export interface RefreshDaemonPlistResult {
|
|
179
|
+
/**
|
|
180
|
+
* - `none`: no daemon plist here (not installed; nothing to upgrade);
|
|
181
|
+
* - `current`: the plist already loads without HQ.app;
|
|
182
|
+
* - `rewritten`: the plist was rewritten; it was not loaded, so nothing was reloaded;
|
|
183
|
+
* - `reloaded`: the plist was rewritten and launchd now runs the new definition.
|
|
184
|
+
*/
|
|
185
|
+
action: "none" | "current" | "rewritten" | "reloaded";
|
|
186
|
+
plistPath: string;
|
|
187
|
+
/** The rewrite or reload did not finish; what is left and how to fix it. */
|
|
188
|
+
error?: string;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Upgrade a daemon LaunchAgent written by an older install (run through the
|
|
192
|
+
* HQ.app launcher and associated with the app's bundle, which launchd skips
|
|
193
|
+
* at login) to the app-independent form, and reload it when it is loaded.
|
|
194
|
+
*
|
|
195
|
+
* Nothing but `hq daemon install` used to rewrite the unit, so a machine that
|
|
196
|
+
* only ever self-updated kept the old plist. This runs from `hq daemon start`,
|
|
197
|
+
* and from the detached helper the managed daemon spawns at startup when it
|
|
198
|
+
* finds the old plist (see daemonPlistNeedsRefresh). It must not run inside
|
|
199
|
+
* the daemon's own job: the bootout would end the caller before the bootstrap.
|
|
200
|
+
*/
|
|
201
|
+
export declare function refreshDaemonPlist(host: Pick<DaemonHost, "home" | "uid">, deps: {
|
|
202
|
+
run: RunCommand;
|
|
203
|
+
sleep?: SleepFn;
|
|
204
|
+
}): RefreshDaemonPlistResult;
|
|
205
|
+
/** The daemon plist on disk still runs through HQ.app or names its bundle. */
|
|
206
|
+
export declare function daemonPlistNeedsRefresh(home: string): boolean;
|
|
207
|
+
/**
|
|
208
|
+
* Who runs the daemon right now, as the service manager and the pid lock see it:
|
|
209
|
+
*
|
|
210
|
+
* - `managed`: the OS unit is loaded (and, where the manager reports one, its
|
|
211
|
+
* pid is the daemon holding the lock), so it comes back after a crash or login;
|
|
212
|
+
* - `unmanaged`: a daemon holds the lock, but the OS unit is not loaded or runs
|
|
213
|
+
* a different process (started by hand, or as the desktop app's child);
|
|
214
|
+
* - `not-loaded`: the OS unit is not loaded and no daemon runs.
|
|
215
|
+
*/
|
|
216
|
+
export type DaemonSupervision = "managed" | "unmanaged" | "not-loaded";
|
|
217
|
+
export interface DaemonServiceStatus {
|
|
218
|
+
supervision: DaemonSupervision;
|
|
219
|
+
/** Which service manager was asked. */
|
|
220
|
+
manager: "launchd" | "systemd-user" | "systemd-system" | "schtasks" | "none";
|
|
221
|
+
/** The unit file (plist, unit, task) is present. */
|
|
222
|
+
unitInstalled: boolean;
|
|
223
|
+
/** The manager has the unit loaded / enabled. */
|
|
224
|
+
loaded: boolean;
|
|
225
|
+
/** The manager reports the unit's process as running. */
|
|
226
|
+
active: boolean;
|
|
227
|
+
/** The manager's pid for the unit, when it reports one. */
|
|
228
|
+
managerPid?: number;
|
|
229
|
+
/** The pid of the live daemon holding the lock, or null. */
|
|
230
|
+
daemonPid: number | null;
|
|
231
|
+
/** One line for people: what the manager said. */
|
|
232
|
+
detail: string;
|
|
233
|
+
/**
|
|
234
|
+
* Problems with the unit that do not change who runs the daemon now, such
|
|
235
|
+
* as a plist launchd skips at login. One line each, with the fix.
|
|
236
|
+
*/
|
|
237
|
+
warnings?: string[];
|
|
238
|
+
}
|
|
239
|
+
export declare function describeSupervision(status: DaemonServiceStatus): string;
|
|
240
|
+
/**
|
|
241
|
+
* Ask launchd, systemd or Task Scheduler whether the daemon's unit is loaded,
|
|
242
|
+
* and combine that with the pid lock. `daemonPid` is the live daemon from the
|
|
243
|
+
* lock (null when none runs).
|
|
244
|
+
*/
|
|
245
|
+
export declare function daemonServiceStatus(host: Pick<DaemonHost, "platform" | "home" | "uid" | "systemUnitDir" | "windowsSid" | "windowsUser">, run: RunCommand, daemonPid: number | null): DaemonServiceStatus;
|
|
246
|
+
export interface StartDaemonResult {
|
|
247
|
+
/**
|
|
248
|
+
* What was done: nothing (already running), loaded the unit, started it, or
|
|
249
|
+
* reloaded it after rewriting an older install's plist (macOS).
|
|
250
|
+
*/
|
|
251
|
+
action: "already-running" | "loaded" | "started" | "reloaded";
|
|
252
|
+
status: DaemonServiceStatus;
|
|
253
|
+
/** macOS: what happened to an older install's plist before starting. */
|
|
254
|
+
refresh?: RefreshDaemonPlistResult;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* `hq daemon start`: load the daemon's OS unit when the manager does not have
|
|
258
|
+
* it, then start it. The unit file must already exist (the caller installs it
|
|
259
|
+
* first when it does not). Throws when the manager refuses.
|
|
260
|
+
*/
|
|
261
|
+
export declare function startDaemonUnit(host: DaemonHost, deps: InstallDeps & {
|
|
262
|
+
daemonPid: () => number | null;
|
|
263
|
+
waitMs?: number;
|
|
264
|
+
}): StartDaemonResult;
|
|
166
265
|
/**
|
|
167
266
|
* PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
|
|
168
267
|
* lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
|