@akagilnc/pi-workflow-roles 0.1.5797 → 0.1.5820

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.
Files changed (80) hide show
  1. package/dist/acp-host/production-host.js +464 -355
  2. package/dist/acp-host/role-turn-host.js +484 -0
  3. package/dist/activation-ledger-session.js +105 -0
  4. package/dist/activation-ledger.js +8 -0
  5. package/dist/activation-trace.js +33 -0
  6. package/dist/audit-escalation.js +7 -0
  7. package/dist/audited-run-identity.js +21 -0
  8. package/dist/auditor-role.js +7 -0
  9. package/dist/book-topology-migration-placement.js +404 -0
  10. package/dist/book-topology-migration.js +125 -0
  11. package/dist/book-topology-migrators-t11.js +677 -0
  12. package/dist/book-topology-mixed-volume-migrators.js +322 -0
  13. package/dist/book-topology-partition-migrators.js +23 -0
  14. package/dist/book-topology-record-class-migrators.js +671 -0
  15. package/dist/book-topology-runs-migrator.js +272 -0
  16. package/dist/collector-role.js +109 -0
  17. package/dist/compliance-transport.js +5 -3
  18. package/dist/countersign-role.js +17 -0
  19. package/dist/diarist-role.js +8 -0
  20. package/dist/diarist.js +15 -0
  21. package/dist/doctor-role.js +80 -0
  22. package/dist/engine-detour-tool.js +51 -37
  23. package/dist/engine-detour.js +22 -11
  24. package/dist/factory-board.js +1478 -0
  25. package/dist/fixer-bash-seatbelt.js +13 -0
  26. package/dist/gatekeeper-role.js +1 -2
  27. package/dist/gh-api-runner.js +81 -0
  28. package/dist/gleaner-left-role.js +8 -0
  29. package/dist/headless-host/production-host.js +464 -355
  30. package/dist/headless-host/role-turn-host.js +691 -0
  31. package/dist/human-format.js +67 -0
  32. package/dist/inspector-role.js +12 -0
  33. package/dist/judge-recording-anti-forge.js +39 -0
  34. package/dist/judge-role.js +21 -3
  35. package/dist/merger-role.js +57 -0
  36. package/dist/migrate-book-topology.js +0 -2
  37. package/dist/navigator-role.js +6 -0
  38. package/dist/navigator-work-context.js +72 -0
  39. package/dist/notary-role.js +113 -0
  40. package/dist/oauth-keepalive.js +149 -0
  41. package/dist/package-contracts/terminating-infrastructure.js +0 -35
  42. package/dist/pi/adapter.js +272 -0
  43. package/dist/public-cli/cli.js +9 -3
  44. package/dist/public-cli/instruction-seat-run.js +163 -44
  45. package/dist/public-cli/main.js +241 -123
  46. package/dist/report-ticket-tool.js +24 -13
  47. package/dist/reviewer-role.js +33 -0
  48. package/dist/role-envelope.js +707 -0
  49. package/dist/role-runtime-dependencies.js +57 -0
  50. package/dist/role-runtime.js +1476 -0
  51. package/dist/secretariat-role.js +5 -0
  52. package/dist/stderr-jsonl.js +26 -0
  53. package/dist/submission-correctable-error.js +2 -6
  54. package/dist/submission-errors.js +0 -30
  55. package/dist/submission-gate.js +61 -54
  56. package/dist/submission-ledger.js +105 -98
  57. package/dist/ticket-snapshot.js +486 -0
  58. package/dist/ticket-trajectory.js +843 -0
  59. package/dist/worker-role.js +184 -0
  60. package/dist/worker-submission-gates.js +333 -0
  61. package/package.json +1 -1
  62. package/resources/engine-dispatch.md +6 -6
  63. package/resources/engines/agy.md +1 -1
  64. package/src/compliance-transport.ts +7 -3
  65. package/src/engine-detour-tool.ts +88 -57
  66. package/src/engine-detour.ts +31 -14
  67. package/src/gatekeeper-role.ts +1 -15
  68. package/src/host-contracts.ts +9 -12
  69. package/src/judge-role.ts +30 -7
  70. package/src/package-contracts/terminating-infrastructure.ts +8 -81
  71. package/src/pi/adapter.ts +0 -1
  72. package/src/public-cli/cli.ts +21 -14
  73. package/src/public-cli/instruction-seat-run.ts +227 -44
  74. package/src/report-ticket-tool.ts +27 -13
  75. package/src/role-envelope.ts +35 -35
  76. package/src/role-runtime.ts +63 -56
  77. package/src/submission-correctable-error.ts +1 -6
  78. package/src/submission-errors.ts +0 -34
  79. package/src/submission-gate.ts +71 -56
  80. package/src/submission-ledger.ts +109 -104
