@indigoai-us/hq-cli 5.196.0 → 5.198.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.
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Contract preserved verbatim: a missing qmd or a non-HQ path is a silent
9
9
  * success (a sync must never fail because indexing could not run), the lexical
10
- * update always runs, and embeddings rebuild only on `--embed`.
10
+ * update normally runs, and embeddings rebuild only on `--embed`. An enabled
11
+ * single-flight canary may skip a pass while another QMD writer owns the lock.
11
12
  */
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
@@ -16,6 +17,7 @@ import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
16
17
  import { defaultEmbedLockDependencies, runWithEmbedLock, } from "../search-index/embed-lock.js";
17
18
  import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from "../search-index/load-gate.js";
18
19
  import { resolveProcessFlagGate } from "../flag-registry.js";
20
+ import { qmdReindexSingleFlightEnabled } from "../search-index/reindex-single-flight-flag.js";
19
21
  import { isQmdNativeBindingError, QMD_NATIVE_BINDING_REMEDY, } from "../../utils/qmd-native-binding-error.js";
20
22
  import { ioFor, line } from "./common.js";
21
23
  export const QMD_REINDEX_STARTUP_WARNING_FLAG = "qmd.reindex-startup-warning";
@@ -58,52 +60,59 @@ export function qmdReindexAfterSync(args = [], options = {}) {
58
60
  startupFailureWarningShown = true;
59
61
  line(stderr, `hq: search index was not updated after sync; ${QMD_NATIVE_BINDING_REMEDY}`);
60
62
  };
