@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
@@ -0,0 +1,223 @@
1
+ import { graphemes } from './cells'
2
+
3
+ /**
4
+ * Deterministic parsing of a rendered dashboard frame.
5
+ *
6
+ * The dashboard deliberately emits a tiny terminal vocabulary: SGR reset,
7
+ * bold/dim and six named foreground colours, plus OSC 8 hyperlinks. This
8
+ * parser accepts exactly that vocabulary and nothing else. Unknown control
9
+ * traffic is an error rather than evidence that merely looks plausible after
10
+ * bytes were dropped. Every rendition of a frame — the PNG evidence, the
11
+ * website's HTML — derives from these same exact cells.
12
+ */
13
+
14
+ const ESC = '\x1b'
15
+ const BEL = '\x07'
16
+
17
+ export type Foreground = 'foreground' | 'red' | 'green' | 'yellow' | 'blue' | 'cyan'
18
+
19
+ export interface FrameStyle {
20
+ foreground: Foreground
21
+ bold: boolean
22
+ dim: boolean
23
+ href?: string
24
+ }
25
+
26
+ export interface FrameRun {
27
+ /** Zero-based terminal cell column the run starts at. */
28
+ column: number
29
+ /** Terminal cells the run occupies. */
30
+ cells: number
31
+ text: string
32
+ style: FrameStyle
33
+ }
34
+
35
+ export interface ParsedFrameLine {
36
+ /** The line with every escape removed. */
37
+ text: string
38
+ /** Terminal cells the whole line occupies. */
39
+ cells: number
40
+ runs: FrameRun[]
41
+ }
42
+
43
+ function cloneStyle(style: FrameStyle): FrameStyle {
44
+ return {
45
+ foreground: style.foreground,
46
+ bold: style.bold,
47
+ dim: style.dim,
48
+ ...(style.href !== undefined ? { href: style.href } : {}),
49
+ }
50
+ }
51
+
52
+ function sameStyle(left: FrameStyle, right: FrameStyle): boolean {
53
+ return (
54
+ left.foreground === right.foreground &&
55
+ left.bold === right.bold &&
56
+ left.dim === right.dim &&
57
+ left.href === right.href
58
+ )
59
+ }
60
+
61
+ function applySgr(style: FrameStyle, raw: string, line: number): void {
62
+ const codes = raw === '' ? [0] : raw.split(';').map((part) => Number(part))
63
+ if (codes.some((code) => !Number.isInteger(code))) {
64
+ throw new Error(`dashboard frame line ${line}: malformed SGR sequence ESC[${raw}m`)
65
+ }
66
+ for (const code of codes) {
67
+ switch (code) {
68
+ case 0:
69
+ style.foreground = 'foreground'
70
+ style.bold = false
71
+ style.dim = false
72
+ break
73
+ case 1:
74
+ style.bold = true
75
+ break
76
+ case 2:
77
+ style.dim = true
78
+ break
79
+ case 22:
80
+ style.bold = false
81
+ style.dim = false
82
+ break
83
+ case 31:
84
+ style.foreground = 'red'
85
+ break
86
+ case 32:
87
+ style.foreground = 'green'
88
+ break
89
+ case 33:
90
+ style.foreground = 'yellow'
91
+ break
92
+ case 34:
93
+ style.foreground = 'blue'
94
+ break
95
+ case 36:
96
+ style.foreground = 'cyan'
97
+ break
98
+ case 39:
99
+ style.foreground = 'foreground'
100
+ break
101
+ default:
102
+ throw new Error(`dashboard frame line ${line}: unsupported SGR code ${code} in ESC[${raw}m`)
103
+ }
104
+ }
105
+ }
106
+
107
+ /** Parse one rendered line into styled runs pinned to their terminal cells.
108
+ * `lineNumber` is one-based and only names the line in errors. */
109
+ export function parseFrameLine(value: string, lineNumber: number): ParsedFrameLine {
110
+ const style: FrameStyle = {
111
+ foreground: 'foreground',
112
+ bold: false,
113
+ dim: false,
114
+ }
115
+ const runs: FrameRun[] = []
116
+ let text = ''
117
+ let cells = 0
118
+
119
+ const append = (cluster: string, width: number): void => {
120
+ const previous = runs.at(-1)
121
+ // ASCII runs may coalesce. Unicode clusters stay independently pinned to
122
+ // their terminal cell columns so font fallback/advance cannot shift the
123
+ // run that follows a wide glyph.
124
+ if (
125
+ previous !== undefined &&
126
+ previous.column + previous.cells === cells &&
127
+ /^[\x20-\x7e]*$/.test(previous.text) &&
128
+ /^[\x20-\x7e]$/.test(cluster) &&
129
+ sameStyle(previous.style, style)
130
+ ) {
131
+ previous.text += cluster
132
+ previous.cells += width
133
+ } else {
134
+ runs.push({ column: cells, cells: width, text: cluster, style: cloneStyle(style) })
135
+ }
136
+ text += cluster
137
+ cells += width
138
+ }
139
+
140
+ for (let index = 0; index < value.length; ) {
141
+ if (value[index] === ESC) {
142
+ const family = value[index + 1]
143
+ if (family === '[') {
144
+ const rest = value.slice(index + 2)
145
+ const match = /^([0-9;]*)m/.exec(rest)
146
+ if (match === null) {
147
+ throw new Error(
148
+ `dashboard frame line ${lineNumber}: unsupported or unterminated CSI sequence`,
149
+ )
150
+ }
151
+ applySgr(style, match[1]!, lineNumber)
152
+ index += 2 + match[0].length
153
+ continue
154
+ }
155
+ if (family === ']') {
156
+ const end = value.indexOf(BEL, index + 2)
157
+ if (end === -1) {
158
+ throw new Error(`dashboard frame line ${lineNumber}: unterminated OSC sequence`)
159
+ }
160
+ const payload = value.slice(index + 2, end)
161
+ const match = /^8;;(.*)$/.exec(payload)
162
+ if (match === null) {
163
+ throw new Error(
164
+ `dashboard frame line ${lineNumber}: unsupported OSC sequence ${JSON.stringify(payload)}`,
165
+ )
166
+ }
167
+ const href = match[1]!
168
+ style.href = href === '' ? undefined : href
169
+ index = end + 1
170
+ continue
171
+ }
172
+ throw new Error(
173
+ `dashboard frame line ${lineNumber}: unsupported escape family ${JSON.stringify(family ?? '')}`,
174
+ )
175
+ }
176
+
177
+ const nextEscape = value.indexOf(ESC, index)
178
+ const stop = nextEscape === -1 ? value.length : nextEscape
179
+ const plain = value.slice(index, stop)
180
+ for (const character of plain) {
181
+ const code = character.codePointAt(0)!
182
+ if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
183
+ throw new Error(
184
+ `dashboard frame line ${lineNumber}: unsupported control U+${code
185
+ .toString(16)
186
+ .toUpperCase()
187
+ .padStart(4, '0')}`,
188
+ )
189
+ }
190
+ }
191
+ for (const cluster of graphemes(plain)) append(cluster.text, cluster.width)
192
+ index = stop
193
+ }
194
+
195
+ if (style.href !== undefined) {
196
+ throw new Error(`dashboard frame line ${lineNumber}: OSC 8 hyperlink was not closed`)
197
+ }
198
+ if (style.foreground !== 'foreground' || style.bold || style.dim) {
199
+ throw new Error(
200
+ `dashboard frame line ${lineNumber}: SGR style was not reset before end of line`,
201
+ )
202
+ }
203
+ return { text, cells, runs }
204
+ }
205
+
206
+ /** Parse a whole frame, holding every line to the declared terminal width. */
207
+ export function parseFrame(lines: readonly string[], columns: number): ParsedFrameLine[] {
208
+ if (!Number.isInteger(columns) || columns <= 0) {
209
+ throw new Error(`dashboard frame columns must be a positive integer, got ${columns}`)
210
+ }
211
+ if (lines.length === 0) {
212
+ throw new Error('dashboard frame is empty')
213
+ }
214
+ const parsed = lines.map((line, index) => parseFrameLine(line, index + 1))
215
+ for (const [index, line] of parsed.entries()) {
216
+ if (line.cells > columns) {
217
+ throw new Error(
218
+ `dashboard frame line ${index + 1} is ${line.cells} cells wide, exceeding declared terminal width ${columns}`,
219
+ )
220
+ }
221
+ }
222
+ return parsed
223
+ }
@@ -40,9 +40,20 @@ import type { RepositoryEvent } from '../../events/repository'
40
40
  import type { Config } from '../../config/schema'