@@ -0,0 +1,486 @@
1
+ /**
2
+ * GitHub ticket snapshot adapter (factory board S2).
3
+ *
4
+ * Thin layer: explicit book→repo bindings + open set (+ named closed drills)
5
+ * → BoardSnapshot. Does not scan ledgers, does not guess git remote.
6
+ *
7
+ * Parent / blocked_by edges come from GitHub's native issue relationship API
8
+ * (GraphQL parent + blockedBy). Binding or API failure throws — callers render
9
+ * the error loudly; never invent an empty successful board.
10
+ *
11
+ * blockedBy is paginated to completion (or fails loudly). Never expose a
12
+ * truncated first page as the full blocker set.
13
+ */
14
+ import { createGhApiRunner } from "./gh-api-runner.js";
15
+ import { isRecord } from "./unknown-value.js";
16
+ import { isPositiveTicketNumber } from "./run-ticket-number.js";
17
+ /** Retention window: merged tickets leave the default board closedAt+24h later. */
18
+ export const RETENTION_WINDOW_MS = 24 * 60 * 60 * 1000;
19
+ export class TicketSnapshotBindingError extends Error {
20
+ kind = "binding";
21
+ bookKey;
22
+ constructor(bookKey, message) {
23
+ super(message);
24
+ this.name = "TicketSnapshotBindingError";
25
+ this.bookKey = bookKey;
26
+ }
27
+ }
28
+ export class TicketSnapshotApiError extends Error {
29
+ kind = "api";
30
+ bookKey;
31
+ constructor(bookKey, message, options) {
32
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
33
+ this.name = "TicketSnapshotApiError";
34
+ this.bookKey = bookKey;
35
+ }
36
+ }
37
+ function requireNonEmpty(value, label) {
38
+ const trimmed = value.trim();
39
+ if (!trimmed)
40
+ throw new Error(`${label} must be a non-empty string`);
41
+ return trimmed;
42
+ }
43
+ function mapIssueState(raw) {
44
+ if (raw === "OPEN" || raw === "open")
45
+ return "open";
46
+ if (raw === "CLOSED" || raw === "closed")
47
+ return "closed";
48
+ throw new Error(`unexpected issue state: ${String(raw)}`);
49
+ }
50
+ /**
51
+ * Parse one blockedBy connection page. Completeness requires pageInfo;
52
+ * missing pageInfo is a loud failure (never treat a truncated first page as full).
53
+ */
54
+ function parseBlockedByPage(raw, label) {
55
+ if (raw === undefined || raw === null) {
56
+ return { edges: [], hasNextPage: false, endCursor: null };
57
+ }
58
+ if (!isRecord(raw) || !Array.isArray(raw.nodes)) {
59
+ throw new Error(`${label} invalid`);
60
+ }
61
+ const pageInfo = raw.pageInfo;
62
+ if (!isRecord(pageInfo) || typeof pageInfo.hasNextPage !== "boolean") {
63
+ throw new Error(`${label}.pageInfo missing — cannot establish blockedBy completeness`);
64
+ }
65
+ let endCursor = null;
66
+ if (pageInfo.endCursor === null || pageInfo.endCursor === undefined) {
67
+ endCursor = null;
68
+ }
69
+ else if (typeof pageInfo.endCursor === "string") {
70
+ endCursor = pageInfo.endCursor;
71
+ }
72
+ else {
73
+ throw new Error(`${label}.pageInfo.endCursor invalid`);
74
+ }
75
+ if (pageInfo.hasNextPage && !endCursor) {
76
+ throw new Error(`${label}.pageInfo endCursor missing while hasNextPage`);
77
+ }
78
+ const edges = [];
79
+ for (const [index, node] of raw.nodes.entries()) {
80
+ if (!isRecord(node) || typeof node.number !== "number") {
81
+ throw new Error(`${label}.nodes[${index}] invalid`);
82
+ }
83
+ edges.push({
84
+ issueNumber: node.number,
85
+ state: mapIssueState(node.state),
86
+ });
87
+ }
88
+ return {
89
+ edges,
90
+ hasNextPage: pageInfo.hasNextPage,
91
+ endCursor,
92
+ };
93
+ }
94
+ function parseClosedAt(raw, label) {
95
+ if (raw === null || raw === undefined)
96
+ return null;
97
+ if (typeof raw !== "string" || !Number.isFinite(Date.parse(raw))) {
98
+ throw new Error(`${label}.closedAt invalid`);
99
+ }
100
+ return raw;
101
+ }
102
+ function parseTicketNode(raw, label) {
103
+ if (!isRecord(raw))
104
+ throw new Error(`${label} is not an object`);
105
+ const number = raw.number;
106
+ if (!isPositiveTicketNumber(number)) {
107
+ throw new Error(`${label}.number invalid`);
108
+ }
109
+ if (typeof raw.title !== "string")
110
+ throw new Error(`${label}.title missing`);
111
+ const milestoneRaw = raw.milestone;
112
+ let milestone = null;
113
+ if (milestoneRaw === null || milestoneRaw === undefined) {
114
+ milestone = null;
115
+ }
116
+ else if (isRecord(milestoneRaw) && typeof milestoneRaw.title === "string") {
117
+ milestone = milestoneRaw.title;
118
+ }
119
+ else {
120
+ throw new Error(`${label}.milestone invalid`);
121
+ }
122
+ let parentIssueNumber = null;
123
+ if (raw.parent === null || raw.parent === undefined) {
124
+ parentIssueNumber = null;
125
+ }
126
+ else if (isRecord(raw.parent) && typeof raw.parent.number === "number") {
127
+ parentIssueNumber = raw.parent.number;
128
+ }
129
+ else {
130
+ throw new Error(`${label}.parent invalid`);
131
+ }
132
+ const state = mapIssueState(raw.state);
133
+ const closedAtRaw = parseClosedAt(raw.closedAt, label);
134
+ if (state === "closed" && closedAtRaw === null) {
135
+ throw new Error(`${label}.closedAt missing for closed issue`);
136
+ }
137
+ // Open tickets never carry a closure endpoint into landing-cycle math.
138
+ const closedAt = state === "closed" ? closedAtRaw : null;
139
+ return {
140
+ ticket: {
141
+ issueNumber: number,
142
+ title: raw.title,
143
+ state,
144
+ milestone,
145
+ parentIssueNumber,
146
+ closedAt,
147
+ },
148
+ blockedByPage: parseBlockedByPage(raw.blockedBy, `${label}.blockedBy`),
149
+ };
150
+ }
151
+ const BLOCKED_BY_PAGE_SIZE = 100;
152
+ function blockedBySelection(afterVar) {
153
+ const afterArg = afterVar ? `, after: ${afterVar}` : "";
154
+ return `blockedBy(first: ${BLOCKED_BY_PAGE_SIZE}${afterArg}) {
155
+ pageInfo { hasNextPage endCursor }
156
+ nodes { number state }
157
+ }`;
158
+ }
159
+ function buildOpenIssuesQuery() {
160
+ return `query($owner: String!, $repo: String!, $after: String) {
161
+ repository(owner: $owner, name: $repo) {
162
+ issues(states: OPEN, first: 100, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
163
+ pageInfo { hasNextPage endCursor }
164
+ nodes {
165
+ number
166
+ title
167
+ state
168
+ closedAt
169
+ milestone { title }
170
+ parent { number }
171
+ ${blockedBySelection()}
172
+ }
173
+ }
174
+ }
175
+ }`;
176
+ }
177
+ function buildClosedIssuesQuery(numbers) {
178
+ const aliases = numbers
179
+ .map((n, index) => `
180
+ c${index}: issue(number: ${n}) {
181
+ number
182
+ title
183
+ state
184
+ closedAt
185
+ milestone { title }
186
+ parent { number }
187
+ ${blockedBySelection()}
188
+ }`)
189
+ .join("\n");
190
+ return `query($owner: String!, $repo: String!) {
191
+ repository(owner: $owner, name: $repo) {
192
+ ${aliases}
193
+ }
194
+ }`;
195
+ }
196
+ function buildClosedIssuesDrainQuery() {
197
+ return `query($owner: String!, $repo: String!, $after: String) {
198
+ repository(owner: $owner, name: $repo) {
199
+ issues(states: CLOSED, first: 100, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
200
+ pageInfo { hasNextPage endCursor }
201
+ nodes {
202
+ number
203
+ title
204
+ state
205
+ closedAt
206
+ milestone { title }
207
+ parent { number }
208
+ ${blockedBySelection()}
209
+ }
210
+ }
211
+ }
212
+ }`;
213
+ }
214
+ function buildBlockedByPageQuery() {
215
+ return `query($owner: String!, $repo: String!, $number: Int!, $after: String) {
216
+ repository(owner: $owner, name: $repo) {
217
+ issue(number: $number) {
218
+ ${blockedBySelection("$after")}
219
+ }
220
+ }
221
+ }`;
222
+ }
223
+ async function ghGraphql(runner, query, variables, signal) {
224
+ const args = [
225
+ "api",
226
+ "graphql",
227
+ "--hostname",
228
+ "github.com",
229
+ "--include",
230
+ "-f",
231
+ `query=${query}`,
232
+ ];
233
+ for (const [key, value] of Object.entries(variables)) {
234
+ if (value === null) {
235
+ args.push("-F", `${key}=null`);
236
+ }
237
+ else if (typeof value === "number") {
238
+ // GraphQL Int variables must be typed, not string-forced.
239
+ args.push("-F", `${key}=${value}`);
240
+ }
241
+ else {
242
+ args.push("-f", `${key}=${value}`);
243
+ }
244
+ }
245
+ const response = await runner(args, signal === undefined ? {} : { signal });
246
+ if (response.status < 200 || response.status >= 300) {
247
+ throw new Error(`GitHub GraphQL HTTP ${response.status}: ${response.bodyText.slice(0, 400)}`);
248
+ }
249
+ let payload;
250
+ try {
251
+ payload = JSON.parse(response.bodyText);
252
+ }
253
+ catch (error) {
254
+ throw new Error("GitHub GraphQL returned malformed JSON", { cause: error });
255
+ }
256
+ if (!isRecord(payload))
257
+ throw new Error("GitHub GraphQL payload is not an object");
258
+ if (payload.errors !== undefined) {
259
+ const msg = JSON.stringify(payload.errors).slice(0, 600);
260
+ throw new Error(`GitHub GraphQL errors: ${msg}`);
261
+ }
262
+ return payload.data;
263
+ }
264
+ /**
265
+ * Drain blockedBy pages until complete. Never return a partial first page as full.
266
+ */
267
+ async function completeBlockedBy(runner, owner, repo, issueNumber, firstPage, signal) {
268
+ const edges = [...firstPage.edges];
269
+ let hasNextPage = firstPage.hasNextPage;
270
+ let endCursor = firstPage.endCursor;
271
+ while (hasNextPage) {
272
+ if (!endCursor) {
273
+ throw new Error(`blockedBy pagination incomplete for #${issueNumber}: missing endCursor`);
274
+ }
275
+ const data = await ghGraphql(runner, buildBlockedByPageQuery(), { owner, repo, number: issueNumber, after: endCursor }, signal);
276
+ if (!isRecord(data) || !isRecord(data.repository) || !isRecord(data.repository.issue)) {
277
+ throw new Error(`blockedBy page missing for #${issueNumber}`);
278
+ }
279
+ const page = parseBlockedByPage(data.repository.issue.blockedBy, `blockedBy[#${issueNumber}]`);
280
+ edges.push(...page.edges);
281
+ hasNextPage = page.hasNextPage;
282
+ endCursor = page.endCursor;
283
+ }
284
+ return edges;
285
+ }
286
+ async function materializeTicket(runner, owner, repo, raw, label, signal) {
287
+ const parsed = parseTicketNode(raw, label);
288
+ const blockedBy = await completeBlockedBy(runner, owner, repo, parsed.ticket.issueNumber, parsed.blockedByPage, signal);
289
+ return {
290
+ ...parsed.ticket,
291
+ blockedBy,
292
+ };
293
+ }
294
+ export function createGhTicketSnapshotTransport(runner = createGhApiRunner()) {
295
+ return {
296
+ async listBookTickets(input) {
297
+ const owner = requireNonEmpty(input.owner, "owner");
298
+ const repo = requireNonEmpty(input.repo, "repo");
299
+ const retentionNow = input.retentionNow;
300
+ if (retentionNow !== undefined && !Number.isFinite(retentionNow.getTime())) {
301
+ throw new Error("retentionNow must be a finite Date");
302
+ }
303
+ const tickets = new Map();
304
+ let after = null;
305
+ for (;;) {
306
+ const data = await ghGraphql(runner, buildOpenIssuesQuery(), { owner, repo, after }, input.signal);
307
+ if (!isRecord(data) || !isRecord(data.repository)) {
308
+ throw new Error("GitHub GraphQL repository missing");
309
+ }
310
+ const issues = data.repository.issues;
311
+ if (!isRecord(issues) || !Array.isArray(issues.nodes)) {
312
+ throw new Error("GitHub GraphQL issues connection missing");
313
+ }
314
+ for (const [index, node] of issues.nodes.entries()) {
315
+ if (node === null)
316
+ continue;
317
+ const ticket = await materializeTicket(runner, owner, repo, node, `open[${index}]`, input.signal);
318
+ tickets.set(ticket.issueNumber, ticket);
319
+ }
320
+ const pageInfo = issues.pageInfo;
321
+ if (!isRecord(pageInfo) ||
322
+ typeof pageInfo.hasNextPage !== "boolean") {
323
+ throw new Error("GitHub GraphQL pageInfo missing");
324
+ }
325
+ if (!pageInfo.hasNextPage)
326
+ break;
327
+ if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor) {
328
+ throw new Error("GitHub GraphQL endCursor missing");
329
+ }
330
+ after = pageInfo.endCursor;
331
+ }
332
+ if (retentionNow !== undefined) {
333
+ // Closed drain: complete pagination only (never a truncated first page),
334
+ // then the mechanical merge+24h candidate set with the family shared clock.
335
+ const closedPool = new Map();
336
+ let closedAfter = null;
337
+ for (;;) {
338
+ const data = await ghGraphql(runner, buildClosedIssuesDrainQuery(), { owner, repo, after: closedAfter }, input.signal);
339
+ if (!isRecord(data) || !isRecord(data.repository)) {
340
+ throw new Error("GitHub GraphQL repository missing");
341
+ }
342
+ const issues = data.repository.issues;
343
+ if (!isRecord(issues) || !Array.isArray(issues.nodes)) {
344
+ throw new Error("GitHub GraphQL closed issues connection missing");
345
+ }
346
+ for (const [index, node] of issues.nodes.entries()) {
347
+ if (node === null)
348
+ continue;
349
+ const ticket = await materializeTicket(runner, owner, repo, node, `closedDrain[${index}]`, input.signal);
350
+ closedPool.set(ticket.issueNumber, ticket);
351
+ }
352
+ const pageInfo = issues.pageInfo;
353
+ if (!isRecord(pageInfo) || typeof pageInfo.hasNextPage !== "boolean") {
354
+ throw new Error("GitHub GraphQL closed issues pageInfo missing — cannot establish completeness");
355
+ }
356
+ if (!pageInfo.hasNextPage)
357
+ break;
358
+ if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor) {
359
+ throw new Error("GitHub GraphQL closed issues endCursor missing");
360
+ }
361
+ closedAfter = pageInfo.endCursor;
362
+ }
363
+ // Family roots from native parent edges across open ∪ closed (cycle-safe).
364
+ const allByNumber = new Map([...tickets, ...closedPool]);
365
+ const rootOf = (ticket) => {
366
+ const seen = new Set();
367
+ let current = ticket;
368
+ for (;;) {
369
+ if (seen.has(current.issueNumber))
370
+ return current;
371
+ seen.add(current.issueNumber);
372
+ const parentNumber = current.parentIssueNumber;
373
+ if (parentNumber === null)
374
+ return current;
375
+ const parent = allByNumber.get(parentNumber);
376
+ if (!parent)
377
+ return current; // edge outside the book — self is the best clock
378
+ current = parent;
379
+ }
380
+ };
381
+ const nowMs = retentionNow.getTime();
382
+ for (const candidate of closedPool.values()) {
383
+ if (tickets.has(candidate.issueNumber))
384
+ continue;
385
+ const root = rootOf(candidate);
386
+ if (root.state === "open") {
387
+ // 陪跑: merged children stay while the family root is open.
388
+ tickets.set(candidate.issueNumber, candidate);
389
+ continue;
390
+ }
391
+ const rootClosedAt = root.closedAt;
392
+ if (rootClosedAt === null) {
393
+ throw new Error(`closed family root #${root.issueNumber} missing closedAt`);
394
+ }
395
+ if (Date.parse(rootClosedAt) + RETENTION_WINDOW_MS > nowMs) {
396
+ tickets.set(candidate.issueNumber, candidate);
397
+ }
398
+ // else: the family has exited (root closedAt+24h passed) — not supplied.
399
+ }
400
+ }
401
+ const closedNumbers = [
402
+ ...new Set(input.closedIssueNumbers.filter((n) => isPositiveTicketNumber(n) && !tickets.has(n))),
403
+ ].sort((a, b) => a - b);
404
+ // Fetch named closed issues in modest batches (alias query size).
405
+ const batchSize = 20;
406
+ for (let offset = 0; offset < closedNumbers.length; offset += batchSize) {
407
+ const batch = closedNumbers.slice(offset, offset + batchSize);
408
+ if (batch.length === 0)
409
+ continue;
410
+ const data = await ghGraphql(runner, buildClosedIssuesQuery(batch), { owner, repo }, input.signal);
411
+ if (!isRecord(data) || !isRecord(data.repository)) {
412
+ throw new Error("GitHub GraphQL repository missing for closed drills");
413
+ }
414
+ for (let index = 0; index < batch.length; index += 1) {
415
+ const node = data.repository[`c${index}`];
416
+ if (node === null || node === undefined) {
417
+ throw new Error(`named closed issue #${batch[index]} not found in ${owner}/${repo}`);
418
+ }
419
+ const ticket = await materializeTicket(runner, owner, repo, node, `closed[#${batch[index]}]`, input.signal);
420
+ tickets.set(ticket.issueNumber, ticket);
421
+ }
422
+ }
423
+ return [...tickets.values()].sort((a, b) => a.issueNumber - b.issueNumber);
424
+ },
425
+ };
426
+ }
427
+ function assertBinding(binding) {
428
+ if (!binding.bookKey.trim()) {
429
+ throw new TicketSnapshotBindingError(binding.bookKey, "bookKey must be a non-empty string");
430
+ }
431
+ const owner = binding.owner?.trim() ?? "";
432
+ const repo = binding.repo?.trim() ?? "";
433
+ if (!owner || !repo) {
434
+ throw new TicketSnapshotBindingError(binding.bookKey, `binding missing owner/repo for book ${binding.bookKey}`);
435
+ }
436
+ return { owner, repo };
437
+ }
438
+ /**
439
+ * Fetch a board snapshot from explicit book→repo bindings.
440
+ * Never inspects git remotes. Transport failures become TicketSnapshotApiError.
441
+ * Duplicate bookKey bindings fail closed before any transport work.
442
+ */
443
+ export async function fetchBoardSnapshot(input) {
444
+ if (!Array.isArray(input.bindings) || input.bindings.length === 0) {
445
+ throw new TicketSnapshotBindingError("", "bindings must be a non-empty array");
446
+ }
447
+ // Reject duplicate bookKeys before any API / ledger work — bookKey is the
448
+ // sole join identity across bindings, snapshot books, and swimlanes.
449
+ const seenBookKeys = new Set();
450
+ for (const binding of input.bindings) {
451
+ const key = binding.bookKey;
452
+ if (seenBookKeys.has(key)) {
453
+ throw new TicketSnapshotBindingError(key, `duplicate bookKey binding: ${key}`);
454
+ }
455
+ seenBookKeys.add(key);
456
+ }
457
+ const books = [];
458
+ for (const binding of input.bindings) {
459
+ const { owner, repo } = assertBinding(binding);
460
+ const closedIssueNumbers = input.closedIssueNumbersByBook?.[binding.bookKey] ?? [];
461
+ try {
462
+ const tickets = await input.transport.listBookTickets({
463
+ owner,
464
+ repo,
465
+ closedIssueNumbers,
466
+ ...(input.retentionNow !== undefined ? { retentionNow: input.retentionNow } : {}),
467
+ ...(input.signal !== undefined ? { signal: input.signal } : {}),
468
+ });
469
+ books.push({
470
+ bookKey: binding.bookKey,
471
+ owner,
472
+ repo,
473
+ tickets,
474
+ });
475
+ }
476
+ catch (error) {
477
+ if (error instanceof TicketSnapshotBindingError)
478
+ throw error;
479
+ const message = error instanceof Error
480
+ ? error.message
481
+ : `ticket snapshot API failed for book ${binding.bookKey}`;
482
+ throw new TicketSnapshotApiError(binding.bookKey, `ticket snapshot API failed for book ${binding.bookKey}: ${message}`, { cause: error });
483
+ }
484
+ }
485
+ return { books };
486
+ }