ai-foreman 1.7.5 → 1.7.7
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/README.md +17 -2
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/github.js +3 -2
- package/dist/branch/gitlab.js +3 -2
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +37 -3
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +51 -18
- package/dist/buildRuns.js +318 -31
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1040 -62
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +615 -80
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +9 -5
- package/dist/foreman.js +76 -20
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +7 -6
- package/dist/qaReview.js +31 -20
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +28 -0
- package/dist/tickets/reset.js +214 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +23 -0
- package/dist/tickets/setupConfig.js +115 -3
- package/dist/tickets/stateDb.d.ts +42 -1
- package/dist/tickets/stateDb.js +261 -23
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +102 -2
- package/dist/workflowDb.js +460 -8
- package/package.json +35 -3
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { parse, stringify } from "yaml";
|
|
5
6
|
import { loadDefaults } from "special-agents";
|
|
6
7
|
import { appendLegacyTicketSources, loadSourceRegistry } from "../sources/sourceRegistry.js";
|
|
8
|
+
import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js";
|
|
7
9
|
export const RAFI_CONFIG_FILE = "rafi-config.yaml";
|
|
8
10
|
const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
|
|
9
11
|
const SKILL_NAMES = [
|
|
10
12
|
"better-sqlite3-rebuild",
|
|
11
13
|
"grill-me",
|
|
14
|
+
"handoff",
|
|
12
15
|
"improve-codebase-architecture",
|
|
13
16
|
"prd-to-issues",
|
|
14
17
|
"tdd",
|
|
@@ -16,6 +19,7 @@ const SKILL_NAMES = [
|
|
|
16
19
|
];
|
|
17
20
|
export const DEFAULT_TICKET_SETUP = {
|
|
18
21
|
sources: [],
|
|
22
|
+
limits: { implementation: 500, view: 20_000 },
|
|
19
23
|
populate: {
|
|
20
24
|
source_handling: "saved",
|
|
21
25
|
agent_preference: "configured",
|
|
@@ -33,6 +37,26 @@ export const DEFAULT_TICKET_SETUP = {
|
|
|
33
37
|
cleanup: true,
|
|
34
38
|
auto_merge_wait: false,
|
|
35
39
|
auto_merge_timeout_minutes: null,
|
|
40
|
+
base_branch: "main",
|
|
41
|
+
branch_prefix: BUILTIN_BRANCH_PREFIX,
|
|
42
|
+
branch_policy: {
|
|
43
|
+
mode: "global",
|
|
44
|
+
global_strategy: "branch-per-ticket",
|
|
45
|
+
by_size: { XS: "shared", S: "shared", M: "per-ticket", L: "per-ticket", XL: "per-ticket" },
|
|
46
|
+
},
|
|
47
|
+
review: {
|
|
48
|
+
title_style: "ticket-title",
|
|
49
|
+
title_template: null,
|
|
50
|
+
description_sections: ["Summary", "Linked ticket", "Changes made", "Tests and validation performed", "Risks or rollback notes", "Checklist"],
|
|
51
|
+
},
|
|
52
|
+
validation_checklist: [
|
|
53
|
+
"Acceptance criteria satisfied",
|
|
54
|
+
"Required tests pass",
|
|
55
|
+
"Configured project checks pass",
|
|
56
|
+
"Documentation updated when needed",
|
|
57
|
+
"Database and deployment changes reviewed when present",
|
|
58
|
+
"Validation evidence attached",
|
|
59
|
+
],
|
|
36
60
|
},
|
|
37
61
|
};
|
|
38
62
|
export function loadRafiConfigObject(projectDir) {
|
|
@@ -75,7 +99,22 @@ export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
|
|
|
75
99
|
delete config.planning;
|
|
76
100
|
}
|
|
77
101
|
mkdirSync(projectDir, { recursive: true });
|
|
78
|
-
|
|
102
|
+
const target = join(projectDir, RAFI_CONFIG_FILE);
|
|
103
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
104
|
+
writeFileSync(temporary, stringify(config, { lineWidth: 100 }), "utf8");
|
|
105
|
+
renameSync(temporary, target);
|
|
106
|
+
}
|
|
107
|
+
/** Synchronize pending setup limits into the tracker, once the tracker exists. */
|
|
108
|
+
export function syncTicketLimits(projectDir, limits) {
|
|
109
|
+
const path = join(projectDir, ".tickets", "config.yaml");
|
|
110
|
+
if (!existsSync(path))
|
|
111
|
+
return;
|
|
112
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
113
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
114
|
+
throw new Error(`${path}: expected a YAML object`);
|
|
115
|
+
raw.implementation_limit = positiveInteger(limits.implementation, 500, "tickets.limits.implementation");
|
|
116
|
+
raw.view_limit = positiveInteger(limits.view, 20_000, "tickets.limits.view");
|
|
117
|
+
writeFileSync(path, stringify(raw, { lineWidth: 100 }), "utf8");
|
|
79
118
|
}
|
|
80
119
|
export function ensureRafiConfigForTicketSetup(projectDir, opts = {}) {
|
|
81
120
|
const existing = loadRafiConfigObject(projectDir);
|
|
@@ -95,6 +134,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
|
|
|
95
134
|
const raw = value;
|
|
96
135
|
const setup = {
|
|
97
136
|
sources: normalizeSources(raw.sources, `${label}.sources`),
|
|
137
|
+
limits: normalizeLimits(raw.limits, `${label}.limits`),
|
|
98
138
|
populate: normalizePopulate(raw.populate, `${label}.populate`),
|
|
99
139
|
build: normalizeBuild(raw.build, `${label}.build`),
|
|
100
140
|
};
|
|
@@ -103,6 +143,7 @@ export function normalizeTicketsSetupConfig(value, label = "tickets") {
|
|
|
103
143
|
export function denormalizeTicketsSetupConfig(setup) {
|
|
104
144
|
return {
|
|
105
145
|
sources: setup.sources.map((source) => ({ ...source })),
|
|
146
|
+
limits: { ...setup.limits },
|
|
106
147
|
populate: { ...setup.populate },
|
|
107
148
|
build: { ...setup.build },
|
|
108
149
|
};
|
|
@@ -111,6 +152,7 @@ export function mergeTicketSetup(current, patch) {
|
|
|
111
152
|
const base = current ? cloneTicketSetup(current) : cloneTicketSetup(DEFAULT_TICKET_SETUP);
|
|
112
153
|
return normalizeTicketsSetupConfig({
|
|
113
154
|
sources: patch.sources ?? base.sources,
|
|
155
|
+
limits: patch.limits ?? base.limits,
|
|
114
156
|
populate: { ...base.populate, ...(patch.populate ?? {}) },
|
|
115
157
|
build: { ...base.build, ...(patch.build ?? {}) },
|
|
116
158
|
});
|
|
@@ -212,6 +254,22 @@ export function detectGitProvider(projectDir) {
|
|
|
212
254
|
}
|
|
213
255
|
return undefined;
|
|
214
256
|
}
|
|
257
|
+
export function detectDefaultBranch(projectDir) {
|
|
258
|
+
const attempts = [
|
|
259
|
+
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
260
|
+
["config", "--get", "init.defaultBranch"],
|
|
261
|
+
["branch", "--show-current"],
|
|
262
|
+
];
|
|
263
|
+
for (const args of attempts) {
|
|
264
|
+
try {
|
|
265
|
+
const value = execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
266
|
+
if (value)
|
|
267
|
+
return value.replace(/^origin\//, "");
|
|
268
|
+
}
|
|
269
|
+
catch { /* try the next safe source */ }
|
|
270
|
+
}
|
|
271
|
+
return "main";
|
|
272
|
+
}
|
|
215
273
|
export function recommendedBuildDefaults(projectDir) {
|
|
216
274
|
const provider = detectGitProvider(projectDir);
|
|
217
275
|
return {
|
|
@@ -221,6 +279,7 @@ export function recommendedBuildDefaults(projectDir) {
|
|
|
221
279
|
pr_ready: Boolean(provider),
|
|
222
280
|
merge_method: "squash",
|
|
223
281
|
cleanup: true,
|
|
282
|
+
base_branch: detectDefaultBranch(projectDir),
|
|
224
283
|
};
|
|
225
284
|
}
|
|
226
285
|
function minimalRafiConfig(projectDir, opts) {
|
|
@@ -354,6 +413,46 @@ function normalizeBuild(value, label) {
|
|
|
354
413
|
cleanup: booleanField(raw.cleanup, DEFAULT_TICKET_SETUP.build.cleanup, `${label}.cleanup`),
|
|
355
414
|
auto_merge_wait: booleanField(raw.auto_merge_wait, DEFAULT_TICKET_SETUP.build.auto_merge_wait, `${label}.auto_merge_wait`),
|
|
356
415
|
auto_merge_timeout_minutes: nullablePositiveInteger(raw.auto_merge_timeout_minutes, DEFAULT_TICKET_SETUP.build.auto_merge_timeout_minutes, `${label}.auto_merge_timeout_minutes`),
|
|
416
|
+
base_branch: stringField(raw.base_branch, DEFAULT_TICKET_SETUP.build.base_branch),
|
|
417
|
+
branch_prefix: validateBranchPrefix(stringField(raw.branch_prefix, DEFAULT_TICKET_SETUP.build.branch_prefix)),
|
|
418
|
+
branch_policy: normalizeBranchPolicy(raw.branch_policy, raw.branch_strategy, `${label}.branch_policy`),
|
|
419
|
+
review: normalizeReview(raw.review, `${label}.review`),
|
|
420
|
+
validation_checklist: stringArray(raw.validation_checklist, DEFAULT_TICKET_SETUP.build.validation_checklist, `${label}.validation_checklist`),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function normalizeLimits(value, label) {
|
|
424
|
+
const raw = objectOrEmpty(value, label);
|
|
425
|
+
return {
|
|
426
|
+
implementation: positiveInteger(raw.implementation, DEFAULT_TICKET_SETUP.limits.implementation, `${label}.implementation`),
|
|
427
|
+
view: positiveInteger(raw.view, DEFAULT_TICKET_SETUP.limits.view, `${label}.view`),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function normalizeBranchPolicy(value, legacyStrategy, label) {
|
|
431
|
+
const raw = objectOrEmpty(value, label);
|
|
432
|
+
const sizes = objectOrEmpty(raw.by_size, `${label}.by_size`);
|
|
433
|
+
const globalStrategy = enumField(raw.global_strategy ?? legacyStrategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_policy.global_strategy, `${label}.global_strategy`);
|
|
434
|
+
return {
|
|
435
|
+
mode: enumField(raw.mode, ["global", "size"], DEFAULT_TICKET_SETUP.build.branch_policy.mode, `${label}.mode`),
|
|
436
|
+
global_strategy: globalStrategy,
|
|
437
|
+
by_size: {
|
|
438
|
+
XS: enumField(sizes.XS, ["shared", "per-ticket"], "shared", `${label}.by_size.XS`),
|
|
439
|
+
S: enumField(sizes.S, ["shared", "per-ticket"], "shared", `${label}.by_size.S`),
|
|
440
|
+
M: enumField(sizes.M, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.M`),
|
|
441
|
+
L: enumField(sizes.L, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.L`),
|
|
442
|
+
XL: enumField(sizes.XL, ["shared", "per-ticket"], "per-ticket", `${label}.by_size.XL`),
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function normalizeReview(value, label) {
|
|
447
|
+
const raw = objectOrEmpty(value, label);
|
|
448
|
+
const titleStyle = enumField(raw.title_style, ["ticket-title", "conventional", "none", "custom"], DEFAULT_TICKET_SETUP.build.review.title_style, `${label}.title_style`);
|
|
449
|
+
const titleTemplate = nullableString(raw.title_template);
|
|
450
|
+
if (titleStyle === "custom" && !titleTemplate)
|
|
451
|
+
throw new Error(`${label}.title_template: required for custom title style`);
|
|
452
|
+
return {
|
|
453
|
+
title_style: titleStyle,
|
|
454
|
+
title_template: titleTemplate,
|
|
455
|
+
description_sections: stringArray(raw.description_sections, DEFAULT_TICKET_SETUP.build.review.description_sections, `${label}.description_sections`),
|
|
357
456
|
};
|
|
358
457
|
}
|
|
359
458
|
function objectOrEmpty(value, label) {
|
|
@@ -418,6 +517,13 @@ function nullablePositiveInteger(value, fallback, label) {
|
|
|
418
517
|
return Number(value);
|
|
419
518
|
throw new Error(`${label}: expected a positive integer or null`);
|
|
420
519
|
}
|
|
520
|
+
function stringArray(value, fallback, label) {
|
|
521
|
+
if (value === undefined)
|
|
522
|
+
return [...fallback];
|
|
523
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim()))
|
|
524
|
+
throw new Error(`${label}: expected non-empty strings`);
|
|
525
|
+
return [...new Set(value.map((item) => String(item).trim()))];
|
|
526
|
+
}
|
|
421
527
|
function normalizeTargets(value) {
|
|
422
528
|
if (!Array.isArray(value))
|
|
423
529
|
return [];
|
|
@@ -431,8 +537,14 @@ function normalizeTargets(value) {
|
|
|
431
537
|
function cloneTicketSetup(setup) {
|
|
432
538
|
return {
|
|
433
539
|
sources: setup.sources.map((source) => ({ ...source, ...(source.type === "local" ? { paths: [...source.paths] } : {}) })),
|
|
540
|
+
limits: { ...setup.limits },
|
|
434
541
|
populate: { ...setup.populate },
|
|
435
|
-
build: {
|
|
542
|
+
build: {
|
|
543
|
+
...setup.build,
|
|
544
|
+
branch_policy: { ...setup.build.branch_policy, by_size: { ...setup.build.branch_policy.by_size } },
|
|
545
|
+
review: { ...setup.build.review, description_sections: [...setup.build.review.description_sections] },
|
|
546
|
+
validation_checklist: [...setup.build.validation_checklist],
|
|
547
|
+
},
|
|
436
548
|
};
|
|
437
549
|
}
|
|
438
550
|
function unique(values) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SavedTicketDefinitionSnapshot, TicketGroup, TicketGroupId, TicketGroupOrigin } from "rafi-spec";
|
|
1
2
|
export type TicketStatus = "planned" | "next" | "in_progress" | "blocked" | "done" | "canceled" | "obsolete";
|
|
2
3
|
export type ValidationResult = "passed" | "failed" | "not_run" | "not_applicable";
|
|
3
4
|
export interface TicketState {
|
|
@@ -89,6 +90,14 @@ export interface OperationReceipt {
|
|
|
89
90
|
completed_at: string;
|
|
90
91
|
payload_json: string;
|
|
91
92
|
}
|
|
93
|
+
export interface TicketGroupValidationIssue {
|
|
94
|
+
code: "missing-membership" | "multiple-membership" | "unknown-member" | "empty-group" | "sequence-corruption" | "reused-id" | "snapshot-corruption" | "receipt-without-group";
|
|
95
|
+
message: string;
|
|
96
|
+
groupId?: TicketGroupId;
|
|
97
|
+
ticketId?: string;
|
|
98
|
+
}
|
|
99
|
+
/** The only valid initial/fully-reset active state for a ticket. */
|
|
100
|
+
export declare function pristineTicketState(ticketId: string, now: string, actor?: string | null): TicketState;
|
|
92
101
|
export declare class StateDb {
|
|
93
102
|
private db;
|
|
94
103
|
constructor(dbPath: string);
|
|
@@ -96,17 +105,22 @@ export declare class StateDb {
|
|
|
96
105
|
close(): void;
|
|
97
106
|
getState(ticketId: string): TicketState | undefined;
|
|
98
107
|
getAllStates(): Map<string, TicketState>;
|
|
108
|
+
deleteState(ticketId: string): void;
|
|
99
109
|
upsertState(ticketId: string, patch: Partial<Omit<TicketState, "ticket_id">>, now: string): void;
|
|
100
|
-
insertEvent(event: Omit<TicketEvent, "id">):
|
|
110
|
+
insertEvent(event: Omit<TicketEvent, "id">): number;
|
|
111
|
+
deleteEvents(ids: number[]): void;
|
|
101
112
|
getRecentEvents(limit: number): TicketEvent[];
|
|
113
|
+
getTicketEvents(ticketId: string): TicketEvent[];
|
|
102
114
|
insertValidationSnapshot(snap: Omit<ValidationSnapshot, "id">): void;
|
|
103
115
|
getRecentValidationSnapshot(): ValidationSnapshot | undefined;
|
|
116
|
+
getValidationSnapshots(scope?: string): ValidationSnapshot[];
|
|
104
117
|
insertFutureWork(item: Omit<FutureWorkItem, "id">): number;
|
|
105
118
|
getFutureWork(): FutureWorkItem[];
|
|
106
119
|
getFutureWorkById(id: number): FutureWorkItem | undefined;
|
|
107
120
|
updateFutureWorkDisposition(id: number, disposition: string): void;
|
|
108
121
|
getRecentCompleted(): RecentCompletedContext[];
|
|
109
122
|
upsertRecentCompleted(row: RecentCompletedContext): void;
|
|
123
|
+
deleteRecentCompleted(ticketId: string): void;
|
|
110
124
|
getArchiveIndex(): ArchiveIndexEntry[];
|
|
111
125
|
insertReviewRecommendation(item: Omit<ReviewRecommendation, "id">): number;
|
|
112
126
|
getReviewRecommendations(status?: ReviewRecommendationStatus): ReviewRecommendation[];
|
|
@@ -114,5 +128,32 @@ export declare class StateDb {
|
|
|
114
128
|
updateReviewRecommendationStatus(id: number, status: ReviewRecommendationStatus, updatedAt: string): void;
|
|
115
129
|
getOperationReceipt(operationId: string): OperationReceipt | undefined;
|
|
116
130
|
recordOperationReceipt(receipt: OperationReceipt): boolean;
|
|
131
|
+
createTicketGroup(input: {
|
|
132
|
+
origin: TicketGroupOrigin;
|
|
133
|
+
operationId: string;
|
|
134
|
+
members: Array<{
|
|
135
|
+
ticketId: string;
|
|
136
|
+
definition: unknown;
|
|
137
|
+
validatedAt?: string;
|
|
138
|
+
}>;
|
|
139
|
+
legacy?: boolean;
|
|
140
|
+
createdAt?: string;
|
|
141
|
+
}): TicketGroup;
|
|
142
|
+
/** Update only the recoverable definition; membership and order never change. */
|
|
143
|
+
updateTicketDefinitionSnapshot(ticketId: string, definition: unknown, validatedAt?: string): SavedTicketDefinitionSnapshot;
|
|
144
|
+
getTicketGroupByOperation(operationId: string): TicketGroup | undefined;
|
|
145
|
+
getTicketGroup(groupId: string): TicketGroup | undefined;
|
|
146
|
+
getTicketGroupForTicket(ticketId: string): TicketGroup | undefined;
|
|
147
|
+
listTicketGroups(): TicketGroup[];
|
|
148
|
+
/** One-time migration for repositories whose complete ticket set predates groups. */
|
|
149
|
+
ensureSyntheticLegacyGroup(definitions: Array<{
|
|
150
|
+
id: string;
|
|
151
|
+
} & Record<string, unknown>>, now?: Date): TicketGroup | undefined;
|
|
152
|
+
ungroupedTicketIds(ticketIds: readonly string[]): string[];
|
|
153
|
+
repairTicketGroups(definitions: Array<{
|
|
154
|
+
id: string;
|
|
155
|
+
} & Record<string, unknown>>, operationId: string, now?: Date): TicketGroup | undefined;
|
|
156
|
+
validateTicketGroups(knownTicketIds?: readonly string[]): TicketGroupValidationIssue[];
|
|
157
|
+
private getTicketGroupBySequence;
|
|
117
158
|
transaction<T>(fn: () => T): T;
|
|
118
159
|
}
|
package/dist/tickets/stateDb.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
/** The only valid initial/fully-reset active state for a ticket. */
|
|
6
|
+
export function pristineTicketState(ticketId, now, actor = null) {
|
|
7
|
+
return {
|
|
8
|
+
ticket_id: ticketId,
|
|
9
|
+
status: "planned",
|
|
10
|
+
owner: null,
|
|
11
|
+
current_step: null,
|
|
12
|
+
next_action: null,
|
|
13
|
+
blocked_by_json: "[]",
|
|
14
|
+
blocker_type: null,
|
|
15
|
+
blocker_notes: null,
|
|
16
|
+
first_blocked_at: null,
|
|
17
|
+
last_checked_at: null,
|
|
18
|
+
last_worked_at: null,
|
|
19
|
+
completed_at: null,
|
|
20
|
+
attempt_count: 0,
|
|
21
|
+
last_error: null,
|
|
22
|
+
evidence: null,
|
|
23
|
+
validation_result: null,
|
|
24
|
+
validation_commands: null,
|
|
25
|
+
validation_notes: null,
|
|
26
|
+
updated_at: now,
|
|
27
|
+
updated_by: actor,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
4
30
|
const INIT_SQL = `
|
|
5
31
|
PRAGMA foreign_keys = ON;
|
|
6
32
|
|
|
@@ -105,6 +131,38 @@ CREATE TABLE IF NOT EXISTS operation_receipts (
|
|
|
105
131
|
payload_json TEXT NOT NULL DEFAULT '{}'
|
|
106
132
|
);
|
|
107
133
|
|
|
134
|
+
CREATE TABLE IF NOT EXISTS ticket_groups (
|
|
135
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
136
|
+
origin TEXT NOT NULL,
|
|
137
|
+
created_at TEXT NOT NULL,
|
|
138
|
+
legacy INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
operation_id TEXT NOT NULL UNIQUE
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
CREATE TABLE IF NOT EXISTS ticket_group_members (
|
|
143
|
+
group_sequence INTEGER NOT NULL REFERENCES ticket_groups(sequence) ON DELETE RESTRICT,
|
|
144
|
+
ticket_id TEXT NOT NULL UNIQUE,
|
|
145
|
+
position INTEGER NOT NULL,
|
|
146
|
+
snapshot_json TEXT NOT NULL,
|
|
147
|
+
snapshot_digest TEXT NOT NULL,
|
|
148
|
+
snapshot_validated_at TEXT NOT NULL,
|
|
149
|
+
PRIMARY KEY(group_sequence, ticket_id),
|
|
150
|
+
UNIQUE(group_sequence, position),
|
|
151
|
+
CHECK(position >= 1)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
CREATE TABLE IF NOT EXISTS ticket_group_receipts (
|
|
155
|
+
operation_id TEXT PRIMARY KEY,
|
|
156
|
+
group_sequence INTEGER NOT NULL UNIQUE REFERENCES ticket_groups(sequence) ON DELETE RESTRICT,
|
|
157
|
+
created_at TEXT NOT NULL
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
CREATE TABLE IF NOT EXISTS ticket_group_sequence_ledger (
|
|
161
|
+
sequence INTEGER PRIMARY KEY,
|
|
162
|
+
operation_id TEXT NOT NULL UNIQUE,
|
|
163
|
+
allocated_at TEXT NOT NULL
|
|
164
|
+
);
|
|
165
|
+
|
|
108
166
|
CREATE INDEX IF NOT EXISTS idx_ticket_state_status ON ticket_state(status);
|
|
109
167
|
CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket_id ON ticket_events(ticket_id);
|
|
110
168
|
CREATE INDEX IF NOT EXISTS idx_ticket_events_timestamp ON ticket_events(timestamp);
|
|
@@ -112,6 +170,7 @@ CREATE INDEX IF NOT EXISTS idx_validation_snapshots_timestamp ON validation_snap
|
|
|
112
170
|
CREATE INDEX IF NOT EXISTS idx_future_work_disposition ON future_work(disposition);
|
|
113
171
|
CREATE INDEX IF NOT EXISTS idx_review_recommendations_status ON review_recommendations(status);
|
|
114
172
|
CREATE INDEX IF NOT EXISTS idx_operation_receipts_run ON operation_receipts(run_id);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_ticket_group_members_group ON ticket_group_members(group_sequence, position);
|
|
115
174
|
`;
|
|
116
175
|
export class StateDb {
|
|
117
176
|
db;
|
|
@@ -120,6 +179,8 @@ export class StateDb {
|
|
|
120
179
|
this.db = new Database(dbPath);
|
|
121
180
|
this.db.pragma("journal_mode = WAL");
|
|
122
181
|
this.db.exec(INIT_SQL);
|
|
182
|
+
this.db.prepare(`INSERT OR IGNORE INTO ticket_group_sequence_ledger(sequence,operation_id,allocated_at)
|
|
183
|
+
SELECT sequence,operation_id,created_at FROM ticket_groups`).run();
|
|
123
184
|
this.migrateObsoleteStatus();
|
|
124
185
|
}
|
|
125
186
|
migrateObsoleteStatus() {
|
|
@@ -160,30 +221,12 @@ export class StateDb {
|
|
|
160
221
|
m.set(row.ticket_id, row);
|
|
161
222
|
return m;
|
|
162
223
|
}
|
|
224
|
+
deleteState(ticketId) {
|
|
225
|
+
this.db.prepare("DELETE FROM ticket_state WHERE ticket_id = ?").run(ticketId);
|
|
226
|
+
}
|
|
163
227
|
upsertState(ticketId, patch, now) {
|
|
164
228
|
const existing = this.getState(ticketId);
|
|
165
|
-
const base = existing ??
|
|
166
|
-
ticket_id: ticketId,
|
|
167
|
-
status: "planned",
|
|
168
|
-
owner: null,
|
|
169
|
-
current_step: null,
|
|
170
|
-
next_action: null,
|
|
171
|
-
blocked_by_json: "[]",
|
|
172
|
-
blocker_type: null,
|
|
173
|
-
blocker_notes: null,
|
|
174
|
-
first_blocked_at: null,
|
|
175
|
-
last_checked_at: null,
|
|
176
|
-
last_worked_at: null,
|
|
177
|
-
completed_at: null,
|
|
178
|
-
attempt_count: 0,
|
|
179
|
-
last_error: null,
|
|
180
|
-
evidence: null,
|
|
181
|
-
validation_result: null,
|
|
182
|
-
validation_commands: null,
|
|
183
|
-
validation_notes: null,
|
|
184
|
-
updated_at: now,
|
|
185
|
-
updated_by: null,
|
|
186
|
-
};
|
|
229
|
+
const base = existing ?? pristineTicketState(ticketId, now);
|
|
187
230
|
const merged = { ...base, ...patch, ticket_id: ticketId, updated_at: now };
|
|
188
231
|
if (existing) {
|
|
189
232
|
this.db.prepare(`
|
|
@@ -218,7 +261,7 @@ export class StateDb {
|
|
|
218
261
|
}
|
|
219
262
|
// ── ticket_events ─────────────────────────────────────────────────────────
|
|
220
263
|
insertEvent(event) {
|
|
221
|
-
this.db.prepare(`
|
|
264
|
+
const result = this.db.prepare(`
|
|
222
265
|
INSERT INTO ticket_events (
|
|
223
266
|
timestamp,actor,ticket_id,event_type,old_status,new_status,
|
|
224
267
|
summary,validation,evidence,payload_json
|
|
@@ -227,12 +270,23 @@ export class StateDb {
|
|
|
227
270
|
@summary,@validation,@evidence,@payload_json
|
|
228
271
|
)
|
|
229
272
|
`).run(event);
|
|
273
|
+
return Number(result.lastInsertRowid);
|
|
274
|
+
}
|
|
275
|
+
deleteEvents(ids) {
|
|
276
|
+
const remove = this.db.prepare("DELETE FROM ticket_events WHERE id = ?");
|
|
277
|
+
for (const id of ids)
|
|
278
|
+
remove.run(id);
|
|
230
279
|
}
|
|
231
280
|
getRecentEvents(limit) {
|
|
232
281
|
return this.db
|
|
233
282
|
.prepare("SELECT * FROM ticket_events ORDER BY timestamp DESC, id DESC LIMIT ?")
|
|
234
283
|
.all(limit);
|
|
235
284
|
}
|
|
285
|
+
getTicketEvents(ticketId) {
|
|
286
|
+
return this.db
|
|
287
|
+
.prepare("SELECT * FROM ticket_events WHERE ticket_id = ? ORDER BY timestamp, id")
|
|
288
|
+
.all(ticketId);
|
|
289
|
+
}
|
|
236
290
|
// ── validation_snapshots ──────────────────────────────────────────────────
|
|
237
291
|
insertValidationSnapshot(snap) {
|
|
238
292
|
this.db.prepare(`
|
|
@@ -245,6 +299,16 @@ export class StateDb {
|
|
|
245
299
|
.prepare("SELECT * FROM validation_snapshots ORDER BY timestamp DESC, id DESC LIMIT 1")
|
|
246
300
|
.get();
|
|
247
301
|
}
|
|
302
|
+
getValidationSnapshots(scope) {
|
|
303
|
+
if (scope !== undefined) {
|
|
304
|
+
return this.db
|
|
305
|
+
.prepare("SELECT * FROM validation_snapshots WHERE scope = ? ORDER BY timestamp, id")
|
|
306
|
+
.all(scope);
|
|
307
|
+
}
|
|
308
|
+
return this.db
|
|
309
|
+
.prepare("SELECT * FROM validation_snapshots ORDER BY timestamp, id")
|
|
310
|
+
.all();
|
|
311
|
+
}
|
|
248
312
|
// ── future_work ───────────────────────────────────────────────────────────
|
|
249
313
|
insertFutureWork(item) {
|
|
250
314
|
const r = this.db.prepare(`
|
|
@@ -287,6 +351,9 @@ export class StateDb {
|
|
|
287
351
|
updated_at=excluded.updated_at
|
|
288
352
|
`).run(row);
|
|
289
353
|
}
|
|
354
|
+
deleteRecentCompleted(ticketId) {
|
|
355
|
+
this.db.prepare("DELETE FROM recent_completed_context WHERE ticket_id = ?").run(ticketId);
|
|
356
|
+
}
|
|
290
357
|
// ── archive_index ─────────────────────────────────────────────────────────
|
|
291
358
|
getArchiveIndex() {
|
|
292
359
|
return this.db
|
|
@@ -338,8 +405,179 @@ export class StateDb {
|
|
|
338
405
|
`).run(receipt);
|
|
339
406
|
return result.changes === 1;
|
|
340
407
|
}
|
|
408
|
+
// ── immutable ticket creation groups ─────────────────────────────────────
|
|
409
|
+
createTicketGroup(input) {
|
|
410
|
+
const existing = this.getTicketGroupByOperation(input.operationId);
|
|
411
|
+
if (existing) {
|
|
412
|
+
const requestedIds = input.members.map((member) => member.ticketId);
|
|
413
|
+
const existingIds = existing.members.map((member) => member.ticketId);
|
|
414
|
+
if (existing.origin !== input.origin || JSON.stringify(existingIds) !== JSON.stringify(requestedIds))
|
|
415
|
+
throw new Error(`ticket group operation ${input.operationId} was retried with different immutable membership`);
|
|
416
|
+
return existing;
|
|
417
|
+
}
|
|
418
|
+
if (!input.operationId.trim())
|
|
419
|
+
throw new Error("ticket group operation ID must not be empty");
|
|
420
|
+
if (input.members.length === 0)
|
|
421
|
+
throw new Error("ticket creation updates/no-ops must not create an empty group");
|
|
422
|
+
const ids = input.members.map((member) => member.ticketId);
|
|
423
|
+
if (new Set(ids).size !== ids.length)
|
|
424
|
+
throw new Error("ticket group members must be unique");
|
|
425
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
426
|
+
return this.transaction(() => {
|
|
427
|
+
const result = this.db.prepare("INSERT INTO ticket_groups(origin,created_at,legacy,operation_id) VALUES(?,?,?,?)")
|
|
428
|
+
.run(input.origin, createdAt, input.legacy ? 1 : 0, input.operationId);
|
|
429
|
+
const sequence = Number(result.lastInsertRowid);
|
|
430
|
+
this.db.prepare("INSERT INTO ticket_group_sequence_ledger(sequence,operation_id,allocated_at) VALUES(?,?,?)").run(sequence, input.operationId, createdAt);
|
|
431
|
+
const insert = this.db.prepare(`INSERT INTO ticket_group_members(group_sequence,ticket_id,position,snapshot_json,snapshot_digest,snapshot_validated_at)
|
|
432
|
+
VALUES(?,?,?,?,?,?)`);
|
|
433
|
+
input.members.forEach((member, index) => {
|
|
434
|
+
const snapshot = makeDefinitionSnapshot(member.ticketId, member.definition, member.validatedAt ?? createdAt);
|
|
435
|
+
insert.run(sequence, member.ticketId, index + 1, JSON.stringify(snapshot.definition), snapshot.digest, snapshot.validatedAt);
|
|
436
|
+
// The creation receipt, immutable membership, recoverable definition,
|
|
437
|
+
// and initial active state are one SQLite publication. Existing state
|
|
438
|
+
// is preserved on replay or when importing an externally completed
|
|
439
|
+
// ticket.
|
|
440
|
+
if (!this.getState(member.ticketId)) {
|
|
441
|
+
this.upsertState(member.ticketId, pristineTicketState(member.ticketId, createdAt, "rafi ticket publication"), createdAt);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
this.db.prepare("INSERT INTO ticket_group_receipts(operation_id,group_sequence,created_at) VALUES(?,?,?)").run(input.operationId, sequence, createdAt);
|
|
445
|
+
return this.getTicketGroupBySequence(sequence);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/** Update only the recoverable definition; membership and order never change. */
|
|
449
|
+
updateTicketDefinitionSnapshot(ticketId, definition, validatedAt = new Date().toISOString()) {
|
|
450
|
+
const snapshot = makeDefinitionSnapshot(ticketId, definition, validatedAt);
|
|
451
|
+
const result = this.db.prepare(`UPDATE ticket_group_members SET snapshot_json=?,snapshot_digest=?,snapshot_validated_at=? WHERE ticket_id=?`)
|
|
452
|
+
.run(JSON.stringify(snapshot.definition), snapshot.digest, snapshot.validatedAt, ticketId);
|
|
453
|
+
if (result.changes !== 1)
|
|
454
|
+
throw new Error(`ticket ${ticketId} has no creation-group membership`);
|
|
455
|
+
return snapshot;
|
|
456
|
+
}
|
|
457
|
+
getTicketGroupByOperation(operationId) {
|
|
458
|
+
const row = this.db.prepare("SELECT sequence FROM ticket_groups WHERE operation_id=?").get(operationId);
|
|
459
|
+
return row ? this.getTicketGroupBySequence(row.sequence) : undefined;
|
|
460
|
+
}
|
|
461
|
+
getTicketGroup(groupId) {
|
|
462
|
+
const match = /^TG-([1-9]\d*)$/.exec(groupId);
|
|
463
|
+
return match ? this.getTicketGroupBySequence(Number(match[1])) : undefined;
|
|
464
|
+
}
|
|
465
|
+
getTicketGroupForTicket(ticketId) {
|
|
466
|
+
const row = this.db.prepare("SELECT group_sequence AS sequence FROM ticket_group_members WHERE ticket_id=?").get(ticketId);
|
|
467
|
+
return row ? this.getTicketGroupBySequence(row.sequence) : undefined;
|
|
468
|
+
}
|
|
469
|
+
listTicketGroups() {
|
|
470
|
+
const rows = this.db.prepare("SELECT sequence FROM ticket_groups ORDER BY sequence DESC").all();
|
|
471
|
+
return rows.map((row) => this.getTicketGroupBySequence(row.sequence));
|
|
472
|
+
}
|
|
473
|
+
/** One-time migration for repositories whose complete ticket set predates groups. */
|
|
474
|
+
ensureSyntheticLegacyGroup(definitions, now = new Date()) {
|
|
475
|
+
if (definitions.length === 0 || this.listTicketGroups().length > 0)
|
|
476
|
+
return undefined;
|
|
477
|
+
return this.createTicketGroup({
|
|
478
|
+
origin: "legacy",
|
|
479
|
+
operationId: "ticket-groups:synthetic-legacy:v1",
|
|
480
|
+
legacy: true,
|
|
481
|
+
createdAt: now.toISOString(),
|
|
482
|
+
members: definitions.map((definition) => ({ ticketId: definition.id, definition, validatedAt: now.toISOString() })),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
ungroupedTicketIds(ticketIds) {
|
|
486
|
+
const grouped = new Set(this.db.prepare("SELECT ticket_id FROM ticket_group_members").all().map((row) => row.ticket_id));
|
|
487
|
+
return ticketIds.filter((ticketId) => !grouped.has(ticketId));
|
|
488
|
+
}
|
|
489
|
+
repairTicketGroups(definitions, operationId, now = new Date()) {
|
|
490
|
+
const ungrouped = new Set(this.ungroupedTicketIds(definitions.map((definition) => definition.id)));
|
|
491
|
+
if (ungrouped.size === 0)
|
|
492
|
+
return undefined;
|
|
493
|
+
return this.createTicketGroup({
|
|
494
|
+
origin: "repair", operationId, createdAt: now.toISOString(),
|
|
495
|
+
members: definitions.filter((definition) => ungrouped.has(definition.id)).map((definition) => ({ ticketId: definition.id, definition })),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
validateTicketGroups(knownTicketIds = []) {
|
|
499
|
+
const issues = [];
|
|
500
|
+
const groups = this.listTicketGroups();
|
|
501
|
+
const known = new Set(knownTicketIds);
|
|
502
|
+
for (const group of groups) {
|
|
503
|
+
if (group.members.length === 0)
|
|
504
|
+
issues.push({ code: "empty-group", groupId: group.id, message: `${group.id} has no members` });
|
|
505
|
+
for (const member of group.members) {
|
|
506
|
+
if (known.size > 0 && !known.has(member.ticketId))
|
|
507
|
+
issues.push({ code: "unknown-member", groupId: group.id, ticketId: member.ticketId, message: `${group.id} references unknown ticket ${member.ticketId}` });
|
|
508
|
+
if (definitionDigest(member.snapshot.definition) !== member.snapshot.digest)
|
|
509
|
+
issues.push({ code: "snapshot-corruption", groupId: group.id, ticketId: member.ticketId, message: `${group.id} snapshot for ${member.ticketId} does not match its digest` });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const ticketId of known)
|
|
513
|
+
if (!this.getTicketGroupForTicket(ticketId))
|
|
514
|
+
issues.push({ code: "missing-membership", ticketId, message: `ticket ${ticketId} has no creation-group membership` });
|
|
515
|
+
const duplicates = this.db.prepare("SELECT ticket_id,COUNT(*) AS count FROM ticket_group_members GROUP BY ticket_id HAVING COUNT(*)<>1").all();
|
|
516
|
+
for (const row of duplicates)
|
|
517
|
+
issues.push({ code: "multiple-membership", ticketId: row.ticket_id, message: `ticket ${row.ticket_id} belongs to ${row.count} creation groups` });
|
|
518
|
+
const maximum = groups.reduce((value, group) => Math.max(value, group.sequence), 0);
|
|
519
|
+
const sequence = this.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='ticket_groups'").get();
|
|
520
|
+
if (maximum > 0 && (!sequence || sequence.seq < maximum))
|
|
521
|
+
issues.push({ code: "sequence-corruption", message: `ticket group sequence is ${sequence?.seq ?? "missing"}, below allocated ${maximum}` });
|
|
522
|
+
const missingLedger = this.db.prepare("SELECT sequence FROM ticket_groups WHERE sequence NOT IN (SELECT sequence FROM ticket_group_sequence_ledger)").all();
|
|
523
|
+
for (const row of missingLedger)
|
|
524
|
+
issues.push({ code: "sequence-corruption", groupId: `TG-${row.sequence}`, message: `TG-${row.sequence} has no immutable allocation ledger entry` });
|
|
525
|
+
const reused = this.db.prepare(`SELECT g.sequence FROM ticket_groups g JOIN ticket_group_sequence_ledger l ON l.sequence=g.sequence WHERE g.operation_id<>l.operation_id`).all();
|
|
526
|
+
for (const row of reused)
|
|
527
|
+
issues.push({ code: "reused-id", groupId: `TG-${row.sequence}`, message: `TG-${row.sequence} was reused for a different creation operation` });
|
|
528
|
+
const deletedAllocations = this.db.prepare("SELECT sequence FROM ticket_group_sequence_ledger WHERE sequence NOT IN (SELECT sequence FROM ticket_groups)").all();
|
|
529
|
+
for (const row of deletedAllocations)
|
|
530
|
+
issues.push({ code: "sequence-corruption", groupId: `TG-${row.sequence}`, message: `allocated TG-${row.sequence} is missing; group IDs must never be deleted or reused` });
|
|
531
|
+
const orphanReceipts = this.db.prepare("SELECT operation_id FROM ticket_group_receipts WHERE group_sequence NOT IN (SELECT sequence FROM ticket_groups)").all();
|
|
532
|
+
for (const row of orphanReceipts)
|
|
533
|
+
issues.push({ code: "receipt-without-group", message: `creation receipt ${row.operation_id} has no group` });
|
|
534
|
+
return issues;
|
|
535
|
+
}
|
|
536
|
+
getTicketGroupBySequence(sequence) {
|
|
537
|
+
const row = this.db.prepare("SELECT * FROM ticket_groups WHERE sequence=?").get(sequence);
|
|
538
|
+
if (!row)
|
|
539
|
+
return undefined;
|
|
540
|
+
const members = this.db.prepare("SELECT * FROM ticket_group_members WHERE group_sequence=? ORDER BY position").all(sequence);
|
|
541
|
+
return {
|
|
542
|
+
id: `TG-${row.sequence}`,
|
|
543
|
+
sequence: row.sequence,
|
|
544
|
+
origin: row.origin,
|
|
545
|
+
createdAt: row.created_at,
|
|
546
|
+
legacy: Boolean(row.legacy),
|
|
547
|
+
operationId: row.operation_id,
|
|
548
|
+
members: members.map((member) => ({
|
|
549
|
+
groupId: `TG-${row.sequence}`,
|
|
550
|
+
ticketId: member.ticket_id,
|
|
551
|
+
position: member.position,
|
|
552
|
+
snapshot: { version: 1, ticketId: member.ticket_id, definition: parseSnapshot(member.snapshot_json), digest: member.snapshot_digest, validatedAt: member.snapshot_validated_at },
|
|
553
|
+
})),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
341
556
|
// ── transactions ──────────────────────────────────────────────────────────
|
|
342
557
|
transaction(fn) {
|
|
343
558
|
return this.db.transaction(fn)();
|
|
344
559
|
}
|
|
345
560
|
}
|
|
561
|
+
function makeDefinitionSnapshot(ticketId, definition, validatedAt) {
|
|
562
|
+
const cloned = JSON.parse(JSON.stringify(definition));
|
|
563
|
+
if (cloned && typeof cloned === "object" && !Array.isArray(cloned)) {
|
|
564
|
+
const definitionId = cloned.id;
|
|
565
|
+
if (definitionId !== undefined && definitionId !== ticketId)
|
|
566
|
+
throw new Error(`ticket definition snapshot ID ${String(definitionId)} does not match group member ${ticketId}`);
|
|
567
|
+
}
|
|
568
|
+
return { version: 1, ticketId, definition: cloned, digest: definitionDigest(cloned), validatedAt };
|
|
569
|
+
}
|
|
570
|
+
function definitionDigest(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); }
|
|
571
|
+
function stableJson(value) {
|
|
572
|
+
if (Array.isArray(value))
|
|
573
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
574
|
+
if (value && typeof value === "object")
|
|
575
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
576
|
+
return JSON.stringify(value) ?? "null";
|
|
577
|
+
}
|
|
578
|
+
function parseSnapshot(value) { try {
|
|
579
|
+
return JSON.parse(value);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return { __rafi_corrupt_snapshot: true, raw: value };
|
|
583
|
+
} }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { parse, stringify } from "yaml";
|
|
3
4
|
import { Ajv } from "ajv";
|
|
4
5
|
import { TICKET_JSON_SCHEMA } from "./ticketSchema.js";
|
|
@@ -13,7 +14,9 @@ export function loadTickets(ticketsPath) {
|
|
|
13
14
|
return raw.tickets;
|
|
14
15
|
}
|
|
15
16
|
export function saveTickets(ticketsPath, tickets) {
|
|
16
|
-
|
|
17
|
+
const temporary = `${ticketsPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
18
|
+
writeFileSync(temporary, stringify({ tickets }, { lineWidth: 120 }), "utf8");
|
|
19
|
+
renameSync(temporary, ticketsPath);
|
|
17
20
|
}
|
|
18
21
|
export function validateTicketDefs(tickets) {
|
|
19
22
|
const errors = [];
|