@indigoai-us/hq-cli 5.219.0 → 5.221.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 +25 -0
- package/dist/command-catalog.generated.d.ts +37 -0
- package/dist/command-catalog.generated.js +48 -0
- package/dist/commands/lanes-checks.d.ts +36 -2
- package/dist/commands/lanes-checks.js +67 -9
- package/dist/commands/lanes-report.d.ts +11 -0
- package/dist/commands/lanes-report.js +330 -0
- package/dist/commands/lanes.d.ts +2 -1
- package/dist/commands/lanes.js +82 -57
- 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/timestamp.d.ts +3 -0
- package/dist/lib/lanes/timestamp.js +26 -0
- package/dist/lib/lanes/tracker-report-state.d.ts +10 -0
- package/dist/lib/lanes/tracker-report-state.js +124 -0
- package/dist/lib/lanes/tracker-report.d.ts +203 -0
- package/dist/lib/lanes/tracker-report.js +522 -0
- 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) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Parse an extended ISO timestamp and return JavaScript's canonical UTC form. */
|
|
2
|
+
export function normalizeIsoTimestamp(value) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return null;
|
|
5
|
+
const match = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d+))?(Z|([+-])(\d\d):(\d\d))$/.exec(value);
|
|
6
|
+
if (!match)
|
|
7
|
+
return null;
|
|
8
|
+
const year = Number(match[1]);
|
|
9
|
+
const month = Number(match[2]);
|
|
10
|
+
const day = Number(match[3]);
|
|
11
|
+
const hour = Number(match[4]);
|
|
12
|
+
const minute = Number(match[5]);
|
|
13
|
+
const second = Number(match[6]);
|
|
14
|
+
const offsetHour = match[9] === undefined ? 0 : Number(match[10]);
|
|
15
|
+
const offsetMinute = match[9] === undefined ? 0 : Number(match[11]);
|
|
16
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
17
|
+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
|
|
18
|
+
if (month < 1 || month > 12 || day < 1 || daysInMonth === undefined || day > daysInMonth ||
|
|
19
|
+
hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59)
|
|
20
|
+
return null;
|
|
21
|
+
const parsed = Date.parse(value);
|
|
22
|
+
if (!Number.isFinite(parsed))
|
|
23
|
+
return null;
|
|
24
|
+
return new Date(parsed).toISOString();
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=timestamp.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const FIRST_TRACKER_REPORT_SINCE = "1970-01-01T00:00:00.000Z";
|
|
2
|
+
export interface TrackerReportState {
|
|
3
|
+
schema_version: "hq-lanes-report-state.v1";
|
|
4
|
+
company: string;
|
|
5
|
+
last_report_at: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function trackerReportStatePath(root: string, company: string): string;
|
|
8
|
+
export declare function readTrackerReportState(root: string, company: string): TrackerReportState | null;
|
|
9
|
+
export declare function writeTrackerReportState(root: string, company: string, at: string): TrackerReportState;
|
|
10
|
+
//# sourceMappingURL=tracker-report-state.d.ts.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
import { normalizeIsoTimestamp } from "./timestamp.js";
|
|
6
|
+
export const FIRST_TRACKER_REPORT_SINCE = "1970-01-01T00:00:00.000Z";
|
|
7
|
+
export function trackerReportStatePath(root, company) {
|
|
8
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(company) || company === "." || company === "..") {
|
|
9
|
+
throw new LanesError("tracker_report_company_invalid", "Company slug must be a safe path segment", { company });
|
|
10
|
+
}
|
|
11
|
+
return path.join(root, "workspace", "lane-briefs", "tracker-report-state", `${company}.json`);
|
|
12
|
+
}
|
|
13
|
+
function stateError(error, message, file) {
|
|
14
|
+
throw new LanesError(error, `${message}: ${file}`, { path: file });
|
|
15
|
+
}
|
|
16
|
+
function validReportTime(value, file) {
|
|
17
|
+
const normalized = normalizeIsoTimestamp(value);
|
|
18
|
+
if (normalized === null) {
|
|
19
|
+
return stateError("tracker_report_state_invalid", "Report state has an invalid last_report_at", file);
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
export function readTrackerReportState(root, company) {
|
|
24
|
+
const file = trackerReportStatePath(root, company);
|
|
25
|
+
if (stateDirectory(root, false) === null)
|
|
26
|
+
return null;
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
const stat = fs.lstatSync(file);
|
|
30
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
31
|
+
return stateError("tracker_report_state_invalid", "Report state path must be a regular file", file);
|
|
32
|
+
}
|
|
33
|
+
raw = fs.readFileSync(file, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code === "ENOENT")
|
|
37
|
+
return null;
|
|
38
|
+
if (err instanceof LanesError)
|
|
39
|
+
throw err;
|
|
40
|
+
return stateError("tracker_report_state_unreadable", `Report state could not be read: ${err instanceof Error ? err.message : String(err)}`, file);
|
|
41
|
+
}
|
|
42
|
+
let value;
|
|
43
|
+
try {
|
|
44
|
+
value = JSON.parse(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return stateError("tracker_report_state_malformed", "Report state contains invalid JSON", file);
|
|
48
|
+
}
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
50
|
+
return stateError("tracker_report_state_invalid", "Report state must be an object", file);
|
|
51
|
+
}
|
|
52
|
+
const record = value;
|
|
53
|
+
if (record.schema_version !== "hq-lanes-report-state.v1" || record.company !== company) {
|
|
54
|
+
return stateError("tracker_report_state_invalid", "Report state schema or company does not match", file);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
schema_version: "hq-lanes-report-state.v1",
|
|
58
|
+
company,
|
|
59
|
+
last_report_at: validReportTime(record.last_report_at, file),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function stateDirectory(root, create) {
|
|
63
|
+
let rootReal;
|
|
64
|
+
try {
|
|
65
|
+
rootReal = fs.realpathSync(root);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return stateError("tracker_report_state_root_invalid", `HQ root could not be resolved: ${err instanceof Error ? err.message : String(err)}`, root);
|
|
69
|
+
}
|
|
70
|
+
let current = rootReal;
|
|
71
|
+
for (const segment of ["workspace", "lane-briefs", "tracker-report-state"]) {
|
|
72
|
+
current = path.join(current, segment);
|
|
73
|
+
let stat;
|
|
74
|
+
try {
|
|
75
|
+
stat = fs.lstatSync(current);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err.code === "ENOENT" && !create)
|
|
79
|
+
return null;
|
|
80
|
+
if (err.code !== "ENOENT") {
|
|
81
|
+
return stateError("tracker_report_state_path_unreadable", `Report state directory could not be inspected: ${err instanceof Error ? err.message : String(err)}`, current);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
85
|
+
}
|
|
86
|
+
catch (mkdirError) {
|
|
87
|
+
return stateError("tracker_report_state_path_unwritable", `Report state directory could not be created: ${mkdirError instanceof Error ? mkdirError.message : String(mkdirError)}`, current);
|
|
88
|
+
}
|
|
89
|
+
stat = fs.lstatSync(current);
|
|
90
|
+
}
|
|
91
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
92
|
+
return stateError("tracker_report_state_path_invalid", "Report state directory must be a real directory", current);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return current;
|
|
96
|
+
}
|
|
97
|
+
export function writeTrackerReportState(root, company, at) {
|
|
98
|
+
const canonicalAt = validReportTime(at, trackerReportStatePath(root, company));
|
|
99
|
+
const directory = stateDirectory(root, true);
|
|
100
|
+
if (directory === null)
|
|
101
|
+
return stateError("tracker_report_state_path_unavailable", "Report state directory is unavailable", trackerReportStatePath(root, company));
|
|
102
|
+
const file = trackerReportStatePath(root, company);
|
|
103
|
+
const temp = path.join(directory, `.${company}.${randomUUID()}.tmp`);
|
|
104
|
+
const value = { schema_version: "hq-lanes-report-state.v1", company, last_report_at: canonicalAt };
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
107
|
+
fs.renameSync(temp, file);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(temp);
|
|
112
|
+
}
|
|
113
|
+
catch (cleanupError) {
|
|
114
|
+
if (cleanupError.code !== "ENOENT") {
|
|
115
|
+
throw new LanesError("tracker_report_state_cleanup_failed", `Report state temporary file could not be removed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { path: temp });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (err instanceof LanesError)
|
|
119
|
+
throw err;
|
|
120
|
+
return stateError("tracker_report_state_write_failed", `Report state could not be written: ${err instanceof Error ? err.message : String(err)}`, file);
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=tracker-report-state.js.map
|