@defrex/autobuild 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -0,0 +1,18 @@
1
+ import type { TicketsConfig } from '../../config/schema'
2
+
3
+ /** Resolve names without changing persisted ticket states or their spelling. */
4
+ export function ticketLifecycle(tickets: TicketsConfig, legacyHosted = false) {
5
+ const hosted = tickets.source === 'hosted'
6
+ const old = tickets.source === 'linear' || (hosted && legacyHosted)
7
+ const triage = tickets.triageState ?? (old ? 'Backlog' : hosted ? 'triage' : 'Triage')
8
+ return {
9
+ triage,
10
+ ready: tickets.readyState,
11
+ doing:
12
+ tickets.claimedState ??
13
+ (tickets.source === 'linear' ? 'In Progress' : old ? 'Doing' : 'doing'),
14
+ done: tickets.doneState ?? (old ? 'Done' : hosted ? 'done' : 'Done'),
15
+ create: tickets.createState ?? triage,
16
+ proposal: tickets.proposalState ?? triage,
17
+ }
18
+ }
@@ -1,3 +1,4 @@
1
+ import { ticketRevision } from './revision'
1
2
  /**
2
3
  * Linear TicketSource (SPEC §3.2, §13): GraphQL over an injectable fetch, so
3
4
  * tests run offline against canned exchanges. Queries are deliberately
@@ -109,7 +110,8 @@ interface GqlIssue {
109
110
  description: string | null
110
111
  url: string
111
112
  state: { name: string; type: string } | null
112
- labels: { nodes: Array<{ name: string }> }
113
+ labels: { nodes: Array<{ name: string }>; pageInfo?: PageInfo }
114
+ comments?: Connection<{ body: string; createdAt: string; user: { name: string } | null }>
113
115
  /** Relations where THIS issue is the `relatedIssue` side. A relation
114
116
  * `{issue: A, relatedIssue: B, type: "blocks"}` reads "A blocks B", so an
115
117
  * issue's blockers are its inverse `blocks` relations, and the blocker is
@@ -237,6 +239,7 @@ function nextCursor(info: PageInfo | undefined, seen: Set<string>): string | nul
237
239
  return cursor
238
240
  }
239
241
 
242
+ const GET_DETAIL_QUERY = `query GetIssue($id: String!) { issue(id: $id) { id identifier title description url state { name type } labels(first: 100) { nodes { name } ${PAGE_INFO} } inverseRelations(first: 100) { nodes { ${READY_RELATIONS} } ${PAGE_INFO} } comments(first: 100, includeArchived: true, orderBy: createdAt) { nodes { body createdAt user { name } } ${PAGE_INFO} } } }`
240
243
  const GET_ISSUE_QUERY = `query GetIssue($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`
241
244
  const RESOLVE_ISSUE_QUERY = `query ResolveIssue($id: String!) { issue(id: $id) { id } }`
242
245
  const ISSUE_STATE_QUERY = `query IssueState($id: String!) { issue(id: $id) { id state { name type } } }`
@@ -343,11 +346,17 @@ export class LinearTicketSource implements TicketSource {
343
346
  const seen = new Set<string>()
344
347
  let after: string | null = null
345
348
  do {
346
- const data: { issues: Connection<GqlIssue> } = await this.gql('listReady', LIST_READY_QUERY, {
347
- filter,
348
- first: READY_PAGE_SIZE,
349
- after,
350
- })
349
+ const data: { issues: Connection<GqlIssue> } = await this.gql(
350
+ 'listReady',
351
+ Object.values(criteria).every((value) => value === undefined)
352
+ ? LIST_READY_QUERY.replace('after: $after)', 'after: $after, includeArchived: true)')
353
+ : LIST_READY_QUERY,
354
+ {
355
+ filter,
356
+ first: READY_PAGE_SIZE,
357
+ after,
358
+ },
359
+ )
351
360
  await this.completeRelations(data.issues.nodes, true)
352
361
  for (const issue of data.issues.nodes) {
353
362
  const relations = issue.inverseRelations!.nodes
@@ -369,7 +378,7 @@ export class LinearTicketSource implements TicketSource {
369
378
  async get(id: string): Promise<Ticket | null> {
370
379
  let data: { issue: GqlIssue | null }
371
380
  try {
372
- data = await this.gql<{ issue: GqlIssue | null }>('get', GET_ISSUE_QUERY, { id })
381
+ data = await this.gql<{ issue: GqlIssue | null }>('get', GET_DETAIL_QUERY, { id })
373
382
  } catch (error) {
374
383
  // Linear's `issue(id:)` field is non-null in the live schema: an unknown
375
384
  // identifier arrives as INPUT_ERROR / Entity not found rather than the
@@ -377,7 +386,54 @@ export class LinearTicketSource implements TicketSource {
377
386
  if (isEntityNotFound(error)) return null
378
387
  throw error
379
388
  }
380
- return data.issue ? this.toTicket(data.issue) : null
389
+ if (!data.issue) return null
390
+ const issue = data.issue
391
+ // Detail reads exhaust labels and relations independently of dispatch bounds.
392
+ const labels: Array<{ name: string }> = [...issue.labels.nodes]
393
+ const labelSeen = new Set<string>()
394
+ let labelAfter = nextCursor(issue.labels.pageInfo, labelSeen)
395
+ while (labelAfter !== null) {
396
+ const page: { issue: { labels: Connection<{ name: string }> } } = await this.gql(
397
+ 'getLabels',
398
+ `query TicketLabels($id: String!, $after: String) { issue(id: $id) { labels(first: 100, after: $after) { nodes { name } ${PAGE_INFO} } } }`,
399
+ { id: issue.id ?? id, after: labelAfter },
400
+ )
401
+ labels.push(...page.issue.labels.nodes)
402
+ labelAfter = nextCursor(page.issue.labels.pageInfo, labelSeen)
403
+ }
404
+ issue.labels = { nodes: labels }
405
+ await this.completeRelations([issue], true)
406
+ const mapComment = (c: { body: string; createdAt: string; user: { name: string } | null }) => ({
407
+ body: c.body,
408
+ createdAt: c.createdAt,
409
+ ...(c.user ? { author: c.user.name } : {}),
410
+ })
411
+ const comments: import('../types').TicketComment[] = (issue.comments?.nodes ?? []).map(
412
+ mapComment,
413
+ )
414
+ const seen = new Set<string>()
415
+ let after = nextCursor(issue.comments?.pageInfo, seen)
416
+ while (after !== null) {
417
+ const page: {
418
+ issue: {
419
+ comments: Connection<{ body: string; createdAt: string; user: { name: string } | null }>
420
+ }
421
+ } = await this.gql(
422
+ 'getComments',
423
+ `query TicketComments($id: String!, $after: String) { issue(id: $id) { comments(first: 100, after: $after, includeArchived: true, orderBy: createdAt) { nodes { body createdAt user { name } } ${PAGE_INFO} } } }`,
424
+ { id: issue.id ?? id, after },
425
+ )
426
+ comments.push(
427
+ ...page.issue.comments.nodes.map((c) => ({
428
+ body: c.body,
429
+ createdAt: c.createdAt,
430
+ ...(c.user ? { author: c.user.name } : {}),
431
+ })),
432
+ )
433
+ after = nextCursor(page.issue.comments.pageInfo, seen)
434
+ }
435
+ comments.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
436
+ return { ...this.toTicket(issue), comments }
381
437
  }
382
438
 
383
439
  /**
@@ -430,6 +486,7 @@ export class LinearTicketSource implements TicketSource {
430
486
  }
431
487
 
432
488
  async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
489
+ if (opts.requestedId !== undefined) throw new Error('linear does not support requested ids')
433
490
  const reservedId = opts.idempotencyKey
434
491
  if (reservedId !== undefined && !linearReservedIssueIdSchema.safeParse(reservedId).success) {
435
492
  throw new Error('linear create: idempotency key must be a UUID v4')
@@ -796,7 +853,8 @@ export class LinearTicketSource implements TicketSource {
796
853
  // The issue projection predates newly created relations. Preserve native
797
854
  // existing blockers and report the requested set now known to be recorded.
798
855
  const blockedBy = [...new Set([...blockersOf(issue), ...requested])]
799
- return { ...this.toTicket(issue), ...(blockedBy.length > 0 ? { blockedBy } : {}) }
856
+ const ticket = { ...this.toTicket(issue), ...(blockedBy.length > 0 ? { blockedBy } : {}) }
857
+ return { ...ticket, revision: ticketRevision(ticket) }
800
858
  }
801
859
 
802
860
  private async adoptCreatedIssue(id: string): Promise<GqlIssue | null> {
@@ -1017,6 +1075,13 @@ export class LinearTicketSource implements TicketSource {
1017
1075
  private toTicket(issue: GqlIssue): Ticket {
1018
1076
  const blockedBy = blockersOf(issue)
1019
1077
  return {
1078
+ revision: ticketRevision({
1079
+ title: issue.title,
1080
+ body: issue.description ?? '',
1081
+ labels: issue.labels.nodes.map((label) => label.name),
1082
+ state: issue.state?.name,
1083
+ blockedBy,
1084
+ }),
1020
1085
  ref: {
1021
1086
  source: this.name,
1022
1087
  id: issue.identifier,
@@ -0,0 +1,336 @@
1
+ import { validateTicketAssetInput } from '../../store/ticket-assets'
2
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { z } from 'zod'
5
+ import type { Ticket, TicketSource, TicketAssetStore } from '../types'
6
+ import { FileTicketSource } from './file'
7
+ import { parseTicketFile, serializeTicketFile } from './file-codec'
8
+ import { validateFileTicketId } from './requested-id'
9
+ import { normalizeStateName } from './states'
10
+ import { encodePortableAsset, decodePortableAsset, type PortableAsset } from './portable-assets'
11
+
12
+ const MANIFEST = '.ab-migration.json'
13
+ export const IMPORT_MAP = '.ab-import-map.json'
14
+ const manifestSchema = z.strictObject({
15
+ version: z.literal(1),
16
+ identity: z.string().min(1),
17
+ source: z.string(),
18
+ keys: z.record(z.string(), z.string().min(1)),
19
+ states: z.record(z.string(), z.string()),
20
+ })
21
+ type Manifest = z.infer<typeof manifestSchema>
22
+ async function atomicJson(path: string, value: unknown) {
23
+ const temp = `${path}.${crypto.randomUUID()}.tmp`
24
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`)
25
+ await rename(temp, path)
26
+ }
27
+ async function inventory(source: TicketSource): Promise<Ticket[]> {
28
+ const listing = await source.listReady({})
29
+ if (listing.diagnostics.length)
30
+ throw new Error(`incomplete ticket scan:\n${listing.diagnostics.join('\n')}`)
31
+ const ids = new Set<string>()
32
+ const tickets: Ticket[] = []
33
+ for (const ticket of listing.tickets) {
34
+ if (ids.has(ticket.ref.id)) throw new Error(`duplicate ticket id: ${ticket.ref.id}`)
35
+ ids.add(ticket.ref.id)
36
+ const detail = await source.get(ticket.ref.id)
37
+ if (!detail) throw new Error(`ticket disappeared: ${ticket.ref.id}`)
38
+ if (!detail.state) throw new Error(`ticket has no state: ${ticket.ref.id}`)
39
+ tickets.push(detail)
40
+ }
41
+ return tickets
42
+ }
43
+ function uniqueStateMap(names: string[], map: (name: string) => string): Record<string, string> {
44
+ const result: Record<string, string> = Object.create(null)
45
+ const groups = new Map<string, string[]>()
46
+ for (const name of new Set(names)) {
47
+ const target = map(name)
48
+ result[name] = target
49
+ groups.set(target, [...(groups.get(target) ?? []), name])
50
+ }
51
+ const collisions = [...groups].filter(([, originals]) => originals.length > 1)
52
+ if (collisions.length)
53
+ throw new Error(
54
+ `state collisions: ${collisions.map(([target, originals]) => `${originals.join(', ')} → ${target}`).join('; ')}`,
55
+ )
56
+ return result
57
+ }
58
+ async function currentAssets(
59
+ store: TicketAssetStore | undefined,
60
+ repo: string,
61
+ id: string,
62
+ ): Promise<PortableAsset[]> {
63
+ if (!store) return []
64
+ const result: PortableAsset[] = []
65
+ for (const summary of await store.listTicketAssets(repo, id)) {
66
+ const asset = await store.getTicketAsset(repo, id, summary.kind, summary.name, summary.revision)
67
+ if (!asset) throw new Error(`asset disappeared: ${id} ${summary.kind}/${summary.name}`)
68
+ result.push(encodePortableAsset({ ...asset.meta, entries: asset.entries }))
69
+ }
70
+ return result
71
+ }
72
+ export interface MigrationOptions {
73
+ source: TicketSource
74
+ dir: string
75
+ repo: string
76
+ assets?: TicketAssetStore
77
+ print?: (line: string) => void
78
+ }
79
+ /** Publish a complete portable tracker only after every source read succeeds. */
80
+ export async function exportTickets(opts: MigrationOptions): Promise<void> {
81
+ const destination = resolve(opts.dir)
82
+ try {
83
+ if ((await readdir(destination)).length)
84
+ throw new Error(`export destination is not empty: ${destination}`)
85
+ } catch (error) {
86
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
87
+ }
88
+ const tickets = await inventory(opts.source)
89
+ const names = [
90
+ ...tickets.map((ticket) => ticket.state!),
91
+ ...((await opts.source.listStates?.()) ?? []).map((state) => state.name),
92
+ ]
93
+ const states = uniqueStateMap(names, normalizeStateName)
94
+ const identity = crypto.randomUUID()
95
+ const manifest: Manifest = {
96
+ version: 1,
97
+ identity,
98
+ source: `${opts.source.name}:${opts.repo}`,
99
+ keys: Object.create(null),
100
+ states,
101
+ }
102
+ const records = []
103
+ for (const ticket of tickets) {
104
+ validateFileTicketId(ticket.ref.id)
105
+ const key = ticket.creationKey ?? `migration:${identity}:${ticket.ref.id}`
106
+ manifest.keys[ticket.ref.id] = key
107
+ records.push({
108
+ state: states[ticket.state!]!,
109
+ id: ticket.ref.id,
110
+ raw: serializeTicketFile(
111
+ {
112
+ id: ticket.ref.id,
113
+ title: ticket.title,
114
+ labels: ticket.labels,
115
+ blockedBy: ticket.blockedBy,
116
+ idempotencyKey: key,
117
+ ...(ticket.comments !== undefined ? { comments: ticket.comments } : {}),
118
+ attachments: await currentAssets(
119
+ opts.assets ?? opts.source.assets,
120
+ opts.repo,
121
+ ticket.ref.id,
122
+ ),
123
+ },
124
+ ticket.body,
125
+ ),
126
+ })
127
+ }
128
+ await mkdir(dirname(destination), { recursive: true })
129
+ const staging = `${destination}.${identity}.tmp`
130
+ await mkdir(staging)
131
+ try {
132
+ for (const state of Object.values(states))
133
+ await mkdir(join(staging, state), { recursive: true })
134
+ for (const record of records)
135
+ await writeFile(join(staging, record.state, `${record.id}.md`), record.raw, { flag: 'wx' })
136
+ await atomicJson(join(staging, MANIFEST), manifest)
137
+ // Recheck immediately before publication; never remove a populated destination.
138
+ try {
139
+ await rmdir(destination)
140
+ } catch (error) {
141
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
142
+ }
143
+ await rename(staging, destination)
144
+ } finally {
145
+ await rm(staging, { recursive: true, force: true })
146
+ }
147
+ opts.print?.(`exported ${tickets.length} tickets to ${destination}`)
148
+ }
149
+ function matchState(name: string, existing: string[]): string | undefined {
150
+ if (existing.includes(name)) return name
151
+ const matches = existing.filter((value) => value.toLowerCase() === name.toLowerCase())
152
+ if (matches.length > 1) throw new Error(`ambiguous target state ${name}: ${matches.join(', ')}`)
153
+ return matches[0]
154
+ }
155
+ /** Resume through creation keys, never through content similarity or mapping files. */
156
+ export async function importTickets(opts: MigrationOptions): Promise<void> {
157
+ const policy = opts.source.requestedIds
158
+ if (!policy || !opts.source.listStates || !opts.source.addState)
159
+ throw new Error(`ticket source ${opts.source.name} does not support import`)
160
+ const dir = resolve(opts.dir)
161
+ const input = new FileTicketSource({ dir })
162
+ const tickets = await inventory(input)
163
+ const bundles = new Map<string, PortableAsset[]>()
164
+ for (const ticket of tickets) {
165
+ const path = join(dir, ticket.state!, `${ticket.ref.id}.md`)
166
+ bundles.set(
167
+ ticket.ref.id,
168
+ parseTicketFile(path, await readFile(path, 'utf8')).front.attachments ?? [],
169
+ )
170
+ }
171
+ let manifest: Manifest
172
+ try {
173
+ manifest = manifestSchema.parse(JSON.parse(await readFile(join(dir, MANIFEST), 'utf8')))
174
+ } catch (error) {
175
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
176
+ manifest = {
177
+ version: 1,
178
+ identity: crypto.randomUUID(),
179
+ source: 'file',
180
+ keys: Object.create(null),
181
+ states: {},
182
+ }
183
+ }
184
+ const sourceIds = new Set(tickets.map((ticket) => ticket.ref.id))
185
+ const keys = new Set<string>()
186
+ for (const ticket of tickets) {
187
+ for (const blocker of ticket.blockedBy ?? [])
188
+ if (!sourceIds.has(blocker) || blocker === ticket.ref.id)
189
+ throw new Error(`invalid blocker ${ticket.ref.id} → ${blocker}`)
190
+ const key =
191
+ ticket.creationKey ??
192
+ manifest.keys[ticket.ref.id] ??
193
+ `migration:${manifest.identity}:${ticket.ref.id}`
194
+ if (keys.has(key)) throw new Error(`duplicate creation key: ${key}`)
195
+ keys.add(key)
196
+ manifest.keys[ticket.ref.id] = key
197
+ }
198
+ const target = await inventory(opts.source)
199
+ const byKey = new Map<string, Ticket>()
200
+ for (const ticket of target)
201
+ if (ticket.creationKey) {
202
+ if (byKey.has(ticket.creationKey))
203
+ throw new Error(`ambiguous target creation key: ${ticket.creationKey}`)
204
+ byKey.set(ticket.creationKey, ticket)
205
+ }
206
+ const existingStates = (await opts.source.listStates()).map((state) => state.name)
207
+ // Only actual input directories participate; virtual lifecycle defaults are harmless empty states.
208
+ const inputStates: string[] = []
209
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
210
+ if (
211
+ !entry.name.startsWith('.') &&
212
+ (entry.isDirectory() ||
213
+ (entry.isSymbolicLink() && (await stat(join(dir, entry.name))).isDirectory()))
214
+ )
215
+ inputStates.push(entry.name)
216
+ }
217
+ const states = uniqueStateMap(
218
+ inputStates,
219
+ (name) =>
220
+ matchState(name, existingStates) ??
221
+ matchState(normalizeStateName(name), existingStates) ??
222
+ normalizeStateName(name),
223
+ )
224
+ const planned = new Map<string, string>()
225
+ const occupied = target.map((ticket) => ticket.ref.id)
226
+ const clashes: string[] = []
227
+ for (const ticket of tickets) {
228
+ const adopted = byKey.get(manifest.keys[ticket.ref.id]!)
229
+ if (adopted) {
230
+ planned.set(ticket.ref.id, adopted.ref.id)
231
+ continue
232
+ }
233
+ const kept = policy.preserve(ticket.ref.id)
234
+ if (kept !== undefined) {
235
+ if (occupied.includes(kept)) clashes.push(`${ticket.ref.id} → ${kept}`)
236
+ planned.set(ticket.ref.id, kept)
237
+ occupied.push(kept)
238
+ }
239
+ }
240
+ if (clashes.length)
241
+ throw new Error(`ticket id clashes (no target changes):\n${clashes.join('\n')}`)
242
+ for (const ticket of tickets)
243
+ if (!planned.has(ticket.ref.id)) {
244
+ const id = policy.allocate(occupied)
245
+ planned.set(ticket.ref.id, id)
246
+ occupied.push(id)
247
+ }
248
+ const assets = opts.assets ?? opts.source.assets
249
+ if ([...bundles.values()].some((value) => value.length) && !assets)
250
+ throw new Error('target has no attachment capability')
251
+ if (assets && [...bundles.values()].some((value) => value.length)) {
252
+ const limits = await assets.ticketAssetLimits(opts.repo)
253
+ for (const values of bundles.values())
254
+ for (const bundle of values) validateTicketAssetInput(decodePortableAsset(bundle), limits)
255
+ }
256
+ for (const ticket of tickets) {
257
+ const adopted = byKey.get(manifest.keys[ticket.ref.id]!)
258
+ if ((ticket.comments?.length ?? 0) > 0 && adopted && adopted.comments === undefined)
259
+ throw new Error(`target cannot read comment history: ${adopted.ref.id}`)
260
+ }
261
+ // Persist identities before the first target write, including for ordinary layouts.
262
+ await atomicJson(join(dir, MANIFEST), manifest)
263
+ const mapping: Record<string, string> = Object.create(null)
264
+ const mappingPath = join(dir, IMPORT_MAP)
265
+ const saveMap = () =>
266
+ atomicJson(mappingPath, { version: 1, identity: manifest.identity, states, tickets: mapping })
267
+ await saveMap()
268
+ try {
269
+ for (const state of new Set(Object.values(states)))
270
+ if (!existingStates.includes(state)) {
271
+ try {
272
+ await opts.source.addState(state)
273
+ } catch (error) {
274
+ const discovered = matchState(
275
+ state,
276
+ (await opts.source.listStates()).map((item) => item.name),
277
+ )
278
+ if (!discovered) throw error
279
+ for (const original of Object.keys(states))
280
+ if (states[original] === state) states[original] = discovered
281
+ await saveMap()
282
+ }
283
+ }
284
+ for (const [from, to] of Object.entries(states)) opts.print?.(`state ${from} → ${to}`)
285
+ for (const ticket of tickets) {
286
+ const key = manifest.keys[ticket.ref.id]!
287
+ const result =
288
+ byKey.get(key) ??
289
+ (await opts.source.create(
290
+ { title: ticket.title, body: ticket.body, labels: ticket.labels },
291
+ {
292
+ state: states[ticket.state!]!,
293
+ idempotencyKey: key,
294
+ requestedId: planned.get(ticket.ref.id)!,
295
+ },
296
+ ))
297
+ mapping[ticket.ref.id] = result.ref.id
298
+ await saveMap()
299
+ opts.print?.(`${ticket.ref.id} → ${result.ref.id}`)
300
+ }
301
+ for (const ticket of tickets) {
302
+ const id = mapping[ticket.ref.id]!
303
+ for (const blocker of ticket.blockedBy ?? [])
304
+ await opts.source.addBlocker(id, mapping[blocker]!)
305
+ const fresh = await opts.source.get(id)
306
+ if (!fresh) throw new Error(`target disappeared: ${id}`)
307
+ if ((ticket.comments?.length ?? 0) && fresh.comments === undefined)
308
+ throw new Error(`target cannot read comment history: ${id}`)
309
+ const bodies = new Set(fresh.comments?.map((comment) => comment.body))
310
+ for (const comment of ticket.comments ?? [])
311
+ if (!bodies.has(comment.body)) {
312
+ await opts.source.comment(id, comment.body)
313
+ bodies.add(comment.body)
314
+ }
315
+ for (const bundle of bundles.get(ticket.ref.id) ?? []) {
316
+ const current = await assets!.getTicketAsset(opts.repo, id, bundle.kind, bundle.name)
317
+ const canonical = (asset: PortableAsset) =>
318
+ JSON.stringify({
319
+ ...asset,
320
+ entries: [...asset.entries].sort((a, b) => a.path.localeCompare(b.path)),
321
+ })
322
+ if (
323
+ !current ||
324
+ canonical(encodePortableAsset({ ...current.meta, entries: current.entries })) !==
325
+ canonical(bundle)
326
+ )
327
+ await assets!.putTicketAsset(opts.repo, id, decodePortableAsset(bundle))
328
+ }
329
+ }
330
+ opts.print?.(`mapping: ${mappingPath}`)
331
+ } catch (error) {
332
+ throw new Error(
333
+ `${error instanceof Error ? error.message : error}\nImport progress retained in ${mappingPath}; rerun import to finish.`,
334
+ )
335
+ }
336
+ }
@@ -1,3 +1,8 @@
1
+ import {
2
+ mutateTicketIfRevision,
3
+ TicketRevisionConflict,
4
+ TicketRevisionUnsupported,
5
+ } from './revision'
1
6
  import type { Config } from '../../config/schema'
