@defrex/autobuild 0.9.0 → 0.10.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 (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
3
- * of state directories — `triage/ ready/ doing/ done/` — each holding
3
+ * of state directories — the lifecycle four `triage/ ready/ doing/ done/`
4
+ * plus any the repository adds, such as `icebox/` — each holding
4
5
  * `<id>.md` files: TOML frontmatter between `+++` fences, then the ticket
5
6
  * body, where the spec lives pre-build (§6.3). This adapter is the policy's
6
7
  * proof: a source with nowhere to put blobs must be fully workable, because
@@ -19,6 +20,7 @@
19
20
  * human edits are explicitly last-write-wins. State renames never cross a
20
21
  * filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
21
22
  */
23
+ import { readdirSync, statSync } from 'node:fs'
22
24
  import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
23
25
  import { join } from 'node:path'
24
26
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
@@ -31,40 +33,64 @@ import type {
31
33
  TicketDraft,
32
34
  TicketListing,
33
35
  TicketSource,
36
+ TicketStateInfo,
34
37
  TicketUpdate,
35
38
  } from '../types'
36
39
  import { validateTicketUpdate } from './update'
37
40
 
41
+ /** Repo-relative fallback when no selected local-state root is supplied. */
42
+ export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
43
+
38
44
  /**
39
- * The canonical states, in workflow order. This set is closed: the states are
40
- * directories, and a tracker whose states are discoverable by `ls` cannot also
41
- * let config invent new ones.
45
+ * The directories the dispatcher's roles (create, ready, claim, triage, done)
46
+ * are played by. Always valid state names, because `ensureLayout` creates them.
42
47
  */
43
- export const TICKET_STATES = ['Triage', 'Ready', 'Doing', 'Done'] as const
44
- export type TicketState = (typeof TICKET_STATES)[number]
48
+ const LIFECYCLE_DIRS = ['triage', 'ready', 'doing', 'done'] as const
45
49
 
46
- /** Repo-relative fallback when no selected local-state root is supplied. */
47
- export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
50
+ /** Dotfile inside a state directory holding its one-line purpose. Dot entries
51
+ * and non-`.md` files are never tickets, so it can never be mistaken for one. */
52
+ const ABOUT_FILE = '.about'
53
+
54
+ const STATE_NAME_RULE =
55
+ 'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
56
+ const STATE_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/
57
+
58
+ /** Throws, stating the rule, unless `name` may be used to create a state. */
59
+ export function validateStateName(name: string): void {
60
+ if (!STATE_NAME_RE.test(name)) {
61
+ throw new Error(`file ticket source: invalid state name "${name}" — ${STATE_NAME_RULE}`)
62
+ }
63
+ }
64
+
65
+ /** The state names a root listing yields: the lifecycle four, then every other
66
+ * non-dot directory alphabetically. Dot entries and files are never states. */
67
+ function statesIn(directories: string[]): string[] {
68
+ const lifecycle: string[] = [...LIFECYCLE_DIRS]
69
+ const extra = directories
70
+ .filter((name) => !name.startsWith('.') && !lifecycle.includes(name))
71
+ .sort()
72
+ return [...lifecycle, ...extra]
73
+ }
48
74
 
49
75
  /**
50
- * Canonicalize a state name to its directory. Case-insensitive in (a config's
51
- * `readyState = "ready"` means the `ready/` directory), canonical out — and
52
- * anything else is a loud error, because a typo'd state must not silently
53
- * create a fifth directory no `ls` of the four would ever show.
76
+ * Resolve a state name to its directory. Case-insensitive in (a config's
77
+ * `readyState = "ready"` means the `ready/` directory), the directory's real
78
+ * name out — and a name matching no directory is a loud error, because a
79
+ * typo'd state must not silently create a directory no `ls` would show.
54
80
  */
55
- export function stateDir(state: string): TicketState {
56
- const match = TICKET_STATES.find((s) => s.toLowerCase() === state.toLowerCase())
57
- if (match === undefined) {
81
+ function resolveState(name: string, existing: string[]): string {
82
+ if (existing.includes(name)) return name
83
+ const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
84
+ if (matches.length === 1) return matches[0] as string
85
+ if (matches.length > 1) {
58
86
  throw new Error(
59
- `file ticket source: unknown state "${state}" — this tracker's states are ` +
60
- `the directories: ${TICKET_STATES.join(', ')}`,
87
+ `file ticket source: ambiguous state "${name}" — it matches the directories ${matches.join(', ')}`,
61
88
  )
62
89
  }
63
- return match
64
- }
65
-
66
- function dirName(state: TicketState): string {
67
- return state.toLowerCase()
90
+ throw new Error(
91
+ `file ticket source: unknown state "${name}" — this tracker's states are ` +
92
+ `the directories: ${existing.join(', ')}`,
93
+ )
68
94
  }
69
95
 
70
96
  const frontmatterSchema = z.strictObject({
@@ -138,7 +164,7 @@ function serializeTicketFile(front: Frontmatter, body: string): string {
138
164
  /** A ticket file found on disk: its id, its state (= its directory), its path. */
139
165
  interface Located {
140
166
  id: string
141
- state: TicketState
167
+ state: string
142
168
  path: string
143
169
  }
144
170
 
@@ -147,8 +173,8 @@ export class FileTicketSource implements TicketSource {
147
173
 
148
174
  private readonly dir: string
149
175
  private readonly clock: Clock
150
- private readonly createState: TicketState
151
- private readonly doneState: TicketState
176
+ private readonly createState: string
177
+ private readonly doneState: string
152
178
  private readonly selfIgnore: boolean
153
179
 
154
180
  constructor(opts: {
@@ -174,14 +200,20 @@ export class FileTicketSource implements TicketSource {
174
200
  }) {
175
201
  this.dir = opts.dir
176
202
  this.clock = opts.clock ?? systemClock
177
- this.createState = stateDir(opts.createState ?? 'Triage')
178
- this.doneState = stateDir(opts.doneState ?? 'Done')
203
+ const existing = this.statesSync()
204
+ this.createState = resolveState(opts.createState ?? 'Triage', existing)
205
+ this.doneState = resolveState(opts.doneState ?? 'Done', existing)
179
206
  this.selfIgnore = opts.selfIgnore ?? false
180
207
  }
181
208
 
182
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
209
+ async listReady(criteria: {
210
+ labels?: string[]
211
+ state?: string
212
+ assignee?: string
213
+ }): Promise<TicketListing> {
183
214
  const labels = criteria.labels ?? []
184
- const state = criteria.state === undefined ? undefined : stateDir(criteria.state)
215
+ const state =
216
+ criteria.state === undefined ? undefined : resolveState(criteria.state, await this.states())
185
217
  // Through listAll, not a single readdir: scan completes its layout and
186
218
  // duplicate-id checks before any per-record validation is contained. A
187
219
  // malformed record can never weaken the invariant preventing a `cp`
@@ -209,8 +241,8 @@ export class FileTicketSource implements TicketSource {
209
241
  * the claim record — no `claimedBy` field, which is what makes "claiming
210
242
  * visibly removes it from ready/" true.
211
243
  *
212
- * The guard refuses a ticket already in Doing or in this source's configured
213
- * terminal state rather than requiring one in Ready. That inversion is
244
+ * The guard refuses a ticket already in doing or in this source's configured
245
+ * terminal state rather than requiring one in ready. That inversion is
214
246
  * deliberate: a legal `[tickets] readyState = "Triage"` would otherwise
215
247
  * stall forever. Other nonterminal states remain claimable after an explicit
216
248
  * handback, while terminal work can never be reopened by claim.
@@ -224,8 +256,8 @@ export class FileTicketSource implements TicketSource {
224
256
  // this closes the list→claim window where a human could corrupt a record
225
257
  // after it was listed but before the dispatcher claims it.
226
258
  await this.loadAt(found)
227
- if (found.state === 'Doing' || found.state === this.doneState) return false
228
- await rename(found.path, this.pathIn('Doing', id))
259
+ if (found.state === 'doing' || found.state === this.doneState) return false
260
+ await rename(found.path, this.pathIn('doing', id))
229
261
  return true
230
262
  }
231
263
 
@@ -250,7 +282,7 @@ export class FileTicketSource implements TicketSource {
250
282
  /** A move, never a rewrite — which is why the body survives byte-exactly. */
251
283
  async transition(id: string, state: string): Promise<void> {
252
284
  await this.ensureLayout()
253
- const target = stateDir(state)
285
+ const target = resolveState(state, await this.states())
254
286
  const found = await this.locate(id)
255
287
  if (found === null) {
256
288
  throw new Error(`file ticket source: transition on unknown ticket "${id}"`)
@@ -270,9 +302,16 @@ export class FileTicketSource implements TicketSource {
270
302
  * or adopts the ticket carrying the same idempotency key. */
271
303
  async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
272
304
  await this.ensureLayout()
305
+ // Resolve before anything is read or written: an unknown state touches no file.
306
+ const targetState =
307
+ opts.state === undefined ? this.createState : resolveState(opts.state, await this.states())
273
308
  const located = await this.scan()
274
309
  if (opts.idempotencyKey !== undefined) {
275
- const matches: Array<{ found: Located; front: Frontmatter; body: string }> = []
310
+ const matches: Array<{
311
+ found: Located
312
+ front: Frontmatter
313
+ body: string
314
+ }> = []
276
315
  for (const found of located) {
277
316
  const loaded = await this.loadAt(found)
278
317
  if (loaded.front.idempotencyKey === opts.idempotencyKey) {
@@ -301,7 +340,6 @@ export class FileTicketSource implements TicketSource {
301
340
  : {}),
302
341
  ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
303
342
  }
304
- const targetState = opts.state === undefined ? this.createState : stateDir(opts.state)
305
343
  await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
306
344
  return this.toTicket(front, draft.body, targetState)
307
345
  }
@@ -397,22 +435,105 @@ export class FileTicketSource implements TicketSource {
397
435
  return states
398
436
  }
399
437
 
438
+ /** Every state with its ticket count and purpose. Reads directories only —
439
+ * never parses a ticket — so a malformed record cannot hide the states. */
440
+ async listStates(): Promise<TicketStateInfo[]> {
441
+ const infos: TicketStateInfo[] = []
442
+ for (const name of await this.states()) infos.push(await this.stateInfo(name))
443
+ return infos
444
+ }
445
+
446
+ async addState(
447
+ name: string,
448
+ opts: { about?: string } = {},
449
+ ): Promise<{ state: TicketStateInfo; created: boolean }> {
450
+ validateStateName(name)
451
+ const about = opts.about === undefined ? undefined : opts.about.trim()
452
+ if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
453
+ throw new Error('file ticket source: a state purpose must be one nonblank line')
454
+ }
455
+ const existing = await this.states()
456
+ const clash = existing.find((s) => s !== name && s.toLowerCase() === name.toLowerCase())
457
+ if (clash !== undefined) {
458
+ throw new Error(
459
+ `file ticket source: state "${name}" collides with the existing state "${clash}" — ${STATE_NAME_RULE}, ` +
460
+ 'and state names are compared case-insensitively',
461
+ )
462
+ }
463
+ const created = !existing.includes(name)
464
+ await this.ensureLayout()
465
+ if (created) await mkdir(join(this.dir, name))
466
+ if (about !== undefined) await writeFile(join(this.dir, name, ABOUT_FILE), `${about}\n`)
467
+ return { state: await this.stateInfo(name), created }
468
+ }
469
+
470
+ private async stateInfo(name: string): Promise<TicketStateInfo> {
471
+ const tickets = (await this.readMarkdown(join(this.dir, name))).length
472
+ let about: string | undefined
473
+ try {
474
+ about = (await readFile(join(this.dir, name, ABOUT_FILE), 'utf8')).trim()
475
+ } catch (error) {
476
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
477
+ }
478
+ return { name, tickets, ...(about ? { about } : {}) }
479
+ }
480
+
400
481
  /**
401
- * The four state dirs, plus — for the defaulted backlog only — a
482
+ * The four lifecycle dirs (never a custom state: `addState` is the
483
+ * explicit act), plus — for the defaulted backlog only — a
402
484
  * self-excluding `.gitignore`. Same move `src/cli/context.ts` makes for
403
485
  * `.ab/`: the dir hides itself from git in ANY repo, so the local backlog is
404
486
  * never staged, without mutating the repo's own tracked `.gitignore` as an
405
487
  * unreviewed side effect. Idempotent; called at the head of every write.
406
488
  */
407
489
  private async ensureLayout(): Promise<void> {
408
- for (const state of TICKET_STATES) {
409
- await mkdir(join(this.dir, dirName(state)), { recursive: true })
490
+ for (const state of LIFECYCLE_DIRS) {
491
+ await mkdir(join(this.dir, state), { recursive: true })
410
492
  }
411
493
  if (this.selfIgnore) await writeFile(join(this.dir, '.gitignore'), '*\n')
412
494
  }
413
495
 
414
- private pathIn(state: TicketState, id: string): string {
415
- return join(this.dir, dirName(state), `${this.safeId(id)}.md`)
496
+ /** The state names that exist now. Re-read per operation so a directory
497
+ * created while a long-running dispatcher is up is seen. */
498
+ private async states(): Promise<string[]> {
499
+ try {
500
+ const entries = await readdir(this.dir, { withFileTypes: true })
501
+ const directories: string[] = []
502
+ for (const entry of entries) {
503
+ if (entry.isDirectory()) directories.push(entry.name)
504
+ else if (entry.isSymbolicLink()) {
505
+ const target = await stat(join(this.dir, entry.name)).catch(() => null)
506
+ if (target?.isDirectory()) directories.push(entry.name)
507
+ }
508
+ }
509
+ return statesIn(directories)
510
+ } catch (error) {
511
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
512
+ throw error
513
+ }
514
+ }
515
+
516
+ /** Constructor-time twin of `states()`: the only sync IO in this adapter. */
517
+ private statesSync(): string[] {
518
+ try {
519
+ const entries = readdirSync(this.dir, { withFileTypes: true })
520
+ const directories = entries
521
+ .filter(
522
+ (e) =>
523
+ e.isDirectory() ||
524
+ (e.isSymbolicLink() &&
525
+ statSync(join(this.dir, e.name), { throwIfNoEntry: false })?.isDirectory()),
526
+ )
527
+ .map((e) => e.name)
528
+ return statesIn(directories)
529
+ } catch (error) {
530
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return statesIn([])
531
+ throw error
532
+ }
533
+ }
534
+
535
+ private pathIn(state: string, id: string): string {
536
+ return join(this.dir, state, `${this.safeId(id)}.md`)
416
537
  }
417
538
 
418
539
  private safeId(id: string): string {
@@ -426,7 +547,7 @@ export class FileTicketSource implements TicketSource {
426
547
  private async locate(id: string): Promise<Located | null> {
427
548
  this.safeId(id)
428
549
  const hits: Located[] = []
429
- for (const state of TICKET_STATES) {
550
+ for (const state of await this.states()) {
430
551
  const path = this.pathIn(state, id)
431
552
  try {
432
553
  await stat(path)
@@ -443,14 +564,14 @@ export class FileTicketSource implements TicketSource {
443
564
  return hits[0] ?? null
444
565
  }
445
566
 
446
- /** Every ticket file across the four state dirs, sorted, duplicates rejected. */
567
+ /** Every ticket file across every state dir, sorted, duplicates rejected. */
447
568
  private async scan(): Promise<Located[]> {
448
569
  await this.assertNoLooseTickets()
449
570
  const byId = new Map<string, Located[]>()
450
- for (const state of TICKET_STATES) {
451
- for (const entry of await this.readMarkdown(join(this.dir, dirName(state)))) {
571
+ for (const state of await this.states()) {
572
+ for (const entry of await this.readMarkdown(join(this.dir, state))) {
452
573
  const id = entry.slice(0, -'.md'.length)
453
- const located = { id, state, path: join(this.dir, dirName(state), entry) }
574
+ const located = { id, state, path: join(this.dir, state, entry) }
454
575
  byId.set(id, [...(byId.get(id) ?? []), located])
455
576
  }
456
577
  }
@@ -473,7 +594,7 @@ export class FileTicketSource implements TicketSource {
473
594
  throw new Error(
474
595
  `${join(this.dir, entry)}: ticket file outside a state directory — a ticket's ` +
475
596
  `state is the directory it sits in; move it into one of: ` +
476
- `${TICKET_STATES.map(dirName).join('/, ')}/`,
597
+ `${(await this.states()).join('/, ')}/`,
477
598
  )
478
599
  }
479
600
  }
@@ -535,7 +656,7 @@ export class FileTicketSource implements TicketSource {
535
656
  return { tickets, diagnostics }
536
657
  }
537
658
 
538
- private toTicket(front: Frontmatter, body: string, state: TicketState): Ticket {
659
+ private toTicket(front: Frontmatter, body: string, state: string): Ticket {
539
660
  return {
540
661
  ref: { source: this.name, id: front.id, title: front.title },
541
662
  ...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
@@ -238,10 +238,42 @@ export class LinearTicketSource implements TicketSource {
238
238
  return [...(await this.getTeamInfo('workflow states')).stateIds.keys()]
239
239
  }
240
240
 
241
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
241
+ /** The Linear user a `readyAssignee` value names: `me` is the API key's
242
+ * owner, anything else is an email. Null when no user matches. */
243
+ async resolveAssignee(
244
+ assignee: string,
245
+ ): Promise<{ id: string; name: string; email: string } | null> {
246
+ if (assignee === 'me') {
247
+ const data = await this.gql<{ viewer: { id: string; name: string; email: string } | null }>(
248
+ 'resolveAssignee',
249
+ 'query Viewer { viewer { id name email } }',
250
+ {},
251
+ )
252
+ return data.viewer ?? null
253
+ }
254
+ const data = await this.gql<{
255
+ users: { nodes: { id: string; name: string; email: string }[] }
256
+ }>(
257
+ 'resolveAssignee',
258
+ 'query UserByEmail($email: String!) { users(filter: { email: { eq: $email } }) { nodes { id name email } } }',
259
+ { email: assignee },
260
+ )
261
+ return data.users.nodes[0] ?? null
262
+ }
263
+
264
+ async listReady(criteria: {
265
+ labels?: string[]
266
+ state?: string
267
+ assignee?: string
268
+ }): Promise<TicketListing> {
242
269
  const filter: Record<string, unknown> = {
243
270
  team: { key: { eq: this.teamKey } },
244
271
  }
272
+ if (criteria.assignee !== undefined) {
273
+ // An unassigned issue matches neither predicate, so it is never ready.
274
+ filter.assignee =
275
+ criteria.assignee === 'me' ? { isMe: { eq: true } } : { email: { eq: criteria.assignee } }
276
+ }
245
277
  if (criteria.state !== undefined) {
246
278
  filter.state = { name: { eq: criteria.state } }
247
279
  }
@@ -44,7 +44,7 @@ export async function requirePostMutationTicket(
44
44
  export function ticketListCriteria(
45
45
  config: Config,
46
46
  filters: { state?: string; labels?: string[] },
47
- ): { state?: string; labels?: string[] } {
47
+ ): { state?: string; labels?: string[]; assignee?: string } {
48
48
  return filters.state === undefined && filters.labels === undefined
49
49
  ? readyCriteria(config)
50
50
  : filters
@@ -86,7 +86,14 @@ export class HostedTicketSource implements TicketSource {
86
86
  return schema.parse(await response.json())
87
87
  }
88
88
 
89
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
89
+ async listReady(criteria: {
90
+ labels?: string[]
91
+ state?: string
92
+ assignee?: string
93
+ }): Promise<TicketListing> {
94
+ if (criteria.assignee !== undefined) {
95
+ throw new Error('the hosted ticket source does not support an assignee gate')
96
+ }
90
97
  const listing = await this.request('list-ready', criteria, ticketListingWireSchema)
91
98
  return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
92
99
  }
@@ -122,12 +122,27 @@ export interface TicketUpdate {
122
122
  labels?: string[]
123
123
  }
124
124
 
125
+ /** One workflow state of a source that can enumerate them. */
126
+ export interface TicketStateInfo {
127
+ name: string
128
+ /** Tickets currently in the state. */
129
+ tickets: number
130
+ /** One-line purpose recorded when the state was created, if any. */
131
+ about?: string
132
+ }
133
+
125
134
  export interface TicketSource {
126
135
  readonly name: string
127
136
  /** Ready tickets matching the dispatch criteria (label/state — §3.3), plus
128
137
  * actionable diagnostics for individual source records excluded as invalid.
129
138
  * Fatal source failures and invariants still reject the call. */
130
- listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing>
139
+ listReady(criteria: {
140
+ labels?: string[]
141
+ state?: string
142
+ /** `me` (the API key's owner) or a user's email; only sources that can honour it
143
+ * accept it — others reject `[tickets].readyAssignee` at config parse. */
144
+ assignee?: string
145
+ }): Promise<TicketListing>
131
146
  get(id: string): Promise<Ticket | null>
132
147
  /** Claim-before-launch (§12): acquires nonterminal work and returns true.
133
148
  * Returns false for an unknown, already-claimed/contested, or terminal ticket.
@@ -154,6 +169,22 @@ export interface TicketSource {
154
169
  * are initiation, so §13's "never consulted mid-build" is untouched.
155
170
  */
156
171
  dependencyStates(ids: string[]): Promise<DependencyState[]>
172
+ /**
173
+ * Optional state discovery (`ab ticket states`): every state the source has,
174
+ * lifecycle states first in workflow order, then the rest alphabetically.
175
+ * Sources that cannot enumerate their states omit it.
176
+ */
177
+ listStates?(): Promise<TicketStateInfo[]>
178
+ /**
179
+ * Optional state creation (`ab ticket state create`). Idempotent: an existing
180
+ * state is reported with `created: false`, and a supplied `about` replaces its
181
+ * purpose. Rejects a malformed or case-colliding name without creating anything.
182
+ * Named `addState` because adapters already hold a `createState` option field.
183
+ */
184
+ addState?(
185
+ name: string,
186
+ opts?: { about?: string },
187
+ ): Promise<{ state: TicketStateInfo; created: boolean }>
157
188
  }
158
189
 
159
190
  // ── Workspace (SPEC §3.2, §7) ────────────────────────────────────────────────
@@ -3,6 +3,7 @@ import { configSchema } from '../config/schema'
3
3
  import { BUILD_OWNED_CONFIG_PATHS } from '../config/live'
4
4
  import { isPipelineSourceRef, type PipelineSourceMeta } from '../config/pipeline-source'
5
5
  import type { AbEvent } from '../events/catalog'
6
+ import { defineReducer } from '../kernel/incremental'
6
7
  import type { Artifact, ArtifactInput } from '../store/types'
7
8
 
8
9
  export const BUILD_EFFECTIVE_CONFIG_ARTIFACT = 'build-runner-effective-config'
@@ -83,26 +84,41 @@ export function buildOwnedSectionsDiffer(a: Config, b: Config): boolean {
83
84
  )
84
85
  }
85
86
 
86
- /** Latest workspace location not followed by release. Historical events use
87
- * the provider ref as the path compatibility fallback. */
88
- export function selectOpenWorkspace(events: readonly AbEvent[]): {
87
+ export interface OpenWorkspaceLocation {
89
88
  ref: string
90
89
  path: string
91
90
  branch: string
92
- } | null {
93
- let open: { ref: string; path: string; branch: string } | null = null
94
- for (const event of events) {
95
- if (event.type === 'workspace.provisioned') {
96
- open = {
97
- ref: event.payload.ref,
98
- path: event.payload.path ?? event.payload.ref,
99
- branch: event.payload.branch,
91
+ }
92
+
93
+ export const OPEN_WORKSPACE_REDUCER_VERSION = 1
94
+
95
+ /** Latest workspace location not followed by release. Historical events use
96
+ * the provider ref as the path compatibility fallback. */
97
+ export const openWorkspaceReducer = defineReducer<
98
+ { open: OpenWorkspaceLocation | null },
99
+ AbEvent,
100
+ OpenWorkspaceLocation | null
101
+ >({
102
+ version: OPEN_WORKSPACE_REDUCER_VERSION,
103
+ initial: () => ({ open: null }),
104
+ fold(acc, events) {
105
+ for (const event of events) {
106
+ if (event.type === 'workspace.provisioned') {
107
+ acc.open = {
108
+ ref: event.payload.ref,
109
+ path: event.payload.path ?? event.payload.ref,
110
+ branch: event.payload.branch,
111
+ }
112
+ } else if (event.type === 'workspace.released') {
113
+ acc.open = null
100
114
  }
101
- } else if (event.type === 'workspace.released') {
102
- open = null
103
115
  }
104
- }
105
- return open
116
+ },
117
+ finish: (acc) => (acc.open === null ? null : { ...acc.open }),
118
+ })
119
+
120
+ export function selectOpenWorkspace(events: readonly AbEvent[]): OpenWorkspaceLocation | null {
121
+ return openWorkspaceReducer.reduce(events)
106
122
  }
107
123
 
108
124
  export function diagnosticArtifact(diagnostic: BuildRunnerDiagnostic): ArtifactInput {