41
41
  import type { PipelineSourceMeta } from '../../config/pipeline-source'
42
42
  import type { BuildState, PhaseContext, PrLifecycle } from '../../kernel/reducer'
43
- import { currentAutoMergeDeferral } from '../../kernel/auto-merge'
44
- import { decideNext } from '../../kernel/engine'
43
+ import {
44
+ currentAutoMergeDeferralFrom,
45
+ currentDeferralObservationReducer,
46
+ type DeferralLedger,
47
+ } from '../../kernel/auto-merge'
48
+ import { decideNextFromFacts } from '../../kernel/engine'
45
49
  import { verifyPhase } from '../../ontology'
50
+ import { indexLog, type LogIndex } from '../../kernel/log-index'
51
+ import {
52
+ dashboardFactsReducer,
53
+ producedAfter,
54
+ type DashboardFacts,
55
+ type PhaseTiming,
56
+ } from './facts'
46
57
  import type { BuildRecord, StreamRecord } from '../../store/types'
47
58
  import { reduceDispatchSettings } from '../../kernel/dispatch-settings'
48
59
  import { projectSessions, type DashboardSession } from './detail'
@@ -386,99 +397,6 @@ function step(label: string, done: boolean, current: boolean, extra: StepExtra =
386
397
  }
387
398
  }
