@defrex/autobuild 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,10 +1,11 @@
1
+ import { DispatchRepositoryFeed } from './dispatch-repository-feed'
1
2
  import { StandbyIdentityResolver } from './dispatcher-liveness'
2
3
  import { configSchema, type Config } from '../config/schema'
3
4
  import { humanActor } from '../events/envelope'
4
5
  import type { RepositoryEvent } from '../events/repository'
5
6
  import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
6
7
  import type { BuildState } from '../kernel/reducer'
7
- import { sampleUnclaimedObservationCount } from '../processes/harvest'
8
+ import { unclaimedObservationCount } from '../processes/harvest'
8
9
  import {
9
10
  controlHarvestRun,
10
11
  OperatorControlError,
@@ -68,15 +69,6 @@ function isStaleAbortControlError(error: unknown): error is BuildControlError {
68
69
  )
69
70
  }
70
71
 
71
- function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
72
- return (
73
- event.type.startsWith('harvest.') ||
74
- event.type === 'dispatcher.intake-set' ||
75
- event.type === 'dispatcher.pause-set' ||
76
- event.type === 'dispatcher.auto-merge-default-set'
77
- )
78
- }
79
-
80
72
  interface ResumePrompt {
81
73
  slug: string
82
74
  escalationIds: string[]
@@ -121,11 +113,7 @@ export class DispatchFrontend {
121
113
  private readonly region: LiveRegion
122
114
  private readonly keyboard
123
115
  private child: DispatchChildHandle | undefined
124
- private repositorySeq = 0
125
- /** Set only after a successful initial fold; every later `events()` call
126
- * takes the delta path. */
127
- private seeded = false
128
- private repositoryEvents: RepositoryEvent[] = []
116
+ private readonly repositoryFeed: DispatchRepositoryFeed
129
117
  private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
130
118
  private eventRefreshTail: Promise<void> = Promise.resolve()
131
119
  private cache: DashboardBuildPollCache | undefined
@@ -155,8 +143,12 @@ export class DispatchFrontend {
155
143
  private operatorStopRequested = false
156
144
 
157
145
  constructor(private readonly opts: DispatchFrontendOptions) {
158
- this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
159
146
  this.clock = opts.clock ?? systemClock
147
+ this.repositoryFeed = new DispatchRepositoryFeed(opts.store, opts.repo, this.clock)
148
+ this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo, {
149
+ allowLookup: () => this.repositoryFeed.incompleteIdentities,
150
+ remember: (events, complete) => this.repositoryFeed.remember(events, complete),
151
+ })
160
152
  this.keyboard = createKeyboardProtocol(
161
153
  (chunk) => opts.terminal.write(chunk),
162
154
  opts.terminal.modes,
@@ -170,47 +162,14 @@ export class DispatchFrontend {
170
162
  * are retained for their existing replay reducers. */
171
163
  private async events(): Promise<RepositoryEvent[]> {
172
164
  const refresh = this.eventRefreshTail.then(async () => {
173
- if (!this.seeded) {
174
- // Initial fold (AUT-534): the whole-journal read the first poll used
175
- // to pay is replaced by AUT-489's bounded read where that is provably
176
- // equivalent. The bounded attempt is confined to the pre-launch
177
- // window (`this.child === undefined`): before `launchChild` no
178
- // run-scoped journal fact can carry `this.runId` (only the supervised
179
- // child appends run-scoped facts with it), so every run-scoped event
180
- // in the bounded subset is skipped by `reduceDispatchStatus` exactly
181
- // as it would be in a full replay, and the dashboard filter keeps
182
- // only durable types, which the subset carries in full. After launch
183
- // a bounded subset cannot guarantee the child's `run-started` under a
184
- // competing dispatcher's later anchor — dropping it would leave
185
- // `effectiveConfig` unset and the first frame would never render —
186
- // so a post-launch seed always takes the full read (the pre-AUT-534
187
- // behavior). `once` mode's teardown fold runs after the child exits,
188
- // so the same guard routes it to the full read.
189
- const seed =
190
- this.child === undefined
191
- ? await this.opts.store.getRepoStateEvents(this.opts.repo).catch(() =>
192
- // Bounded-read failure falls back to the exact pre-AUT-534
193
- // full read within this same seed attempt.
194
- this.opts.store.getRepoEvents(this.opts.repo, 0),
195
- )
196
- : await this.opts.store.getRepoEvents(this.opts.repo, 0)
197
- this.standbyResolver.ingest(seed)
198
- this.repositorySeq = seed.at(-1)?.seq ?? 0
199
- this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
200
- this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
201
- this.seeded = true
202
- return
203
- }
204
- const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
205
- if (delta.length === 0) return
165
+ const delta = await this.repositoryFeed.refresh(false)
166
+ this.standbyResolver.ingest(this.repositoryFeed.identities)
206
167
  this.standbyResolver.ingest(delta)
207
- this.repositorySeq = delta.at(-1)!.seq
208
168
  this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
209
- this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
210
169
  })
211
170
  this.eventRefreshTail = refresh.catch(() => {})
212
171
  await refresh
213
- return this.repositoryEvents
172
+ return this.repositoryFeed.events
214
173
  }
215
174
 
216
175
  private async report(message: string, level: 'info' | 'warning' = 'info'): Promise<void> {
@@ -333,14 +292,10 @@ export class DispatchFrontend {
333
292
  }
334
293
  const config = await this.loadConfig(ref.kind, ref.rev)
335
294
  try {
336
- // Journal + digests (AUT-487): one journal read for the claimed set
337
- // (this class's cached `repositoryEvents` is dashboard-filtered, so the
338
- // claimed set needs the full journal, exactly as the old per-build scan
339
- // read it) plus one repo-scoped digest read — the sample's store
340
- // traffic stays flat as finished builds accumulate. A journal record
341
- // that does not yet exist reads as an empty journal (AUT-524); the
342
- // sample writes nothing.
343
- this.observationCount = await sampleUnclaimedObservationCount(this.opts.store, this.opts.repo)
295
+ this.observationCount = unclaimedObservationCount({
296
+ digests: await this.opts.store.getRepoBuildDigests(this.opts.repo),
297
+ harvestEvents: repositoryEvents,
298
+ })
344
299
  } catch {
345
300
  // Before the first successful sample there is no factual zero to place in
346
301
  // a complete frame. Leave the prior frame untouched and retry on the next
@@ -401,6 +356,7 @@ export class DispatchFrontend {
401
356
  ) {
402
357
  this.view = undefined
403
358
  }
359
+ await this.repositoryFeed.save()
404
360
  this.model = projected
405
361
  this.syncControls()
406
362
  this.paint()
@@ -1057,13 +1013,13 @@ export class DispatchFrontend {
1057
1013
  })
1058
1014
  }
1059
1015
 
1060
- // Warm the initial fold before `launchChild` (AUT-534): the bounded seed
1061
- // is only safe while `this.child === undefined`, so the one opportunity
1062
- // to take it is here. A failed warm-up leaves the seed unset; the poll
1063
- // loop's existing failure reporting covers the retry, which then takes
1064
- // the full-read path post-launch. `once` mode gains no read — its single
1065
- // fold runs from `finishPresentation()` after the child exits.
1066
- if (!this.opts.once) await this.events().catch(() => {})
1016
+ try {
1017
+ const delta = await this.repositoryFeed.warm()
1018
+ this.standbyResolver.ingest(this.repositoryFeed.identities)
1019
+ this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
1020
+ } catch {
1021
+ /* Display initialization must not gate the kernel. */
1022
+ }
1067
1023
 
1068
1024
  const options: DispatchChildOptions = {
1069
1025
  targetRepo: this.opts.checkout ?? this.opts.repo,
@@ -1104,6 +1060,7 @@ export class DispatchFrontend {
1104
1060
  this.opts.signal?.removeEventListener('abort', stop)
1105
1061
  await this.child.stop()
1106
1062
  await this.finishPresentation()
1063
+ await this.repositoryFeed.save(true)
1107
1064
  }
1108
1065
  }
1109
1066
  }
@@ -1,6 +1,6 @@
1
+ import { readDispatcherRunFacts, readOpenTick } from '../store/reduced-reads'
1
2
  import { distributionPath } from '../distribution'
2
3
  import { DISPATCHER } from '../events/envelope'
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'
@@ -168,10 +168,8 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
168
168
  // chooses/awaits process termination, avoiding check-then-append races.
169
169
  const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
170
170
  const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
171
- const events = await deps.store.getRepoEvents(deps.repo)
172
- const recorded = events.findLast(
173
- (event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
174
- )
171
+ const recorded = (await readDispatcherRunFacts(deps.store, deps.repo, deps.run, false)).value
172
+ .stop
175
173
  if (recorded?.type === 'dispatcher.run-stopped') {
176
174
  const signal = processSignal ?? recorded.payload.signal
177
175
  return {
@@ -245,12 +243,21 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
245
243
  // Ready with no durable build to recover. Let an open tick reach its
246
244
  // normal boundary; all non-tick work is lease-backed/recoverable and a
247
245
  // child stuck there is safe to terminate.
248
- const events = await deps.store.getRepoEvents(deps.repo)
246
+ let open = true
247
+ try {
248
+ const record = await deps.store.getRepo(deps.repo)
249
+ open =
250
+ record?.tick?.run === deps.run
251
+ ? record.tick.open !== undefined
252
+ : await readOpenTick(deps.store, deps.repo, deps.run, false)
253
+ } catch {
254
+ // Unreadable telemetry cannot authorize an unsafe kill.
255
+ }
249
256
  // The child may have exited while the asynchronous journal read was in
250
257
  // flight. Re-check at the force boundary so evidence never claims a
251
258
  // SIGKILL that was not actually sent.
252
259
  if (child.exitCode !== null) return
253
- if (hasOpenTick(events, deps.run)) {
260
+ if (open) {
254
261
  await child.exited
255
262
  return
256
263
  }
@@ -0,0 +1,196 @@
1
+ import { type RepositoryEvent, validateRepositoryEventWrite } from '../events/repository'
2
+ import { defineReducer } from '../kernel/incremental'
3
+ import { loadReducerSnapshot, persistReducerSnapshot } from '../store/snapshots'
4
+ import type { BuildStore, Clock } from '../store/types'
5
+
6
+ export function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
7
+ return (
8
+ event.type.startsWith('harvest.') ||
9
+ event.type === 'dispatcher.intake-set' ||
10
+ event.type === 'dispatcher.pause-set' ||
11
+ event.type === 'dispatcher.auto-merge-default-set'
12
+ )
13
+ }
14
+
15
+ export const dispatchFrontendReducer = defineReducer({
16
+ version: 2,
17
+ initial: (): { retained: RepositoryEvent[]; incompleteIdentities: boolean } => ({
18
+ retained: [],
19
+ incompleteIdentities: true,
20
+ }),
21
+ fold(acc, events: readonly RepositoryEvent[]) {
22
+ const facts = new Map(acc.retained.map((event) => [event.seq, event]))
23
+ for (const event of events) {
24
+ if (isDashboardRepositoryEvent(event) || event.type === 'dispatcher.run-started')
25
+ facts.set(event.seq, event)
26
+ }
27
+ acc.retained = [...facts.values()].sort((a, b) => a.seq - b.seq)
28
+ },
29
+ finish: (acc) => acc.retained,
30
+ })
31
+
32
+ /** One serialized, frontend-only repository projection. The consumed cursor is
33
+ * independent of retained facts: quiet ticks must never survive in the cache. */
34
+ export class DispatchRepositoryFeed {
35
+ private cursor = 0
36
+ private ready = false
37
+ private retained: RepositoryEvent[] = []
38
+ private tail: Promise<void> = Promise.resolve()
39
+ private savedAt = -Infinity
40
+ private boundary: number | undefined
41
+ private recovery = false
42
+ incompleteIdentities = true
43
+
44
+ constructor(
45
+ private readonly store: BuildStore,
46
+ private readonly repo: string,
47
+ private readonly clock: Clock,
48
+ ) {}
49
+
50
+ get events(): RepositoryEvent[] {
51
+ return this.retained.filter(isDashboardRepositoryEvent)
52
+ }
53
+ get identities(): RepositoryEvent[] {
54
+ return this.retained.filter((e) => e.type === 'dispatcher.run-started')
55
+ }
56
+
57
+ private validate(events: RepositoryEvent[], cursor: number, contiguous: boolean): void {
58
+ let previous = cursor
59
+ for (const event of events) {
60
+ if (
61
+ event.repo !== this.repo ||
62
+ !Number.isInteger(event.seq) ||
63
+ event.seq <= previous ||
64
+ (contiguous && event.seq !== previous + 1) ||
65
+ typeof event.ts !== 'string' ||
66
+ !Number.isFinite(Date.parse(event.ts))
67
+ ) {
68
+ throw new Error('invalid dashboard repository delta')
69
+ }
70
+ validateRepositoryEventWrite(event)
71
+ previous = event.seq
72
+ }
73
+ }
74
+
75
+ async warm(): Promise<RepositoryEvent[]> {
76
+ this.recovery = true
77
+ this.boundary = await this.store.getRepoHighWater(this.repo)
78
+ this.recovery = false
79
+ try {
80
+ return await this.refresh()
81
+ } catch (error) {
82
+ this.recovery = true
83
+ throw error
84
+ }
85
+ }
86
+
87
+ refresh(persist = true): Promise<RepositoryEvent[]> {
88
+ let result: RepositoryEvent[] = []
89
+ const next = this.tail.then(async () => {
90
+ let cursor = this.cursor
91
+ let retained = this.retained
92
+ let incompleteIdentities = this.incompleteIdentities
93
+ let delta: RepositoryEvent[]
94
+ if (!this.ready) {
95
+ if ((await this.store.getRepo(this.repo)) === null) {
96
+ this.ready = true
97
+ this.recovery = false
98
+ return
99
+ }
100
+ const high = await this.store.getRepoHighWater(this.repo)
101
+ const snapshot = await loadReducerSnapshot(
102
+ this.store,
103
+ { kind: 'repo', repo: this.repo },
104
+ 'dispatchFrontend',
105
+ dispatchFrontendReducer,
106
+ )
107
+ let restored = false
108
+ if (snapshot && snapshot.cursor <= high) {
109
+ try {
110
+ const state = snapshot.state as {
111
+ retained: RepositoryEvent[]
112
+ incompleteIdentities: boolean
113
+ }
114
+ if (!Array.isArray(state.retained) || typeof state.incompleteIdentities !== 'boolean')
115
+ throw new Error('invalid projection')
116
+ this.validate(state.retained, 0, false)
117
+ if (
118
+ state.retained.some(
119
+ (e) =>
120
+ e.seq > snapshot.cursor ||
121
+ !(isDashboardRepositoryEvent(e) || e.type === 'dispatcher.run-started'),
122
+ )
123
+ )
124
+ throw new Error('invalid retained fact')
125
+ incompleteIdentities = state.incompleteIdentities
126
+ retained = state.retained
127
+ cursor = snapshot.cursor
128
+ restored = true
129
+ } catch {
130
+ /* A malformed cache uses the bounded seed. */
131
+ }
132
+ }
133
+ if (!restored) {
134
+ const seed = await this.store.getRepoStateEvents(this.repo)
135
+ this.validate(seed, 0, false)
136
+ incompleteIdentities = true
137
+ retained = dispatchFrontendReducer.reduce(seed)
138
+ cursor = Math.max(high, seed.at(-1)?.seq ?? 0)
139
+ }
140
+ delta = await this.store.getRepoEvents(this.repo, cursor)
141
+ this.validate(delta, cursor, true)
142
+ // A failed prelaunch seed must not skip this invocation's config/start
143
+ // even if a competing run advanced the bounded seed's anchor.
144
+ if (this.recovery) {
145
+ const recovery = await this.store.getRepoEvents(this.repo, this.boundary ?? 0)
146
+ this.validate(recovery, this.boundary ?? 0, true)
147
+ delta = [...new Map([...delta, ...recovery].map((e) => [e.seq, e])).values()].sort(
148
+ (a, b) => a.seq - b.seq,
149
+ )
150
+ }
151
+ } else {
152
+ delta = await this.store.getRepoEvents(this.repo, cursor)
153
+ this.validate(delta, cursor, true)
154
+ // A standby correlation may have supplied an older identity while the
155
+ // delta read was pending. Keep those memoized facts in this commit.
156
+ retained = this.retained
157
+ incompleteIdentities = this.incompleteIdentities
158
+ }
159
+ retained = dispatchFrontendReducer.advance({ retained, incompleteIdentities }, delta).retained
160
+ this.cursor = Math.max(cursor, delta.at(-1)?.seq ?? 0)
161
+ this.incompleteIdentities = incompleteIdentities
162
+ this.retained = retained
163
+ this.ready = true
164
+ this.recovery = false
165
+ result = delta
166
+ if (persist) await this.save()
167
+ })
168
+ this.tail = next.catch(() => {})
169
+ return next.then(() => result)
170
+ }
171
+
172
+ remember(events: readonly RepositoryEvent[], complete = false): void {
173
+ this.retained = dispatchFrontendReducer.advance(
174
+ { retained: this.retained, incompleteIdentities: this.incompleteIdentities },
175
+ events.filter((e) => e.seq <= this.cursor),
176
+ ).retained
177
+ if (complete) this.incompleteIdentities = false
178
+ }
179
+
180
+ async save(force = false): Promise<void> {
181
+ if (!this.ready || (!force && this.clock().getTime() - this.savedAt < 5000)) return
182
+ // A rejected equal-cursor put and an unavailable store still cost a
183
+ // request. Bound attempts, not just successful writes, on quiet polls.
184
+ this.savedAt = this.clock().getTime()
185
+ await persistReducerSnapshot(
186
+ this.store,
187
+ { kind: 'repo', repo: this.repo },
188
+ 'dispatchFrontend',
189
+ {
190
+ version: dispatchFrontendReducer.version,
191
+ cursor: this.cursor,
192
+ state: { retained: this.retained, incompleteIdentities: this.incompleteIdentities },
193
+ },
194
+ )
195
+ }
196
+ }