@indigoai-us/hq-cli 5.189.0 → 5.190.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 +25 -1
- package/dist/command-catalog.generated.js +32 -1
- package/dist/commands/lanes.d.ts +3 -1
- package/dist/commands/lanes.js +135 -16
- package/dist/lib/bot/promotion-upload.js +17 -2
- package/dist/lib/lanes/index.d.ts +2 -0
- package/dist/lib/lanes/index.js +1 -0
- package/dist/lib/lanes/store.js +129 -6
- package/dist/lib/lanes/stories.js +3 -0
- package/dist/lib/lanes/story-ledger.d.ts +78 -0
- package/dist/lib/lanes/story-ledger.js +762 -0
- package/dist/lib/lanes/types.d.ts +10 -1
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.js +2 -0
- package/dist/utils/vault-api.js +1 -1
- package/package.json +1 -1
package/dist/lib/lanes/store.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveLanesHqRoot } from "./hq-root.js";
|
|
|
15
15
|
import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
|
|
16
16
|
import { HISTORY_CAP, HOST_KINDS, LANE_STORY_STATUSES, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isQueuedReason, isLaneState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
|
|
17
17
|
import { laneStories } from "./stories.js";
|
|
18
|
+
import { laneStoryLedgerEnabled, syncLaneToProjectStoryLedger, } from "./story-ledger.js";
|
|
18
19
|
export const LANES_STORE_REL = path.join("workspace", "lanes");
|
|
19
20
|
export const LANE_RECORDS_DIR = "lanes";
|
|
20
21
|
export const QUESTION_RECORDS_DIR = "questions";
|
|
@@ -319,13 +320,18 @@ function legacyLaneStory(raw) {
|
|
|
319
320
|
}
|
|
320
321
|
function parseLaneStories(raw, filePath) {
|
|
321
322
|
if (raw.stories === undefined) {
|
|
323
|
+
if (raw.story_id === "" && typeof raw.no_story_reason === "string") {
|
|
324
|
+
raw.stories = [];
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
322
327
|
// v1 records carried one story in the top-level fields. Keep the old
|
|
323
328
|
// fields authoritative for the migration and give worker-completed work
|
|
324
329
|
// the review state until a senior explicitly verifies it.
|
|
325
330
|
raw.stories = [legacyLaneStory(raw)];
|
|
326
331
|
return;
|
|
327
332
|
}
|
|
328
|
-
if (!Array.isArray(raw.stories) ||
|
|
333
|
+
if (!Array.isArray(raw.stories) ||
|
|
334
|
+
(raw.stories.length === 0 && !(raw.story_id === "" && typeof raw.no_story_reason === "string"))) {
|
|
329
335
|
throw new LaneSchemaError("stories must be a non-empty array when present", {
|
|
330
336
|
path: filePath,
|
|
331
337
|
field: "stories",
|
|
@@ -397,7 +403,28 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
397
403
|
});
|
|
398
404
|
}
|
|
399
405
|
requireString(raw.project_id, "project_id");
|
|
400
|
-
|
|
406
|
+
if (typeof raw.story_id !== "string" ||
|
|
407
|
+
(raw.story_id.trim() === "" && raw.no_story_reason === undefined)) {
|
|
408
|
+
throw new LaneSchemaError("Expected non-empty string for story_id", {
|
|
409
|
+
path: filePath,
|
|
410
|
+
field: "story_id",
|
|
411
|
+
value: raw.story_id,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (raw.no_story_reason !== undefined && (typeof raw.no_story_reason !== "string" || raw.no_story_reason.trim() === "")) {
|
|
415
|
+
throw new LaneSchemaError("no_story_reason must be a non-empty string", { path: filePath, field: "no_story_reason" });
|
|
416
|
+
}
|
|
417
|
+
if (raw.story_id.trim() !== "" && raw.no_story_reason !== undefined) {
|
|
418
|
+
throw new LaneSchemaError("no_story_reason requires an empty story_id", { path: filePath, field: "no_story_reason" });
|
|
419
|
+
}
|
|
420
|
+
if (raw.ledger_sync_error !== undefined && (typeof raw.ledger_sync_error !== "string" || raw.ledger_sync_error.trim() === "")) {
|
|
421
|
+
throw new LaneSchemaError("ledger_sync_error must be a non-empty string", { path: filePath, field: "ledger_sync_error" });
|
|
422
|
+
}
|
|
423
|
+
if (raw.pending_question_ids !== undefined && (!Array.isArray(raw.pending_question_ids) ||
|
|
424
|
+
raw.pending_question_ids.some((id) => typeof id !== "string" || id.trim() === "") ||
|
|
425
|
+
new Set(raw.pending_question_ids).size !== raw.pending_question_ids.length)) {
|
|
426
|
+
throw new LaneSchemaError("pending_question_ids must be an array of unique non-empty ids", { path: filePath, field: "pending_question_ids" });
|
|
427
|
+
}
|
|
401
428
|
requireTimestamp(raw.created_at, "created_at", filePath);
|
|
402
429
|
requireTimestamp(raw.updated_at, "updated_at", filePath);
|
|
403
430
|
if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
|
|
@@ -435,6 +462,15 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
435
462
|
throw new LaneSchemaError(`Unknown worker policy delivery mode ${JSON.stringify(raw.worker_policy_delivery)}`, { path: filePath, field: "worker_policy_delivery", value: raw.worker_policy_delivery });
|
|
436
463
|
}
|
|
437
464
|
parseLaneStories(raw, filePath);
|
|
465
|
+
if (raw.last_envelope !== undefined) {
|
|
466
|
+
if (!isPlainObject(raw.last_envelope) || typeof raw.last_envelope.ref !== "string" ||
|
|
467
|
+
typeof raw.last_envelope.at !== "string" ||
|
|
468
|
+
!["done", "ask", "blocked"].includes(raw.last_envelope.decision) ||
|
|
469
|
+
(raw.last_envelope.artifacts !== undefined && !Array.isArray(raw.last_envelope.artifacts)) ||
|
|
470
|
+
(raw.last_envelope.blocked_reason !== undefined && typeof raw.last_envelope.blocked_reason !== "string")) {
|
|
471
|
+
throw new LaneSchemaError("last_envelope is malformed", { path: filePath, field: "last_envelope" });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
438
474
|
parseOptionalCompanyField(raw, filePath);
|
|
439
475
|
parseOptionalMeshCompanyField(raw, filePath);
|
|
440
476
|
if (!isPlainObject(raw.senior)) {
|
|
@@ -867,13 +903,14 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
867
903
|
attribution_history: init.attribution_history ?? [],
|
|
868
904
|
};
|
|
869
905
|
parseLaneRecord(record, filePath);
|
|
870
|
-
|
|
906
|
+
const created = withRecordLock(filePath, deps, () => {
|
|
871
907
|
if (deps.existsSync(filePath)) {
|
|
872
908
|
throw new LanesError("lane_exists", `Lane ${lane_id} already exists`, { lane_id });
|
|
873
909
|
}
|
|
874
910
|
atomicWriteJson(filePath, record, deps);
|
|
875
911
|
return record;
|
|
876
912
|
});
|
|
913
|
+
return syncStoryLedgerForLane(hqRoot, created, deps);
|
|
877
914
|
}
|
|
878
915
|
/** Hierarchy lock: separate from record writes so callers can CAS-write while holding it. */
|
|
879
916
|
export function withLaneHireLocks(hqRoot, laneIds, fn, deps = defaultStoreDeps()) {
|
|
@@ -899,7 +936,9 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
|
|
|
899
936
|
if (!LANE_WRITERS.includes(writer)) {
|
|
900
937
|
throw new LanesError("lane_writer_unknown", `Unknown lane writer ${String(writer)}`, { writer });
|
|
901
938
|
}
|
|
902
|
-
|
|
939
|
+
let before;
|
|
940
|
+
const updated = casWrite(laneRecordPath(hqRoot, laneId), expectedVersion, parseLaneRecord, mergeLaneRecords, (current) => {
|
|
941
|
+
before = current;
|
|
903
942
|
// `current` is the pre-update snapshot and stays untouched: the updater
|
|
904
943
|
// receives a deep clone, so mutating its argument in place cannot reach
|
|
905
944
|
// the record the ownership check compares against.
|
|
@@ -916,6 +955,66 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
|
|
|
916
955
|
}
|
|
917
956
|
return updated;
|
|
918
957
|
}, deps);
|
|
958
|
+
if (laneStoryLedgerEnabled() && before && laneLedgerRelevantChange(before, updated)) {
|
|
959
|
+
return syncStoryLedgerForLane(hqRoot, updated, deps);
|
|
960
|
+
}
|
|
961
|
+
return updated;
|
|
962
|
+
}
|
|
963
|
+
function laneLedgerRelevantChange(before, after) {
|
|
964
|
+
return before.state !== after.state || before.project_id !== after.project_id ||
|
|
965
|
+
before.story_id !== after.story_id || before.no_story_reason !== after.no_story_reason ||
|
|
966
|
+
JSON.stringify(before.pending_question_ids) !== JSON.stringify(after.pending_question_ids) ||
|
|
967
|
+
JSON.stringify(before.stories) !== JSON.stringify(after.stories) ||
|
|
968
|
+
JSON.stringify(before.last_envelope) !== JSON.stringify(after.last_envelope);
|
|
969
|
+
}
|
|
970
|
+
function syncStoryLedgerForLane(hqRoot, lane, deps) {
|
|
971
|
+
if (!laneStoryLedgerEnabled())
|
|
972
|
+
return lane;
|
|
973
|
+
try {
|
|
974
|
+
const questionRefs = [];
|
|
975
|
+
for (const questionId of lane.pending_question_ids ?? []) {
|
|
976
|
+
const question = readQuestion(hqRoot, questionId, deps);
|
|
977
|
+
const sameCompany = question.company === null
|
|
978
|
+
? lane.company === null
|
|
979
|
+
: lane.company !== null &&
|
|
980
|
+
question.company.uid === lane.company.uid &&
|
|
981
|
+
question.company.slug === lane.company.slug;
|
|
982
|
+
if (question.lane_id !== lane.lane_id || !sameCompany) {
|
|
983
|
+
throw new LanesError("story_ledger_question_mismatch", `Pending question ${questionId} does not belong to lane ${lane.lane_id}`, {
|
|
984
|
+
lane_id: lane.lane_id,
|
|
985
|
+
question_id: questionId,
|
|
986
|
+
question_lane_id: question.lane_id,
|
|
987
|
+
company: question.company?.slug ?? null,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
if (question.status === "pending") {
|
|
991
|
+
questionRefs.push({
|
|
992
|
+
question_id: question.question_id,
|
|
993
|
+
project_id: question.project_id,
|
|
994
|
+
story_id: question.story_id,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
syncLaneToProjectStoryLedger(hqRoot, lane, questionRefs);
|
|
999
|
+
if (lane.ledger_sync_error !== undefined) {
|
|
1000
|
+
retryUpdateLane(hqRoot, lane.lane_id, "story-ledger", (current) => current.ledger_sync_error === undefined
|
|
1001
|
+
? current
|
|
1002
|
+
: { ...current, ledger_sync_error: undefined }, deps);
|
|
1003
|
+
}
|
|
1004
|
+
return readLane(hqRoot, lane.lane_id, deps);
|
|
1005
|
+
}
|
|
1006
|
+
catch (error) {
|
|
1007
|
+
const message = (error instanceof Error ? error.message : String(error)).slice(0, 1000) || "Story ledger synchronization failed";
|
|
1008
|
+
try {
|
|
1009
|
+
retryUpdateLane(hqRoot, lane.lane_id, "story-ledger", (current) => current.ledger_sync_error === message
|
|
1010
|
+
? current
|
|
1011
|
+
: { ...current, ledger_sync_error: message }, deps);
|
|
1012
|
+
return readLane(hqRoot, lane.lane_id, deps);
|
|
1013
|
+
}
|
|
1014
|
+
catch (recordError) {
|
|
1015
|
+
throw new LanesError("story_ledger_sync_error_unrecorded", "Story ledger synchronization failed and the failure could not be recorded on lane " + lane.lane_id, { lane_id: lane.lane_id, cause: recordError instanceof Error ? recordError.message : String(recordError) });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
919
1018
|
}
|
|
920
1019
|
/**
|
|
921
1020
|
* Replace the primary copy with a terminal snapshot returned by the Outpost
|
|
@@ -948,7 +1047,7 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
|
|
|
948
1047
|
});
|
|
949
1048
|
}
|
|
950
1049
|
const filePath = laneRecordPath(hqRoot, laneId);
|
|
951
|
-
|
|
1050
|
+
const synced = withRecordLock(filePath, deps, () => {
|
|
952
1051
|
const current = readMergedRecord(filePath, parseLaneRecord, mergeLaneRecords, deps);
|
|
953
1052
|
if (current === null) {
|
|
954
1053
|
throw new LaneSchemaError(`Record disappeared under lock: ${filePath}`, {
|
|
@@ -982,6 +1081,7 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
|
|
|
982
1081
|
atomicWriteJson(filePath, synced, deps);
|
|
983
1082
|
return synced;
|
|
984
1083
|
});
|
|
1084
|
+
return syncStoryLedgerForLane(hqRoot, synced, deps);
|
|
985
1085
|
}
|
|
986
1086
|
/** Compare-and-set with conflict retry. Callers that race a concurrent writer re-read. */
|
|
987
1087
|
export function retryUpdateLane(hqRoot, laneId, writer, updater, deps = defaultStoreDeps(), attempts = 5) {
|
|
@@ -1056,7 +1156,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
1056
1156
|
routing_history: init.routing_history ?? [],
|
|
1057
1157
|
};
|
|
1058
1158
|
parseQuestionRecord(record, filePath);
|
|
1059
|
-
|
|
1159
|
+
const created = withRecordLock(filePath, deps, () => {
|
|
1060
1160
|
if (deps.existsSync(filePath)) {
|
|
1061
1161
|
throw new LanesError("question_exists", `Question ${question_id} already exists`, { question_id });
|
|
1062
1162
|
}
|
|
@@ -1064,12 +1164,35 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
1064
1164
|
syncPendingMarker(hqRoot, record, deps);
|
|
1065
1165
|
return record;
|
|
1066
1166
|
});
|
|
1167
|
+
syncLaneQuestionReference(hqRoot, created, deps);
|
|
1168
|
+
return created;
|
|
1067
1169
|
}
|
|
1068
1170
|
export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
|
|
1069
1171
|
const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
|
|
1070
1172
|
syncPendingMarker(hqRoot, next, deps);
|
|
1173
|
+
syncLaneQuestionReference(hqRoot, next, deps);
|
|
1071
1174
|
return next;
|
|
1072
1175
|
}
|
|
1176
|
+
function syncLaneQuestionReference(hqRoot, question, deps) {
|
|
1177
|
+
if (!laneStoryLedgerEnabled())
|
|
1178
|
+
return;
|
|
1179
|
+
const lane = tryReadLane(hqRoot, question.lane_id, deps);
|
|
1180
|
+
if (!lane || question.company?.slug !== lane.company?.slug || question.company?.uid !== lane.company?.uid)
|
|
1181
|
+
return;
|
|
1182
|
+
retryUpdateLane(hqRoot, lane.lane_id, "story-ledger", (current) => {
|
|
1183
|
+
if (question.company?.slug !== current.company?.slug || question.company?.uid !== current.company?.uid)
|
|
1184
|
+
return current;
|
|
1185
|
+
const ids = new Set(current.pending_question_ids ?? []);
|
|
1186
|
+
const hadQuestion = ids.has(question.question_id);
|
|
1187
|
+
if (question.status === "pending")
|
|
1188
|
+
ids.add(question.question_id);
|
|
1189
|
+
else
|
|
1190
|
+
ids.delete(question.question_id);
|
|
1191
|
+
if (hadQuestion === (question.status === "pending"))
|
|
1192
|
+
return current;
|
|
1193
|
+
return { ...current, pending_question_ids: [...ids].sort() };
|
|
1194
|
+
}, deps);
|
|
1195
|
+
}
|
|
1073
1196
|
export function appendQuestionRouting(hqRoot, questionId, expectedVersion, entry, deps = defaultStoreDeps()) {
|
|
1074
1197
|
return updateQuestion(hqRoot, questionId, expectedVersion, (current) => ({
|
|
1075
1198
|
...current,
|
|
@@ -26,6 +26,9 @@ function legacyStatus(lane) {
|
|
|
26
26
|
* lane write, so reading an old record never changes it on disk by itself.
|
|
27
27
|
*/
|
|
28
28
|
export function laneStories(lane) {
|
|
29
|
+
if (lane.no_story_reason && (!Array.isArray(lane.stories) || lane.stories.length === 0)) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
29
32
|
if (Array.isArray(lane.stories) && lane.stories.length > 0) {
|
|
30
33
|
return lane.stories;
|
|
31
34
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type LaneRecord, type LaneState } from "./types.js";
|
|
2
|
+
export declare const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
|
|
3
|
+
declare const STORY_STATES: readonly ["todo", "in_progress", "blocked", "in_review", "verification", "done", "dropped"];
|
|
4
|
+
export interface ProjectStoryLedger {
|
|
5
|
+
projectId: string;
|
|
6
|
+
companySlug?: string;
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StoryLedgerWriteDeps {
|
|
10
|
+
/** Test seam for an unlocked manager edit immediately before the conflict check. */
|
|
11
|
+
beforeRename?: (filePath: string, attempt: number) => void;
|
|
12
|
+
/** Test seam for a fresh lock replacing the path after a stale lock was inspected. */
|
|
13
|
+
beforeStaleLockRename?: (lockPath: string) => void;
|
|
14
|
+
/** Deterministic clock seam for stale-lock and timeout tests. */
|
|
15
|
+
clock?: {
|
|
16
|
+
now: () => number;
|
|
17
|
+
sleep: (ms: number) => void;
|
|
18
|
+
};
|
|
19
|
+
lockTimeoutMs?: number;
|
|
20
|
+
lockRetryMs?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface StoryLedgerQuestionRef {
|
|
23
|
+
question_id: string;
|
|
24
|
+
project_id: string;
|
|
25
|
+
story_id: string;
|
|
26
|
+
}
|
|
27
|
+
export interface LaneStatusSnapshot {
|
|
28
|
+
schema_version: "hq-lanes-status.v1";
|
|
29
|
+
project_id: string;
|
|
30
|
+
ledger_path: string;
|
|
31
|
+
generated_at: string;
|
|
32
|
+
workers: Array<{
|
|
33
|
+
lane_id: string;
|
|
34
|
+
company: string | null;
|
|
35
|
+
provider: string;
|
|
36
|
+
account: string | null;
|
|
37
|
+
model: string | null;
|
|
38
|
+
effort: string | null;
|
|
39
|
+
stories: string[];
|
|
40
|
+
no_story_reason: string | null;
|
|
41
|
+
state: LaneState;
|
|
42
|
+
queued_reason: string | null;
|
|
43
|
+
}>;
|
|
44
|
+
prs: Array<{
|
|
45
|
+
story_id: string;
|
|
46
|
+
reference: string;
|
|
47
|
+
}>;
|
|
48
|
+
blockers: Array<{
|
|
49
|
+
story_id: string;
|
|
50
|
+
lane_id?: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}>;
|
|
53
|
+
pending_questions: Array<{
|
|
54
|
+
story_id: string;
|
|
55
|
+
lane_id: string;
|
|
56
|
+
question_ids: string[];
|
|
57
|
+
}>;
|
|
58
|
+
story_counts: {
|
|
59
|
+
total: number;
|
|
60
|
+
by_state: Record<(typeof STORY_STATES)[number], number>;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export declare function laneStoryLedgerEnabled(): boolean;
|
|
64
|
+
export declare function resolveProjectStoryLedger(hqRoot: string, projectId: string, companySlug?: string): ProjectStoryLedger;
|
|
65
|
+
export declare function validateProjectStories(hqRoot: string, projectId: string, storyIds: readonly string[], companySlug?: string): ProjectStoryLedger;
|
|
66
|
+
export declare function syncLaneToProjectStoryLedger(hqRoot: string, lane: LaneRecord, questionRefs?: readonly StoryLedgerQuestionRef[], deps?: StoryLedgerWriteDeps): void;
|
|
67
|
+
export declare function buildLaneStatusSnapshot(input: {
|
|
68
|
+
hqRoot: string;
|
|
69
|
+
projectId: string;
|
|
70
|
+
companySlug?: string;
|
|
71
|
+
companyUid?: string;
|
|
72
|
+
lanes: readonly LaneRecord[];
|
|
73
|
+
generatedAt: string;
|
|
74
|
+
pendingQuestions?: ReadonlyMap<string, readonly StoryLedgerQuestionRef[]>;
|
|
75
|
+
}): LaneStatusSnapshot;
|
|
76
|
+
export declare function formatLaneStatusHuman(snapshot: LaneStatusSnapshot): string;
|
|
77
|
+
export {};
|
|
78
|
+
//# sourceMappingURL=story-ledger.d.ts.map
|