2
7
  import { readyCriteria } from '../../processes/dispatcher'
3
8
  import type { Ticket, TicketDraft, TicketSource, TicketUpdate } from '../types'
@@ -91,6 +96,8 @@ export async function createTicket(
91
96
  ? await source.create(draft)
92
97
  : await source.create(draft, { state: input.state })
93
98
  } catch (error) {
99
+ if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
100
+ throw error
94
101
  throw new TicketOperationError(
95
102
  'refusal',
96
103
  error instanceof Error ? error.message : String(error),
@@ -103,10 +110,15 @@ export async function updateTicket(
103
110
  id: string,
104
111
  patch: TicketUpdate,
105
112
  readAfter = true,
113
+ expectedRevision?: string,
106
114
  ): Promise<Ticket | null> {
107
115
  try {
108
- await source.update(id, patch)
116
+ if (expectedRevision !== undefined)
117
+ await mutateTicketIfRevision(source, id, expectedRevision, { kind: 'update', patch })
118
+ else await source.update(id, patch)
109
119
  } catch (error) {
120
+ if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
121
+ throw error
110
122
  throw new TicketOperationError(
111
123
  'refusal',
112
124
  error instanceof Error ? error.message : String(error),
@@ -115,11 +127,20 @@ export async function updateTicket(
115
127
  return readAfter ? requirePostMutationTicket(source, id, 'the update') : null
116
128
  }
117
129
 
118
- export async function moveTicket(source: TicketSource, id: string, state: string): Promise<Ticket> {
130
+ export async function moveTicket(
131
+ source: TicketSource,
132
+ id: string,
133
+ state: string,
134
+ expectedRevision?: string,
135
+ ): Promise<Ticket> {
119
136
  await requireTicket(source, id)
120
137
  try {
121
- await source.transition(id, state)
138
+ if (expectedRevision !== undefined)
139
+ await mutateTicketIfRevision(source, id, expectedRevision, { kind: 'transition', state })
140
+ else await source.transition(id, state)
122
141
  } catch (error) {
142
+ if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
143
+ throw error
123
144
  throw new TicketOperationError(
124
145
  'refusal',
125
146
  error instanceof Error ? error.message : String(error),
@@ -133,6 +154,7 @@ export async function changeBlockers(
133
154
  id: string,
134
155
  blockerIds: string[],
135
156
  operation: 'block' | 'unblock',
157
+ expectedRevision?: string,
136
158
  ): Promise<Ticket> {
137
159
  await requireTicket(source, id)
138
160
  const ids = await knownBlockers(source, blockerIds, '')
@@ -143,11 +165,20 @@ export async function changeBlockers(
143
165
  )
144
166
  }
145
167
  try {
146
- for (const blockerId of ids) {
147
- if (operation === 'block') await source.addBlocker(id, blockerId)
148
- else await source.removeBlocker(id, blockerId)
149
- }
168
+ if (expectedRevision !== undefined)
169
+ await mutateTicketIfRevision(source, id, expectedRevision, {
170
+ kind: 'blockers',
171
+ operation,
172
+ ids,
173
+ })
174
+ else
175
+ for (const blockerId of ids) {
176
+ if (operation === 'block') await source.addBlocker(id, blockerId)
177
+ else await source.removeBlocker(id, blockerId)
178
+ }
150
179
  } catch (error) {
180
+ if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
181
+ throw error
151
182
  throw new TicketOperationError(
152
183
  'refusal',
153
184
  error instanceof Error ? error.message : String(error),
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod'
2
+ import { validateTicketAssetInput, type TicketAssetInput } from '../../store/ticket-assets'
3
+
4
+ export const portableAssetSchema = z
5
+ .strictObject({
6
+ kind: z.string(),
7
+ name: z.string(),
8
+ layout: z.enum(['file', 'tree']),
9
+ entries: z.array(
10
+ z.discriminatedUnion('type', [
11
+ z.strictObject({ type: z.literal('dir'), path: z.string() }),
12
+ z.strictObject({
13
+ type: z.literal('file'),
14
+ path: z.string(),
15
+ base64: z
16
+ .string()
17
+ .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
18
+ }),
19
+ ]),
20
+ ),
21
+ })
22
+ .superRefine((value, ctx) => {
23
+ try {
24
+ validateTicketAssetInput(decodePortableAsset(value))
25
+ } catch (error) {
26
+ ctx.addIssue({ code: 'custom', message: String(error) })
27
+ }
28
+ })
29
+ export type PortableAsset = z.infer<typeof portableAssetSchema>
30
+ export function decodePortableAsset(value: PortableAsset): TicketAssetInput {
31
+ return {
32
+ kind: value.kind,
33
+ name: value.name,
34
+ layout: value.layout,
35
+ entries: value.entries.map((entry) =>
36
+ entry.type === 'dir'
37
+ ? entry
38
+ : { type: 'file', path: entry.path, content: Buffer.from(entry.base64, 'base64') },
39
+ ),
40
+ }
41
+ }
42
+ export function encodePortableAsset(input: TicketAssetInput): PortableAsset {
43
+ validateTicketAssetInput(input)
44
+ return {
45
+ kind: input.kind,
46
+ name: input.name,
47
+ layout: input.layout,
48
+ entries: input.entries.map((entry) =>
49
+ entry.type === 'dir'
50
+ ? entry
51
+ : { type: 'file', path: entry.path, base64: Buffer.from(entry.content).toString('base64') },
52
+ ),
53
+ }
54
+ }