388
399
 
389
- /** One phase occurrence's wall-clock span, keyed by phase. `startSeq` is the
390
- * seq of the `*.started` event, so callers can scope by the same seq
391
- * boundaries (`restartSince`/`cycleSince`) the step STATES already use. */
392
- interface PhaseInterval {
393
- start: number
394
- end: number
395
- startSeq: number
396
- }
397
- interface PhaseTiming {
398
- closed: PhaseInterval[]
399
- open?: { start: number; startSeq: number }
400
- }
401
-
402
- /**
403
- * Walk the raw event log into per-phase `[start, end]` intervals in epoch-ms
404
- * (`Date.parse(ev.ts)`), the durations the reducer collapses away. Each
405
- * `*.started` opens an interval for its phase key and its terminal event
406
- * (`.completed`/`.verdict`) closes it. A second `*.started` while one is still
407
- * open REPLACES the open start — a §15.6-C cross-sandbox re-run starts the
408
- * phase afresh, so the crashed attempt contributes nothing. Finalize post-steps
409
- * (`finalize.step-completed`) have no `.started` and so no interval.
410
- */
411
- function phaseIntervals(events: AbEvent[]): Map<string, PhaseTiming> {
412
- const timings = new Map<string, PhaseTiming>()
413
- const get = (key: string): PhaseTiming => {
414
- let t = timings.get(key)
415
- if (t === undefined) {
416
- t = { closed: [] }
417
- timings.set(key, t)
418
- }
419
- return t
420
- }
421
- for (const ev of events) {
422
- const ms = Date.parse(ev.ts)
423
- const open = (key: string): void => {
424
- get(key).open = { start: ms, startSeq: ev.seq }
425
- }
426
- const close = (key: string): void => {
427
- const t = get(key)
428
- if (t.open === undefined) return
429
- t.closed.push({ start: t.open.start, end: ms, startSeq: t.open.startSeq })
430
- t.open = undefined
431
- }
432
- switch (ev.type) {
433
- case 'plan.started':
434
- open('plan')
435
- break
436
- case 'plan.completed':
437
- close('plan')
438
- break
439
- case 'plan-review.started':
440
- open('plan-review')
441
- break
442
- case 'plan-review.verdict':
443
- close('plan-review')
444
- break
445
- case 'implement.started':
446
- open('implement')
447
- break
448
- case 'implement.completed':
449
- close('implement')
450
- break
451
- case 'code-review.started':
452
- open('code-review')
453
- break
454
- case 'code-review.verdict':
455
- close('code-review')
456
- break
457
- case 'verify.started':
458
- open(verifyPhase(ev.payload.step))
459
- break
460
- case 'verify.completed':
461
- close(verifyPhase(ev.payload.step))
462
- break
463
- case 'finalize.started':
464
- open('finalize')
465
- break
466
- case 'finalize.completed':
467
- close('finalize')
468
- break
469
- case 'reconcile.started':
470
- open('reconcile')
471
- break
472
- case 'reconcile.completed':
473
- close('reconcile')
474
- break
475
- default:
476
- break
477
- }
478
- }
479
- return timings
480
- }
481
-
482
400
  /**
483
401
  * Sum the in-scope occurrences of one phase into a `StepTiming`, or `undefined`
484
402
  * when the phase never ran in scope (the step shows no time, AC 6). Scope is
@@ -489,12 +407,12 @@ function phaseIntervals(events: AbEvent[]): Map<string, PhaseTiming> {
489
407
  * not advance (AC 10).
490
408
  */
