@indigoai-us/hq-cli 5.126.0 → 5.127.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 +211 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-written provider quota, keyed by account and model.
|
|
3
|
+
*
|
|
4
|
+
* `enabled` and `disabled_reason` live in personal/lanes/config.json and are
|
|
5
|
+
* the operator's hand-written switch. `quota_reset_at` and `needs_credits`
|
|
6
|
+
* are written here, in personal/lanes/quota.json, from the provider's own
|
|
7
|
+
* 429. They are separate fields because a quota hold expires on its own:
|
|
8
|
+
* copying it into `disabled_reason` or flipping `enabled` would erase the
|
|
9
|
+
* operator's reason, and would leave the account switched off after the
|
|
10
|
+
* provider reset. The balancer reads this file and does not rewrite the
|
|
11
|
+
* operator fields.
|
|
12
|
+
*
|
|
13
|
+
* A weekly-limit 429 names a reset and applies to every model on that
|
|
14
|
+
* account (measured: account b, every model, until the reset). A credits
|
|
15
|
+
* 429 names one model and no reset (measured: account d refuses
|
|
16
|
+
* claude-fable-5-1 and still runs claude-opus-5). The record is therefore
|
|
17
|
+
* per (account, model), plus an account-wide reset when the text is a
|
|
18
|
+
* usage limit rather than a switch-model refusal.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
|
|
23
|
+
export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
|
|
24
|
+
const MONTHS = {
|
|
25
|
+
jan: 0,
|
|
26
|
+
feb: 1,
|
|
27
|
+
mar: 2,
|
|
28
|
+
apr: 3,
|
|
29
|
+
may: 4,
|
|
30
|
+
jun: 5,
|
|
31
|
+
jul: 6,
|
|
32
|
+
aug: 7,
|
|
33
|
+
sep: 8,
|
|
34
|
+
oct: 9,
|
|
35
|
+
nov: 10,
|
|
36
|
+
dec: 11,
|
|
37
|
+
};
|
|
38
|
+
function emptyState() {
|
|
39
|
+
return { schema_version: QUOTA_SCHEMA_VERSION, providers: {} };
|
|
40
|
+
}
|
|
41
|
+
function isPlainObject(value) {
|
|
42
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
export function quotaStatePath(hqRoot) {
|
|
45
|
+
return path.join(hqRoot, QUOTA_STATE_REL);
|
|
46
|
+
}
|
|
47
|
+
export function loadQuotaState(hqRoot) {
|
|
48
|
+
const filePath = quotaStatePath(hqRoot);
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const code = err.code;
|
|
55
|
+
if (code === "ENOENT")
|
|
56
|
+
return emptyState();
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return emptyState();
|
|
65
|
+
}
|
|
66
|
+
if (!isPlainObject(parsed))
|
|
67
|
+
return emptyState();
|
|
68
|
+
const providers = {};
|
|
69
|
+
const rawProviders = isPlainObject(parsed.providers) ? parsed.providers : {};
|
|
70
|
+
for (const provider of ["claude", "codex", "grok"]) {
|
|
71
|
+
const rawAccounts = rawProviders[provider];
|
|
72
|
+
if (!isPlainObject(rawAccounts))
|
|
73
|
+
continue;
|
|
74
|
+
const accounts = {};
|
|
75
|
+
for (const [id, raw] of Object.entries(rawAccounts)) {
|
|
76
|
+
if (!isPlainObject(raw))
|
|
77
|
+
continue;
|
|
78
|
+
const models = {};
|
|
79
|
+
if (isPlainObject(raw.models)) {
|
|
80
|
+
for (const [model, entry] of Object.entries(raw.models)) {
|
|
81
|
+
if (!isPlainObject(entry))
|
|
82
|
+
continue;
|
|
83
|
+
const quota = {};
|
|
84
|
+
if (typeof entry.quota_reset_at === "string" && entry.quota_reset_at.length > 0) {
|
|
85
|
+
quota.quota_reset_at = entry.quota_reset_at;
|
|
86
|
+
}
|
|
87
|
+
if (entry.needs_credits === true)
|
|
88
|
+
quota.needs_credits = true;
|
|
89
|
+
if (typeof entry.marker === "string" && entry.marker.length > 0) {
|
|
90
|
+
quota.marker = entry.marker;
|
|
91
|
+
}
|
|
92
|
+
if (quota.quota_reset_at || quota.needs_credits || quota.marker) {
|
|
93
|
+
models[model] = quota;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const account = { models };
|
|
98
|
+
if (typeof raw.all_models_quota_reset_at === "string" &&
|
|
99
|
+
raw.all_models_quota_reset_at.length > 0) {
|
|
100
|
+
account.all_models_quota_reset_at = raw.all_models_quota_reset_at;
|
|
101
|
+
}
|
|
102
|
+
accounts[id] = account;
|
|
103
|
+
}
|
|
104
|
+
providers[provider] = accounts;
|
|
105
|
+
}
|
|
106
|
+
return { schema_version: QUOTA_SCHEMA_VERSION, providers };
|
|
107
|
+
}
|
|
108
|
+
function writeQuotaState(hqRoot, state) {
|
|
109
|
+
const filePath = quotaStatePath(hqRoot);
|
|
110
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
111
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
112
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
113
|
+
fs.renameSync(tmp, filePath);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse "resets Sep 24, 2am (UTC)" into an ISO timestamp. The year is the
|
|
117
|
+
* current UTC year, rolled forward when that instant is already past.
|
|
118
|
+
* Returns null when the text names no reset.
|
|
119
|
+
*/
|
|
120
|
+
export function parseProviderResetTime(text, now) {
|
|
121
|
+
const match = /resets\s+([A-Za-z]+)\s+(\d{1,2})(?:,\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*\(UTC\))?/i.exec(text);
|
|
122
|
+
if (!match)
|
|
123
|
+
return null;
|
|
124
|
+
const month = MONTHS[(match[1] ?? "").slice(0, 3).toLowerCase()];
|
|
125
|
+
if (month === undefined)
|
|
126
|
+
return null;
|
|
127
|
+
const day = Number(match[2]);
|
|
128
|
+
if (!Number.isInteger(day) || day < 1 || day > 31)
|
|
129
|
+
return null;
|
|
130
|
+
let hour = 0;
|
|
131
|
+
let minute = 0;
|
|
132
|
+
if (match[3] !== undefined) {
|
|
133
|
+
hour = Number(match[3]);
|
|
134
|
+
minute = match[4] !== undefined ? Number(match[4]) : 0;
|
|
135
|
+
const ap = (match[5] ?? "").toLowerCase();
|
|
136
|
+
if (ap === "pm" && hour < 12)
|
|
137
|
+
hour += 12;
|
|
138
|
+
if (ap === "am" && hour === 12)
|
|
139
|
+
hour = 0;
|
|
140
|
+
if (hour > 23 || minute > 59)
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
let year = now.getUTCFullYear();
|
|
144
|
+
let ts = Date.UTC(year, month, day, hour, minute, 0, 0);
|
|
145
|
+
if (ts < now.getTime() - 36 * 60 * 60 * 1000) {
|
|
146
|
+
year += 1;
|
|
147
|
+
ts = Date.UTC(year, month, day, hour, minute, 0, 0);
|
|
148
|
+
}
|
|
149
|
+
return new Date(ts).toISOString();
|
|
150
|
+
}
|
|
151
|
+
function isCreditsRefusal(text) {
|
|
152
|
+
return /requires usage credits/i.test(text) || /switch to another model/i.test(text);
|
|
153
|
+
}
|
|
154
|
+
function isUsageLimitShape(text) {
|
|
155
|
+
return /weekly limit|usage limit|rate limit|quota|usage credits|hit your .+ limit/i.test(text);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Read only `api_error_status`, `is_error`, and `result` off the last
|
|
159
|
+
* `type: "result"` object. Nothing else from the payload is returned, and
|
|
160
|
+
* this function does not log.
|
|
161
|
+
*/
|
|
162
|
+
export function quotaFieldsFromOutput(text) {
|
|
163
|
+
const lines = text.split("\n");
|
|
164
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
165
|
+
const line = (lines[i] ?? "").trim();
|
|
166
|
+
if (!line.startsWith("{"))
|
|
167
|
+
continue;
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(line);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!isPlainObject(parsed) || parsed.type !== "result")
|
|
176
|
+
continue;
|
|
177
|
+
return {
|
|
178
|
+
api_error_status: parsed.api_error_status,
|
|
179
|
+
is_error: parsed.is_error,
|
|
180
|
+
result: parsed.result,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
export function quotaSignalFromFields(fields, model, now) {
|
|
186
|
+
const text = typeof fields.result === "string" ? fields.result : "";
|
|
187
|
+
const is429 = fields.api_error_status === 429;
|
|
188
|
+
const usageShaped = fields.is_error === true && isUsageLimitShape(text);
|
|
189
|
+
if (!is429 && !usageShaped)
|
|
190
|
+
return null;
|
|
191
|
+
if (isCreditsRefusal(text) && !/resets\s+/i.test(text)) {
|
|
192
|
+
const marker = model ? `needs credits for ${model}` : "needs credits";
|
|
193
|
+
return {
|
|
194
|
+
reason: "account_model_unavailable",
|
|
195
|
+
model,
|
|
196
|
+
needs_credits: true,
|
|
197
|
+
marker,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const quota_reset_at = text.length > 0 ? parseProviderResetTime(text, now) ?? undefined : undefined;
|
|
201
|
+
return {
|
|
202
|
+
reason: "account_quota_exhausted",
|
|
203
|
+
quota_reset_at,
|
|
204
|
+
model,
|
|
205
|
+
account_wide: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
|
|
209
|
+
const outPath = path.join(runDir, "logs", "worker.out");
|
|
210
|
+
let text;
|
|
211
|
+
try {
|
|
212
|
+
const bytes = fs.readFileSync(outPath);
|
|
213
|
+
text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
const code = err.code;
|
|
217
|
+
if (code === "ENOENT")
|
|
218
|
+
return null;
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
const fields = quotaFieldsFromOutput(text);
|
|
222
|
+
if (!fields)
|
|
223
|
+
return null;
|
|
224
|
+
return quotaSignalFromFields(fields, model, now);
|
|
225
|
+
}
|
|
226
|
+
export function recordAccountQuota(hqRoot, provider, accountId, signal) {
|
|
227
|
+
const state = loadQuotaState(hqRoot);
|
|
228
|
+
const accounts = state.providers[provider] ?? {};
|
|
229
|
+
const current = accounts[accountId] ?? { models: {} };
|
|
230
|
+
const models = { ...current.models };
|
|
231
|
+
if (signal.model) {
|
|
232
|
+
const prior = models[signal.model] ?? {};
|
|
233
|
+
const next = { ...prior };
|
|
234
|
+
if (signal.needs_credits) {
|
|
235
|
+
next.needs_credits = true;
|
|
236
|
+
next.marker = signal.marker ?? `needs credits for ${signal.model}`;
|
|
237
|
+
delete next.quota_reset_at;
|
|
238
|
+
}
|
|
239
|
+
if (signal.quota_reset_at && !signal.needs_credits) {
|
|
240
|
+
next.quota_reset_at = signal.quota_reset_at;
|
|
241
|
+
}
|
|
242
|
+
models[signal.model] = next;
|
|
243
|
+
}
|
|
244
|
+
const account = { ...current, models };
|
|
245
|
+
if (signal.account_wide && signal.quota_reset_at) {
|
|
246
|
+
account.all_models_quota_reset_at = signal.quota_reset_at;
|
|
247
|
+
}
|
|
248
|
+
accounts[accountId] = account;
|
|
249
|
+
state.providers[provider] = accounts;
|
|
250
|
+
writeQuotaState(hqRoot, state);
|
|
251
|
+
return state;
|
|
252
|
+
}
|
|
253
|
+
function resetStillAhead(iso, now) {
|
|
254
|
+
if (!iso)
|
|
255
|
+
return false;
|
|
256
|
+
const ts = Date.parse(iso);
|
|
257
|
+
return Number.isFinite(ts) && ts > now.getTime();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Skip an account only for the models it cannot run. An account-wide reset
|
|
261
|
+
* blocks every model. A needs-credits marker blocks only that model, with
|
|
262
|
+
* no expiry. A past reset does not block.
|
|
263
|
+
*/
|
|
264
|
+
export function quotaBlockFor(state, provider, accountId, model, now) {
|
|
265
|
+
const account = state.providers[provider]?.[accountId];
|
|
266
|
+
if (!account)
|
|
267
|
+
return { blocked: false };
|
|
268
|
+
if (resetStillAhead(account.all_models_quota_reset_at, now)) {
|
|
269
|
+
return {
|
|
270
|
+
blocked: true,
|
|
271
|
+
reason: "account_quota_exhausted",
|
|
272
|
+
quota_reset_at: account.all_models_quota_reset_at,
|
|
273
|
+
model,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (!model)
|
|
277
|
+
return { blocked: false };
|
|
278
|
+
const entry = account.models[model];
|
|
279
|
+
if (!entry)
|
|
280
|
+
return { blocked: false };
|
|
281
|
+
if (entry.needs_credits) {
|
|
282
|
+
return {
|
|
283
|
+
blocked: true,
|
|
284
|
+
reason: "account_model_unavailable",
|
|
285
|
+
marker: entry.marker ?? `needs credits for ${model}`,
|
|
286
|
+
model,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (resetStillAhead(entry.quota_reset_at, now)) {
|
|
290
|
+
return {
|
|
291
|
+
blocked: true,
|
|
292
|
+
reason: "account_quota_exhausted",
|
|
293
|
+
quota_reset_at: entry.quota_reset_at,
|
|
294
|
+
model,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return { blocked: false };
|
|
298
|
+
}
|
|
299
|
+
export function accountQuotaView(state, provider, accountId) {
|
|
300
|
+
const account = state.providers[provider]?.[accountId];
|
|
301
|
+
if (!account)
|
|
302
|
+
return { quota_reset_at: null, model_quota: [] };
|
|
303
|
+
const model_quota = Object.entries(account.models)
|
|
304
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
305
|
+
.map(([model, entry]) => {
|
|
306
|
+
const row = { model };
|
|
307
|
+
if (entry.quota_reset_at)
|
|
308
|
+
row.quota_reset_at = entry.quota_reset_at;
|
|
309
|
+
if (entry.needs_credits)
|
|
310
|
+
row.needs_credits = true;
|
|
311
|
+
if (entry.marker)
|
|
312
|
+
row.marker = entry.marker;
|
|
313
|
+
return row;
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
quota_reset_at: account.all_models_quota_reset_at ?? null,
|
|
317
|
+
model_quota,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
//# sourceMappingURL=quota.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* systemd user timer install for the lanes reconcile pass.
|
|
3
|
+
*
|
|
4
|
+
* Policy: hq-infra-watchdogs-systemd-not-session. The pass is a oneshot
|
|
5
|
+
* invoked by a timer, never a session-bound background job. Install writes
|
|
6
|
+
* unit files under a systemd user dir and is idempotent and reversible.
|
|
7
|
+
* Enabling the timer is optional — the CLI pass works without it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const RECONCILE_TIMER_INTERVAL = "1min";
|
|
10
|
+
export interface ProviderPathEnv {
|
|
11
|
+
/** PATH for the systemd unit: resolved provider dirs, systemd defaults, then the shell PATH. */
|
|
12
|
+
path: string;
|
|
13
|
+
/** Providers whose binary resolved on the installing shell's PATH. */
|
|
14
|
+
resolved: string[];
|
|
15
|
+
/** Providers whose binary did not resolve. Reported, never fatal on their own. */
|
|
16
|
+
unresolved: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve provider commands with the installing shell's PATH. The resulting
|
|
20
|
+
* directories are carried into the systemd unit, whose default PATH is too
|
|
21
|
+
* narrow for per-user provider installations.
|
|
22
|
+
*
|
|
23
|
+
* A default config enables one account per provider, so `providers` usually
|
|
24
|
+
* names every provider even on a box with one installed. A provider that does
|
|
25
|
+
* not resolve is reported in `unresolved`; the install only refuses when none
|
|
26
|
+
* of the requested providers resolves, because then the timer has nothing to
|
|
27
|
+
* run.
|
|
28
|
+
*/
|
|
29
|
+
export declare function providerPathEnv(providers: readonly string[], pathEnv?: string | undefined): ProviderPathEnv;
|
|
30
|
+
export interface RunCommandResult {
|
|
31
|
+
status: number;
|
|
32
|
+
stdout?: string;
|
|
33
|
+
stderr?: string;
|
|
34
|
+
}
|
|
35
|
+
export type RunCommand = (command: string, args: string[]) => RunCommandResult;
|
|
36
|
+
export interface ReconcileInstallOpts {
|
|
37
|
+
systemdRoot: string;
|
|
38
|
+
hqRoot: string;
|
|
39
|
+
nodeBinary?: string;
|
|
40
|
+
hqBinary?: string;
|
|
41
|
+
pathEnv?: string;
|
|
42
|
+
home?: string;
|
|
43
|
+
enable?: boolean;
|
|
44
|
+
runCommand?: RunCommand;
|
|
45
|
+
}
|
|
46
|
+
export interface ReconcileInstallResult {
|
|
47
|
+
ok: true;
|
|
48
|
+
action: "reconcile.install" | "reconcile.uninstall";
|
|
49
|
+
systemd_root: string;
|
|
50
|
+
unit: string;
|
|
51
|
+
timer: string;
|
|
52
|
+
written?: boolean;
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
removed?: boolean;
|
|
55
|
+
}
|
|
56
|
+
export declare function renderReconcileService(opts: {
|
|
57
|
+
nodeBinary: string;
|
|
58
|
+
hqBinary: string;
|
|
59
|
+
hqRoot: string;
|
|
60
|
+
home?: string;
|
|
61
|
+
pathEnv?: string;
|
|
62
|
+
}): string;
|
|
63
|
+
export declare function renderReconcileTimer(): string;
|
|
64
|
+
export declare function defaultSystemdUserDir(home?: string): string;
|
|
65
|
+
export declare function installReconcileTimer(opts: ReconcileInstallOpts): ReconcileInstallResult;
|
|
66
|
+
export declare function uninstallReconcileTimer(opts: Pick<ReconcileInstallOpts, "systemdRoot" | "runCommand"> & {
|
|
67
|
+
disable?: boolean;
|
|
68
|
+
}): ReconcileInstallResult;
|
|
69
|
+
//# sourceMappingURL=reconcile-install.d.ts.map
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* systemd user timer install for the lanes reconcile pass.
|
|
3
|
+
*
|
|
4
|
+
* Policy: hq-infra-watchdogs-systemd-not-session. The pass is a oneshot
|
|
5
|
+
* invoked by a timer, never a session-bound background job. Install writes
|
|
6
|
+
* unit files under a systemd user dir and is idempotent and reversible.
|
|
7
|
+
* Enabling the timer is optional — the CLI pass works without it.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { LanesError } from "./errors.js";
|
|
13
|
+
import { RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME } from "./reconcile.js";
|
|
14
|
+
import { resolveHqBinary, resolveNodeBinary, } from "../mesh/live/daemon/install.js";
|
|
15
|
+
import { SYSTEMD_PATH_ENV } from "../service-manager/systemd.js";
|
|
16
|
+
export const RECONCILE_TIMER_INTERVAL = "1min";
|
|
17
|
+
/**
|
|
18
|
+
* Resolve provider commands with the installing shell's PATH. The resulting
|
|
19
|
+
* directories are carried into the systemd unit, whose default PATH is too
|
|
20
|
+
* narrow for per-user provider installations.
|
|
21
|
+
*
|
|
22
|
+
* A default config enables one account per provider, so `providers` usually
|
|
23
|
+
* names every provider even on a box with one installed. A provider that does
|
|
24
|
+
* not resolve is reported in `unresolved`; the install only refuses when none
|
|
25
|
+
* of the requested providers resolves, because then the timer has nothing to
|
|
26
|
+
* run.
|
|
27
|
+
*/
|
|
28
|
+
export function providerPathEnv(providers, pathEnv = process.env.PATH) {
|
|
29
|
+
const searched = pathEnv ?? "";
|
|
30
|
+
const directories = searched.split(path.delimiter).filter(Boolean);
|
|
31
|
+
const resolvedDirs = [];
|
|
32
|
+
const resolved = [];
|
|
33
|
+
const unresolved = [];
|
|
34
|
+
for (const provider of providers) {
|
|
35
|
+
const found = directories.find((directory) => {
|
|
36
|
+
try {
|
|
37
|
+
fs.accessSync(path.join(directory, provider), fs.constants.X_OK);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
if (!found) {
|
|
45
|
+
unresolved.push(provider);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
resolved.push(provider);
|
|
49
|
+
// Keep the real binary's directory, not only a wrapper directory that
|
|
50
|
+
// happened to be on the installing shell's PATH. Systemd must be able to
|
|
51
|
+
// invoke the resolved provider after it starts with its own environment.
|
|
52
|
+
resolvedDirs.push(path.dirname(fs.realpathSync(path.join(found, provider))));
|
|
53
|
+
}
|
|
54
|
+
if (providers.length > 0 && resolved.length === 0) {
|
|
55
|
+
throw new LanesError("provider_binary_unresolved", `Cannot resolve any provider binary (${providers.join(", ")}) from PATH`, { providers: [...providers], searched_path: searched });
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
path: [...new Set([...resolvedDirs, ...SYSTEMD_PATH_ENV.split(":"), ...directories])]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(":"),
|
|
61
|
+
resolved,
|
|
62
|
+
unresolved,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function shellQuote(v) {
|
|
66
|
+
return /^[A-Za-z0-9_./:-]+$/.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`;
|
|
67
|
+
}
|
|
68
|
+
export function renderReconcileService(opts) {
|
|
69
|
+
const exec = [opts.nodeBinary, opts.hqBinary, "lanes", "reconcile"]
|
|
70
|
+
.map(shellQuote)
|
|
71
|
+
.join(" ");
|
|
72
|
+
const home = opts.home ?? os.homedir();
|
|
73
|
+
const pathEnv = opts.pathEnv ?? SYSTEMD_PATH_ENV;
|
|
74
|
+
return `[Unit]
|
|
75
|
+
Description=HQ lanes reconcile pass
|
|
76
|
+
Documentation=file://personal/projects/hq-cli-lanes/SPEC.md
|
|
77
|
+
|
|
78
|
+
[Service]
|
|
79
|
+
Type=oneshot
|
|
80
|
+
ExecStart=${exec}
|
|
81
|
+
WorkingDirectory=${shellQuote(opts.hqRoot)}
|
|
82
|
+
Nice=10
|
|
83
|
+
Environment=HOME=${shellQuote(home)}
|
|
84
|
+
Environment=PATH=${shellQuote(pathEnv)}
|
|
85
|
+
Environment=HQ_ROOT=${shellQuote(opts.hqRoot)}
|
|
86
|
+
Environment=HQ_NO_UPDATE_CHECK=1
|
|
87
|
+
KillMode=process
|
|
88
|
+
|
|
89
|
+
[Install]
|
|
90
|
+
WantedBy=default.target
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
export function renderReconcileTimer() {
|
|
94
|
+
return `[Unit]
|
|
95
|
+
Description=Periodic HQ lanes reconcile pass
|
|
96
|
+
|
|
97
|
+
[Timer]
|
|
98
|
+
OnBootSec=30s
|
|
99
|
+
OnUnitActiveSec=${RECONCILE_TIMER_INTERVAL}
|
|
100
|
+
AccuracySec=10s
|
|
101
|
+
Persistent=true
|
|
102
|
+
Unit=${RECONCILE_UNIT_NAME}
|
|
103
|
+
|
|
104
|
+
[Install]
|
|
105
|
+
WantedBy=timers.target
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
export function defaultSystemdUserDir(home = os.homedir()) {
|
|
109
|
+
return path.join(home, ".config", "systemd", "user");
|
|
110
|
+
}
|
|
111
|
+
function writeIfChanged(filePath, contents) {
|
|
112
|
+
try {
|
|
113
|
+
const existing = fs.readFileSync(filePath, "utf8");
|
|
114
|
+
if (existing === contents)
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
const code = err.code;
|
|
119
|
+
if (code !== "ENOENT")
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
123
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(tmp, contents, { encoding: "utf8", mode: 0o644 });
|
|
126
|
+
fs.renameSync(tmp, filePath);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(tmp);
|
|
131
|
+
}
|
|
132
|
+
catch (unlinkErr) {
|
|
133
|
+
const unlinkCode = unlinkErr.code;
|
|
134
|
+
if (unlinkCode !== "ENOENT") {
|
|
135
|
+
process.stderr.write(`hq lanes reconcile install: unlink tmp failed for ${tmp}: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function unlinkIfExists(filePath) {
|
|
143
|
+
try {
|
|
144
|
+
fs.unlinkSync(filePath);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const code = err.code;
|
|
149
|
+
if (code === "ENOENT")
|
|
150
|
+
return false;
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function runSystemctl(args, runCommand) {
|
|
155
|
+
const result = runCommand("systemctl", args);
|
|
156
|
+
if (result.status !== 0) {
|
|
157
|
+
throw new LanesError("systemd_failed", `systemctl ${args.join(" ")} failed: ${(result.stderr ?? "").trim() || `exit ${result.status}`}`, { args, status: result.status, stderr: result.stderr });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function installReconcileTimer(opts) {
|
|
161
|
+
const nodeBinary = opts.nodeBinary ?? resolveNodeBinary();
|
|
162
|
+
const hqBinary = opts.hqBinary ?? resolveHqBinary();
|
|
163
|
+
const unitPath = path.join(opts.systemdRoot, RECONCILE_UNIT_NAME);
|
|
164
|
+
const timerPath = path.join(opts.systemdRoot, RECONCILE_TIMER_NAME);
|
|
165
|
+
const unitChanged = writeIfChanged(unitPath, renderReconcileService({
|
|
166
|
+
nodeBinary,
|
|
167
|
+
hqBinary,
|
|
168
|
+
hqRoot: opts.hqRoot,
|
|
169
|
+
home: opts.home,
|
|
170
|
+
pathEnv: opts.pathEnv,
|
|
171
|
+
}));
|
|
172
|
+
const timerChanged = writeIfChanged(timerPath, renderReconcileTimer());
|
|
173
|
+
let enabled = false;
|
|
174
|
+
if (opts.enable) {
|
|
175
|
+
if (!opts.runCommand) {
|
|
176
|
+
throw new LanesError("systemd_enable_requires_runner", "--enable needs a systemctl runner; pass runCommand or omit --enable to write unit files only", {});
|
|
177
|
+
}
|
|
178
|
+
runSystemctl(["--user", "daemon-reload"], opts.runCommand);
|
|
179
|
+
runSystemctl(["--user", "enable", "--now", RECONCILE_TIMER_NAME], opts.runCommand);
|
|
180
|
+
enabled = true;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
action: "reconcile.install",
|
|
185
|
+
systemd_root: opts.systemdRoot,
|
|
186
|
+
unit: unitPath,
|
|
187
|
+
timer: timerPath,
|
|
188
|
+
written: unitChanged || timerChanged,
|
|
189
|
+
enabled,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function uninstallReconcileTimer(opts) {
|
|
193
|
+
if (opts.disable) {
|
|
194
|
+
if (!opts.runCommand) {
|
|
195
|
+
throw new LanesError("systemd_enable_requires_runner", "--disable needs a systemctl runner", {});
|
|
196
|
+
}
|
|
197
|
+
const disable = opts.runCommand("systemctl", [
|
|
198
|
+
"--user",
|
|
199
|
+
"disable",
|
|
200
|
+
"--now",
|
|
201
|
+
RECONCILE_TIMER_NAME,
|
|
202
|
+
]);
|
|
203
|
+
if (disable.status !== 0) {
|
|
204
|
+
process.stderr.write(`hq lanes reconcile uninstall: systemctl disable exited ${disable.status}: ${(disable.stderr ?? "").trim()}\n`);
|
|
205
|
+
}
|
|
206
|
+
opts.runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
207
|
+
}
|
|
208
|
+
const unitPath = path.join(opts.systemdRoot, RECONCILE_UNIT_NAME);
|
|
209
|
+
const timerPath = path.join(opts.systemdRoot, RECONCILE_TIMER_NAME);
|
|
210
|
+
const removedUnit = unlinkIfExists(unitPath);
|
|
211
|
+
const removedTimer = unlinkIfExists(timerPath);
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
action: "reconcile.uninstall",
|
|
215
|
+
systemd_root: opts.systemdRoot,
|
|
216
|
+
unit: unitPath,
|
|
217
|
+
timer: timerPath,
|
|
218
|
+
removed: removedUnit || removedTimer,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=reconcile-install.js.map
|