@indigoai-us/hq-cli 5.274.1 → 5.276.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 +88 -4
- package/README.md +12 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
- package/dist/command-catalog.generated.d.ts +34 -1
- package/dist/command-catalog.generated.js +43 -1
- package/dist/commands/cloud.js +22 -9
- package/dist/commands/company.js +66 -3
- package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
- package/dist/commands/core-checkpoint-gate-setting.js +163 -0
- package/dist/commands/core-checkpoint.d.ts +4 -2
- package/dist/commands/core-checkpoint.js +34 -3
- package/dist/commands/core-command-catalog.d.ts +4 -2
- package/dist/commands/core-command-catalog.js +5 -2
- package/dist/commands/core-ontology.js +3 -3
- package/dist/commands/core.js +3 -1
- package/dist/commands/daemon.d.ts +1 -0
- package/dist/commands/daemon.js +9 -3
- package/dist/commands/feedback.d.ts +6 -0
- package/dist/commands/feedback.js +86 -37
- package/dist/commands/lanes.js +33 -11
- package/dist/commands/run-feature-telemetry.d.ts +3 -0
- package/dist/commands/run-feature-telemetry.js +6 -0
- package/dist/commands/run.js +3 -2
- package/dist/commands/secrets.js +7 -1
- package/dist/commands/whoami.d.ts +4 -6
- package/dist/commands/whoami.js +39 -8
- package/dist/fast-core.d.ts +6 -7
- package/dist/fast-core.js +22 -8
- package/dist/fast-scaffold.d.ts +16 -7
- package/dist/fast-scaffold.js +13 -8
- package/dist/index.js +16 -1
- package/dist/lib/billing/plan-lock.d.ts +3 -1
- package/dist/lib/billing/plan-lock.js +7 -1
- package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
- package/dist/lib/checkpoint-ontology/company.js +15 -7
- package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
- package/dist/lib/checkpoint-ontology/plan.js +53 -4
- package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
- package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
- package/dist/lib/core-utils/timeout-guard-native.js +40 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
- package/dist/lib/core-utils/timeout-guard.js +28 -13
- package/dist/lib/daemon/catalog.d.ts +7 -0
- package/dist/lib/daemon/catalog.js +6 -0
- package/dist/lib/daemon/flag.d.ts +23 -0
- package/dist/lib/daemon/flag.js +57 -0
- package/dist/lib/daemon/install.js +6 -1
- package/dist/lib/daemon/remote-control.d.ts +6 -0
- package/dist/lib/daemon/remote-control.js +12 -0
- package/dist/lib/daemon/run.js +115 -2
- package/dist/lib/daemon/succession.d.ts +116 -0
- package/dist/lib/daemon/succession.js +262 -0
- package/dist/lib/daemon/supervisor.d.ts +34 -1
- package/dist/lib/daemon/supervisor.js +190 -23
- package/dist/lib/flag-registry.d.ts +12 -7
- package/dist/lib/flag-registry.js +66 -75
- package/dist/lib/flag-snapshot-file.d.ts +20 -0
- package/dist/lib/flag-snapshot-file.js +91 -0
- package/dist/lib/lanes/host-capacity.js +7 -1
- package/dist/lib/lanes/models.d.ts +2 -0
- package/dist/lib/lanes/models.js +6 -1
- package/dist/lib/lanes/reconcile.js +20 -4
- package/dist/lib/process-flag-company-context.d.ts +8 -0
- package/dist/lib/process-flag-company-context.js +67 -0
- package/dist/main.js +3 -1
- package/dist/native-fast-route.d.ts +3 -0
- package/dist/native-fast-route.js +39 -0
- package/dist/utils/cli-telemetry.d.ts +26 -2
- package/dist/utils/cli-telemetry.js +178 -3
- package/dist/utils/hq-root-resolver.d.ts +13 -0
- package/dist/utils/hq-root-resolver.js +103 -0
- package/dist/utils/scaffold-fast-import-guard.js +6 -1
- package/dist/utils/vault-api.d.ts +8 -0
- package/dist/utils/vault-api.js +13 -1
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { writeCompanyBrief } from "./company-brief.js";
|
|
14
14
|
import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
|
|
15
15
|
import { startMultiWriterRun } from "./multi-writer.js";
|
|
16
|
+
import { claimQueuedTouches, recordQueuedTouches } from "./queued-touches.js";
|
|
16
17
|
import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
|
|
17
18
|
import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
|
|
18
19
|
export const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
@@ -98,6 +99,15 @@ export const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-li
|
|
|
98
99
|
* and lost no gold link.
|
|
99
100
|
*/
|
|
100
101
|
export const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
|
|
102
|
+
/**
|
|
103
|
+
* CO-59: consume the files touched by company checkpoints that were queued
|
|
104
|
+
* behind a busy sibling (see queued-touches.ts). On, the plan adds the
|
|
105
|
+
* company's queued touches to this run's session-touched set, and a run whose
|
|
106
|
+
* own payload names no company plans the company the parent picked for it
|
|
107
|
+
* (`ontology_company`), without that run's session text. Off, queued touches
|
|
108
|
+
* wait on disk and a company-less run skips the ontology step as before.
|
|
109
|
+
*/
|
|
110
|
+
export const QUEUED_TOUCHES_FLAG = "checkpoint.ontology.queued-touches";
|
|
101
111
|
export const PLAN_FILE = "ontology-plan.json";
|
|
102
112
|
export const CANDIDATES_FILE = "ontology-candidates.json";
|
|
103
113
|
export const REPORT_FILE = "ontology-report.json";
|
|
@@ -255,13 +265,32 @@ export function refreshLedger(company, existing, touched) {
|
|
|
255
265
|
}
|
|
256
266
|
return { ledger: { schema: LEDGER_SCHEMA, company: company.slug, boundary, entries }, firstRun };
|
|
257
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* The company this run's ontology step works on: the payload's own company,
|
|
270
|
+
* or else the `ontology_company` the checkpoint parent picked for queued
|
|
271
|
+
* touches (`borrowed`).
|
|
272
|
+
*/
|
|
273
|
+
export function payloadOntologyCompany(payload) {
|
|
274
|
+
if (typeof payload.company === "string" && payload.company)
|
|
275
|
+
return { slug: payload.company, borrowed: false };
|
|
276
|
+
if (typeof payload.ontology_company === "string" && payload.ontology_company) {
|
|
277
|
+
return { slug: payload.ontology_company, borrowed: true };
|
|
278
|
+
}
|
|
279
|
+
return { slug: null, borrowed: false };
|
|
280
|
+
}
|
|
258
281
|
/** Plan this run. Never throws for a skip; returns the reason instead. */
|
|
259
282
|
export async function planOntologyRun(options) {
|
|
260
283
|
const { hqRoot, runDir, payload } = options;
|
|
261
|
-
const
|
|
284
|
+
const { slug, borrowed } = payloadOntologyCompany(payload);
|
|
285
|
+
const resolution = resolveOntologyCompany(hqRoot, slug);
|
|
262
286
|
if (!resolution.ok)
|
|
263
287
|
return { status: "skipped", reason: resolution.reason };
|
|
264
288
|
const company = resolution.company;
|
|
289
|
+
// A borrowed company exists only for queued touches, so its run needs the
|
|
290
|
+
// queued-touches flag before anything else is read or written.
|
|
291
|
+
if (borrowed && !(await options.readFlag(QUEUED_TOUCHES_FLAG, company))) {
|
|
292
|
+
return { status: "skipped", reason: "flag off" };
|
|
293
|
+
}
|
|
265
294
|
const ontologyOn = (await options.readFlag(ONTOLOGY_FLAG, company)) || (await options.readFlag(ONTOLOGY_SYNC_GATED_FLAG, company));
|
|
266
295
|
const syncClient = ontologyOn ? checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)()) : null;
|
|
267
296
|
if (!ontologyOn || !syncClient?.ok) {
|
|
@@ -278,7 +307,14 @@ export async function planOntologyRun(options) {
|
|
|
278
307
|
const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
|
|
279
308
|
const conceptProjectLink = await options.readFlag(CONCEPT_PROJECT_LINK_FLAG, company);
|
|
280
309
|
const namedEvidence = await options.readFlag(NAMED_EVIDENCE_FLAG, company);
|
|
281
|
-
const
|
|
310
|
+
const queuedTouchesOn = borrowed || (await options.readFlag(QUEUED_TOUCHES_FLAG, company));
|
|
311
|
+
// A borrowed company's run belongs to another session: its own files and
|
|
312
|
+
// session text are not this company's input.
|
|
313
|
+
const touched = sessionTouched(hqRoot, company, borrowed ? [] : payload.files);
|
|
314
|
+
if (queuedTouchesOn) {
|
|
315
|
+
for (const rel of sessionTouched(hqRoot, company, claimQueuedTouches(hqRoot, company, runDir)))
|
|
316
|
+
touched.add(rel);
|
|
317
|
+
}
|
|
282
318
|
// A first run takes its boundary from the entity tree, so the ledger is
|
|
283
319
|
// refreshed before merge-back moves any `last_updated` forward.
|
|
284
320
|
const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched);
|
|
@@ -293,9 +329,11 @@ export async function planOntologyRun(options) {
|
|
|
293
329
|
const inputs = [];
|
|
294
330
|
let tokens = 0;
|
|
295
331
|
let remaining = 0;
|
|
332
|
+
const leftover = [];
|
|
296
333
|
for (const [rel, entry] of pending) {
|
|
297
334
|
if (inputs.length >= BUDGET.maxInputs || tokens >= BUDGET.maxTokens) {
|
|
298
335
|
remaining += 1;
|
|
336
|
+
leftover.push(rel);
|
|
299
337
|
continue;
|
|
300
338
|
}
|
|
301
339
|
const text = fs.readFileSync(path.join(company.dir, rel), "utf8");
|
|
@@ -329,10 +367,21 @@ export async function planOntologyRun(options) {
|
|
|
329
367
|
});
|
|
330
368
|
tokens += cost;
|
|
331
369
|
}
|
|
332
|
-
if (index < chunks.length)
|
|
370
|
+
if (index < chunks.length) {
|
|
333
371
|
remaining += 1;
|
|
372
|
+
leftover.push(rel);
|
|
373
|
+
}
|
|
334
374
|
}
|
|
335
375
|
writeLedger(hqRoot, company, ledger);
|
|
376
|
+
// The leftover files stay queued so a later company-less run can borrow this company again.
|
|
377
|
+
if (queuedTouchesOn && leftover.length > 0) {
|
|
378
|
+
try {
|
|
379
|
+
recordQueuedTouches(hqRoot, company.slug, leftover.map((rel) => path.relative(hqRoot, path.join(company.dir, rel))), options.now ?? new Date());
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Re-queueing leftovers must not fail the plan.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
336
385
|
if (inputs.length === 0) {
|
|
337
386
|
const reason = pending.length > 0 ? "over budget" : "nothing changed";
|
|
338
387
|
return {
|
|
@@ -345,7 +394,7 @@ export async function planOntologyRun(options) {
|
|
|
345
394
|
// Session content is scoped to the session's user (design section 6, D2).
|
|
346
395
|
// v1 records the existing entities it names as links in the report and
|
|
347
396
|
// writes nothing from it, so it rides along only when files changed.
|
|
348
|
-
const sessionText = sessionContent(payload);
|
|
397
|
+
const sessionText = borrowed ? null : sessionContent(payload);
|
|
349
398
|
if (sessionText) {
|
|
350
399
|
const cost = estTokens(Buffer.byteLength(sessionText, "utf8"));
|
|
351
400
|
inputs.unshift({
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type OntologyCompany } from "./company.js";
|
|
2
|
+
export declare const QUEUED_TOUCHES_FILE = "queued-touches.jsonl";
|
|
3
|
+
export declare const QUEUED_TOUCHES_CLAIM_FILE = "queued-touches-claimed.jsonl";
|
|
4
|
+
export declare const QUEUED_TOUCHES_BORROWED_FILE = "queued-touches-borrowed-at";
|
|
5
|
+
export declare const MAX_QUEUED_TOUCHES = 200;
|
|
6
|
+
export declare function queuedTouchesPath(hqRoot: string, slug: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Record the files a queued checkpoint touched inside its company. Files
|
|
9
|
+
* outside `companies/<slug>/` are dropped. Returns the number of files
|
|
10
|
+
* recorded; 0 when the company is not a manifest company or nothing is inside
|
|
11
|
+
* it. The queue keeps its newest {@link MAX_QUEUED_TOUCHES} lines.
|
|
12
|
+
*/
|
|
13
|
+
export declare function recordQueuedTouches(hqRoot: string, slug: string | null, files: string[], now: Date): number;
|
|
14
|
+
/**
|
|
15
|
+
* Record that a company-less run borrowed `slug`, so the next such run prefers
|
|
16
|
+
* a company that has waited longer.
|
|
17
|
+
*/
|
|
18
|
+
export declare function markBorrowed(hqRoot: string, slug: string, now: Date): void;
|
|
19
|
+
/**
|
|
20
|
+
* The manifest company whose queued touches have waited longest, or null.
|
|
21
|
+
* The order key is the later of the first queued record and the last borrow,
|
|
22
|
+
* so a company a company-less run just borrowed goes to the back. A run whose
|
|
23
|
+
* own payload names no company uses it for its ontology step.
|
|
24
|
+
*/
|
|
25
|
+
export declare function oldestQueuedCompany(hqRoot: string): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Take the company's queued touches by renaming the file into the run
|
|
28
|
+
* directory, then return the HQ-relative paths it lists. Renaming is atomic,
|
|
29
|
+
* so a touch recorded during the claim lands in a fresh file for a later run.
|
|
30
|
+
*/
|
|
31
|
+
export declare function claimQueuedTouches(hqRoot: string, company: OntologyCompany, runDir: string): string[];
|
|
32
|
+
//# sourceMappingURL=queued-touches.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company files touched by checkpoints that were queued behind a busy sibling.
|
|
3
|
+
*
|
|
4
|
+
* `hq core checkpoint` queues its payload when a sibling is already running,
|
|
5
|
+
* and the next run claims the queue. That run renders the ontology step only
|
|
6
|
+
* for its own payload's company, and the ontology plan records any changed
|
|
7
|
+
* file the run's own payload did not touch as seen. Without this file, the
|
|
8
|
+
* files a queued company checkpoint touched never reach the ontology (CO-59).
|
|
9
|
+
*
|
|
10
|
+
* The checkpoint parent performs no network I/O, so it cannot read the
|
|
11
|
+
* `checkpoint.ontology.queued-touches` flag. It always records the touches in
|
|
12
|
+
* the company's local, unsynced state directory; the ontology plan consumes
|
|
13
|
+
* them only while the flag is on. The file is capped so it stays small while
|
|
14
|
+
* the flag is off.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { assertSafeTarget, resolveOntologyCompany, safeWriteFile, WriteBoundsError, } from "./company.js";
|
|
19
|
+
export const QUEUED_TOUCHES_FILE = "queued-touches.jsonl";
|
|
20
|
+
export const QUEUED_TOUCHES_CLAIM_FILE = "queued-touches-claimed.jsonl";
|
|
21
|
+
export const QUEUED_TOUCHES_BORROWED_FILE = "queued-touches-borrowed-at";
|
|
22
|
+
export const MAX_QUEUED_TOUCHES = 200;
|
|
23
|
+
function ontologyStateBase(hqRoot) {
|
|
24
|
+
return path.join(hqRoot, "workspace", "checkpoints", "ontology");
|
|
25
|
+
}
|
|
26
|
+
function companyStateDir(hqRoot, slug) {
|
|
27
|
+
return path.join(ontologyStateBase(hqRoot), slug);
|
|
28
|
+
}
|
|
29
|
+
export function queuedTouchesPath(hqRoot, slug) {
|
|
30
|
+
return path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_FILE);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Record the files a queued checkpoint touched inside its company. Files
|
|
34
|
+
* outside `companies/<slug>/` are dropped. Returns the number of files
|
|
35
|
+
* recorded; 0 when the company is not a manifest company or nothing is inside
|
|
36
|
+
* it. The queue keeps its newest {@link MAX_QUEUED_TOUCHES} lines.
|
|
37
|
+
*/
|
|
38
|
+
export function recordQueuedTouches(hqRoot, slug, files, now) {
|
|
39
|
+
if (!slug)
|
|
40
|
+
return 0;
|
|
41
|
+
const resolution = resolveOntologyCompany(hqRoot, slug);
|
|
42
|
+
if (!resolution.ok)
|
|
43
|
+
return 0;
|
|
44
|
+
const inside = files.filter((file) => {
|
|
45
|
+
if (typeof file !== "string" || !file.trim())
|
|
46
|
+
return false;
|
|
47
|
+
const rel = path.relative(resolution.company.dir, path.resolve(hqRoot, file.trim()));
|
|
48
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
49
|
+
});
|
|
50
|
+
if (inside.length === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
const target = queuedTouchesPath(hqRoot, slug);
|
|
53
|
+
try {
|
|
54
|
+
assertSafeTarget(hqRoot, [companyStateDir(hqRoot, slug)], target);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof WriteBoundsError)
|
|
58
|
+
return 0;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW);
|
|
62
|
+
try {
|
|
63
|
+
fs.writeSync(fd, `${JSON.stringify({ queued_at: now.toISOString(), files: inside })}\n`);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
const lines = fs.readFileSync(target, "utf8").split("\n").filter((line) => line.trim());
|
|
69
|
+
if (lines.length > MAX_QUEUED_TOUCHES) {
|
|
70
|
+
// Best-effort trim, as for the sibling's pending queue: it can race a
|
|
71
|
+
// concurrent append, but only once the file is already over its cap.
|
|
72
|
+
safeWriteFile(hqRoot, [companyStateDir(hqRoot, slug)], target, `${lines.slice(-MAX_QUEUED_TOUCHES).join("\n")}\n`);
|
|
73
|
+
}
|
|
74
|
+
return inside.length;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Record that a company-less run borrowed `slug`, so the next such run prefers
|
|
78
|
+
* a company that has waited longer.
|
|
79
|
+
*/
|
|
80
|
+
export function markBorrowed(hqRoot, slug, now) {
|
|
81
|
+
const target = path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_BORROWED_FILE);
|
|
82
|
+
safeWriteFile(hqRoot, [companyStateDir(hqRoot, slug)], target, `${now.toISOString()}\n`);
|
|
83
|
+
}
|
|
84
|
+
/** `queued_at` of the oldest record, or the file mtime when no line has one. */
|
|
85
|
+
function firstQueuedMs(file, mtimeMs) {
|
|
86
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
87
|
+
if (!line.trim())
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(line);
|
|
91
|
+
if (typeof parsed.queued_at !== "string")
|
|
92
|
+
continue;
|
|
93
|
+
const ms = Date.parse(parsed.queued_at);
|
|
94
|
+
if (!Number.isNaN(ms))
|
|
95
|
+
return ms;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// A partial line is not a record; a later valid line still counts.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return mtimeMs;
|
|
102
|
+
}
|
|
103
|
+
/** When this company was last borrowed, or -Infinity when it never was. */
|
|
104
|
+
function borrowedMs(hqRoot, slug) {
|
|
105
|
+
const file = path.join(companyStateDir(hqRoot, slug), QUEUED_TOUCHES_BORROWED_FILE);
|
|
106
|
+
let stat;
|
|
107
|
+
try {
|
|
108
|
+
stat = fs.lstatSync(file);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return Number.NEGATIVE_INFINITY;
|
|
112
|
+
}
|
|
113
|
+
if (!stat.isFile())
|
|
114
|
+
return Number.NEGATIVE_INFINITY;
|
|
115
|
+
const ms = Date.parse(fs.readFileSync(file, "utf8").trim());
|
|
116
|
+
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The manifest company whose queued touches have waited longest, or null.
|
|
120
|
+
* The order key is the later of the first queued record and the last borrow,
|
|
121
|
+
* so a company a company-less run just borrowed goes to the back. A run whose
|
|
122
|
+
* own payload names no company uses it for its ontology step.
|
|
123
|
+
*/
|
|
124
|
+
export function oldestQueuedCompany(hqRoot) {
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = fs.readdirSync(ontologyStateBase(hqRoot));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
let oldest = null;
|
|
133
|
+
for (const slug of entries.sort()) {
|
|
134
|
+
let stat;
|
|
135
|
+
try {
|
|
136
|
+
stat = fs.lstatSync(queuedTouchesPath(hqRoot, slug));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!stat.isFile() || stat.size === 0)
|
|
142
|
+
continue;
|
|
143
|
+
if (!resolveOntologyCompany(hqRoot, slug).ok)
|
|
144
|
+
continue;
|
|
145
|
+
const key = Math.max(firstQueuedMs(queuedTouchesPath(hqRoot, slug), stat.mtimeMs), borrowedMs(hqRoot, slug));
|
|
146
|
+
if (!oldest || key < oldest.key)
|
|
147
|
+
oldest = { slug, key };
|
|
148
|
+
}
|
|
149
|
+
return oldest?.slug ?? null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Take the company's queued touches by renaming the file into the run
|
|
153
|
+
* directory, then return the HQ-relative paths it lists. Renaming is atomic,
|
|
154
|
+
* so a touch recorded during the claim lands in a fresh file for a later run.
|
|
155
|
+
*/
|
|
156
|
+
export function claimQueuedTouches(hqRoot, company, runDir) {
|
|
157
|
+
const queuedPath = queuedTouchesPath(hqRoot, company.slug);
|
|
158
|
+
try {
|
|
159
|
+
assertSafeTarget(hqRoot, [companyStateDir(hqRoot, company.slug)], queuedPath);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error instanceof WriteBoundsError)
|
|
163
|
+
return [];
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const claimPath = path.join(runDir, QUEUED_TOUCHES_CLAIM_FILE);
|
|
167
|
+
try {
|
|
168
|
+
fs.renameSync(queuedPath, claimPath);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error?.code === "ENOENT")
|
|
172
|
+
return [];
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
const files = [];
|
|
176
|
+
for (const line of fs.readFileSync(claimPath, "utf8").split("\n")) {
|
|
177
|
+
if (!line.trim())
|
|
178
|
+
continue;
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(line);
|
|
181
|
+
if (Array.isArray(parsed.files)) {
|
|
182
|
+
for (const file of parsed.files)
|
|
183
|
+
if (typeof file === "string")
|
|
184
|
+
files.push(file);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// A partial line must not drop the valid lines around it.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return files;
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=queued-touches.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Native fast-route adapter for the shared timeout-guard command. */
|
|
2
|
+
import type { FlagReader } from "../flag-registry.js";
|
|
3
|
+
import { type PersistedFlagSnapshot } from "../flag-snapshot-file.js";
|
|
4
|
+
import { type TimeoutGuardDeps } from "./timeout-guard.js";
|
|
5
|
+
export declare function createNativeSnapshotFlagReader(persistedSnapshot: PersistedFlagSnapshot | null, companyIdentifiers: readonly string[], env: NodeJS.ProcessEnv): FlagReader;
|
|
6
|
+
export declare function runTimeoutGuardFast(_argv: readonly string[], stdin: string, _cwd: string, _hqRoot: string | undefined, dependencies?: TimeoutGuardDeps): Promise<number>;
|
|
7
|
+
//# sourceMappingURL=timeout-guard-native.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Native fast-route adapter for the shared timeout-guard command. */
|
|
2
|
+
import { readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
import { resolveProcessFlagCompanyContext } from "../process-flag-company-context.js";
|
|
4
|
+
import { readFlagSnapshotFile, } from "../flag-snapshot-file.js";
|
|
5
|
+
import { timeoutGuardCommand } from "./timeout-guard.js";
|
|
6
|
+
export function createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env) {
|
|
7
|
+
const snapshot = persistedSnapshot
|
|
8
|
+
? { version: 0, flags: persistedSnapshot.flags }
|
|
9
|
+
: null;
|
|
10
|
+
return {
|
|
11
|
+
isEnabled(flagKey, lookup = {}) {
|
|
12
|
+
if (snapshot) {
|
|
13
|
+
return resolveFlag({
|
|
14
|
+
flagKey,
|
|
15
|
+
env,
|
|
16
|
+
snapshot,
|
|
17
|
+
companyIdentifiers,
|
|
18
|
+
lookup,
|
|
19
|
+
}).value;
|
|
20
|
+
}
|
|
21
|
+
return readLocalOverride(flagKey, env) ?? (lookup.fallback ?? false);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function snapshotFlagReader(companyUid, companySlug, env) {
|
|
26
|
+
const persistedSnapshot = companyUid ? readFlagSnapshotFile(companyUid, { env }) : null;
|
|
27
|
+
const companyIdentifiers = [companyUid, companySlug].filter((value) => Boolean(value));
|
|
28
|
+
return createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env);
|
|
29
|
+
}
|
|
30
|
+
export async function runTimeoutGuardFast(_argv, stdin, _cwd, _hqRoot, dependencies = {}) {
|
|
31
|
+
const env = dependencies.env ?? process.env;
|
|
32
|
+
const company = resolveProcessFlagCompanyContext(env);
|
|
33
|
+
const flagReader = dependencies.flagReader ?? snapshotFlagReader(company.companyUid, company.companySlug, env);
|
|
34
|
+
return timeoutGuardCommand(stdin, {
|
|
35
|
+
...dependencies,
|
|
36
|
+
env,
|
|
37
|
+
flagReader,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=timeout-guard-native.js.map
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Exit codes: 0 = allow, 2 = block.
|
|
22
22
|
*/
|
|
23
|
-
import {
|
|
23
|
+
import type { FlagReader } from "../flag-registry.js";
|
|
24
24
|
/**
|
|
25
25
|
* Worst-case inner deadline (seconds) declared in a command: a `timeout` /
|
|
26
26
|
* `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
|
|
@@ -55,11 +55,11 @@ export interface TimeoutGuardDeps {
|
|
|
55
55
|
flagReader?: FlagReader;
|
|
56
56
|
}
|
|
57
57
|
/** Whether the guard is active for this identity (rollout gate). */
|
|
58
|
-
export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): boolean
|
|
58
|
+
export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): Promise<boolean>;
|
|
59
59
|
/**
|
|
60
60
|
* Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
|
|
61
61
|
* return the exit code (0 allow / 2 block). Never throws — a parse failure or a
|
|
62
62
|
* missing identity fails OPEN (allow), because a hook must not break tool calls.
|
|
63
63
|
*/
|
|
64
|
-
export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number
|
|
64
|
+
export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): Promise<number>;
|
|
65
65
|
//# sourceMappingURL=timeout-guard.d.ts.map
|
|
@@ -20,9 +20,6 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Exit codes: 0 = allow, 2 = block.
|
|
22
22
|
*/
|
|
23
|
-
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
24
|
-
import { peekIdToken } from "../../utils/id-token.js";
|
|
25
|
-
import { resolveFlagGate, resolveProcessFlagGate, } from "../flag-registry.js";
|
|
26
23
|
// Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
|
|
27
24
|
// deadline up to the max; with none declared the default applies.
|
|
28
25
|
const HARNESS_DEFAULT_MS = 120_000; // 2 min
|
|
@@ -153,8 +150,14 @@ supervise yourself.)
|
|
|
153
150
|
|
|
154
151
|
Policy: hq-foreground-timeout-killed-by-harness-deadline.
|
|
155
152
|
Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
|
|
156
|
-
function defaultEmail() {
|
|
153
|
+
async function defaultEmail() {
|
|
157
154
|
try {
|
|
155
|
+
// The cached identity is needed only for commands that would otherwise
|
|
156
|
+
// block. Keep hq-cloud and the JWT decoder out of the common allow path.
|
|
157
|
+
const [{ loadCachedTokens }, { peekIdToken }] = await Promise.all([
|
|
158
|
+
import("@indigoai-us/hq-cloud"),
|
|
159
|
+
import("../../utils/id-token.js"),
|
|
160
|
+
]);
|
|
158
161
|
const cached = loadCachedTokens();
|
|
159
162
|
if (!cached)
|
|
160
163
|
return undefined;
|
|
@@ -166,23 +169,35 @@ function defaultEmail() {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
/** Whether the guard is active for this identity (rollout gate). */
|
|
169
|
-
export function isGatedUser(email, flagReader) {
|
|
172
|
+
export async function isGatedUser(email, flagReader) {
|
|
170
173
|
const legacyFallback = () => typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
|
|
171
174
|
const lookup = {
|
|
172
175
|
defaultEmailDomains: ["getindigo.ai"],
|
|
173
176
|
personEmail: email,
|
|
174
177
|
fallback: legacyFallback(),
|
|
175
178
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
if (flagReader) {
|
|
180
|
+
try {
|
|
181
|
+
return flagReader.isEnabled("cli.foreground-timeout-guard", lookup);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return legacyFallback();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const { resolveProcessFlagGate } = await import("../flag-registry.js");
|
|
189
|
+
return resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return legacyFallback();
|
|
193
|
+
}
|
|
179
194
|
}
|
|
180
195
|
/**
|
|
181
196
|
* Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
|
|
182
197
|
* return the exit code (0 allow / 2 block). Never throws — a parse failure or a
|
|
183
198
|
* missing identity fails OPEN (allow), because a hook must not break tool calls.
|
|
184
199
|
*/
|
|
185
|
-
export function timeoutGuardCommand(stdin, deps = {}) {
|
|
200
|
+
export async function timeoutGuardCommand(stdin, deps = {}) {
|
|
186
201
|
const env = deps.env ?? process.env;
|
|
187
202
|
const err = deps.stderr ?? process.stderr;
|
|
188
203
|
let payload;
|
|
@@ -201,15 +216,15 @@ export function timeoutGuardCommand(stdin, deps = {}) {
|
|
|
201
216
|
return 0;
|
|
202
217
|
if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
|
|
203
218
|
return 0;
|
|
204
|
-
// Rollout gate: only act for @getindigo.ai identities.
|
|
205
|
-
const getEmail = deps.getEmail ?? defaultEmail;
|
|
206
|
-
if (!isGatedUser(getEmail(), deps.flagReader))
|
|
207
|
-
return 0;
|
|
208
219
|
const runInBackground = ti.run_in_background === true;
|
|
209
220
|
const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
|
|
210
221
|
const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
|
|
211
222
|
if (!verdict.block)
|
|
212
223
|
return 0;
|
|
224
|
+
// Rollout gate: only act for @getindigo.ai identities.
|
|
225
|
+
const getEmail = deps.getEmail ?? defaultEmail;
|
|
226
|
+
if (!await isGatedUser(await getEmail(), deps.flagReader))
|
|
227
|
+
return 0;
|
|
213
228
|
err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
|
|
214
229
|
return 2;
|
|
215
230
|
}
|
|
@@ -54,6 +54,13 @@ export interface UnitPlan {
|
|
|
54
54
|
command: UnitCommand | null;
|
|
55
55
|
env: Record<string, string>;
|
|
56
56
|
blocked?: UnitBlock;
|
|
57
|
+
/**
|
|
58
|
+
* Signal that asks the unit to finish its current work and exit, used when a
|
|
59
|
+
* new daemon replaces it after a CLI update. Without one it gets SIGTERM.
|
|
60
|
+
*/
|
|
61
|
+
drainSignal?: "SIGUSR2";
|
|
62
|
+
/** A new CLI version alone does not restart this unit; it restarts itself when idle. */
|
|
63
|
+
keepAcrossCliUpdates?: true;
|
|
57
64
|
}
|
|
58
65
|
export interface PlanContext {
|
|
59
66
|
machineType: MachineType;
|
|
@@ -183,6 +183,8 @@ function planService(service, ctx) {
|
|
|
183
183
|
description: `runs the local bot ${name}`,
|
|
184
184
|
restart: "on-failure",
|
|
185
185
|
command: { type: "hq", args: ["bot", "run", name] },
|
|
186
|
+
// A bot drains on SIGUSR2: running turns finish, then it exits.
|
|
187
|
+
drainSignal: "SIGUSR2",
|
|
186
188
|
}));
|
|
187
189
|
}
|
|
188
190
|
case "session-host":
|
|
@@ -267,6 +269,8 @@ function planService(service, ctx) {
|
|
|
267
269
|
type: "hq",
|
|
268
270
|
args: ["daemon", "service", "codex-remote-control", "--script", script ?? "", "--codex", ctx.codexPath ?? ""],
|
|
269
271
|
},
|
|
272
|
+
// Restarting the host ends the tmux session people are using.
|
|
273
|
+
keepAcrossCliUpdates: true,
|
|
270
274
|
env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
|
|
271
275
|
blocked: firstBlock([
|
|
272
276
|
[!script, `this machine has no Codex remote-control runner (${CODEX_RUNNER_SCRIPT})`],
|
|
@@ -290,6 +294,8 @@ function planService(service, ctx) {
|
|
|
290
294
|
base(service, {
|
|
291
295
|
command: { type: "hq", args },
|
|
292
296
|
env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
|
|
297
|
+
// Restarting the host ends the tmux session people are using.
|
|
298
|
+
keepAcrossCliUpdates: true,
|
|
293
299
|
blocked: firstBlock([
|
|
294
300
|
[!ctx.claudePath, "the claude CLI is not installed"],
|
|
295
301
|
[!ctx.tmuxPath, "tmux is not installed (remote control runs Claude inside tmux)"],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flags read by the daemon. The daemon runs for weeks, so each read
|
|
3
|
+
* asks the registry again instead of holding a per-process answer, and it
|
|
4
|
+
* reads as off unless the registry itself says on (or a local override does).
|
|
5
|
+
*/
|
|
6
|
+
import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
|
|
7
|
+
/** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
|
|
8
|
+
export declare const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
|
|
9
|
+
export interface DaemonFlagClient {
|
|
10
|
+
ready(): Promise<void>;
|
|
11
|
+
explain(flagKey: string, options?: {
|
|
12
|
+
fallback?: boolean;
|
|
13
|
+
}): FlagResolution;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
export interface ReadDaemonFlagOptions {
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
createClient?: (options: FlagClientOptions) => DaemonFlagClient;
|
|
19
|
+
getToken?: () => Promise<string>;
|
|
20
|
+
onError?: (error: unknown) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function readDaemonFlag(flagKey: string, options?: ReadDaemonFlagOptions): Promise<boolean>;
|
|
23
|
+
//# sourceMappingURL=flag.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flags read by the daemon. The daemon runs for weeks, so each read
|
|
3
|
+
* asks the registry again instead of holding a per-process answer, and it
|
|
4
|
+
* reads as off unless the registry itself says on (or a local override does).
|
|
5
|
+
*/
|
|
6
|
+
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
7
|
+
import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
|
|
8
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
9
|
+
import { resolveProcessFlagCompanyContext } from "../flag-registry.js";
|
|
10
|
+
/** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
|
|
11
|
+
export const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
|
|
12
|
+
const READ_TIMEOUT_MS = 5_000;
|
|
13
|
+
export async function readDaemonFlag(flagKey, options = {}) {
|
|
14
|
+
const env = options.env ?? process.env;
|
|
15
|
+
const override = readLocalOverride(flagKey, env);
|
|
16
|
+
if (override !== null)
|
|
17
|
+
return override;
|
|
18
|
+
let failed = false;
|
|
19
|
+
const onError = (error) => {
|
|
20
|
+
failed = true;
|
|
21
|
+
options.onError?.(error);
|
|
22
|
+
};
|
|
23
|
+
let client;
|
|
24
|
+
try {
|
|
25
|
+
const company = resolveProcessFlagCompanyContext(env);
|
|
26
|
+
// Signed out reads as off without reaching the registry.
|
|
27
|
+
const token = await (options.getToken ?? (() => ensureCognitoIdToken({ interactive: false })))();
|
|
28
|
+
if (typeof token !== "string" || token.trim() === "")
|
|
29
|
+
return false;
|
|
30
|
+
client = (options.createClient ?? createFlagClient)({
|
|
31
|
+
endpoint: resolveFlagRegistryEndpoint(undefined, env),
|
|
32
|
+
getToken: () => token,
|
|
33
|
+
env: {},
|
|
34
|
+
refreshIntervalMs: 0,
|
|
35
|
+
requestTimeoutMs: READ_TIMEOUT_MS,
|
|
36
|
+
...(company.companyUid ? { companyUid: company.companyUid } : {}),
|
|
37
|
+
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
38
|
+
onError,
|
|
39
|
+
});
|
|
40
|
+
await client.ready();
|
|
41
|
+
const resolution = client.explain(flagKey, { fallback: false });
|
|
42
|
+
return !failed && resolution.source === "snapshot" && resolution.value === true;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
onError(error);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
try {
|
|
50
|
+
client?.close();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// closing a one-shot client cannot change the answer
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=flag.js.map
|
|
@@ -61,6 +61,11 @@ function serviceHost(host) {
|
|
|
61
61
|
* Output goes to the journal: an `append:` log file would be created by
|
|
62
62
|
* systemd as root inside the user's home, and the daemon (running as the user)
|
|
63
63
|
* writes its own logs under ~/.hq/daemon/logs.
|
|
64
|
+
*
|
|
65
|
+
* `KillMode=process`: systemd signals only the daemon. The daemon stops its
|
|
66
|
+
* services itself on SIGTERM, and after a CLI update it can leave them running
|
|
67
|
+
* for the next daemon to adopt (see succession.ts). A daemon that starts and
|
|
68
|
+
* finds services it did not adopt stops them first.
|
|
64
69
|
*/
|
|
65
70
|
function renderSystemdSystemUnit(host, user, userHome) {
|
|
66
71
|
const exec = [host.nodeBinary, host.hqBinary, "daemon", "run", "--managed"].map(shellQuote).join(" ");
|
|
@@ -87,7 +92,7 @@ ExecStart=${exec}
|
|
|
87
92
|
WorkingDirectory=${userHome}
|
|
88
93
|
Restart=always
|
|
89
94
|
RestartSec=10
|
|
90
|
-
KillMode=
|
|
95
|
+
KillMode=process
|
|
91
96
|
TimeoutStopSec=20
|
|
92
97
|
${envLines}
|
|
93
98
|
|
|
@@ -31,6 +31,12 @@ export declare function remoteControlArgs(opts: {
|
|
|
31
31
|
* (`--name outpost-1-2`), so taking the runner over keeps the name people see.
|
|
32
32
|
*/
|
|
33
33
|
export declare function runnerSessionName(script: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Fingerprint of what a host runs: the tool first, then other installs it
|
|
36
|
+
* depends on (the hq-cli package). Null while the tool cannot be read; an
|
|
37
|
+
* unreadable extra install is left out rather than hiding a tool update.
|
|
38
|
+
*/
|
|
39
|
+
export declare function installFingerprint(paths: readonly string[], fingerprint: (file: string) => string | null): string | null;
|
|
34
40
|
/** A tool updated on disk restarts its session once quiet this long... */
|
|
35
41
|
export declare const IDLE_BEFORE_RESTART_MS: number;
|
|
36
42
|
/** ...or after this long, however busy the session is. */
|