@indigoai-us/hq-cli 5.219.0 → 5.220.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 +13 -0
- package/dist/commands/lanes.js +58 -51
- package/dist/lib/flag-registry-worker-mode-cache.d.ts +13 -0
- package/dist/lib/flag-registry-worker-mode-cache.js +146 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +2 -0
- package/dist/lib/flag-registry-worker-source.generated.js +3 -0
- package/dist/lib/flag-registry.d.ts +4 -1
- package/dist/lib/flag-registry.js +100 -9
- package/dist/lib/lanes/questions.d.ts +9 -1
- package/dist/lib/lanes/questions.js +84 -5
- package/dist/lib/lanes/reconcile.js +2 -4
- package/dist/lib/lanes/watcher.js +13 -7
- package/dist/sentry.js +41 -0
- package/package.json +5 -2
|
@@ -17,7 +17,10 @@ interface WorkerLike {
|
|
|
17
17
|
}
|
|
18
18
|
export interface FlagRegistryDependencies {
|
|
19
19
|
endpoint?: string;
|
|
20
|
-
createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
|
|
20
|
+
createWorker?: (filename: string | URL, options: WorkerOptions) => WorkerLike;
|
|
21
|
+
/** Test seam for exercising the no-source fail-open path. */
|
|
22
|
+
workerSource?: string;
|
|
23
|
+
captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
|
|
21
24
|
}
|
|
22
25
|
export interface ProcessFlagCompanyContext {
|
|
23
26
|
companyUid?: string;
|
|
@@ -12,8 +12,10 @@ import { readMachineCompanyBinding } from "./work-context/company.js";
|
|
|
12
12
|
import { readSessionState } from "./work-context/state.js";
|
|
13
13
|
import { workContextRoot } from "./work-context/paths.js";
|
|
14
14
|
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
15
|
-
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
15
|
+
import { loadCachedTokens, resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
16
16
|
import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
17
|
+
import { FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, flagRegistryStateDir, flagRegistryWorkerModeCachePath, readCachedFlagRegistryWorkerMode, writeCachedFlagRegistryWorkerMode, } from "./flag-registry-worker-mode-cache.js";
|
|
18
|
+
import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
|
|
17
19
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
18
20
|
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
19
21
|
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -99,6 +101,75 @@ function processSnapshotReader(companyIdentifiers) {
|
|
|
99
101
|
},
|
|
100
102
|
};
|
|
101
103
|
}
|
|
104
|
+
function personSubjectForWorkerMode(idToken) {
|
|
105
|
+
if (typeof idToken !== "string")
|
|
106
|
+
return null;
|
|
107
|
+
const payload = idToken.split(".")[1];
|
|
108
|
+
if (!payload)
|
|
109
|
+
return null;
|
|
110
|
+
try {
|
|
111
|
+
const subject = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).sub;
|
|
112
|
+
return typeof subject === "string" && subject.trim() ? subject : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
Sentry.captureMessage("Could not scope cached flag-registry worker mode because the cached ID token payload is invalid", { level: "warning", tags: { component: "flag-registry-worker-mode-cache" } });
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Scope a persisted rollout value to the same identity that fetched it. */
|
|
120
|
+
function workerModeCacheScopes(company) {
|
|
121
|
+
const tokens = loadCachedTokens();
|
|
122
|
+
const personSubject = tokens ? personSubjectForWorkerMode(tokens.idToken) : null;
|
|
123
|
+
if (tokens && !personSubject)
|
|
124
|
+
return null;
|
|
125
|
+
return [
|
|
126
|
+
...(company.companyUid ? [`company:${company.companyUid}`] : []),
|
|
127
|
+
...(company.companySlug ? [`company-slug:${company.companySlug}`] : []),
|
|
128
|
+
...(personSubject ? [`person:${personSubject}`] : []),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
function selfContainedWorkerEnabled(company) {
|
|
132
|
+
const localOverride = readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env);
|
|
133
|
+
if (localOverride !== null)
|
|
134
|
+
return localOverride;
|
|
135
|
+
const scopes = workerModeCacheScopes(company);
|
|
136
|
+
if (!scopes)
|
|
137
|
+
return false;
|
|
138
|
+
const filePath = flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes);
|
|
139
|
+
try {
|
|
140
|
+
return readCachedFlagRegistryWorkerMode(filePath)?.value ?? false;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
Sentry.captureException(error, {
|
|
144
|
+
tags: { component: "flag-registry-worker-mode-cache", worker: "flag-registry" },
|
|
145
|
+
});
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function cacheWorkerModeFromSnapshot(snapshot, company) {
|
|
150
|
+
// A developer's local hq-flags override is process-local and must not become
|
|
151
|
+
// the value selected by a future process.
|
|
152
|
+
if (readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env) !== null)
|
|
153
|
+
return;
|
|
154
|
+
const scopes = workerModeCacheScopes(company);
|
|
155
|
+
if (!scopes)
|
|
156
|
+
return;
|
|
157
|
+
const decision = resolveFlag({
|
|
158
|
+
flagKey: FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG,
|
|
159
|
+
env: {},
|
|
160
|
+
snapshot,
|
|
161
|
+
companyIdentifiers: [company.companyUid, company.companySlug].filter((value) => Boolean(value)),
|
|
162
|
+
lookup: { fallback: false },
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
writeCachedFlagRegistryWorkerMode(flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes), { version: snapshot.version, value: decision.value });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
Sentry.captureException(error, {
|
|
169
|
+
tags: { component: "flag-registry-worker-mode-cache", worker: "flag-registry" },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
102
173
|
/**
|
|
103
174
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
104
175
|
*
|
|
@@ -117,23 +188,43 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
117
188
|
const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
|
|
118
189
|
processReader = processSnapshotReader(companyIdentifiers);
|
|
119
190
|
try {
|
|
191
|
+
let workerSource = new URL("./flag-registry-worker.js", import.meta.url);
|
|
192
|
+
let workerOptions = { workerData: {
|
|
193
|
+
endpoint,
|
|
194
|
+
...(companyUid ? { companyUid } : {}),
|
|
195
|
+
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
196
|
+
} };
|
|
197
|
+
if (selfContainedWorkerEnabled(company)) {
|
|
198
|
+
// The worker source is generated and embedded in this parent module at
|
|
199
|
+
// build time. It is already resident before company, identity, and cache
|
|
200
|
+
// reads can yield to an updater replacing this package tree.
|
|
201
|
+
const embeddedSource = dependencies.workerSource ?? FLAG_REGISTRY_WORKER_SOURCE;
|
|
202
|
+
if (embeddedSource.length === 0) {
|
|
203
|
+
(dependencies.captureWorkerFailure ?? ((error, tags) => {
|
|
204
|
+
Sentry.captureException(error, { tags });
|
|
205
|
+
}))(new Error("Self-contained flag-registry worker source is unavailable"), {
|
|
206
|
+
component: "flag-registry-worker-startup",
|
|
207
|
+
worker: "flag-registry",
|
|
208
|
+
flag_worker_failure: "embedded_source_unavailable",
|
|
209
|
+
});
|
|
210
|
+
return processReader;
|
|
211
|
+
}
|
|
212
|
+
workerSource = embeddedSource;
|
|
213
|
+
workerOptions = { ...workerOptions, eval: true };
|
|
214
|
+
}
|
|
120
215
|
// The SDK's child-process integration captures every Worker error unless
|
|
121
216
|
// its process-wide captureWorkerErrors option is disabled. Scope only this
|
|
122
217
|
// worker so the reporting boundary can classify its startup errors without
|
|
123
218
|
// changing capture for unrelated workers.
|
|
124
219
|
const worker = Sentry.withScope((scope) => {
|
|
125
220
|
scope.setTag("worker", "flag-registry");
|
|
126
|
-
return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(
|
|
127
|
-
workerData: {
|
|
128
|
-
endpoint,
|
|
129
|
-
...(companyUid ? { companyUid } : {}),
|
|
130
|
-
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
131
|
-
},
|
|
132
|
-
});
|
|
221
|
+
return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(workerSource, workerOptions);
|
|
133
222
|
});
|
|
134
223
|
worker.on("message", (value) => {
|
|
135
|
-
if (isFlagSnapshot(value))
|
|
224
|
+
if (isFlagSnapshot(value)) {
|
|
136
225
|
heldSnapshot = value;
|
|
226
|
+
cacheWorkerModeFromSnapshot(value, company);
|
|
227
|
+
}
|
|
137
228
|
});
|
|
138
229
|
// A failed background refresh must stay invisible to an offline CLI.
|
|
139
230
|
worker.on("error", () => { });
|
|
@@ -16,7 +16,15 @@ export declare function assertQuestionBodySecretSafe(body: QuestionBody): void;
|
|
|
16
16
|
export declare function questionRecordRefusalCode(err: unknown): "secret_text_refused" | "question_company_unavailable" | null;
|
|
17
17
|
export declare function parseQuestionBodyStore(value: unknown): QuestionBodyStore;
|
|
18
18
|
export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company" | "mesh_company">, resolvedCompany?: LaneCompanyRef): QuestionBodyStore;
|
|
19
|
-
export declare function resolveQuestionCompany(lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company">, activeCompany?: LaneCompanyRef | null): LaneCompanyRef;
|
|
19
|
+
export declare function resolveQuestionCompany(lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, activeCompany?: LaneCompanyRef | null, hqRoot?: string): LaneCompanyRef;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve project ownership only when exactly one manifest company contains
|
|
22
|
+
* the project's directory. Multiple matches are unresolved so the caller can
|
|
23
|
+
* continue to an explicit active-company choice without picking an owner.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveProjectQuestionCompany(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "project_id">): LaneCompanyRef | null;
|
|
26
|
+
/** Resolve the lane/project company before lazily consulting active HQ scope. */
|
|
27
|
+
export declare function resolveQuestionCompanyWithFallback(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, resolveActiveCompany?: () => Promise<LaneCompanyRef | null>): Promise<LaneCompanyRef>;
|
|
20
28
|
export declare function questionBodyRef(questionId: string): string;
|
|
21
29
|
export declare function sha256Text(text: string): string;
|
|
22
30
|
/** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
|
|
@@ -8,6 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { LanesError } from "./errors.js";
|
|
10
10
|
import { assertNoSecretText } from "./text-scan.js";
|
|
11
|
+
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
11
12
|
import { appendCappedHistory, createQuestion, retryUpdateQuestion, } from "./store.js";
|
|
12
13
|
import { QUESTION_BODY_STORES, } from "./types.js";
|
|
13
14
|
export function assertQuestionBodySecretSafe(body) {
|
|
@@ -39,13 +40,91 @@ export function parseQuestionBodyStore(value) {
|
|
|
39
40
|
export function bodyStoreForLane(lane, resolvedCompany) {
|
|
40
41
|
return lane.company || lane.mesh_company || resolvedCompany ? "company" : "personal";
|
|
41
42
|
}
|
|
42
|
-
export function resolveQuestionCompany(lane, activeCompany) {
|
|
43
|
-
const
|
|
43
|
+
export function resolveQuestionCompany(lane, activeCompany, hqRoot) {
|
|
44
|
+
const laneCompany = lane.company ?? lane.mesh_company;
|
|
45
|
+
if (laneCompany !== null && laneCompany !== undefined) {
|
|
46
|
+
return requireQuestionCompany(laneCompany, lane.lane_id, "lane");
|
|
47
|
+
}
|
|
48
|
+
if (hqRoot) {
|
|
49
|
+
const projectCompany = resolveProjectQuestionCompany(hqRoot, lane);
|
|
50
|
+
if (projectCompany)
|
|
51
|
+
return projectCompany;
|
|
52
|
+
}
|
|
53
|
+
const company = activeCompany;
|
|
44
54
|
if (!company || !company.slug.trim() || !company.uid.trim()) {
|
|
45
|
-
throw new LanesError("question_company_unavailable", `Cannot raise a question for lane ${lane.lane_id}: no lane, project, or active company could be resolved`, { lane_id: lane.lane_id });
|
|
55
|
+
throw new LanesError("question_company_unavailable", `Cannot raise a question for lane ${lane.lane_id}: no lane, project, or active company could be resolved`, { lane_id: lane.lane_id, resolution: "active_company_required" });
|
|
56
|
+
}
|
|
57
|
+
return company;
|
|
58
|
+
}
|
|
59
|
+
function requireQuestionCompany(company, laneId, source) {
|
|
60
|
+
if (!company.slug.trim() || !company.uid.trim()) {
|
|
61
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the ${source} company for lane ${laneId}: its slug or uid is missing`, { lane_id: laneId, resolution: `${source}_company_invalid` });
|
|
46
62
|
}
|
|
47
63
|
return company;
|
|
48
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve project ownership only when exactly one manifest company contains
|
|
67
|
+
* the project's directory. Multiple matches are unresolved so the caller can
|
|
68
|
+
* continue to an explicit active-company choice without picking an owner.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveProjectQuestionCompany(hqRoot, lane) {
|
|
71
|
+
const projectId = lane.project_id?.trim();
|
|
72
|
+
if (!projectId)
|
|
73
|
+
return null;
|
|
74
|
+
if (projectId === "." || projectId === ".." || /[\\/\0]/.test(projectId)) {
|
|
75
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: its project id is invalid`, { lane_id: lane.lane_id, resolution: "project_id_invalid" });
|
|
76
|
+
}
|
|
77
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
78
|
+
if (!fs.existsSync(manifestPath))
|
|
79
|
+
return null;
|
|
80
|
+
const companies = readCompaniesManifestMap(hqRoot);
|
|
81
|
+
if (!companies) {
|
|
82
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: companies/manifest.yaml is unreadable or malformed`, { lane_id: lane.lane_id, resolution: "company_manifest_unavailable" });
|
|
83
|
+
}
|
|
84
|
+
const matches = [];
|
|
85
|
+
for (const [slug, entry] of Object.entries(companies)) {
|
|
86
|
+
if (!entry || typeof entry !== "object" || !slug || /[\\/\0]/.test(slug))
|
|
87
|
+
continue;
|
|
88
|
+
const projectDir = path.join(hqRoot, "companies", slug, "projects", projectId);
|
|
89
|
+
let isDirectory = false;
|
|
90
|
+
try {
|
|
91
|
+
isDirectory = fs.statSync(projectDir).isDirectory();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== "ENOENT" && err.code !== "ENOTDIR") {
|
|
95
|
+
throw new LanesError("question_company_unavailable", `Cannot verify project ownership for lane ${lane.lane_id}`, { lane_id: lane.lane_id, resolution: "project_directory_unavailable" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!isDirectory)
|
|
99
|
+
continue;
|
|
100
|
+
const uid = typeof entry.cloud_uid === "string" ? entry.cloud_uid.trim() : "";
|
|
101
|
+
matches.push({ slug, uid });
|
|
102
|
+
}
|
|
103
|
+
if (matches.length > 1)
|
|
104
|
+
return null;
|
|
105
|
+
const match = matches[0];
|
|
106
|
+
if (!match)
|
|
107
|
+
return null;
|
|
108
|
+
if (!match.uid) {
|
|
109
|
+
throw new LanesError("question_company_unavailable", `Cannot resolve the project company for lane ${lane.lane_id}: its manifest company has no cloud uid`, { lane_id: lane.lane_id, resolution: "project_company_uid_missing" });
|
|
110
|
+
}
|
|
111
|
+
return requireQuestionCompany({ slug: match.slug, uid: match.uid }, lane.lane_id, "project");
|
|
112
|
+
}
|
|
113
|
+
/** Resolve the lane/project company before lazily consulting active HQ scope. */
|
|
114
|
+
export async function resolveQuestionCompanyWithFallback(hqRoot, lane, resolveActiveCompany) {
|
|
115
|
+
try {
|
|
116
|
+
return resolveQuestionCompany(lane, null, hqRoot);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (!(err instanceof LanesError) ||
|
|
120
|
+
err.error !== "question_company_unavailable" ||
|
|
121
|
+
err.details.resolution !== "active_company_required" ||
|
|
122
|
+
!resolveActiveCompany) {
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
return resolveQuestionCompany(lane, await resolveActiveCompany(), hqRoot);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
49
128
|
export function questionBodyRef(questionId) {
|
|
50
129
|
return path.posix.join("lanes", "questions", `${questionId}.json`);
|
|
51
130
|
}
|
|
@@ -82,7 +161,7 @@ export function writeQuestionBody(hqRoot, question, body) {
|
|
|
82
161
|
*/
|
|
83
162
|
export function raiseLaneQuestion(input) {
|
|
84
163
|
assertQuestionBodySecretSafe(input.body);
|
|
85
|
-
const company = resolveQuestionCompany(input.lane, input.activeCompany);
|
|
164
|
+
const company = resolveQuestionCompany(input.lane, input.activeCompany, input.hqRoot);
|
|
86
165
|
const store = bodyStoreForLane(input.lane, company);
|
|
87
166
|
let questionId = input.questionId;
|
|
88
167
|
if (!questionId) {
|
|
@@ -282,7 +361,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at,
|
|
|
282
361
|
for (const item of presentItems) {
|
|
283
362
|
assertQuestionBodySecretSafe(envelopeQuestionBody(item));
|
|
284
363
|
}
|
|
285
|
-
resolveQuestionCompany(lane, activeCompany);
|
|
364
|
+
resolveQuestionCompany(lane, activeCompany, hqRoot);
|
|
286
365
|
const raised = [];
|
|
287
366
|
for (let i = 0; i < items.length; i += 1) {
|
|
288
367
|
const item = items[i];
|
|
@@ -24,7 +24,7 @@ import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
|
24
24
|
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
|
|
25
25
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
26
26
|
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
27
|
-
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, } from "./questions.js";
|
|
27
|
+
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
28
28
|
import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
29
29
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
30
30
|
import { laneStories, storyStatusForEnvelope } from "./stories.js";
|
|
@@ -1054,10 +1054,8 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1054
1054
|
latest &&
|
|
1055
1055
|
(latest.envelope.questions?.length ?? 0) > 0) {
|
|
1056
1056
|
const questionLane = readLane(hqRoot, lane.lane_id);
|
|
1057
|
-
const activeCompany = questionLane.company || questionLane.mesh_company
|
|
1058
|
-
? undefined
|
|
1059
|
-
: await deps.resolveQuestionActiveCompany?.();
|
|
1060
1057
|
try {
|
|
1058
|
+
const activeCompany = await resolveQuestionCompanyWithFallback(hqRoot, questionLane, deps.resolveQuestionActiveCompany);
|
|
1061
1059
|
raiseEnvelopeQuestions(hqRoot, questionLane, latest.envelope, latest.ref, nowIso, undefined, activeCompany);
|
|
1062
1060
|
}
|
|
1063
1061
|
catch (err) {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { LanesError } from "./errors.js";
|
|
37
|
-
import { questionRecordRefusalCode, raiseEnvelopeQuestions } from "./questions.js";
|
|
37
|
+
import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
38
38
|
import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, } from "./types.js";
|
|
39
39
|
import { laneStories, storyStatusForEnvelope } from "./stories.js";
|
|
40
40
|
import { readLane, retryUpdateLane } from "./store.js";
|
|
@@ -1070,12 +1070,18 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
1070
1070
|
}
|
|
1071
1071
|
}
|
|
1072
1072
|
const terminalLane = readLane(hqRoot, laneId);
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1073
|
+
let activeQuestionCompany;
|
|
1074
|
+
if (envelope?.decision === "ask" && (envelope.questions?.length ?? 0) > 0) {
|
|
1075
|
+
try {
|
|
1076
|
+
activeQuestionCompany = await resolveQuestionCompanyWithFallback(hqRoot, terminalLane, deps.resolveQuestionActiveCompany);
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
if (questionRecordRefusalCode(err) !== "question_company_unavailable")
|
|
1080
|
+
throw err;
|
|
1081
|
+
// applyTerminalToLaneWithResult repeats the synchronous lane and
|
|
1082
|
+
// project checks, then terminalizes this ask with the named refusal.
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1079
1085
|
const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
|
|
1080
1086
|
envelope,
|
|
1081
1087
|
envelopeRef: latest?.ref,
|
package/dist/sentry.js
CHANGED
|
@@ -26,6 +26,9 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
26
26
|
return null;
|
|
27
27
|
event.level = "warning";
|
|
28
28
|
event.tags = { ...event.tags, worker: "flag-registry" };
|
|
29
|
+
const missingFile = flagRegistryWorkerMissingFileTags(hint?.originalException, event);
|
|
30
|
+
if (missingFile)
|
|
31
|
+
event.tags = { ...event.tags, ...missingFile };
|
|
29
32
|
}
|
|
30
33
|
if (!flagRegistryWorkerEvent && isEpipe(hint?.originalException))
|
|
31
34
|
return null;
|
|
@@ -123,6 +126,44 @@ function isExpectedFlagRegistryWorkerStartupFailure(error) {
|
|
|
123
126
|
return missingModule.replaceAll("\\", "/").endsWith("/flag-registry-worker.js") ||
|
|
124
127
|
missingModule === "flag-registry-worker.js";
|
|
125
128
|
}
|
|
129
|
+
function flagRegistryWorkerMissingFileTags(error, event) {
|
|
130
|
+
if (typeof error !== "object" || error === null)
|
|
131
|
+
return null;
|
|
132
|
+
const details = error;
|
|
133
|
+
if (details.code !== "ENOENT" || !["open", "lstat"].includes(String(details.syscall))) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
let missingPath = typeof details.path === "string" ? details.path : "";
|
|
137
|
+
if (!missingPath && typeof details.message === "string") {
|
|
138
|
+
missingPath = /\b(?:open|lstat)\s+['"]([^'"]+)['"]/.exec(details.message)?.[1] ?? "";
|
|
139
|
+
}
|
|
140
|
+
const basename = missingPath.replaceAll("\\", "/").split("/").at(-1) ?? "";
|
|
141
|
+
if (!basename)
|
|
142
|
+
return null;
|
|
143
|
+
const normalizedPath = missingPath.replaceAll("\\", "/");
|
|
144
|
+
const role = basename === "flag-registry-worker.js"
|
|
145
|
+
? "worker_entry"
|
|
146
|
+
: normalizedPath.includes("/node_modules/")
|
|
147
|
+
? "dependency"
|
|
148
|
+
: "other";
|
|
149
|
+
const eventText = [
|
|
150
|
+
event.message,
|
|
151
|
+
...(event.exception?.values?.map((value) => value.value) ?? []),
|
|
152
|
+
].filter((value) => typeof value === "string").join("\n");
|
|
153
|
+
const pathVisibility = eventText.includes(missingPath)
|
|
154
|
+
? "full_path"
|
|
155
|
+
: eventText.includes(basename)
|
|
156
|
+
? "basename_only"
|
|
157
|
+
: "omitted";
|
|
158
|
+
return {
|
|
159
|
+
flag_worker_load_failure: "1",
|
|
160
|
+
flag_worker_missing_basename: basename,
|
|
161
|
+
flag_worker_missing_file_role: role,
|
|
162
|
+
flag_worker_path_in_event: pathVisibility,
|
|
163
|
+
// The HQ beforeSend scrubber redacts credentials but does not strip paths.
|
|
164
|
+
flag_worker_path_scrubbed_by_hq_cli: "no",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
126
167
|
/**
|
|
127
168
|
* Tests substitute only the transport so the real SDK lifecycle can be
|
|
128
169
|
* exercised without an ingest request. Production callers pass nothing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.220.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,12 +22,14 @@
|
|
|
22
22
|
"scripts/mesh-daemon-if-stale.mjs"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
|
|
25
|
+
"build": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
|
+
"pretypecheck": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
|
|
27
28
|
"typecheck": "tsc --noEmit",
|
|
28
29
|
"gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
|
|
29
30
|
"gen:command-catalog": "tsc && node scripts/generate-command-catalog.mjs",
|
|
30
31
|
"lint": "eslint .",
|
|
32
|
+
"pretest": "node scripts/generate-dsn.mjs && node scripts/build-flag-registry-worker.mjs",
|
|
31
33
|
"test": "vitest run",
|
|
32
34
|
"test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
|
|
33
35
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
"@vitest/coverage-v8": "4.1.6",
|
|
67
69
|
"aedes": "1.1.2",
|
|
68
70
|
"aws-sdk-client-mock": "^4.1.0",
|
|
71
|
+
"esbuild": "0.27.0",
|
|
69
72
|
"eslint": "^10.5.0",
|
|
70
73
|
"husky": "^9.1.7",
|
|
71
74
|
"lint-staged": "^15.5.2",
|