@indigoai-us/hq-cli 5.197.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.
- package/CHANGELOG.md +25 -0
- package/dist/command-catalog.generated.d.ts +390 -0
- package/dist/command-catalog.generated.js +506 -0
- package/dist/commands/index-cmd.d.ts +7 -1
- package/dist/commands/index-cmd.js +46 -21
- package/dist/commands/lanes-tracker.d.ts +20 -0
- package/dist/commands/lanes-tracker.js +420 -0
- package/dist/commands/lanes.d.ts +6 -0
- package/dist/commands/lanes.js +18 -0
- package/dist/lib/bot/inflight.d.ts +12 -0
- package/dist/lib/bot/run.js +21 -2
- package/dist/lib/bot/runtime/messages-stream.d.ts +21 -1
- package/dist/lib/bot/runtime/messages-stream.js +54 -2
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +3 -1
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +69 -40
- package/dist/lib/lanes/tracker-store.d.ts +27 -0
- package/dist/lib/lanes/tracker-store.js +277 -0
- package/dist/lib/search-index/embed-lock.d.ts +5 -4
- package/dist/lib/search-index/embed-lock.js +5 -4
- package/dist/lib/search-index/reindex-single-flight-flag.d.ts +5 -0
- package/dist/lib/search-index/reindex-single-flight-flag.js +8 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
const KIND_SET = new Set(["blocker", "kpi", "okr", "directive"]);
|
|
6
|
+
const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
7
|
+
function storeError(error, message, details = {}) {
|
|
8
|
+
return new LanesError(error, message, details);
|
|
9
|
+
}
|
|
10
|
+
function validateId(id) {
|
|
11
|
+
if (!ID_RE.test(id) || id === "." || id === "..") {
|
|
12
|
+
throw storeError("tracker_id_invalid", "Tracker record id must be a safe path segment", { id });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function recordPath(root, kind, id) {
|
|
16
|
+
validateId(id);
|
|
17
|
+
if (!KIND_SET.has(kind))
|
|
18
|
+
throw storeError("tracker_kind_invalid", "Unknown tracker record kind", { kind });
|
|
19
|
+
return path.join(root, kind, `${id}.json`);
|
|
20
|
+
}
|
|
21
|
+
function assertRealDirectory(directory) {
|
|
22
|
+
try {
|
|
23
|
+
const stat = fs.lstatSync(directory);
|
|
24
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
25
|
+
throw storeError("tracker_store_path_invalid", "Tracker storage path must be a real directory", { path: directory });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code !== "ENOENT")
|
|
30
|
+
throw err;
|
|
31
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function ensureKindDirectory(root, kind) {
|
|
35
|
+
assertRealDirectory(root);
|
|
36
|
+
const directory = path.join(root, kind);
|
|
37
|
+
assertRealDirectory(directory);
|
|
38
|
+
return directory;
|
|
39
|
+
}
|
|
40
|
+
function existingKindDirectory(root, kind) {
|
|
41
|
+
try {
|
|
42
|
+
const rootStat = fs.lstatSync(root);
|
|
43
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
44
|
+
throw storeError("tracker_store_path_invalid", "Tracker storage path must be a real directory", { path: root });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err.code === "ENOENT")
|
|
49
|
+
return null;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
const directory = path.join(root, kind);
|
|
53
|
+
try {
|
|
54
|
+
const kindStat = fs.lstatSync(directory);
|
|
55
|
+
if (!kindStat.isDirectory() || kindStat.isSymbolicLink()) {
|
|
56
|
+
throw storeError("tracker_store_path_invalid", "Tracker kind path must be a real directory", { path: directory });
|
|
57
|
+
}
|
|
58
|
+
return directory;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err.code === "ENOENT")
|
|
62
|
+
return null;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function parseRecord(text, expectedKind, id, file) {
|
|
67
|
+
let value;
|
|
68
|
+
try {
|
|
69
|
+
value = JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw storeError("tracker_record_malformed", "Tracker record contains invalid JSON", { kind: expectedKind, id, path: file });
|
|
73
|
+
}
|
|
74
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
75
|
+
throw storeError("tracker_record_invalid", "Tracker record must be a JSON object", { kind: expectedKind, id, path: file });
|
|
76
|
+
}
|
|
77
|
+
const record = value;
|
|
78
|
+
if (record.schema_version !== "hq-lanes-tracker-record.v1" ||
|
|
79
|
+
record.kind !== expectedKind ||
|
|
80
|
+
record.id !== id ||
|
|
81
|
+
typeof record.company !== "string" || !record.company ||
|
|
82
|
+
typeof record.project_id !== "string" || !record.project_id ||
|
|
83
|
+
!Number.isSafeInteger(record.version) || (record.version ?? 0) < 1 ||
|
|
84
|
+
typeof record.created_at !== "string" || typeof record.updated_at !== "string") {
|
|
85
|
+
throw storeError("tracker_record_invalid", "Tracker record is missing valid common fields", { kind: expectedKind, id, path: file });
|
|
86
|
+
}
|
|
87
|
+
return record;
|
|
88
|
+
}
|
|
89
|
+
function writeExclusive(file, contents) {
|
|
90
|
+
const dir = path.dirname(file);
|
|
91
|
+
const temp = path.join(dir, `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
92
|
+
try {
|
|
93
|
+
fs.writeFileSync(temp, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
94
|
+
// link is an atomic create-if-absent operation; rename would overwrite a
|
|
95
|
+
// concurrent creator's record if a generated id ever collided.
|
|
96
|
+
fs.linkSync(temp, file);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err.code === "EEXIST") {
|
|
100
|
+
throw storeError("tracker_record_exists", "Tracker record id already exists", { path: file });
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
removeTempFile(temp);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function removeTempFile(file) {
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(file);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err.code !== "ENOENT")
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function withRecordLock(file, action) {
|
|
118
|
+
const lock = `${file}.lock`;
|
|
119
|
+
let fd;
|
|
120
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
121
|
+
try {
|
|
122
|
+
fd = fs.openSync(lock, "wx", 0o600);
|
|
123
|
+
fs.writeSync(fd, `${process.pid}\n`);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err.code !== "EEXIST")
|
|
128
|
+
throw err;
|
|
129
|
+
// Recover a lock only when it is old and its owner is gone. Rechecking
|
|
130
|
+
// inode and mtime before unlinking avoids deleting a freshly replaced lock.
|
|
131
|
+
let first;
|
|
132
|
+
try {
|
|
133
|
+
first = fs.lstatSync(lock);
|
|
134
|
+
}
|
|
135
|
+
catch (statError) {
|
|
136
|
+
if (statError.code === "ENOENT")
|
|
137
|
+
continue;
|
|
138
|
+
throw statError;
|
|
139
|
+
}
|
|
140
|
+
if (Date.now() - first.mtimeMs < 5 * 60_000) {
|
|
141
|
+
throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
|
|
142
|
+
}
|
|
143
|
+
let owner = null;
|
|
144
|
+
try {
|
|
145
|
+
const raw = fs.readFileSync(lock, "utf8").trim();
|
|
146
|
+
const parsed = Number(raw);
|
|
147
|
+
if (Number.isSafeInteger(parsed) && parsed > 0)
|
|
148
|
+
owner = parsed;
|
|
149
|
+
}
|
|
150
|
+
catch (readError) {
|
|
151
|
+
if (readError.code !== "ENOENT")
|
|
152
|
+
throw readError;
|
|
153
|
+
}
|
|
154
|
+
if (owner !== null) {
|
|
155
|
+
try {
|
|
156
|
+
process.kill(owner, 0);
|
|
157
|
+
throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
|
|
158
|
+
}
|
|
159
|
+
catch (probeError) {
|
|
160
|
+
if (probeError instanceof LanesError)
|
|
161
|
+
throw probeError;
|
|
162
|
+
if (probeError.code !== "ESRCH")
|
|
163
|
+
throw probeError;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const second = fs.lstatSync(lock);
|
|
168
|
+
if (second.ino === first.ino && second.dev === first.dev && second.mtimeMs === first.mtimeMs)
|
|
169
|
+
fs.unlinkSync(lock);
|
|
170
|
+
}
|
|
171
|
+
catch (unlinkError) {
|
|
172
|
+
if (unlinkError.code !== "ENOENT")
|
|
173
|
+
throw unlinkError;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (fd === undefined)
|
|
178
|
+
throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
|
|
179
|
+
try {
|
|
180
|
+
return action();
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
fs.closeSync(fd);
|
|
184
|
+
fs.unlinkSync(lock);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function createLocalTrackerStore(projectDirectory, now = () => new Date()) {
|
|
188
|
+
const root = path.join(projectDirectory, "tracker");
|
|
189
|
+
const read = (kind, id) => {
|
|
190
|
+
const file = recordPath(root, kind, id);
|
|
191
|
+
let text;
|
|
192
|
+
try {
|
|
193
|
+
const stat = fs.lstatSync(file);
|
|
194
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
195
|
+
throw storeError("tracker_record_invalid", "Tracker record path must be a regular file", { kind, id, path: file });
|
|
196
|
+
}
|
|
197
|
+
text = fs.readFileSync(file, "utf8");
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (err.code === "ENOENT")
|
|
201
|
+
return null;
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
return parseRecord(text, kind, id, file);
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
read,
|
|
208
|
+
list(kind) {
|
|
209
|
+
const directory = existingKindDirectory(root, kind);
|
|
210
|
+
if (directory === null)
|
|
211
|
+
return [];
|
|
212
|
+
return fs.readdirSync(directory)
|
|
213
|
+
.filter((name) => name.endsWith(".json"))
|
|
214
|
+
.sort()
|
|
215
|
+
.map((name) => {
|
|
216
|
+
const id = name.slice(0, -5);
|
|
217
|
+
return read(kind, id);
|
|
218
|
+
})
|
|
219
|
+
.filter((record) => record !== null)
|
|
220
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(b.id));
|
|
221
|
+
},
|
|
222
|
+
create(input) {
|
|
223
|
+
const kind = input.kind;
|
|
224
|
+
const directory = ensureKindDirectory(root, kind);
|
|
225
|
+
const id = typeof input.id === "string" && input.id ? input.id : randomUUID();
|
|
226
|
+
const time = now().toISOString();
|
|
227
|
+
const record = {
|
|
228
|
+
...input,
|
|
229
|
+
schema_version: "hq-lanes-tracker-record.v1",
|
|
230
|
+
kind,
|
|
231
|
+
id,
|
|
232
|
+
company: input.company,
|
|
233
|
+
project_id: input.project_id,
|
|
234
|
+
version: 1,
|
|
235
|
+
created_at: time,
|
|
236
|
+
updated_at: time,
|
|
237
|
+
};
|
|
238
|
+
writeExclusive(path.join(directory, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`);
|
|
239
|
+
return record;
|
|
240
|
+
},
|
|
241
|
+
update(kind, id, expectedVersion, change) {
|
|
242
|
+
const file = recordPath(root, kind, id);
|
|
243
|
+
const directory = ensureKindDirectory(root, kind);
|
|
244
|
+
return withRecordLock(file, () => {
|
|
245
|
+
const current = read(kind, id);
|
|
246
|
+
if (!current)
|
|
247
|
+
throw storeError("tracker_record_not_found", `Tracker ${kind} record ${id} was not found`, { kind, id });
|
|
248
|
+
if (current.version !== expectedVersion) {
|
|
249
|
+
throw storeError("tracker_version_conflict", `Tracker record version conflict: expected ${expectedVersion}, found ${current.version}`, {
|
|
250
|
+
kind, id, expected: expectedVersion, actual: current.version,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const changed = change(current);
|
|
254
|
+
if (changed.id !== current.id || changed.kind !== current.kind || changed.company !== current.company || changed.project_id !== current.project_id || changed.created_at !== current.created_at) {
|
|
255
|
+
throw storeError("tracker_update_invalid", "Tracker update cannot change record identity", { kind, id });
|
|
256
|
+
}
|
|
257
|
+
const next = {
|
|
258
|
+
...changed,
|
|
259
|
+
schema_version: "hq-lanes-tracker-record.v1",
|
|
260
|
+
version: current.version + 1,
|
|
261
|
+
created_at: current.created_at,
|
|
262
|
+
updated_at: now().toISOString(),
|
|
263
|
+
};
|
|
264
|
+
const temp = path.join(directory, `.${id}.${randomUUID()}.tmp`);
|
|
265
|
+
try {
|
|
266
|
+
fs.writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
267
|
+
fs.renameSync(temp, file);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
removeTempFile(temp);
|
|
271
|
+
}
|
|
272
|
+
return next;
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=tracker-store.js.map
|
|
@@ -12,9 +12,10 @@ export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
|
|
|
12
12
|
};
|
|
13
13
|
export type EmbedLockRunResult = 'ran' | 'skipped' | 'busy' | 'unavailable';
|
|
14
14
|
/**
|
|
15
|
-
* Serializes
|
|
16
|
-
* another tool that does not call this module) cannot
|
|
17
|
-
* is a third-party package and does not know about
|
|
15
|
+
* Serializes QMD writes started through HQ-managed hq-cli paths. A bare `qmd
|
|
16
|
+
* update` / `qmd embed` (or another tool that does not call this module) cannot
|
|
17
|
+
* participate, because qmd is a third-party package and does not know about
|
|
18
|
+
* HQ's lock protocol.
|
|
18
19
|
*/
|
|
19
20
|
export declare const EMBED_LOCK_NAME = "qmd-reindex-bg.lock";
|
|
20
21
|
export declare function defaultEmbedLockDependencies(env?: NodeJS.ProcessEnv): EmbedLockDependencies;
|
|
@@ -26,7 +27,7 @@ export declare function releaseEmbedLock(home: string, dependencies: EmbedLockDe
|
|
|
26
27
|
/** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
|
|
27
28
|
export declare function installEmbedLockCleanup(cleanup: () => void, processEvents?: EmbedLockProcessEvents, exit?: (code: number) => void): () => void;
|
|
28
29
|
/**
|
|
29
|
-
* Run one synchronous
|
|
30
|
+
* Run one synchronous QMD write or reindex pass while owning the shared lock.
|
|
30
31
|
*
|
|
31
32
|
* This deliberately does not install signal handlers. `run` is a synchronous
|
|
32
33
|
* qmd child, so JavaScript cannot execute a cleanup handler until it returns;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
/**
|
|
4
|
-
* Serializes
|
|
5
|
-
* another tool that does not call this module) cannot
|
|
6
|
-
* is a third-party package and does not know about
|
|
4
|
+
* Serializes QMD writes started through HQ-managed hq-cli paths. A bare `qmd
|
|
5
|
+
* update` / `qmd embed` (or another tool that does not call this module) cannot
|
|
6
|
+
* participate, because qmd is a third-party package and does not know about
|
|
7
|
+
* HQ's lock protocol.
|
|
7
8
|
*/
|
|
8
9
|
export const EMBED_LOCK_NAME = 'qmd-reindex-bg.lock';
|
|
9
10
|
export function defaultEmbedLockDependencies(env = process.env) {
|
|
@@ -261,7 +262,7 @@ export function installEmbedLockCleanup(cleanup, processEvents = process, exit =
|
|
|
261
262
|
};
|
|
262
263
|
}
|
|
263
264
|
/**
|
|
264
|
-
* Run one synchronous
|
|
265
|
+
* Run one synchronous QMD write or reindex pass while owning the shared lock.
|
|
265
266
|
*
|
|
266
267
|
* This deliberately does not install signal handlers. `run` is a synchronous
|
|
267
268
|
* qmd child, so JavaScript cannot execute a cleanup handler until it returns;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
|
|
2
|
+
export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
|
|
3
|
+
/** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
|
|
4
|
+
export declare function qmdReindexSingleFlightEnabled(): boolean;
|
|
5
|
+
//# sourceMappingURL=reindex-single-flight-flag.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { resolveProcessFlagGate } from "../flag-registry.js";
|
|
2
|
+
export const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
|
|
3
|
+
export const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
|
|
4
|
+
/** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
|
|
5
|
+
export function qmdReindexSingleFlightEnabled() {
|
|
6
|
+
return resolveProcessFlagGate(QMD_REINDEX_SINGLE_FLIGHT_FLAG, { fallback: QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK }, () => QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK);
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=reindex-single-flight-flag.js.map
|