@defrex/autobuild 0.12.0 → 0.13.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 (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -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
+ }
@@ -1,6 +1,11 @@
1
1
  import { z } from 'zod'
2
2
  import { ticketRefSchema } from '../../ontology'
3
3
 
4
+ /** Explicit opt-in: released clients through 0.12 use strict decoders without revisions.
5
+ * Source checkouts can share their release version, so version alone is insufficient. */
6
+ export const TICKET_REVISIONS_HEADER = 'x-autobuild-ticket-revisions'
7
+ export const TICKET_REVISIONS_CAPABILITY = '1'
8
+
4
9
  /** Repository-owned policy sent with every hosted ticket operation. */
5
10
  export const hostedTicketContextSchema = z.strictObject({
6
11
  teamKey: z.string().min(1),
@@ -10,8 +15,19 @@ export const hostedTicketContextSchema = z.strictObject({
10
15
  })
11
16
  export type HostedTicketContext = z.infer<typeof hostedTicketContextSchema>
12
17
 
18
+ export const TICKET_COMMENTS_HEADER = 'x-autobuild-ticket-comments'
19
+ export const TICKET_COMMENTS_CAPABILITY = '1'
20
+
21
+ export const ticketCommentWireSchema = z.strictObject({
22
+ body: z.string(),
23
+ createdAt: z.string(),
24
+ author: z.string().optional(),
25
+ })
26
+
13
27
  export const ticketWireSchema = z.strictObject({
28
+ revision: z.string().min(1),
14
29
  ref: ticketRefSchema,
30
+ comments: z.array(ticketCommentWireSchema).optional(),
15
31
  creationKey: z.string().optional(),
16
32
  title: z.string(),
17
33
  body: z.string(),
@@ -47,6 +63,7 @@ const draftSchema = z.strictObject({
47
63
  blockedBy: z.array(z.string()).optional(),
48
64
  })
49
65
  const createOptionsSchema = z.strictObject({
66
+ requestedId: z.string().min(1).optional(),
50
67
  state: z.string().optional(),
51
68
  idempotencyKey: z.string().optional(),
52
69
  })
@@ -62,8 +79,27 @@ function envelope<T extends z.ZodType>(input: T) {
62
79
  return z.strictObject({ context: hostedTicketContextSchema, input })
63
80
  }
64
81
 
82
+ const mutationSchema = z.discriminatedUnion('kind', [
83
+ z.strictObject({ kind: z.literal('update'), patch: updateSchema }),
84
+ z.strictObject({ kind: z.literal('transition'), state: z.string().min(1) }),
85
+ z.strictObject({
86
+ kind: z.literal('blockers'),
87
+ operation: z.enum(['block', 'unblock']),
88
+ ids: z.array(z.string().min(1)),
89
+ }),
90
+ ])
91
+
65
92
  export const hostedTicketRequestSchemas = {
93
+ 'mutate-if-revision': envelope(
94
+ z.strictObject({
95
+ id: z.string().min(1),
96
+ expectedRevision: z.string().refine((v) => v.trim().length > 0),
97
+ mutation: mutationSchema,
98
+ }),
99
+ ),
66
100
  'list-ready': envelope(criteriaSchema),
101
+ states: envelope(z.strictObject({})),
102
+ 'add-state': envelope(z.strictObject({ name: z.string(), about: z.string().optional() })),
67
103
  get: envelope(idSchema),
68
104
  claim: envelope(idSchema),
69
105
  comment: envelope(z.strictObject({ id: z.string().min(1), body: z.string() })),
@@ -81,3 +117,62 @@ export type HostedTicketOperation = keyof typeof hostedTicketRequestSchemas
81
117
  export const HOSTED_TICKET_OPERATIONS = Object.keys(
82
118
  hostedTicketRequestSchemas,
83
119
  ) as HostedTicketOperation[]
120
+
121
+ /** Project policy is separate from CRUD context and cannot be supplied by delegates. */
122
+ export const hostedTicketPolicySchema = z.strictObject({
123
+ version: z.literal(1),
124
+ tickets: z.strictObject({
125
+ source: z.literal('hosted'),
126
+ teamKey: z.string().min(1),
127
+ readyState: z
128
+ .string()
129
+ .min(1)
130
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
131
+ triageState: z
132
+ .string()
133
+ .min(1)
134
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
135
+ claimedState: z
136
+ .string()
137
+ .min(1)
138
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
139
+ doneState: z
140
+ .string()
141
+ .min(1)
142
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
143
+ createState: z
144
+ .string()
145
+ .min(1)
146
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
147
+ proposalState: z
148
+ .string()
149
+ .min(1)
150
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
151
+ readyLabels: z.array(z.string().min(1)).optional(),
152
+ }),
153
+ })
154
+ export type HostedTicketPolicy = z.infer<typeof hostedTicketPolicySchema>
155
+
156
+ export const ticketGrantSchema = z.strictObject({
157
+ id: z.string().min(1),
158
+ repo: z.string().min(1),
159
+ execution: z.string().min(1),
160
+ kind: z.enum(['runner', 'phase']),
161
+ parent: z.string().nullable(),
162
+ expiresAt: z.string().datetime(),
163
+ })
164
+ export const ticketGrantResponseSchema = z.strictObject({
165
+ token: z.string().startsWith('abt_'),
166
+ grant: ticketGrantSchema,
167
+ })
168
+
169
+ export const ticketStateWireSchema = z.strictObject({
170
+ name: z.string(),
171
+ tickets: z.number().int().nonnegative(),
172
+ about: z.string().optional(),
173
+ })
174
+ export const ticketStatesWireSchema = z.array(ticketStateWireSchema)
175
+ export const ticketStateCreateWireSchema = z.strictObject({
176
+ state: ticketStateWireSchema,
177
+ created: z.boolean(),
178
+ })