@defrex/autobuild 0.11.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -31,6 +31,7 @@
31
31
  * Read-only: it appends no event, claims or renews no lease, and creates no
32
32
  * build or repository record. Output carries no ANSI, ever, in either form.
33
33
  */
34
+ import { buildNeedsAttention, mergeDecision, mergeIdentity } from './attention'
34
35
  import type { AbEvent } from '../events/catalog'
35
36
  import type { EventType } from '../events/payloads'
36
37
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
@@ -54,6 +55,8 @@ import {
54
55
  encodeCursor,
55
56
  parseDurationMs,
56
57
  projectRecord,
58
+ projectAttentionRecord,
59
+ renderAttentionLine,
57
60
  renderWatchLine,
58
61
  watchSelection,
59
62
  } from './watch'
@@ -153,8 +156,8 @@ export function parseConditions(
153
156
  return { conditions, globs }
154
157
  }
155
158
 
156
- /** Does one condition body hold over a reduction? Attention is event-only. */
157
- function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
159
+ /** Does one condition body hold over a reduction? Attention includes current escalation and merge state. */
160
+ function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[] = []): boolean {
158
161
  switch (cond.kind) {
159
162
  case 'status':
160
163
  return state.status === cond.status
@@ -165,7 +168,7 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
165
168
  case 'phase':
166
169
  return (state.phase ?? null) === cond.phase
167
170
  case 'attention':
168
- return false
171
+ return buildNeedsAttention(events, state)
169
172
  }
170
173
  }
171
174
 
@@ -174,9 +177,13 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
174
177
  * the start-of-command and start-of-registration checks — never during a
175
178
  * backlog replay, where only event conditions may fire.
176
179
  */
