@indigoai-us/hq-cli 5.275.0 → 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 +36 -0
- package/README.md +12 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
- 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 +1 -1
- package/dist/commands/daemon.d.ts +1 -0
- package/dist/commands/daemon.js +2 -1
- package/dist/commands/feedback.d.ts +6 -0
- package/dist/commands/feedback.js +86 -37
- package/dist/commands/lanes.js +3 -2
- 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/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/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
|
@@ -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
|
}
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* keep a short-lived, offline CLI alive until the client's network deadline.
|
|
7
7
|
*/
|
|
8
8
|
import { type WorkerOptions } from "node:worker_threads";
|
|
9
|
-
import { type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
|
|
9
|
+
import { createFlagClient, type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
|
|
10
|
+
export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
11
|
+
export type { ProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
10
12
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
11
13
|
/** The synchronous capability that command gate call sites need. */
|
|
12
14
|
export type FlagReader = Pick<FlagClient, "isEnabled">;
|
|
@@ -22,12 +24,13 @@ export interface FlagRegistryDependencies {
|
|
|
22
24
|
workerSource?: string;
|
|
23
25
|
captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
|
|
24
26
|
}
|
|
25
|
-
export interface
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
export interface CompanyFlagGateDependencies {
|
|
28
|
+
endpoint?: string;
|
|
29
|
+
requestTimeoutMs?: number;
|
|
30
|
+
createClient?: typeof createFlagClient;
|
|
28
31
|
}
|
|
29
|
-
/**
|
|
30
|
-
export declare function
|
|
32
|
+
/** Create the same snapshot-backed reader used by process gates. */
|
|
33
|
+
export declare function createProcessSnapshotReader(companyIdentifiers: string[], getSnapshot?: () => FlagSnapshot | null, env?: NodeJS.ProcessEnv): FlagReader;
|
|
31
34
|
/**
|
|
32
35
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
33
36
|
*
|
|
@@ -43,7 +46,9 @@ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDep
|
|
|
43
46
|
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
44
47
|
* falls through to the exact legacy predicate owned by the caller.
|
|
45
48
|
*/
|
|
46
|
-
export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
49
|
+
export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, env?: NodeJS.ProcessEnv): boolean;
|
|
50
|
+
/** Resolve one explicit company's flag without relying on process-wide binding. */
|
|
51
|
+
export declare function resolveCompanyFlagGate(companyUid: string, idToken: string, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, dependencies?: CompanyFlagGateDependencies): Promise<boolean>;
|
|
47
52
|
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
48
53
|
export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
49
54
|
/**
|
|
@@ -7,99 +7,43 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Worker } from "node:worker_threads";
|
|
9
9
|
import * as Sentry from "@sentry/node";
|
|
10
|
-
import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
11
|
-
import { readMachineCompanyBinding } from "./work-context/company.js";
|
|
12
|
-
import { readSessionState } from "./work-context/state.js";
|
|
13
|
-
import { workContextRoot } from "./work-context/paths.js";
|
|
14
|
-
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
15
|
-
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
10
|
+
import { createFlagClient, isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
16
11
|
import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
17
12
|
import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
|
|
13
|
+
import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
14
|
+
import { writeFlagSnapshotFile } from "./flag-snapshot-file.js";
|
|
15
|
+
export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
18
16
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
19
17
|
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
20
|
-
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
21
|
-
let heldSnapshot = null;
|
|
22
|
-
let processReader = null;
|
|
23
|
-
function processLocalOverride(flagKey) {
|
|
24
|
-
return readLocalOverride(flagKey, process.env);
|
|
25
|
-
}
|
|
26
|
-
function localOverrideOrFallback(flagKey, legacyFallback) {
|
|
27
|
-
return processLocalOverride(flagKey) ?? legacyFallback();
|
|
28
|
-
}
|
|
29
18
|
function normalizedCompanyUid(value) {
|
|
30
19
|
const candidate = value?.trim() ?? "";
|
|
31
20
|
return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
|
|
32
21
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Resolve only an explicit machine/session company binding. Device defaults
|
|
39
|
-
* and cwd/repository heuristics do not scope process-wide feature flags.
|
|
40
|
-
*/
|
|
41
|
-
function processCompanyContext(env = process.env) {
|
|
42
|
-
const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
|
|
43
|
-
const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
|
|
44
|
-
if (explicitUid || explicitSlug) {
|
|
45
|
-
return { companyUid: explicitUid, companySlug: explicitSlug };
|
|
46
|
-
}
|
|
47
|
-
const machine = readMachineCompanyBinding(env);
|
|
48
|
-
const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
|
|
49
|
-
const spawnSlug = normalizedCompanySlug(spawnValue);
|
|
50
|
-
const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
|
|
51
|
-
? { companyUid: spawnValue }
|
|
52
|
-
: spawnSlug
|
|
53
|
-
? { companySlug: spawnSlug }
|
|
54
|
-
: {
|
|
55
|
-
companyUid: normalizedCompanyUid(machine?.uid),
|
|
56
|
-
companySlug: normalizedCompanySlug(machine?.slug),
|
|
57
|
-
};
|
|
58
|
-
if (machineContext.companyUid || machineContext.companySlug)
|
|
59
|
-
return machineContext;
|
|
60
|
-
const sessionId = env.HQ_SESSION_ID?.trim();
|
|
61
|
-
if (!sessionId)
|
|
62
|
-
return {};
|
|
63
|
-
const session = readSessionState(sessionId, workContextRoot(undefined, env));
|
|
64
|
-
if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
|
|
65
|
-
return {};
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
companyUid: normalizedCompanyUid(session.companyUid),
|
|
69
|
-
companySlug: normalizedCompanySlug(session.companySlug),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
function companyUidForContext(context, env = process.env) {
|
|
73
|
-
if (context.companyUid)
|
|
74
|
-
return context.companyUid;
|
|
75
|
-
if (!context.companySlug)
|
|
76
|
-
return undefined;
|
|
77
|
-
const hqRoot = resolveDefaultHqRoot({ env });
|
|
78
|
-
const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
|
|
79
|
-
return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
|
|
22
|
+
let heldSnapshot = null;
|
|
23
|
+
let processReader = null;
|
|
24
|
+
function processLocalOverride(flagKey, env = process.env) {
|
|
25
|
+
return readLocalOverride(flagKey, env);
|
|
80
26
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const context = processCompanyContext(env);
|
|
84
|
-
const companyUid = companyUidForContext(context, env);
|
|
85
|
-
return {
|
|
86
|
-
...(companyUid ? { companyUid } : {}),
|
|
87
|
-
...(context.companySlug ? { companySlug: context.companySlug } : {}),
|
|
88
|
-
};
|
|
27
|
+
function localOverrideOrFallback(flagKey, legacyFallback, env = process.env) {
|
|
28
|
+
return processLocalOverride(flagKey, env) ?? legacyFallback();
|
|
89
29
|
}
|
|
90
|
-
|
|
30
|
+
/** Create the same snapshot-backed reader used by process gates. */
|
|
31
|
+
export function createProcessSnapshotReader(companyIdentifiers, getSnapshot = () => heldSnapshot, env = process.env) {
|
|
91
32
|
return {
|
|
92
33
|
isEnabled(flagKey, lookup = {}) {
|
|
93
34
|
return resolveFlag({
|
|
94
35
|
flagKey,
|
|
95
|
-
env
|
|
96
|
-
snapshot:
|
|
36
|
+
env,
|
|
37
|
+
snapshot: getSnapshot(),
|
|
97
38
|
companyIdentifiers,
|
|
98
39
|
lookup,
|
|
99
40
|
}).value;
|
|
100
41
|
},
|
|
101
42
|
};
|
|
102
43
|
}
|
|
44
|
+
function processSnapshotReader(companyIdentifiers) {
|
|
45
|
+
return createProcessSnapshotReader(companyIdentifiers);
|
|
46
|
+
}
|
|
103
47
|
/**
|
|
104
48
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
105
49
|
*
|
|
@@ -150,6 +94,19 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
150
94
|
worker.on("message", (value) => {
|
|
151
95
|
if (isFlagSnapshot(value)) {
|
|
152
96
|
heldSnapshot = value;
|
|
97
|
+
if (companyUid) {
|
|
98
|
+
try {
|
|
99
|
+
writeFlagSnapshotFile(companyUid, value.flags);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
Sentry.captureException(error, {
|
|
103
|
+
tags: {
|
|
104
|
+
component: "flag-snapshot-cache-write",
|
|
105
|
+
company_uid_present: "true",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
153
110
|
}
|
|
154
111
|
});
|
|
155
112
|
// A failed background refresh must stay invisible to an offline CLI.
|
|
@@ -169,15 +126,49 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
169
126
|
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
170
127
|
* falls through to the exact legacy predicate owned by the caller.
|
|
171
128
|
*/
|
|
172
|
-
export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
|
|
129
|
+
export function resolveFlagGate(reader, flagKey, lookup, legacyFallback, env = process.env) {
|
|
173
130
|
if (!reader)
|
|
174
|
-
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
131
|
+
return localOverrideOrFallback(flagKey, legacyFallback, env);
|
|
175
132
|
try {
|
|
176
133
|
return reader.isEnabled(flagKey, lookup);
|
|
177
134
|
}
|
|
178
135
|
catch {
|
|
136
|
+
return localOverrideOrFallback(flagKey, legacyFallback, env);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Resolve one explicit company's flag without relying on process-wide binding. */
|
|
140
|
+
export async function resolveCompanyFlagGate(companyUid, idToken, flagKey, lookup, legacyFallback, dependencies = {}) {
|
|
141
|
+
const localOverride = processLocalOverride(flagKey);
|
|
142
|
+
if (localOverride !== null)
|
|
143
|
+
return localOverride;
|
|
144
|
+
const targetCompanyUid = normalizedCompanyUid(companyUid);
|
|
145
|
+
if (!targetCompanyUid || idToken.trim() === "") {
|
|
179
146
|
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
180
147
|
}
|
|
148
|
+
let client;
|
|
149
|
+
try {
|
|
150
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
151
|
+
endpoint: resolveFlagRegistryEndpoint(dependencies.endpoint, process.env),
|
|
152
|
+
getToken: () => idToken,
|
|
153
|
+
companyUid: targetCompanyUid,
|
|
154
|
+
refreshIntervalMs: 0,
|
|
155
|
+
requestTimeoutMs: dependencies.requestTimeoutMs ?? 1_200,
|
|
156
|
+
onError: (error) => {
|
|
157
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
158
|
+
console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
await client.ready();
|
|
162
|
+
return resolveFlagGate(client, flagKey, lookup, legacyFallback);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
166
|
+
console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
|
|
167
|
+
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
client?.close();
|
|
171
|
+
}
|
|
181
172
|
}
|
|
182
173
|
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
183
174
|
export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Private, synchronous, offline snapshot used by the native CLI fast route. */
|
|
2
|
+
export declare const FLAG_SNAPSHOT_MAX_AGE_MS: number;
|
|
3
|
+
export interface PersistedFlagSnapshot {
|
|
4
|
+
company_uid: string;
|
|
5
|
+
fetched_at: string;
|
|
6
|
+
flags: Record<string, boolean>;
|
|
7
|
+
}
|
|
8
|
+
export declare function flagSnapshotPath(companyUid: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
9
|
+
/** Write just the validated flag decisions and company scope, never auth data. */
|
|
10
|
+
export declare function writeFlagSnapshotFile(companyUid: string, flags: Readonly<Record<string, unknown>>, options?: {
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
fetchedAt?: string;
|
|
13
|
+
}): string;
|
|
14
|
+
/** Return null for every miss, malformed file, company mismatch, or stale file. */
|
|
15
|
+
export declare function readFlagSnapshotFile(companyUid: string, options?: {
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
now?: number;
|
|
18
|
+
maxAgeMs?: number;
|
|
19
|
+
}): PersistedFlagSnapshot | null;
|
|
20
|
+
//# sourceMappingURL=flag-snapshot-file.d.ts.map
|