491
409
  function timingFor(
492
- intervals: Map<string, PhaseTiming>,
410
+ intervals: Record<string, PhaseTiming>,
493
411
  key: string,
494
412
  sinceSeq: number,
495
413
  frozenNow: number | undefined,
496
414
  ): StepTiming | undefined {
497
- const t = intervals.get(key)
415
+ const t = intervals[key]
498
416
  if (t === undefined) return undefined
499
417
  let accumulatedMs = 0
500
418
  let inScope = false
@@ -532,8 +450,41 @@ export function projectBuild(
532
450
  events: AbEvent[],
533
451
  streams?: readonly StreamRecord[],
534
452
  pinnedConfig?: Config,
453
+ ): DashboardBuild | null {
454
+ return projectBuildFromFacts(
455
+ record,
456
+ state,
457
+ {
458
+ dashboard: dashboardFactsReducer.reduce(events),
459
+ log: indexLog(events),
460
+ deferral: currentDeferralObservationReducer.reduce(events),
461
+ },
462
+ config,
463
+ streams,
464
+ pinnedConfig,
465
+ )
466
+ }
467
+
468
+ /** The reduced facts a row renders from, beside the build state: what
469
+ * `projectBuild` derives from the event array, advanced incrementally. */
470
+ export interface DashboardRowFacts {
471
+ dashboard: DashboardFacts
472
+ log: LogIndex
473
+ deferral: DeferralLedger
474
+ }
475
+
476
+ /** `projectBuild` over already-reduced facts — the one implementation of the
477
+ * row projection, so a snapshot-backed row and a replayed row cannot differ. */
478
+ export function projectBuildFromFacts(
479
+ record: BuildRecord,
480
+ state: BuildState,
481
+ facts: DashboardRowFacts,
482
+ config: Config,
483
+ streams?: readonly StreamRecord[],
484
+ pinnedConfig?: Config,
535
485
  ): DashboardBuild | null {
536
486
  const pipeline = pinnedConfig ?? config
487
+ const dashboard = facts.dashboard
537
488
  const status = effectiveStatus(state)
538
489
  if (!isVisible(status)) return null
539
490
  const abortProgress =
@@ -546,35 +497,29 @@ export function projectBuild(
546
497
  : undefined
547
498
 
548
499
  if (status === 'queued') {
549
- const created = events.findLast((event) => event.type === 'build.created')
550
- const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
551
- const released = events.findLast((event) => event.type === 'workspace.released')
500
+ const created = dashboard.created
501
+ const workspace = dashboard.workspaceProvisioned
502
+ const released = dashboard.workspaceReleased
552
503
  const openWorkspace =
553
- workspace !== undefined && (released === undefined || workspace.seq > released.seq)
554
- const spec = events.findLast(
555
- (event) => event.type === 'spec.imported' || event.type === 'spec.authored',
556
- )
557
- const commentPosted = events.findLast((event) => event.type === 'dispatch.comment-posted')
558
- const latestFailure = events.findLast((event) => event.type === 'dispatch.failed')
504
+ workspace !== undefined && (released === undefined || workspace > released)
505
+ const spec = dashboard.specLanded
506
+ const commentPosted = dashboard.commentPosted
507
+ const latestFailure = dashboard.latestFailure
559
508
  const failureSuperseded =
560
509
  latestFailure !== undefined &&
561
- ((latestFailure.payload.stage === 'create' &&
562
- created !== undefined &&
563
- created.seq > latestFailure.seq) ||
564
- (latestFailure.payload.stage === 'workspace' &&
510
+ ((latestFailure.stage === 'create' && created !== undefined && created > latestFailure.seq) ||
511
+ (latestFailure.stage === 'workspace' &&
565
512
  workspace !== undefined &&
566
- workspace.seq > latestFailure.seq) ||
567
- (latestFailure.payload.stage === 'spec' &&
568
- spec !== undefined &&
569
- spec.seq > latestFailure.seq) ||
570
- (latestFailure.payload.stage === 'comment' &&
513
+ workspace > latestFailure.seq) ||
514
+ (latestFailure.stage === 'spec' && spec !== undefined && spec > latestFailure.seq) ||
515
+ (latestFailure.stage === 'comment' &&
571
516
  commentPosted !== undefined &&
572
- commentPosted.seq > latestFailure.seq))
517
+ commentPosted > latestFailure.seq))
573
518
  const dispatch =
574
519
  state.discardRequest !== undefined
575
520
  ? 'discard requested; cleanup and Ready handback pending'
576
521
  : latestFailure !== undefined && !failureSuperseded
577
- ? `dispatch ${latestFailure.payload.stage} failed (attempt ${latestFailure.payload.attempt}): ${latestFailure.payload.error}`
522
+ ? `dispatch ${latestFailure.stage} failed (attempt ${latestFailure.attempt}): ${latestFailure.error}`
578
523
  : created === undefined
579
524
  ? 'dispatch initialization pending'
580
525
  : !openWorkspace
@@ -591,7 +536,7 @@ export function projectBuild(
591
536
  dispatch,
592
537
  blockers: [],
593
538
  autoMerge: autoMergeDisplay(state),
594
- sessions: projectSessions(events, streams),
539
+ sessions: projectSessions(dashboard.sessionEvents, streams),
595
540
  }
596
541
  }
597
542
 
@@ -612,7 +557,7 @@ export function projectBuild(
612
557
  ...(state.pr !== undefined && state.prState !== undefined
613
558
  ? { pr: { url: state.pr.url, state: state.prState } }
614
559
  : {}),
615
- sessions: projectSessions(events, streams),
560
+ sessions: projectSessions(dashboard.sessionEvents, streams),
616
561
  }
617
562
  }
618
563
 
@@ -676,11 +621,8 @@ export function projectBuild(
676
621
  // blocked build freezes at the latest durable event. Timers therefore do not
677
622
  // advance behind either gate (AC 10). Each step scopes by the SAME seq boundary its state uses, so
678
623
  // durations and states restart in lockstep (AC 12).
679
- const intervals = phaseIntervals(events)
680
- const pausedAt =
681
- state.status === 'paused'
682
- ? events.findLast((event) => event.type === 'build.paused')?.ts
683
- : undefined
624
+ const intervals = dashboard.intervals
625
+ const pausedAt = state.status === 'paused' ? dashboard.pausedAt : undefined
684
626
  const frozenNow =
685
627
  state.status === 'running'
686
628
  ? undefined
@@ -721,29 +663,25 @@ export function projectBuild(
721
663
  // boundary. Starts and timing intervals are deliberately insufficient: a
722
664
  // crashed phase has run, but has produced no output. Matching the round also
723
665
  // drops a review verdict as soon as the next producer round starts.
724
- const planProduced = events.some(
725
- (ev) =>
726
- ev.seq > restartSince &&
727
- ev.type === 'plan.completed' &&
728
- ev.payload.round === state.plan.round,
666
+ const planProduced = producedAfter(
667
+ dashboard.produced.planCompleted,
668
+ state.plan.round,
669
+ restartSince,
729
670
  )
730
- const planReviewProduced = events.some(
731
- (ev) =>
732
- ev.seq > restartSince &&
733
- ev.type === 'plan-review.verdict' &&
734
- ev.payload.round === state.plan.round,
671
+ const planReviewProduced = producedAfter(
672
+ dashboard.produced.planReviewVerdict,
673
+ state.plan.round,
674
+ restartSince,
735
675
  )
736
- const implementProduced = events.some(
737
- (ev) =>
738
- ev.seq > restartSince &&
739
- ev.type === 'implement.completed' &&
740
- ev.payload.round === state.implement.round,
676
+ const implementProduced = producedAfter(
677
+ dashboard.produced.implementCompleted,
678
+ state.implement.round,
679
+ restartSince,
741
680
  )
742
- const codeReviewProduced = events.some(
743
- (ev) =>
744
- ev.seq > restartSince &&
745
- ev.type === 'code-review.verdict' &&
746
- ev.payload.round === state.implement.round,
681
+ const codeReviewProduced = producedAfter(
682
+ dashboard.produced.codeReviewVerdict,
683
+ state.implement.round,
684
+ restartSince,
747
685
  )
748
686
 
749
687
  // Finalize ran for the CURRENT spec — NOT `prState !== undefined`, which a
@@ -902,10 +840,10 @@ export function projectBuild(
902
840
  : `exit status ${state.setupFailure.exitStatus}`
903
841
  }): ${state.setupFailure.output || '(no output)'}`
904
842
 
905
- const decision = decideNext(events, pipeline)
843
+ const decision = decideNextFromFacts(state, facts.log, pipeline)
906
844
  const mergeWaitReason =
907
845
  decision.kind === 'wait' && decision.reason === 'awaiting-pr'
908
- ? currentAutoMergeDeferral(events, state)
846
+ ? currentAutoMergeDeferralFrom(facts.deferral, state)
909
847
  : undefined
910
848
 
911
849
  return {
@@ -926,7 +864,7 @@ export function projectBuild(
926
864
  ...(state.pr !== undefined && state.prState !== undefined
927
865
  ? { pr: { url: state.pr.url, state: state.prState } }
928
866
  : {}),
929
- sessions: projectSessions(events, streams),
867
+ sessions: projectSessions(dashboard.sessionEvents, streams),
930
868
  }
931
869
  }
932
870
 
@@ -2,7 +2,7 @@ import type { Config } from '../../config/schema'
2
2
  import { composeBuildConfig } from '../../config/live'
3
3
  import type { PipelineSourceMeta } from '../../config/pipeline-source'
4
4
  import type { AbEvent } from '../../events/catalog'
5
- import { reduceBuild, type BuildState } from '../../kernel/reducer'
5
+ import { buildReducer, type BuildAcc, type BuildState } from '../../kernel/reducer'
6
6
  import {
7
7
  BUILD_EFFECTIVE_CONFIG_ARTIFACT,
8
8
  parseBuildConfigMetadata,
@@ -38,6 +38,9 @@ export interface DashboardPollSnapshot {
38
38
  interface LiveEntry {
39
39
  kind: 'live'
40
40
  events: AbEvent[]
41
+ /** The reducer accumulator `state` was derived from; the next delta is
42
+ * folded into a copy of it instead of re-reducing the whole log. */
43
+ acc: BuildAcc
41
44
  state: BuildState
42
45
  build: DashboardBuild | null
43
46
  }
@@ -219,7 +222,8 @@ export class DashboardBuildPollCache {
219
222
  }
220
223
 
221
224
  const events = current === undefined ? delta : [...current.events, ...delta]
222
- const state = reduceBuild(events)
225
+ const acc = buildReducer.advance(current?.acc ?? buildReducer.initial(), delta)
226
+ const state = buildReducer.finish(acc)
223
227
  if (isTerminal(state)) {
224
228
  next.set(record.slug, { kind: 'terminal' })
225
229
  continue
@@ -227,6 +231,7 @@ export class DashboardBuildPollCache {
227
231
  next.set(record.slug, {
228
232
  kind: 'live',
229
233
  events,
234
+ acc,
230
235
  state,
231
236
  build: await this.projectRow(record, state, events, config),
232
237
  })
@@ -1,6 +1,6 @@
1
1
  import { distributionPath } from '../distribution'
2
2
  import { DISPATCHER } from '../events/envelope'
3
- import type { RepositoryEvent } from '../events/repository'
3
+ import { hasOpenTick } from '../processes/tick-state'
4
4
  import type { BuildStore } from '../store/types'
5
5
 
6
6
  export const DISPATCH_CHILD_OPTIONS_ENV = 'AB_DISPATCH_CHILD_OPTIONS'
@@ -132,18 +132,6 @@ export interface DispatchChildSupervisorDeps {
132
132
  }) => DispatchSubprocess
133
133
  }
134
134
 
135
- function hasOpenTick(events: readonly RepositoryEvent[], run: string): boolean {
136
- const boundary = events.findLast(
137
- (event) =>
138
- 'run' in event.payload &&
139
- event.payload.run === run &&
140
- (event.type === 'dispatcher.tick-started' ||
141
- event.type === 'dispatcher.tick-completed' ||
142
- event.type === 'dispatcher.tick-failed'),
143
- )
144
- return boundary?.type === 'dispatcher.tick-started'
145
- }
146
-
147
135
  function defaultSpawn(input: {
148
136
  entrypoint: string
149
137
  cwd: string