ai-foreman 1.7.6 → 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 +16 -1
- 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/planner.d.ts +1 -1
- package/dist/branch/planner.js +2 -7
- 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 +13 -4
- package/dist/buildRuns.js +85 -19
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +940 -42
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +5 -1
- package/dist/foreman.js +41 -6
- 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 +6 -6
- package/dist/qaReview.js +20 -14
- 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/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 +1 -0
- package/dist/tickets/reset.js +1 -0
- package/dist/tickets/setupConfig.d.ts +1 -0
- package/dist/tickets/setupConfig.js +10 -2
- package/dist/tickets/stateDb.d.ts +34 -0
- package/dist/tickets/stateDb.js +207 -0
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +99 -2
- package/dist/workflowDb.js +434 -8
- package/package.json +31 -3
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { stringify } from "yaml";
|
|
5
|
+
import { recoverableBuildRuns } from "../buildRuns.js";
|
|
6
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
7
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
8
|
+
import { renderProgressDoc, writeProgressDoc } from "./renderMarkdown.js";
|
|
9
|
+
import { StateDb, pristineTicketState } from "./stateDb.js";
|
|
10
|
+
import { loadTickets, validateTicketDefs } from "./ticketLoader.js";
|
|
11
|
+
export class TicketResetDependencyConflictError extends Error {
|
|
12
|
+
conflicts;
|
|
13
|
+
constructor(conflicts) {
|
|
14
|
+
super(`cannot restore deleted ticket definitions atomically: ${conflicts.map((conflict) => `${conflict.ticketId} depends on missing ${conflict.dependencyId}`).join("; ")}`);
|
|
15
|
+
this.conflicts = conflicts;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const SCOPE_STATUSES = {
|
|
19
|
+
all: ["planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete"],
|
|
20
|
+
"completed-and-unfinished": ["planned", "next", "in_progress", "blocked", "done"],
|
|
21
|
+
unfinished: ["planned", "next", "in_progress", "blocked"],
|
|
22
|
+
};
|
|
23
|
+
/** Resolve a requested target exactly once into immutable groups, rows, and restorations. */
|
|
24
|
+
export function resolveTicketResetSelection(projectDir, requested, options = {}, now = new Date()) {
|
|
25
|
+
const opened = open(projectDir);
|
|
26
|
+
const { definitions, db } = opened;
|
|
27
|
+
try {
|
|
28
|
+
db.ensureSyntheticLegacyGroup(definitions, now);
|
|
29
|
+
const groupCatalog = db.listTicketGroups();
|
|
30
|
+
const groups = selectGroups(groupCatalog, requested);
|
|
31
|
+
const current = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
32
|
+
const snapshots = new Map(groupCatalog.flatMap((group) => group.members.map((member) => [member.ticketId, member.snapshot])));
|
|
33
|
+
const states = db.getAllStates();
|
|
34
|
+
const targetIds = selectTicketIds(requested, groups, definitions, states);
|
|
35
|
+
const selectedGroupIds = new Set(groups.map((group) => group.id));
|
|
36
|
+
const restoreDependencies = new Set(options.restoreDependencies ?? []);
|
|
37
|
+
const definitionRestorations = [];
|
|
38
|
+
const previewRows = [];
|
|
39
|
+
for (const ticketId of targetIds) {
|
|
40
|
+
const definition = current.get(ticketId);
|
|
41
|
+
const snapshot = snapshots.get(ticketId);
|
|
42
|
+
if (!definition && !snapshot)
|
|
43
|
+
throw new Error(`ticket ${ticketId} is missing and has no valid Rafi definition snapshot`);
|
|
44
|
+
const policy = options.perTicket?.[ticketId] ?? options.deletedTickets ?? "ignore";
|
|
45
|
+
const restore = !definition && policy === "restore";
|
|
46
|
+
if (restore && snapshot)
|
|
47
|
+
definitionRestorations.push({ ticketId, digest: snapshot.digest, restore: true });
|
|
48
|
+
const display = definition ?? snapshot?.definition;
|
|
49
|
+
previewRows.push({
|
|
50
|
+
ticketId,
|
|
51
|
+
title: display?.title ?? "(definition unavailable)",
|
|
52
|
+
status: states.get(ticketId)?.status ?? "planned",
|
|
53
|
+
definitionMissing: !definition,
|
|
54
|
+
restoreDefinition: restore,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const dependencyId of restoreDependencies) {
|
|
58
|
+
if (current.has(dependencyId))
|
|
59
|
+
continue;
|
|
60
|
+
const snapshot = snapshots.get(dependencyId);
|
|
61
|
+
if (!snapshot)
|
|
62
|
+
throw new Error(`deleted dependency ${dependencyId} has no valid Rafi definition snapshot`);
|
|
63
|
+
if (!definitionRestorations.some((item) => item.ticketId === dependencyId))
|
|
64
|
+
definitionRestorations.push({ ticketId: dependencyId, digest: snapshot.digest, restore: true, dependencyOnly: !targetIds.includes(dependencyId) });
|
|
65
|
+
}
|
|
66
|
+
const restoredIds = new Set(definitionRestorations.filter((item) => item.restore).map((item) => item.ticketId));
|
|
67
|
+
const conflicts = [];
|
|
68
|
+
for (const restoration of definitionRestorations.filter((item) => item.restore && !item.dependencyOnly)) {
|
|
69
|
+
const restored = snapshots.get(restoration.ticketId)?.definition;
|
|
70
|
+
for (const dependencyId of restored.depends_on ?? []) {
|
|
71
|
+
if (!current.has(dependencyId) && !restoredIds.has(dependencyId))
|
|
72
|
+
conflicts.push({ ticketId: restoration.ticketId, dependencyId });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (conflicts.length)
|
|
76
|
+
throw new TicketResetDependencyConflictError(conflicts);
|
|
77
|
+
const selectedIds = new Set(targetIds);
|
|
78
|
+
const relatedRuns = recoverableBuildRuns(projectDir, now)
|
|
79
|
+
.filter((run) => run.tickets.some((ticketId) => selectedIds.has(ticketId)))
|
|
80
|
+
.map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] }));
|
|
81
|
+
const base = {
|
|
82
|
+
version: 1, requested, resolvedAt: now.toISOString(),
|
|
83
|
+
groups: groups.map((group) => ({ id: group.id, sequence: group.sequence, memberTicketIds: group.members.map((member) => member.ticketId) })),
|
|
84
|
+
ticketIds: targetIds,
|
|
85
|
+
previewRows,
|
|
86
|
+
definitionRestorations,
|
|
87
|
+
relatedRuns,
|
|
88
|
+
};
|
|
89
|
+
return { ...base, inputFingerprint: selectionFingerprint(base, current, states, snapshots, groupCatalog) };
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function applyResolvedTicketReset(projectDir, selection, actor = "user", now = new Date(), hooks = {}) {
|
|
96
|
+
const opened = open(projectDir);
|
|
97
|
+
const { config, paths, definitions, db } = opened;
|
|
98
|
+
const workflow = new WorkflowDb(projectDir);
|
|
99
|
+
const resetId = hooks.resetId ?? randomUUID();
|
|
100
|
+
const current = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
101
|
+
const groupCatalog = db.listTicketGroups();
|
|
102
|
+
const snapshots = new Map(groupCatalog.flatMap((group) => group.members.map((member) => [member.ticketId, member.snapshot])));
|
|
103
|
+
const states = db.getAllStates();
|
|
104
|
+
const selectedIds = new Set(selection.ticketIds);
|
|
105
|
+
const currentRelatedRuns = recoverableBuildRuns(projectDir, now)
|
|
106
|
+
.filter((run) => run.tickets.some((ticketId) => selectedIds.has(ticketId)))
|
|
107
|
+
.map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] }));
|
|
108
|
+
const comparable = { ...selection, relatedRuns: currentRelatedRuns };
|
|
109
|
+
delete comparable.inputFingerprint;
|
|
110
|
+
const actualFingerprint = selectionFingerprint(comparable, current, states, snapshots, groupCatalog);
|
|
111
|
+
if (actualFingerprint !== selection.inputFingerprint) {
|
|
112
|
+
db.close();
|
|
113
|
+
workflow.close();
|
|
114
|
+
throw new Error("ticket/group/reset inputs changed after preview; no reset was applied—preview the exact selection again");
|
|
115
|
+
}
|
|
116
|
+
const restore = selection.definitionRestorations.filter((item) => item.restore);
|
|
117
|
+
const restored = restore.map((item) => item.ticketId);
|
|
118
|
+
const restoredSet = new Set(restored);
|
|
119
|
+
const restoredDefinitions = restore.map((item) => snapshots.get(item.ticketId)?.definition);
|
|
120
|
+
if (restoredDefinitions.some((item) => !item)) {
|
|
121
|
+
db.close();
|
|
122
|
+
workflow.close();
|
|
123
|
+
throw new Error("a selected definition snapshot became unavailable");
|
|
124
|
+
}
|
|
125
|
+
const nextDefinitions = [...definitions, ...restoredDefinitions.filter((definition) => !current.has(definition.id))].sort((a, b) => a.order - b.order);
|
|
126
|
+
const definitionIssues = validateTicketDefs(nextDefinitions);
|
|
127
|
+
if (definitionIssues.length) {
|
|
128
|
+
db.close();
|
|
129
|
+
workflow.close();
|
|
130
|
+
throw new Error(`restored ticket definitions are invalid: ${definitionIssues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
131
|
+
}
|
|
132
|
+
const resettable = selection.ticketIds.filter((ticketId) => current.has(ticketId) || restoredSet.has(ticketId));
|
|
133
|
+
const ignoredMissing = selection.ticketIds.filter((ticketId) => !current.has(ticketId) && !restoredSet.has(ticketId));
|
|
134
|
+
if (resettable.length === 0 && restored.length === 0) {
|
|
135
|
+
db.close();
|
|
136
|
+
workflow.close();
|
|
137
|
+
return { resetId, tickets: [], restored: [], ignoredMissing };
|
|
138
|
+
}
|
|
139
|
+
const stageDir = join(projectDir, ".rafi", "reset-stage", resetId);
|
|
140
|
+
const stagedDefinitions = join(stageDir, "tickets.yaml");
|
|
141
|
+
const stagedProgress = join(stageDir, "progress.md");
|
|
142
|
+
const definitionsBefore = existsSync(paths.tickets) ? readFileSync(paths.tickets) : undefined;
|
|
143
|
+
const progressBefore = existsSync(paths.progressDoc) ? readFileSync(paths.progressDoc) : undefined;
|
|
144
|
+
const oldStates = new Map([...new Set([...resettable, ...restored])].map((ticketId) => [ticketId, db.getState(ticketId)]));
|
|
145
|
+
const oldRecent = db.getRecentCompleted().filter((row) => resettable.includes(row.ticket_id));
|
|
146
|
+
const eventIds = [];
|
|
147
|
+
const run = workflow.createRun({ kind: "recovery", checkpoint: "group-reset-intent", originalWork: selection, remainingWork: { tickets: resettable }, state: { resetId } }, now);
|
|
148
|
+
const publication = workflow.beginPublication(run.runId, { operation: "group-ticket-reset", resetId, selection, stagedDefinitions, stagedProgress, definitionsTarget: paths.tickets, progressTarget: paths.progressDoc }, { definitions: digest(definitionsBefore), progress: digest(progressBefore) }, now);
|
|
149
|
+
try {
|
|
150
|
+
const stamp = now.toISOString();
|
|
151
|
+
db.transaction(() => {
|
|
152
|
+
for (const definition of restoredDefinitions)
|
|
153
|
+
if (!db.getState(definition.id))
|
|
154
|
+
db.upsertState(definition.id, pristineTicketState(definition.id, stamp, actor), stamp);
|
|
155
|
+
for (const row of selection.previewRows) {
|
|
156
|
+
if (!resettable.includes(row.ticketId)) {
|
|
157
|
+
eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset_skipped_missing", old_status: row.status, new_status: row.status, summary: `Skipped missing definition ${row.ticketId}`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, input_fingerprint: selection.inputFingerprint }) }));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const old = db.getState(row.ticketId)?.status ?? row.status;
|
|
161
|
+
db.upsertState(row.ticketId, pristineTicketState(row.ticketId, stamp, actor), stamp);
|
|
162
|
+
db.deleteRecentCompleted(row.ticketId);
|
|
163
|
+
eventIds.push(db.insertEvent({ timestamp: stamp, actor, ticket_id: row.ticketId, event_type: "ticket_reset", old_status: old, new_status: "planned", summary: `Reset ${row.ticketId} from approved immutable selection`, validation: null, evidence: null, payload_json: JSON.stringify({ reset_id: resetId, groups: selection.groups.map((group) => group.id), restored_definition: restoredSet.has(row.ticketId), input_fingerprint: selection.inputFingerprint }) }));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
mkdirSync(stageDir, { recursive: true });
|
|
167
|
+
writeFileSync(stagedDefinitions, stringify({ tickets: nextDefinitions }, { lineWidth: 120 }), "utf8");
|
|
168
|
+
writeFileSync(stagedProgress, renderProgressDoc({ config, projectDir, ticketDefs: nextDefinitions, states: db.getAllStates(), db, now: stamp }), "utf8");
|
|
169
|
+
workflow.updatePublication(publication.transactionId, "staged", now);
|
|
170
|
+
workflow.updatePublication(publication.transactionId, "tracker_committed", now);
|
|
171
|
+
hooks.beforePublish?.();
|
|
172
|
+
atomicCopy(stagedDefinitions, paths.tickets);
|
|
173
|
+
writeProgressDoc(paths.progressDoc, readFileSync(stagedProgress, "utf8"), true);
|
|
174
|
+
workflow.updatePublication(publication.transactionId, "published", now);
|
|
175
|
+
workflow.updatePublication(publication.transactionId, "committed", now);
|
|
176
|
+
workflow.transition(run.runId, { status: "completed", checkpoint: "group-reset-committed", remainingWork: {}, state: { resetId, tickets: resettable, restored, ignoredMissing } }, now);
|
|
177
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
178
|
+
return { resetId, tickets: resettable, restored, ignoredMissing };
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
db.transaction(() => {
|
|
182
|
+
db.deleteEvents(eventIds);
|
|
183
|
+
for (const [ticketId, state] of oldStates)
|
|
184
|
+
state ? db.upsertState(ticketId, state, state.updated_at) : db.deleteState(ticketId);
|
|
185
|
+
for (const row of oldRecent)
|
|
186
|
+
db.upsertRecentCompleted(row);
|
|
187
|
+
});
|
|
188
|
+
restoreFile(paths.tickets, definitionsBefore);
|
|
189
|
+
restoreFile(paths.progressDoc, progressBefore);
|
|
190
|
+
workflow.updatePublication(publication.transactionId, "rolled_back", now);
|
|
191
|
+
workflow.transition(run.runId, { status: "failed", checkpoint: "group-reset-rolled-back", state: { resetId, error: error instanceof Error ? error.message : String(error) } }, now);
|
|
192
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
db.close();
|
|
197
|
+
workflow.close();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function selectGroups(groups, requested) {
|
|
201
|
+
if (requested.kind === "all-groups")
|
|
202
|
+
return groups;
|
|
203
|
+
if (requested.kind === "recent-groups") {
|
|
204
|
+
if (!Number.isInteger(requested.count) || requested.count < 1)
|
|
205
|
+
throw new Error("recent group count must be a positive integer");
|
|
206
|
+
return groups.slice(0, requested.count);
|
|
207
|
+
}
|
|
208
|
+
if (requested.kind === "group") {
|
|
209
|
+
const group = groups.find((candidate) => candidate.id === requested.groupId);
|
|
210
|
+
if (!group)
|
|
211
|
+
throw new Error(`ticket group ${requested.groupId} not found`);
|
|
212
|
+
return [group];
|
|
213
|
+
}
|
|
214
|
+
if (requested.kind === "group-index") {
|
|
215
|
+
if (!Number.isInteger(requested.position) || requested.position < 1)
|
|
216
|
+
throw new Error("group index must be a positive 1-based position");
|
|
217
|
+
const group = groups[requested.position - 1];
|
|
218
|
+
if (!group)
|
|
219
|
+
throw new Error(`ticket group index ${requested.position} not found`);
|
|
220
|
+
return [group];
|
|
221
|
+
}
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
function selectTicketIds(requested, groups, definitions, states) {
|
|
225
|
+
if (["all-groups", "recent-groups", "group", "group-index"].includes(requested.kind))
|
|
226
|
+
return [...new Set(groups.flatMap((group) => group.members.map((member) => member.ticketId)))];
|
|
227
|
+
if (requested.kind === "ticket") {
|
|
228
|
+
if (!definitions.some((definition) => definition.id === requested.ticketId))
|
|
229
|
+
throw new Error(`Ticket ${requested.ticketId} not found. Run \`rafi tickets queue\` to list tickets.`);
|
|
230
|
+
return [requested.ticketId];
|
|
231
|
+
}
|
|
232
|
+
if (requested.kind === "run") {
|
|
233
|
+
const known = new Set(definitions.map((definition) => definition.id));
|
|
234
|
+
const missing = requested.ticketIds.filter((ticketId) => !known.has(ticketId));
|
|
235
|
+
if (missing.length)
|
|
236
|
+
throw new Error(`Tickets not found: ${missing.join(", ")}`);
|
|
237
|
+
return [...new Set(requested.ticketIds)];
|
|
238
|
+
}
|
|
239
|
+
if (requested.kind !== "scope")
|
|
240
|
+
throw new Error(`unsupported reset target: ${requested.kind}`);
|
|
241
|
+
const statuses = new Set(SCOPE_STATUSES[requested.scope]);
|
|
242
|
+
return definitions.filter((definition) => statuses.has(states.get(definition.id)?.status ?? "planned")).map((definition) => definition.id);
|
|
243
|
+
}
|
|
244
|
+
function selectionFingerprint(base, definitions, states, snapshots, groups) {
|
|
245
|
+
const relevant = [...new Set([...base.ticketIds, ...base.definitionRestorations.map((item) => item.ticketId)])].sort();
|
|
246
|
+
return hash({
|
|
247
|
+
requested: base.requested,
|
|
248
|
+
groups: base.groups,
|
|
249
|
+
groupCatalog: groups.map((group) => ({
|
|
250
|
+
id: group.id, sequence: group.sequence, origin: group.origin, createdAt: group.createdAt, legacy: group.legacy, operationId: group.operationId,
|
|
251
|
+
members: group.members.map((member) => ({ ticketId: member.ticketId, position: member.position, snapshotDigest: member.snapshot.digest })),
|
|
252
|
+
})),
|
|
253
|
+
definitionCatalog: [...definitions.entries()].sort(([a], [b]) => a.localeCompare(b)),
|
|
254
|
+
tickets: relevant.map((ticketId) => ({ ticketId, definition: definitions.get(ticketId) ?? null, state: states.get(ticketId) ?? null, snapshotDigest: snapshots.get(ticketId)?.digest ?? null })),
|
|
255
|
+
relatedRuns: base.relatedRuns,
|
|
256
|
+
restorations: base.definitionRestorations,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function open(projectDir) {
|
|
260
|
+
const config = loadTicketsConfig(projectDir);
|
|
261
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
262
|
+
return { config, paths, definitions: loadTickets(paths.tickets), db: new StateDb(paths.stateDb) };
|
|
263
|
+
}
|
|
264
|
+
function hash(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); }
|
|
265
|
+
function digest(value) { return value ? createHash("sha256").update(value).digest("hex") : null; }
|
|
266
|
+
function stableJson(value) {
|
|
267
|
+
if (Array.isArray(value))
|
|
268
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
269
|
+
if (value && typeof value === "object")
|
|
270
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
271
|
+
return JSON.stringify(value) ?? "null";
|
|
272
|
+
}
|
|
273
|
+
function atomicCopy(source, target) { mkdirSync(dirname(target), { recursive: true }); const temp = `${target}.${process.pid}.${randomUUID()}.tmp`; writeFileSync(temp, readFileSync(source)); renameSync(temp, target); }
|
|
274
|
+
function restoreFile(path, value) { if (value) {
|
|
275
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
276
|
+
writeFileSync(path, value);
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
rmSync(path, { force: true }); }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TicketGroup, TicketGroupId } from "rafi-spec";
|
|
2
|
+
import { type TicketStatus } from "./stateDb.js";
|
|
3
|
+
export interface TicketGroupListMember {
|
|
4
|
+
ticketId: string;
|
|
5
|
+
position: number;
|
|
6
|
+
definitionMissing: boolean;
|
|
7
|
+
snapshotDigest: string;
|
|
8
|
+
status: TicketStatus;
|
|
9
|
+
}
|
|
10
|
+
export interface TicketGroupListRow {
|
|
11
|
+
id: TicketGroupId;
|
|
12
|
+
recencyPosition: number;
|
|
13
|
+
origin: TicketGroup["origin"];
|
|
14
|
+
createdAt: string;
|
|
15
|
+
legacy: boolean;
|
|
16
|
+
members: TicketGroupListMember[];
|
|
17
|
+
statusTotals: Partial<Record<TicketStatus, number>>;
|
|
18
|
+
recoverableRuns: Array<{
|
|
19
|
+
runId: string;
|
|
20
|
+
status: string;
|
|
21
|
+
tickets: string[];
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export interface TicketGroupListEnvelope {
|
|
25
|
+
version: 1;
|
|
26
|
+
generatedAt: string;
|
|
27
|
+
groups: TicketGroupListRow[];
|
|
28
|
+
}
|
|
29
|
+
export declare function listTicketGroups(projectDir: string, now?: Date): TicketGroupListEnvelope;
|
|
30
|
+
export interface TicketGroupRepairPreview {
|
|
31
|
+
version: 1;
|
|
32
|
+
ticketIds: string[];
|
|
33
|
+
inputFingerprint: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function previewTicketGroupRepair(projectDir: string): TicketGroupRepairPreview;
|
|
36
|
+
export declare function repairTicketGroups(projectDir: string, expectedFingerprint: string, now?: Date): TicketGroup | undefined;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { recoverableBuildRuns } from "../buildRuns.js";
|
|
3
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
4
|
+
import { StateDb, pristineTicketState } from "./stateDb.js";
|
|
5
|
+
import { loadTickets } from "./ticketLoader.js";
|
|
6
|
+
export function listTicketGroups(projectDir, now = new Date()) {
|
|
7
|
+
const { definitions, db } = open(projectDir);
|
|
8
|
+
try {
|
|
9
|
+
db.ensureSyntheticLegacyGroup(definitions, now);
|
|
10
|
+
const current = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
11
|
+
const states = db.getAllStates();
|
|
12
|
+
const runs = recoverableBuildRuns(projectDir, now);
|
|
13
|
+
const groups = db.listTicketGroups().map((group, index) => {
|
|
14
|
+
const members = group.members.map((member) => ({
|
|
15
|
+
ticketId: member.ticketId,
|
|
16
|
+
position: member.position,
|
|
17
|
+
definitionMissing: !current.has(member.ticketId),
|
|
18
|
+
snapshotDigest: member.snapshot.digest,
|
|
19
|
+
status: states.get(member.ticketId)?.status ?? pristineTicketState(member.ticketId, now.toISOString()).status,
|
|
20
|
+
}));
|
|
21
|
+
const memberIds = new Set(members.map((member) => member.ticketId));
|
|
22
|
+
const statusTotals = {};
|
|
23
|
+
for (const member of members)
|
|
24
|
+
statusTotals[member.status] = (statusTotals[member.status] ?? 0) + 1;
|
|
25
|
+
return {
|
|
26
|
+
id: group.id, recencyPosition: index + 1, origin: group.origin, createdAt: group.createdAt, legacy: group.legacy,
|
|
27
|
+
members, statusTotals,
|
|
28
|
+
recoverableRuns: runs.filter((run) => run.tickets.some((ticket) => memberIds.has(ticket))).map((run) => ({ runId: run.runId, status: run.status, tickets: [...run.tickets] })),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
return { version: 1, generatedAt: now.toISOString(), groups };
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
db.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function previewTicketGroupRepair(projectDir) {
|
|
38
|
+
const { definitions, db } = open(projectDir);
|
|
39
|
+
try {
|
|
40
|
+
db.ensureSyntheticLegacyGroup(definitions);
|
|
41
|
+
const ticketIds = db.ungroupedTicketIds(definitions.map((definition) => definition.id));
|
|
42
|
+
return { version: 1, ticketIds, inputFingerprint: repairFingerprint(definitions.filter((definition) => ticketIds.includes(definition.id))) };
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function repairTicketGroups(projectDir, expectedFingerprint, now = new Date()) {
|
|
49
|
+
const { definitions, db } = open(projectDir);
|
|
50
|
+
try {
|
|
51
|
+
const ticketIds = db.ungroupedTicketIds(definitions.map((definition) => definition.id));
|
|
52
|
+
const selected = definitions.filter((definition) => ticketIds.includes(definition.id));
|
|
53
|
+
const actual = repairFingerprint(selected);
|
|
54
|
+
if (actual !== expectedFingerprint)
|
|
55
|
+
throw new Error("ticket definitions changed after the repair preview; preview again before approving");
|
|
56
|
+
return db.repairTicketGroups(selected, `ticket-groups:repair:${actual}`, now);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function repairFingerprint(definitions) {
|
|
63
|
+
return createHash("sha256").update(JSON.stringify(definitions.map((definition) => [definition.id, definition]).sort(([a], [b]) => String(a).localeCompare(String(b))))).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
function open(projectDir) {
|
|
66
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
67
|
+
return { definitions: loadTickets(paths.tickets), db: new StateDb(paths.stateDb) };
|
|
68
|
+
}
|
package/dist/tickets/importer.js
CHANGED
|
@@ -6,6 +6,7 @@ import { nowTimestamp } from "./events.js";
|
|
|
6
6
|
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
7
7
|
import { insertXlSplitRecommendations } from "./recommendations.js";
|
|
8
8
|
import { currentActivity, withActivityPhase } from "../activity.js";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
9
10
|
export function importFromMarkdown(_progressDocPath) {
|
|
10
11
|
throw new Error("foreman tickets import is not yet implemented.\n\n" +
|
|
11
12
|
"To start fresh:\n" +
|
|
@@ -157,6 +158,9 @@ export function applyImportedItems(projectDir, items) {
|
|
|
157
158
|
const db = new StateDb(paths.stateDb);
|
|
158
159
|
let created = 0;
|
|
159
160
|
let updated = 0;
|
|
161
|
+
const createdTickets = [];
|
|
162
|
+
const statePatches = [];
|
|
163
|
+
let definitionsPublished = false;
|
|
160
164
|
try {
|
|
161
165
|
let nextNumber = nextTicketNumber(tickets);
|
|
162
166
|
let nextOrder = nextTicketOrder(tickets);
|
|
@@ -171,7 +175,9 @@ export function applyImportedItems(projectDir, items) {
|
|
|
171
175
|
const id = `T${String(nextNumber++).padStart(3, "0")}`;
|
|
172
176
|
const order = nextOrder;
|
|
173
177
|
nextOrder += 1000;
|
|
174
|
-
|
|
178
|
+
const createdTicket = importedItemToTicket(item, id, order);
|
|
179
|
+
tickets.push(createdTicket);
|
|
180
|
+
createdTickets.push(createdTicket);
|
|
175
181
|
created++;
|
|
176
182
|
}
|
|
177
183
|
const ticket = matchIndex >= 0 ? tickets[matchIndex] : tickets[tickets.length - 1];
|
|
@@ -186,16 +192,34 @@ export function applyImportedItems(projectDir, items) {
|
|
|
186
192
|
patch.validation_result = "not_applicable";
|
|
187
193
|
patch.validation_notes = "External tracker marked this item complete before import.";
|
|
188
194
|
}
|
|
189
|
-
|
|
195
|
+
statePatches.push({ ticketId: ticket.id, patch });
|
|
190
196
|
}
|
|
191
197
|
}
|
|
192
198
|
const errors = validateTicketDefs(tickets).filter((issue) => issue.message);
|
|
193
199
|
if (errors.length > 0) {
|
|
194
200
|
throw new Error(`import produced invalid tickets:\n${errors.map((err) => `- ${err.path}: ${err.message}`).join("\n")}`);
|
|
195
201
|
}
|
|
196
|
-
|
|
202
|
+
db.transaction(() => {
|
|
203
|
+
for (const { ticketId, patch } of statePatches)
|
|
204
|
+
db.upsertState(ticketId, patch, now);
|
|
205
|
+
db.ensureSyntheticLegacyGroup(existing);
|
|
206
|
+
if (createdTickets.length) {
|
|
207
|
+
const operationDigest = createHash("sha256").update(JSON.stringify(items.map((item) => [item.provider, item.providerId]))).digest("hex");
|
|
208
|
+
db.createTicketGroup({ origin: "import", operationId: `ticket-import:${operationDigest}`, createdAt: now, members: createdTickets.map((ticket) => ({ ticketId: ticket.id, definition: ticket, validatedAt: now })) });
|
|
209
|
+
}
|
|
210
|
+
saveTickets(paths.tickets, tickets);
|
|
211
|
+
definitionsPublished = true;
|
|
212
|
+
for (const ticket of tickets)
|
|
213
|
+
if (db.getTicketGroupForTicket(ticket.id))
|
|
214
|
+
db.updateTicketDefinitionSnapshot(ticket.id, ticket, now);
|
|
215
|
+
});
|
|
197
216
|
return { created, updated, tickets };
|
|
198
217
|
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (definitionsPublished)
|
|
220
|
+
saveTickets(paths.tickets, existing);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
199
223
|
finally {
|
|
200
224
|
db.close();
|
|
201
225
|
}
|
|
@@ -3,6 +3,7 @@ import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
|
3
3
|
import { StateDb } from "./stateDb.js";
|
|
4
4
|
import { nowTimestamp } from "./events.js";
|
|
5
5
|
import { renderAndWrite } from "./renderMarkdown.js";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
export function createReviewRecommendation(db, now, input) {
|
|
7
8
|
return db.insertReviewRecommendation({
|
|
8
9
|
created_at: now,
|
|
@@ -38,19 +39,42 @@ export function cmdReviewRecommendations(projectDir, opts = {}) {
|
|
|
38
39
|
return { pending, changed: 0, action: opts.action };
|
|
39
40
|
const now = nowTimestamp(config.timezone);
|
|
40
41
|
if (opts.action === "accept") {
|
|
41
|
-
|
|
42
|
+
const before = loadTickets(paths.tickets);
|
|
43
|
+
db.ensureSyntheticLegacyGroup(before);
|
|
44
|
+
let tickets = before;
|
|
45
|
+
const requestedAdditions = new Set();
|
|
42
46
|
for (const rec of selected) {
|
|
43
|
-
|
|
47
|
+
const patch = parseRecommendationPatch(rec);
|
|
48
|
+
for (const ticket of patch.add ?? [])
|
|
49
|
+
requestedAdditions.add(ticket.id);
|
|
50
|
+
tickets = applyRecommendationPatch(tickets, patch);
|
|
44
51
|
}
|
|
45
52
|
const issues = validateTicketDefs(tickets);
|
|
46
53
|
if (issues.length > 0) {
|
|
47
54
|
throw new Error(`accepted recommendation produced invalid tickets:\n${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`);
|
|
48
55
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
let definitionsPublished = false;
|
|
57
|
+
try {
|
|
58
|
+
db.transaction(() => {
|
|
59
|
+
const additions = tickets.filter((ticket) => requestedAdditions.has(ticket.id) && !db.getTicketGroupForTicket(ticket.id));
|
|
60
|
+
if (additions.length) {
|
|
61
|
+
const operationDigest = createHash("sha256").update(JSON.stringify(selected.map((rec) => rec.id).sort())).digest("hex");
|
|
62
|
+
db.createTicketGroup({ origin: "production", operationId: `recommendations:${operationDigest}`, createdAt: now, members: additions.map((ticket) => ({ ticketId: ticket.id, definition: ticket, validatedAt: now })) });
|
|
63
|
+
}
|
|
64
|
+
saveTickets(paths.tickets, tickets);
|
|
65
|
+
definitionsPublished = true;
|
|
66
|
+
for (const ticket of tickets)
|
|
67
|
+
if (db.getTicketGroupForTicket(ticket.id))
|
|
68
|
+
db.updateTicketDefinitionSnapshot(ticket.id, ticket, now);
|
|
69
|
+
for (const rec of selected)
|
|
70
|
+
db.updateReviewRecommendationStatus(rec.id, "accepted", now);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (definitionsPublished)
|
|
75
|
+
saveTickets(paths.tickets, before);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
54
78
|
const states = db.getAllStates();
|
|
55
79
|
renderAndWrite({ config, projectDir, ticketDefs: tickets, states, db });
|
|
56
80
|
}
|
package/dist/tickets/reset.d.ts
CHANGED
|
@@ -25,3 +25,4 @@ export declare function resetTickets(projectDir: string, target: string | Ticket
|
|
|
25
25
|
};
|
|
26
26
|
/** Finish or roll back a reset publication left incomplete by process interruption. */
|
|
27
27
|
export declare function recoverTicketResetPublications(projectDir: string): string[];
|
|
28
|
+
export { applyResolvedTicketReset, resolveTicketResetSelection, TicketResetDependencyConflictError, type DeletedDependencyConflict, type ResolveGroupResetOptions, } from "./groupReset.js";
|
package/dist/tickets/reset.js
CHANGED
|
@@ -211,3 +211,4 @@ function activeFieldCategories(state) {
|
|
|
211
211
|
function digest(value) {
|
|
212
212
|
return value ? createHash("sha256").update(value).digest("hex") : null;
|
|
213
213
|
}
|
|
214
|
+
export { applyResolvedTicketReset, resolveTicketResetSelection, TicketResetDependencyConflictError, } from "./groupReset.js";
|
|
@@ -44,6 +44,7 @@ export interface TicketBuildSetupConfig {
|
|
|
44
44
|
auto_merge_wait: boolean;
|
|
45
45
|
auto_merge_timeout_minutes: number | null;
|
|
46
46
|
base_branch: string;
|
|
47
|
+
branch_prefix: string;
|
|
47
48
|
branch_policy: {
|
|
48
49
|
mode: TicketBranchPolicyMode;
|
|
49
50
|
global_strategy: TicketBuildBranchStrategy;
|
|
@@ -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",
|
|
@@ -35,6 +38,7 @@ export const DEFAULT_TICKET_SETUP = {
|
|
|
35
38
|
auto_merge_wait: false,
|
|
36
39
|
auto_merge_timeout_minutes: null,
|
|
37
40
|
base_branch: "main",
|
|
41
|
+
branch_prefix: BUILTIN_BRANCH_PREFIX,
|
|
38
42
|
branch_policy: {
|
|
39
43
|
mode: "global",
|
|
40
44
|
global_strategy: "branch-per-ticket",
|
|
@@ -95,7 +99,10 @@ export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
|
|
|
95
99
|
delete config.planning;
|
|
96
100
|
}
|
|
97
101
|
mkdirSync(projectDir, { recursive: true });
|
|
98
|
-
|
|
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);
|
|
99
106
|
}
|
|
100
107
|
/** Synchronize pending setup limits into the tracker, once the tracker exists. */
|
|
101
108
|
export function syncTicketLimits(projectDir, limits) {
|
|
@@ -407,6 +414,7 @@ function normalizeBuild(value, label) {
|
|
|
407
414
|
auto_merge_wait: booleanField(raw.auto_merge_wait, DEFAULT_TICKET_SETUP.build.auto_merge_wait, `${label}.auto_merge_wait`),
|
|
408
415
|
auto_merge_timeout_minutes: nullablePositiveInteger(raw.auto_merge_timeout_minutes, DEFAULT_TICKET_SETUP.build.auto_merge_timeout_minutes, `${label}.auto_merge_timeout_minutes`),
|
|
409
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)),
|
|
410
418
|
branch_policy: normalizeBranchPolicy(raw.branch_policy, raw.branch_strategy, `${label}.branch_policy`),
|
|
411
419
|
review: normalizeReview(raw.review, `${label}.review`),
|
|
412
420
|
validation_checklist: stringArray(raw.validation_checklist, DEFAULT_TICKET_SETUP.build.validation_checklist, `${label}.validation_checklist`),
|
|
@@ -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,12 @@ 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
|
+
}
|
|
92
99
|
/** The only valid initial/fully-reset active state for a ticket. */
|
|
93
100
|
export declare function pristineTicketState(ticketId: string, now: string, actor?: string | null): TicketState;
|
|
94
101
|
export declare class StateDb {
|
|
@@ -121,5 +128,32 @@ export declare class StateDb {
|
|
|
121
128
|
updateReviewRecommendationStatus(id: number, status: ReviewRecommendationStatus, updatedAt: string): void;
|
|
122
129
|
getOperationReceipt(operationId: string): OperationReceipt | undefined;
|
|
123
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;
|
|
124
158
|
transaction<T>(fn: () => T): T;
|
|
125
159
|
}
|