61
- // Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
62
- try {
63
- reconcile(hqRoot, { bin });
64
- }
65
- catch {
66
- /* registration is advisory; an update still helps */
67
- }
68
- // The cap has to land before the update, not after: qmd decides what to read
69
- // at glob time, so an ignore entry written afterwards saves nothing on this
70
- // pass. This is the post-sync entry point, which reaches `qmd update` without
71
- // going through `hq index sync` at all.
72
- try {
73
- sizeLimit(hqRoot);
74
- }
75
- catch {
76
- /* an uncapped index is worse than none, but still better than a failed sync */
77
- }
78
- try {
79
- run(["update"], { bin, cwd: hqRoot });
80
- }
81
- catch (error) {
82
- warnOnNativeStartupFailure(error);
83
- /* never fail a sync on index freshness */
84
- }
85
- if (embed) {
63
+ const updateIndex = () => {
64
+ // Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
65
+ try {
66
+ reconcile(hqRoot, { bin });
67
+ }
68
+ catch {
69
+ /* registration is advisory; an update still helps */
70
+ }
71
+ // The cap has to land before the update: qmd decides what to read
72
+ // at glob time, so an ignore entry written afterwards saves nothing on this
73
+ // pass. This is the post-sync entry point, which reaches `qmd update` without
74
+ // going through `hq index sync` at all.
75
+ try {
76
+ sizeLimit(hqRoot);
77
+ }
78
+ catch {
79
+ /* an uncapped index is worse than none, but still better than a failed sync */
80
+ }
81
+ try {
82
+ run(["update"], { bin, cwd: hqRoot });
83
+ }
84
+ catch (error) {
85
+ warnOnNativeStartupFailure(error);
86
+ /* never fail a sync on index freshness */
87
+ }
88
+ };
89
+ const runEmbed = (home, lockDependencies) => {
90
+ const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
91
+ const decision = checkEmbedLoad(home, loadGateDependencies);
92
+ if (decision.action === "skip") {
93
+ line(stderr, decision.notice.trimEnd());
94
+ return "skipped";
95
+ }
96
+ if (decision.forced)
97
+ line(stderr, decision.notice.trimEnd());
98
+ run(["embed"], { bin, cwd: hqRoot });
99
+ recordSuccessfulEmbed(home, loadGateDependencies);
100
+ return undefined;
101
+ };
102
+ if ((options.singleFlightEnabled ?? qmdReindexSingleFlightEnabled)()) {
86
103
  const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
87
104
  const home = resolveQmdHome(lockDependencies.env);
88
- const loadGateDependencies = options.loadGateDependencies ?? defaultLoadGateDependencies(lockDependencies.env);
89
105
  try {
90
106
  const outcome = runWithEmbedLock(home, lockDependencies, () => {
91
- const decision = checkEmbedLoad(home, loadGateDependencies);
92
- if (decision.action === "skip") {
93
- line(stderr, decision.notice.trimEnd());
94
- return "skipped";
95
- }
96
- if (decision.forced)
97
- line(stderr, decision.notice.trimEnd());
98
- run(["embed"], { bin, cwd: hqRoot });
99
- recordSuccessfulEmbed(home, loadGateDependencies);
100
- return undefined;
107
+ updateIndex();
108
+ return embed ? runEmbed(home, lockDependencies) : undefined;
101
109
  });
102
110
  if (outcome === "busy") {
103
- line(stderr, "hq: qmd embed is already running; skipping this pass.");
111
+ line(stderr, "hq: qmd reindex is already running; skipping this post-sync pass.");
112
+ }
113
+ if (outcome === "unavailable") {
114
+ line(stderr, "hq: cannot access the qmd reindex lock; skipping this post-sync pass.");
104
115
  }
105
- if (outcome === "unavailable")
106
- line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
107
116
  }
108
117
  catch (error) {
109
118
  if (error instanceof LoadGateConfigurationError)
@@ -111,6 +120,26 @@ export function qmdReindexAfterSync(args = [], options = {}) {
111
120
  warnOnNativeStartupFailure(error);
112
121
  /* embeddings are deferred-cost and optional */
113
122
  }
123
+ return 0;
124
+ }
125
+ updateIndex();
126
+ if (!embed)
127
+ return 0;
128
+ const lockDependencies = options.embedLockDependencies ?? defaultEmbedLockDependencies();
129
+ const home = resolveQmdHome(lockDependencies.env);
130
+ try {
131
+ const outcome = runWithEmbedLock(home, lockDependencies, () => runEmbed(home, lockDependencies));
132
+ if (outcome === "busy") {
133
+ line(stderr, "hq: qmd embed is already running; skipping this pass.");
134
+ }
135
+ if (outcome === "unavailable")
136
+ line(stderr, "hq: cannot access the qmd embed lock; skipping this pass.");
137
+ }
138
+ catch (error) {
139
+ if (error instanceof LoadGateConfigurationError)
140
+ line(stderr, `hq: ${error.message}`);
141
+ warnOnNativeStartupFailure(error);
142
+ /* embeddings are deferred-cost and optional */
114
143
  }
115
144
  return 0;
116
145
  }
@@ -2,13 +2,24 @@
2
2
  import { parentPort, workerData } from "node:worker_threads";
3
3
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
4
4
  import { createFlagClient } from "@indigoai-us/hq-flags-client";
5
- const endpoint = workerData.endpoint;
5
+ const data = workerData;
6
+ const endpoint = data.endpoint;
7
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
8
+ const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
9
  async function refresh() {
7
10
  if (!parentPort || typeof endpoint !== "string" || endpoint.trim() === "")
8
11
  return;
9
12
  try {
13
+ const companyUid = typeof data.companyUid === "string" && COMPANY_UID_RE.test(data.companyUid)
14
+ ? data.companyUid
15
+ : undefined;
16
+ const companyIdentifiers = Array.isArray(data.companyIdentifiers)
17
+ ? data.companyIdentifiers.filter((value) => typeof value === "string" && COMPANY_SLUG_RE.test(value))
18
+ : [];
10
19
  const client = createFlagClient({
11
20
  endpoint,
21
+ ...(companyUid ? { companyUid } : {}),
22
+ ...(companyIdentifiers.length ? { companyIdentifiers } : {}),
12
23
  getToken: () => loadCachedTokens()?.idToken ?? "",
13
24
  refreshIntervalMs: 0,
14
25
  onError: () => { },
@@ -7,8 +7,15 @@
7
7
  */
8
8
  import { Worker } from "node:worker_threads";
9
9
  import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
10
+ import { readMachineCompanyBinding } from "./work-context/company.js";
11
+ import { readSessionState } from "./work-context/state.js";
12
+ import { workContextRoot } from "./work-context/paths.js";
13
+ import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
14
+ import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
10
15
  /** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
11
16
  export const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
17
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
18
+ const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
19
  let heldSnapshot = null;
13
20
  let processReader = null;
14
21
  function processLocalOverride(flagKey) {
@@ -17,14 +24,66 @@ function processLocalOverride(flagKey) {
17
24
  function localOverrideOrFallback(flagKey, legacyFallback) {
18
25
  return processLocalOverride(flagKey) ?? legacyFallback();
19
26
  }
20
- function processSnapshotReader() {
27
+ function normalizedCompanyUid(value) {
28
+ const candidate = value?.trim() ?? "";
29
+ return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
30
+ }
31
+ function normalizedCompanySlug(value) {
32
+ const candidate = value?.trim() ?? "";
33
+ return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
34
+ }
35
+ /**
36
+ * Resolve only an explicit machine/session company binding. Device defaults
37
+ * and cwd/repository heuristics do not scope process-wide feature flags.
38
+ */
39
+ function processCompanyContext(env = process.env) {
40
+ const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
41
+ const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
42
+ if (explicitUid || explicitSlug) {
43
+ return { companyUid: explicitUid, companySlug: explicitSlug };
44
+ }
45
+ const machine = readMachineCompanyBinding(env);
46
+ const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
47
+ const spawnSlug = normalizedCompanySlug(spawnValue);
48
+ const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
49
+ ? { companyUid: spawnValue }
50
+ : spawnSlug
51
+ ? { companySlug: spawnSlug }
52
+ : {
53
+ companyUid: normalizedCompanyUid(machine?.uid),
54
+ companySlug: normalizedCompanySlug(machine?.slug),
55
+ };
56
+ if (machineContext.companyUid || machineContext.companySlug)
57
+ return machineContext;
58
+ const sessionId = env.HQ_SESSION_ID?.trim();
59
+ if (!sessionId)
60
+ return {};
61
+ const session = readSessionState(sessionId, workContextRoot(undefined, env));
62
+ if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
63
+ return {};
64
+ }
65
+ return {
66
+ companyUid: normalizedCompanyUid(session.companyUid),
67
+ companySlug: normalizedCompanySlug(session.companySlug),
68
+ };
69
+ }
70
+ function companyUidForContext(context, env = process.env) {
71
+ if (context.companyUid)
72
+ return context.companyUid;
73
+ if (!context.companySlug)
74
+ return undefined;
75
+ const hqRoot = resolveDefaultHqRoot({ env });
76
+ const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
77
+ return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
78
+ }
79
+ function processSnapshotReader(companyIdentifiers) {
21
80
  return {
22
81
  isEnabled(flagKey, lookup = {}) {
23
82
  return resolveFlag({
24
83
  flagKey,
25
84
  env: process.env,
26
85
  snapshot: heldSnapshot,
27
- companyIdentifiers: [],
86
+ companyIdentifiers,
28
87
  lookup,
29
88
  }).value;
30
89
  },
@@ -45,9 +104,18 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
45
104
  const endpoint = (dependencies.endpoint ?? process.env[FLAG_REGISTRY_ENDPOINT_ENV] ?? "").trim();
46
105
  if (!endpoint)
47
106
  return null;
48
- processReader = processSnapshotReader();
107
+ const company = processCompanyContext();
108
+ const companyUid = companyUidForContext(company);
109
+ const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
110
+ processReader = processSnapshotReader(companyIdentifiers);
49
111
  try {
50
- const worker = (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), { workerData: { endpoint } });
112
+ const worker = (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), {
113
+ workerData: {
114
+ endpoint,
115
+ ...(companyUid ? { companyUid } : {}),
116
+ ...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
117
+ },
118
+ });
51
119
  worker.on("message", (value) => {
52
120
  if (isFlagSnapshot(value))
53
121
  heldSnapshot = value;
@@ -122,6 +122,11 @@ function lockPathFor(filePath) {
122
122
  function lockOwnerPath(lockPath) {
123
123
  return path.join(lockPath, "owner.json");
124
124
  }
125
+ function isInvalidLockOwnerReceipt(err, lockPath) {
126
+ return err instanceof LanesError &&
127
+ err.error === "story_ledger_lock_invalid" &&
128
+ err.details.path === lockOwnerPath(lockPath);
129
+ }
125
130
  function readLockOwner(lockPath) {
126
131
  const ownerPath = lockOwnerPath(lockPath);
127
132
  let contents;
@@ -157,15 +162,17 @@ function lockSleep(deps, ms) {
157
162
  }
158
163
  function restoreMovedLockWithoutReplacement(lockPath, stalePath) {
159
164
  const entries = fs.readdirSync(stalePath);
160
- if (entries.some((entry) => entry !== "owner.json")) {
165
+ const isOwnerTemp = (entry) => /^\.owner-[a-f0-9]{24}\.tmp$/.test(entry);
166
+ if (entries.some((entry) => entry !== "owner.json" && !isOwnerTemp(entry))) {
161
167
  throw new Error("Unexpected entry in moved story ledger lock");
162
168
  }
163
- const ownerContents = entries.includes("owner.json")
164
- ? fs.readFileSync(lockOwnerPath(stalePath), "utf8")
165
- : undefined;
169
+ const files = entries.map((entry) => ({
170
+ name: entry,
171
+ contents: fs.readFileSync(path.join(stalePath, entry), "utf8"),
172
+ }));
166
173
  fs.mkdirSync(lockPath);
167
- if (ownerContents !== undefined) {
168
- fs.writeFileSync(lockOwnerPath(lockPath), ownerContents, {
174
+ for (const file of files) {
175
+ fs.writeFileSync(path.join(lockPath, file.name), file.contents, {
169
176
  encoding: "utf8",
170
177
  flag: "wx",
171
178
  mode: 0o600,
@@ -186,10 +193,18 @@ function clearStaleLock(lockPath, deps) {
186
193
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
187
194
  throw new LanesError("story_ledger_lock_invalid", "Story ledger lock path is not a regular directory: " + lockPath, { path: lockPath });
188
195
  }
189
- const ownerRead = readLockOwner(lockPath);
190
- const hasOwnerToken = hasLockOwnerToken(ownerRead);
196
+ let ownerRead;
197
+ try {
198
+ ownerRead = readLockOwner(lockPath);
199
+ }
200
+ catch (err) {
201
+ if (!isInvalidLockOwnerReceipt(err, lockPath) || lockNow(deps) - stat.mtimeMs >= LOCK_STALE_MS) {
202
+ throw err;
203
+ }
204
+ return false;
205
+ }
191
206
  let lockAt = stat.mtimeMs;
192
- if (hasOwnerToken && typeof ownerRead.owner.at === "string") {
207
+ if (hasLockOwnerToken(ownerRead) && typeof ownerRead.owner.at === "string") {
193
208
  const parsedAt = Date.parse(ownerRead.owner.at);
194
209
  if (Number.isFinite(parsedAt))
195
210
  lockAt = parsedAt;
@@ -207,7 +222,26 @@ function clearStaleLock(lockPath, deps) {
207
222
  throw err;
208
223
  }
209
224
  const moved = fs.lstatSync(stalePath);
210
- const movedOwnerRead = readLockOwner(stalePath);
225
+ let movedOwnerRead;
226
+ try {
227
+ movedOwnerRead = readLockOwner(stalePath);
228
+ }
229
+ catch (err) {
230
+ if (!isInvalidLockOwnerReceipt(err, stalePath))
231
+ throw err;
232
+ const stillFresh = lockNow(deps) - moved.mtimeMs < LOCK_STALE_MS;
233
+ try {
234
+ // Put the moved directory back before retrying or reporting the invalid receipt.
235
+ restoreMovedLockWithoutReplacement(lockPath, stalePath);
236
+ }
237
+ catch {
238
+ throw new LanesError("story_ledger_lock_race", "A replacement story ledger lock was preserved at " + stalePath +
239
+ " because it could not safely be restored to " + lockPath, { path: lockPath, preserved_path: stalePath });
240
+ }
241
+ if (stillFresh)
242
+ return false;
243
+ throw err;
244
+ }
211
245
  let movedLockAt = moved.mtimeMs;
212
246
  if (hasLockOwnerToken(movedOwnerRead) && typeof movedOwnerRead.owner.at === "string") {
213
247
  const parsedAt = Date.parse(movedOwnerRead.owner.at);
@@ -239,7 +273,14 @@ function acquireLedgerLock(filePath, deps) {
239
273
  while (lockNow(deps) - started < timeoutMs) {
240
274
  try {
241
275
  fs.mkdirSync(lockPath);
242
- fs.writeFileSync(lockOwnerPath(lockPath), JSON.stringify({ pid: process.pid, token, at: new Date(lockNow(deps)).toISOString() }) + "\n", { mode: 0o600 });
276
+ const ownerTempPath = path.join(lockPath, ".owner-" + token + ".tmp");
277
+ try {
278
+ fs.writeFileSync(ownerTempPath, JSON.stringify({ pid: process.pid, token, at: new Date(lockNow(deps)).toISOString() }) + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
279
+ fs.renameSync(ownerTempPath, lockOwnerPath(lockPath));
280
+ }
281
+ finally {
282
+ fs.rmSync(ownerTempPath, { force: true });
283
+ }
243
284
  return { lockPath, token };
244
285
  }
245
286
  catch (err) {
@@ -0,0 +1,27 @@
1
+ export type TrackerKind = "blocker" | "kpi" | "okr" | "directive";
2
+ export interface TrackerRecord {
3
+ schema_version: "hq-lanes-tracker-record.v1";
4
+ kind: TrackerKind;
5
+ id: string;
6
+ company: string;
7
+ project_id: string;
8
+ version: number;
9
+ created_at: string;
10
+ updated_at: string;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface TrackerRecordInput {
14
+ kind: TrackerKind;
15
+ id?: string;
16
+ company: string;
17
+ project_id: string;
18
+ [key: string]: unknown;
19
+ }
20
+ export interface TrackerStore {
21
+ read(kind: TrackerKind, id: string): TrackerRecord | null;
22
+ list(kind: TrackerKind): TrackerRecord[];
23
+ create(record: TrackerRecordInput): TrackerRecord;
24
+ update(kind: TrackerKind, id: string, expectedVersion: number, change: (current: TrackerRecord) => TrackerRecord): TrackerRecord;
25
+ }
26
+ export declare function createLocalTrackerStore(projectDirectory: string, now?: () => Date): TrackerStore;
27
+ //# sourceMappingURL=tracker-store.d.ts.map