177
- export function matchState(conditions: readonly WaitCondition[], state: BuildState): string | null {
180
+ export function matchState(
181
+ conditions: readonly WaitCondition[],
182
+ state: BuildState,
183
+ events: AbEvent[] = [],
184
+ ): string | null {
178
185
  for (const { text, cond } of conditions) {
179
- if (stateSatisfies(cond, state)) return text
186
+ if (stateSatisfies(cond, state, events)) return text
180
187
  }
181
188
  return null
182
189
  }
@@ -188,9 +195,15 @@ export function matchEvent(
188
195
  event: Pick<AbEvent, 'type'>,
189
196
  conditions: readonly WaitCondition[],
190
197
  globs: readonly EventGlob[],
198
+ events: AbEvent[] = [],
191
199
  ): string | null {
192
200
  for (const { text, cond } of conditions) {
193
- if (cond.kind === 'attention' && isAttentionEvent(event.type)) return text
201
+ if (
202
+ cond.kind === 'attention' &&
203
+ (isAttentionEvent(event.type) ||
204
+ (events.length > 0 && mergeDecision(events, new Date(0)) !== null))
205
+ )
206
+ return text
194
207
  }
195
208
  for (const { regex, text } of globs) {
196
209
  if (regex.test(event.type)) return text
@@ -209,10 +222,16 @@ function matchFirst(
209
222
  state: BuildState,
210
223
  conditions: readonly WaitCondition[],
211
224
  globs: readonly EventGlob[],
225
+ events: AbEvent[] = [],
212
226
  ): string | null {
213
227
  for (const { text, cond } of conditions) {
214
- if (stateSatisfies(cond, state)) return text
215
- if (cond.kind === 'attention' && isAttentionEvent(event.type)) return text
228
+ if (stateSatisfies(cond, state, events)) return text
229
+ if (
230
+ cond.kind === 'attention' &&
231
+ (isAttentionEvent(event.type) ||
232
+ (events.length > 0 && mergeDecision(events, new Date(0)) !== null))
233
+ )
234
+ return text
216
235
  }
217
236
  for (const { regex, text } of globs) {
218
237
  if (regex.test(event.type)) return text
@@ -305,11 +324,12 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
305
324
  const interval = intervalMs ?? watchSelection(storeRef).intervalMs
306
325
 
307
326
  // Decode --since before any read: a foreign cursor is rejected outright.
308
- const sinceStreams =
327
+ const resumedCursor =
309
328
  opts.since !== undefined
310
329
  ? decodeCursor(opts.since, { store: storeRef, repo }, { verb: 'wait', usage: WAIT_USAGE })
311
- .streams
312
- : {}
330
+ : undefined
331
+ const sinceStreams = resumedCursor?.streams ?? {}
332
+ const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
313
333
 
314
334
  const identity = normalizeGitRemoteUrl(repo)
315
335
  const mine = (record: BuildRecord): boolean =>
@@ -322,17 +342,40 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
322
342
  const positions: Record<string, number> = { ...sinceStreams }
323
343
 
324
344
  const encodeCurrent = (): string =>
325
- encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
345
+ encodeCursor({
346
+ v: 1,
347
+ store: storeRef,
348
+ repo,
349
+ streams: { ...positions },
350
+ ...(resumedCursor?.lostRuns ? { lostRuns: resumedCursor.lostRuns } : {}),
351
+ deliveredAttention: [...deliveredAttention],
352
+ })
326
353
 
327
354
  /** The one success/terminal record. Exit 0 is contractually tied to
328
355
  * exactly one condition-satisfying record; the exit-2 record carries
329
356
  * `condition` null so the caller can see how the build ended. */
330
357
  const finish = (condition: string | null, event: AbEvent, state: BuildState): number => {
331
- const record = { ...projectRecord(event, state, encodeCurrent()), condition }
358
+ const observation =
359
+ condition === 'attention' && event.type !== 'escalation.raised'
360
+ ? mergeDecision(
361
+ streams.get(event.build)?.events.filter((e) => e.seq <= event.seq) ?? [],
362
+ now(),
363
+ state,
364
+ )
365
+ : null
366
+ if (observation) deliveredAttention.add(mergeIdentity(event.build, observation.pr.number))
367
+ const record = {
368
+ ...(observation
369
+ ? projectAttentionRecord(observation, state, encodeCurrent(), { build: event.build })
370
+ : projectRecord(event, state, encodeCurrent())),
371
+ condition,
372
+ }
332
373
  if (json) {
333
374
  opts.stdout(JSON.stringify(record))
334
375
  } else {
335
- const line = renderWatchLine(event, state)
376
+ const line = observation
377
+ ? renderAttentionLine(observation, event.build)
378
+ : renderWatchLine(event, state)
336
379
  opts.stdout(condition === null ? line : `${line} (${condition})`)
337
380
  }
338
381
  return condition === null ? 2 : 0
@@ -351,6 +394,15 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
351
394
  if (satisfied === null) satisfied = { event, state, condition }
352
395
  }
353
396
 
397
+ const eventConditions = (event: AbEvent, prefix: AbEvent[]): readonly WaitCondition[] => {
398
+ if (event.type === 'escalation.raised') return conditions
399
+ const observation = mergeDecision(prefix, now())
400
+ return observation &&
401
+ !deliveredAttention.has(mergeIdentity(event.build, observation.pr.number))
402
+ ? conditions
403
+ : conditions.filter(({ cond }) => cond.kind !== 'attention')
404
+ }
405
+
354
406
  const registerStream = (slug: string, events: AbEvent[], named: boolean): WaitStream => {
355
407
  const state = reduceBuild(events)
356
408
  const stream: WaitStream = {
@@ -370,7 +422,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
370
422
  * latest event. Not cursor-relative — `--since` does not suppress it. */
371
423
  const checkRegistrationState = (stream: WaitStream): void => {
372
424
  const state = reduceBuild(stream.events)
373
- const condition = matchState(conditions, state)
425
+ const condition = matchState(conditions, state, stream.events)
374
426
  if (condition !== null && stream.events.length > 0) {
375
427
  match(stream.events.at(-1)!, state, condition)
376
428
  }
@@ -401,12 +453,20 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
401
453
  // the reduction of the prefix ENDING at that event, never the
402
454
  // reduction of the full loaded prefix.
403
455
  const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
456
+ if (resumedCursor?.deliveredAttention === undefined) {
457
+ const historical: AbEvent[] = []
458
+ for (const event of replay) {
459
+ historical.push(event)
460
+ const observation = mergeDecision(historical, now())
461
+ if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
462
+ }
463
+ }
404
464
  for (const event of events) {
405
465
  if (event.seq <= resumeSeq) continue
406
466
  replay.push(event)
407
467
  stream.lastSeq = event.seq
408
468
  positions[slug] = event.seq
409
- const condition = matchEvent(event, conditions, globs)
469
+ const condition = matchEvent(event, eventConditions(event, replay), globs, replay)
410
470
  if (condition !== null) {
411
471
  match(event, reduceBuild(replay), condition)
412
472
  break
@@ -470,7 +530,13 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
470
530
  positions[stream.slug] = event.seq
471
531
  const state = reduceBuild(stream.events)
472
532
  stream.status = state.status
473
- const condition = matchFirst(event, state, conditions, globs)
533
+ const condition = matchFirst(
534
+ event,
535
+ state,
536
+ eventConditions(event, stream.events),
537
+ globs,
538
+ stream.events,
539
+ )
474
540
  if (condition !== null) {
475
541
  match(event, state, condition)
476
542
  return
@@ -564,6 +630,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
564
630
  // Cursor streams that are no longer named resume anyway: finishing
565
631
  // their backlog is what makes a resumed wait lossless.
566
632
  for (const key of Object.keys(sinceStreams)) {
633
+ if (key === '#repo') continue
567
634
  await trackBuild(key, sinceStreams[key])
568
635
  if (hasMatch()) return finishSatisfied()
569
636
  }
@@ -42,8 +42,15 @@ import type { RepositoryRecord } from '../store/types'
42
42
  */
43
43
  import type { AbEvent } from '../events/catalog'
44
44
  import type { EventType } from '../events/payloads'
45
- import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
46
- import { REPOSITORY_ATTENTION_EVENTS } from '../events/repository'
45
+ import type { RepositoryEvent } from '../events/repository'
46
+ import {
47
+ mergeDecision,
48
+ mergeIdentity,
49
+ RepositoryAttention,
50
+ repositoryNeedsAttention,
51
+ type MergeDecision,
52
+ type PersistenceObservation,
53
+ } from './attention'
47
54
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
48
55
  import { reduceBuild } from '../kernel/reducer'
49
56
  import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
@@ -84,20 +91,7 @@ const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
84
91
  * a watch emits exactly these — deciding what to do about a blocker or a
85
92
  * merge stays with the caller's skill.
86
93
  */
87
- export const BUILD_ATTENTION_EVENTS = [
88
- 'escalation.raised',
89
- 'phase.failed',
90
- 'infrastructure.failed',
91
- 'runner.setup-failed',
92
- 'dispatch.failed',
93
- 'publication.lost',
94
- 'finalize.completed',
95
- 'pr.conflicted',
96
- 'pr.merged',
97
- 'pr.closed',
98
- 'build.completed',
99
- 'build.aborted',
100
- ] as const satisfies readonly EventType[]
94
+ export const BUILD_ATTENTION_EVENTS = ['escalation.raised'] as const satisfies readonly EventType[]
101
95
 
102
96
  // ── Glob compilation and matching ────────────────────────────────────────────
103
97
 
@@ -121,12 +115,6 @@ function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolea
121
115
  : (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
122
116
  }
123
117
 
124
- function matchesRepositoryFilter(type: RepositoryEventType, filters: readonly RegExp[]): boolean {
125
- return filters.length > 0
126
- ? filters.some((regex) => regex.test(type))
127
- : (REPOSITORY_ATTENTION_EVENTS as readonly string[]).includes(type)
128
- }
129
-
130
118
  // ── The cursor ───────────────────────────────────────────────────────────────
131
119
 
132
120
  interface CursorPayload {
@@ -136,10 +124,29 @@ interface CursorPayload {
136
124
  /** The repository identity (normalized origin or checkout path). */
137
125
  repo: string
138
126
  /** Per-stream watch position: build slug → seq, `#repo` → seq. */
127
+ deliveredAttention?: string[]
139
128
  lostRuns?: string[]
140
129
  streams: Record<string, number>
141
130
  }
142
131
 
132
+ function validAttentionIdentity(id: unknown): boolean {
133
+ if (typeof id !== 'string') return false
134
+ if (/^(source|tick):[1-9][0-9]*$/.test(id)) return true
135
+ try {
136
+ const value: unknown = JSON.parse(id)
137
+ return (
138
+ Array.isArray(value) &&
139
+ value.length === 2 &&
140
+ typeof value[0] === 'string' &&
141
+ value[0].length > 0 &&
142
+ Number.isSafeInteger(value[1]) &&
143
+ value[1] > 0
144
+ )
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
143
150
  const CURSOR_PREFIX = 'v1.'
144
151
 
145
152
  /** Opaque to callers; the format is internal, not contract. */
@@ -173,13 +180,22 @@ export function decodeCursor(
173
180
  throw malformed()
174
181
  }
175
182
  if (typeof payload !== 'object' || payload === null) throw malformed()
176
- const { v, store, repo, streams, lostRuns } = payload as Record<string, unknown>
183
+ const { v, store, repo, streams, lostRuns, deliveredAttention } = payload as Record<
184
+ string,
185
+ unknown
186
+ >
177
187
  if (
178
188
  lostRuns !== undefined &&
179
189
  (!Array.isArray(lostRuns) ||
180
190
  lostRuns.some((run) => typeof run !== 'string' || run.length === 0))
181
191
  )
182
192
  throw malformed()
193
+ if (
194
+ deliveredAttention !== undefined &&
195
+ (!Array.isArray(deliveredAttention) ||
196
+ deliveredAttention.some((id) => !validAttentionIdentity(id)))
197
+ )
198
+ throw malformed()
183
199
  if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
184
200
  if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
185
201
  for (const seq of Object.values(streams)) {
@@ -202,6 +218,9 @@ export function decodeCursor(
202
218
  store,
203
219
  repo,
204
220
  streams: streams as Record<string, number>,
221
+ ...(deliveredAttention === undefined
222
+ ? {}
223
+ : { deliveredAttention: deliveredAttention as string[] }),
205
224
  ...(lostRuns === undefined ? {} : { lostRuns: lostRuns as string[] }),
206
225
  }
207
226
  }
@@ -272,6 +291,36 @@ export interface WatchObservationRecord {
272
291
  cursor: string
273
292
  }
274
293
 
294
+ export interface AttentionObservationRecord {
295
+ repo?: string
296
+ build: string | null
297
+ state: WatchRecordState | null
298
+ observation: MergeDecision | PersistenceObservation
299
+ cursor: string
300
+ }
301
+
302
+ export function projectAttentionRecord(
303
+ observation: MergeDecision | PersistenceObservation,
304
+ state: BuildState | null,
305
+ cursor: string,
306
+ scope: { build: string } | { repo: string },
307
+ ): AttentionObservationRecord {
308
+ return {
309
+ ...scope,
310
+ build: 'build' in scope ? scope.build : null,
311
+ state: state ? projectState(state) : null,
312
+ observation,
313
+ cursor,
314
+ }
315
+ }
316
+
317
+ export function renderAttentionLine(
318
+ observation: MergeDecision | PersistenceObservation,
319
+ label: string,
320
+ ): string {
321
+ return `${observation.observedAt} ${singleLine(label)} ${observation.type} ${singleLine(JSON.stringify(observation))}`
322
+ }
323
+
275
324
  export interface WatchRecord {
276
325
  /** The build slug, or null for a repository-journal event. */
277
326
  build: string | null
@@ -544,6 +593,12 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
544
593
  const lostRuns = new Set(
545
594
  opts.since ? (decodeCursor(opts.since, { store: storeRef, repo }).lostRuns ?? []) : [],
546
595
  )
596
+ const resumedCursor = opts.since
597
+ ? decodeCursor(opts.since, { store: storeRef, repo })
598
+ : undefined
599
+ const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
600
+ const repositoryAttention = new RepositoryAttention()
601
+ let attentionInitialized = false
547
602
  let livenessEvents: RepositoryEvent[] = []
548
603
  let livenessSeq = 0
549
604
  let livenessInitialized = false
@@ -571,6 +626,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
571
626
  repo,
572
627
  streams: { ...positions },
573
628
  lostRuns: [...lostRuns],
629
+ deliveredAttention: [...deliveredAttention],
574
630
  })
575
631
 
576
632
  /** One record, flushed the moment it is projected. `cursor` is the watch
@@ -591,6 +647,44 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
591
647
  }
592
648
  }
593
649
 
650
+ const emitObservation = (
651
+ observation: MergeDecision | PersistenceObservation,
652
+ state: BuildState | null,
653
+ scope: { build: string } | { repo: string },
654
+ identity: string,
655
+ ): void => {
656
+ if (stop || deliveredAttention.has(identity)) return
657
+ deliveredAttention.add(identity)
658
+ const cursor = encodeCurrent()
659
+ opts.stdout(
660
+ opts.json
661
+ ? JSON.stringify(projectAttentionRecord(observation, state, cursor, scope))
662
+ : renderAttentionLine(observation, 'build' in scope ? scope.build : '(repository)'),
663
+ )
664
+ emitted += 1
665
+ if (opts.count !== undefined && emitted >= opts.count) {
666
+ countCursor = cursor
667
+ requestStop()
668
+ }
669
+ }
670
+ const checkPersistence = (): void => {
671
+ if (filters.length || !attentionInitialized || stop) return
672
+ for (const { identity, observation } of repositoryAttention.pending(now())) {
673
+ emitObservation(observation, null, { repo }, identity)
674
+ if (stop) return
675
+ }
676
+ }
677
+ const baselineMerge = (slug: string, events: AbEvent[]): void => {
678
+ // Old cursors already consumed finalize attention. Infer every eligible
679
+ // historical prefix, even if consent was subsequently armed again.
680
+ const prefix: AbEvent[] = []
681
+ for (const event of events) {
682
+ prefix.push(event)
683
+ const observation = mergeDecision(prefix, now())
684
+ if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
685
+ }
686
+ }
687
+
594
688
  const checkLiveness = async (): Promise<void> => {
595
689
  if (!followRepository || stop) return
596
690
  if (!livenessInitialized) {
@@ -665,17 +759,34 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
665
759
  * into a scratch prefix so a resumed record never leaks state from
666
760
  * events that landed after it. */
667
761
  const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
762
+ if (stop) return
668
763
  stream.lastSeq = event.seq
669
764
  positions[stream.slug] = event.seq
670
- if (!matchesBuildFilter(event.type, filters)) return
671
- emit(event, reduceBuild(prefix))
765
+ const state = reduceBuild(prefix)
766
+ if (matchesBuildFilter(event.type, filters)) emit(event, state)
767
+ if (!filters.length && !stop) {
768
+ const observation = mergeDecision(prefix, now(), state)
769
+ if (observation)
770
+ emitObservation(
771
+ observation,
772
+ state,
773
+ { build: stream.slug },
774
+ mergeIdentity(stream.slug, observation.pr.number),
775
+ )
776
+ }
672
777
  }
673
778
 
674
779
  const processRepositoryEvent = (stream: RepositoryStream, event: RepositoryEvent): void => {
780
+ if (stop) return
675
781
  stream.lastSeq = event.seq
676
782
  positions[REPO_STREAM_KEY] = event.seq
677
- if (!matchesRepositoryFilter(event.type, filters)) return
678
- emit(event, null)
783
+ repositoryAttention.fold(event)
784
+ if (
785
+ filters.length
786
+ ? filters.some((regex) => regex.test(event.type))
787
+ : repositoryNeedsAttention(event)
788
+ )
789
+ emit(event, null)
679
790
  }
680
791
 
681
792
  /**
@@ -705,7 +816,16 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
705
816
  }
706
817
  streams.set(slug, stream)
707
818
  positions[slug] = stream.lastSeq
708
- if (resumeSeq === undefined) return true
819
+ if (resumeSeq === undefined) {
820
+ baselineMerge(slug, events)
821
+ return true
822
+ }
823
+ positions[slug] = resumeSeq
824
+ if (resumedCursor?.deliveredAttention === undefined)
825
+ baselineMerge(
826
+ slug,
827
+ events.filter((event) => event.seq <= resumeSeq),
828
+ )
709
829
  stream.lastSeq = resumeSeq
710
830
  // Replay the backlog into a scratch prefix: each record's state is
711
831
  // the reduction of the prefix ENDING at that event, never the
@@ -771,6 +891,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
771
891
  status,
772
892
  }
773
893
  streams.set(record.slug, stream)
894
+ baselineMerge(record.slug, events)
774
895
  positions[record.slug] = stream.lastSeq
775
896
  }
776
897
  }
@@ -781,35 +902,22 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
781
902
  streams.set(REPO_STREAM_KEY, stream)
782
903
  positions[REPO_STREAM_KEY] = stream.lastSeq
783
904
  try {
784
- // A fresh store has no repository row; do not create one.
785
- if (resumeSeq === undefined && (await store.getRepo(repo)) !== null) {
786
- // Bounded baseline (AUT-534): the baseline needs only the
787
- // journal's end position, and AUT-489's bounded read answers that
788
- // whenever the journal contains a `dispatcher.run-started` — the
789
- // tail from the latest anchor runs to the journal end, so the
790
- // subset's max seq equals the journal's max seq.
791
- const subset = await store.getRepoStateEvents(repo)
792
- let last = subset.at(-1)?.seq ?? 0
793
- if (!subset.some((event) => event.type === 'dispatcher.run-started')) {
794
- // Anchor-less journal: the subset may have dropped run-scoped
795
- // facts appended before any anchor, so one bounded delta read
796
- // recovers the true end; its events are baselined silently,
797
- // exactly as the full read baselines them. The skip condition
798
- // is exact: a run-started is its own anchor or lies in a later
799
- // anchor's tail, so "no run-started in the subset" means "no
800
- // run-started in the journal" — the only case where the subset
801
- // can trail.
802
- const tail = await store.getRepoEvents(repo, last)
803
- last = tail.at(-1)?.seq ?? last
905
+ if ((await store.getRepo(repo)) !== null) {
906
+ // Full bootstrap is intentional: degradation can predate run anchors.
907
+ const history = await store.getRepoEvents(repo)
908
+ const baseline = resumeSeq ?? history.at(-1)?.seq ?? 0
909
+ for (const event of history) {
910
+ if (event.seq <= baseline) repositoryAttention.fold(event)
804
911
  }
805
- const input = await readDispatcherLiveness(store, repo, now(), subset)
806
- livenessEvents = input.events
807
- livenessSeq = Math.max(last, input.events.at(-1)?.seq ?? 0)
808
- livenessInitialized = true
809
- if (input.record?.lease) observedOwners.set(input.record.lease.holder, input.record)
810
- stream.lastSeq = last
811
- positions[REPO_STREAM_KEY] = last
812
- }
912
+ attentionInitialized = true
913
+ stream.lastSeq = baseline
914
+ positions[REPO_STREAM_KEY] = baseline
915
+ for (const event of history) {
916
+ if (event.seq <= baseline || stop) continue
917
+ processRepositoryEvent(stream, event)
918
+ }
919
+ if (!stop) checkPersistence()
920
+ } else attentionInitialized = true
813
921
  return true
814
922
  } catch {
815
923
  return false
@@ -822,6 +930,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
822
930
  ): Promise<boolean> => {
823
931
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
824
932
  for (const event of fresh) {
933
+ if (stop) break
825
934
  stream.events.push(event)
826
935
  processBuildEvent(stream, event, stream.events)
827
936
  if (stop) break
@@ -832,17 +941,43 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
832
941
  return true
833
942
  }
834
943
 
944
+ let repositoryQueue = Promise.resolve()
945
+ let repositoryInitialization: Promise<void> | undefined
835
946
  const pollRepository = async (
836
947
  stream: RepositoryStream,
837
948
  readOpts?: RemotePollReadOpts,
838
949
  ): Promise<boolean> => {
839
950
  // A repository row may be created mid-watch by the first dispatch.
840
951
  if ((await store.getRepo(repo)) === null) return true
841
- const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
842
- for (const event of fresh) {
843
- processRepositoryEvent(stream, event)
844
- if (stop) break
952
+ if (!attentionInitialized) {
953
+ repositoryInitialization ??= (async () => {
954
+ const history = await store.getRepoEvents(repo)
955
+ const baseline = sinceStreams[REPO_STREAM_KEY] ?? history.at(-1)?.seq ?? 0
956
+ for (const event of history) {
957
+ if (event.seq <= baseline) repositoryAttention.fold(event)
958
+ }
959
+ stream.lastSeq = baseline
960
+ positions[REPO_STREAM_KEY] = baseline
961
+ attentionInitialized = true
962
+ })()
963
+ try {
964
+ await repositoryInitialization
965
+ } catch (error) {
966
+ repositoryInitialization = undefined
967
+ throw error
968
+ }
845
969
  }
970
+ const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
971
+ const apply = repositoryQueue.then(() => {
972
+ for (const event of fresh) {
973
+ if (stop) break
974
+ if (event.seq <= stream.lastSeq) continue
975
+ processRepositoryEvent(stream, event)
976
+ }
977
+ if (!stop) checkPersistence()
978
+ })
979
+ repositoryQueue = apply.catch(() => {})
980
+ await apply
846
981
  return true
847
982
  }
848
983
 
@@ -1023,6 +1158,8 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
1023
1158
  void remoteRunner.runDiscovery({
1024
1159
  step: async (): Promise<void> => {
1025
1160
  if (slugs.length === 0) await discoverBuilds()
1161
+ const repositoryStream = streams.get(REPO_STREAM_KEY)
1162
+ if (repositoryStream?.kind === 'repo' && !stop) await pollRepository(repositoryStream)
1026
1163
  await checkLiveness()
1027
1164
  },
1028
1165
  // Unconditional, after the catch — exactly where the inline
@@ -12,6 +12,7 @@ export const DISPATCHER_CONFIG_ARTIFACT = 'dispatcher-config'
12
12
  * behavior.
13
13
  */
14
14
  export const CONFIG_RELOAD_CLASSIFICATION = {
15
+ store: 'restart',
15
16
  baseBranch: 'hot',
16
17
  capacity: 'hot',
17
18
  ticketScanIntervalSeconds: 'hot',
@@ -49,6 +50,7 @@ export const CONFIG_RELOAD_CLASSIFICATION = {
49
50
  }
50
51
 
51
52
  export const RESTART_REQUIRED_CONFIG_PATHS = [
53
+ 'store',
52
54
  'forge',
53
55
  'plugins',
54
56
  'workspace.provider',
@@ -118,6 +120,7 @@ export function composeBuildConfig(pipeline: Config, deployment: Config): Config
118
120
  export function composeReloadedConfig(startup: Config, candidate: Config): Config {
119
121
  return {
120
122
  ...candidate,
123
+ store: startup.store,
121
124
  forge: startup.forge,
122
125
  plugins: startup.plugins,
123
126
  workspace: startup.workspace,