@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,60 +1,16 @@
1
- /**
2
- * The dispatcher's in-process repository view (AUT-647): a `BuildStore`
3
- * decorator that builds the state a tick needs once, at the first refresh, and
4
- * on every later refresh reads only events newer than the cursors it already
5
- * holds — one journal delta, one delta per live build, and the discovery
6
- * listing/digest reads that find builds appearing since.
7
- *
8
- * Every tick stage talks to this object instead of the raw store, so
9
- *
10
- * - reads (`getEvents`, `getRepoEvents`, `getRepoStateEvents`,
11
- * `getRepoBuildDigests`, `listBuilds`) are answered from memory;
12
- * - an event a stage appends is folded in immediately, so a later stage of the
13
- * same tick sees it with no re-read (a foreign append interleaved with ours
14
- * leaves a seq gap, which marks the log dirty so the next read deltas);
15
- * - a failed compare-and-set marks its log dirty, so every existing CAS loop
16
- * that re-reads after a conflict observes the winner's event unchanged.
17
- *
18
- * Concurrency: every state transition of one source (a build slug, the journal,
19
- * the listing/discovery pair) runs on that source's single in-flight promise
20
- * chain — snapshot reads, delta reads, window opening and widening, the fold
21
- * after an own append, a lost compare-and-set's invalidation. Readers run on the
22
- * chain too, so a second reader joins the repair already in flight and never
23
- * observes half-applied state; nothing interleaves inside a transition, which
24
- * is why no write-generation or re-check bookkeeping is needed. Chain tasks only
25
- * call the backing store and the private unlocked helpers, never a public
26
- * method (that would deadlock behind itself).
27
- *
28
- * Safety net: every delta must begin at `cursor + 1` and stay contiguous; if
29
- * it does not, the log is discarded and re-read in full, so "no event is ever
30
- * skipped" is enforced here rather than assumed of the adapters.
31
- *
32
- * What is held:
33
- *
34
- * - the bounded repository journal (`getRepoStateEvents`' subset), pruned on
35
- * arrival by the same keep rule — durable facts, latest-only measurements,
36
- * and the tail (excluding latest-only types) from the latest
37
- * `dispatcher.run-started` — which is exact for seq-ordered input;
38
- * - for *work* builds (nonterminal, aborted, reclaim-pending, execution-open),
39
- * the full event array. Settled terminal builds hold only the discovery
40
- * digest and cost no log read;
41
- * - read windows: cursor-bearing readers (the orchestrator wake pass scans
42
- * every build, settled ones included, from a per-session cursor) get a
43
- * per-source window holding only events above their lowest cursor.
44
- *
45
- * Cold start (reducer snapshots, `store/snapshots.ts`): a process with no hot
46
- * memory loads each source's stored reducer state — the pruned journal view; the
47
- * build, log-index and open-execution reducers for a work build — as of a
48
- * cursor, reads only events newer than it, and advances. A snapshot-backed work
49
- * log holds just that delta plus the reduced state of everything before it, so a
50
- * quiet tick reads zero event rows. Every snapshot is a cache the log
51
- * regenerates: a missing, mismatched-version, ahead-of-log, malformed or
52
- * unreadable one is a miss and the source is replayed in full, with identical
53
- * results. `persistSnapshots` writes the advanced state back (best effort;
54
- * cursors only ever move forward in the store).
55
- */
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ /** Repository-scoped sequencing and metadata coordination. Current reductions
3
+ * come exclusively from persisted values; raw methods return actual event output. */
4
+ import {
5
+ readBuildFacts,
6
+ readPersistedBuildDigests,
7
+ readRepositoryFacts,
8
+ type CurrentBuildFacts,
9
+ type RepositoryReadFacts,
10
+ } from '../store/reduced-reads'
11
+ import { repositoryReadFactsReducer } from '../kernel/read-facts'
12
+ import type { ReducerValueRequest, ReducerValue } from '../store/reducer-values'
56
13
  import type { AbEvent, EventEnvelope, EventWrite } from '../events/catalog'
57
- import { validateRepositoryEventWrite } from '../events/repository'
58
14
  import type { EventType } from '../events/payloads'
59
15
  import type {
60
16
  RepositoryEvent,
@@ -68,32 +24,9 @@ import type {
68
24
  SessionEventType,
69
25
  SessionEventWrite,
70
26
  } from '../events/sessions'
71
- import { dashboardFactsReducer, type DashboardFacts } from '../cli/dashboard/facts'
72
- import { currentDeferralObservationReducer, type DeferralLedger } from '../kernel/auto-merge'
73
- import { logIndexReducer, type LogIndex } from '../kernel/log-index'
74
- import { buildReducer, reduceBuild, type BuildAcc, type BuildState } from '../kernel/reducer'
27
+ import type { BuildState } from '../kernel/reducer'
75
28
  import { createBuildScopedStore } from '../store/build-scope'
76
- import { buildDigestReducer, reduceBuildDigest, type DigestEventRow } from '../store/digest'
77
- import {
78
- foldJournalView,
79
- JOURNAL_VIEW_REDUCER_VERSION,
80
- journalViewReducer,
81
- type JournalViewAcc,
82
- } from '../store/repo-state-events'
83
- import {
84
- loadReducerSnapshot,
85
- persistReducerSnapshot,
86
- type ReducerSnapshot,
87
- type SnapshotScope,
88
- } from '../store/snapshots'
89
- import { openBuildWorkspaceReducer, type OpenBuildWorkspace } from './dispatcher-selectors'
90
- import {
91
- type ExecutionOutcome,
92
- lastExecutionOutcomeReducer,
93
- openExecutionReducer,
94
- type OpenExecution,
95
- } from './execution-settlement'
96
- import { type PublicationView, publicationStateReducer } from './publication-state'
29
+ import type { ReducerSnapshot, SnapshotScope } from '../store/snapshots'
97
30
  import { createSessionScopedStore } from '../store/session-handle'
98
31
  import type {
99
32
  StreamChunk,
@@ -131,76 +64,13 @@ import type {
131
64
  Unsubscribe,
132
65
  } from '../store/types'
133
66
 
134
- /** Registry names the snapshots are keyed by (kernel/reducer-registry.ts). */
135
- const SNAPSHOT_JOURNAL = 'journalView'
136
- /** A resident view persists at most this often; a cold single-tick process
137
- * (the hosted dispatcher) always persists once at its tick's end. */
138
- const PERSIST_MIN_INTERVAL_MS = 30_000
139
- /** Builds written back concurrently. */
140
- const PERSIST_CONCURRENCY = 4
141
-
142
- /** The reducers a work build's snapshots hold beside `build`, keyed by registry
143
- * name (`kernel/reducer-registry.ts`). Every quiet-path tick stage reads its
144
- * facts from these, so a snapshot-backed log needs no event array. */
145
- const EXTRA_REDUCERS = {
146
- // The digest reducer folds the narrow rows `digestRow` projects, so it is
147
- // adapted to the event-array form the rest of the bundle shares. A restored
148
- // log derives its own digest from this, never from discovery.
149
- buildDigest: {
150
- version: buildDigestReducer.version,
151
- initial: buildDigestReducer.initial,
152
- advance: (acc: ReturnType<typeof buildDigestReducer.initial>, events: readonly AbEvent[]) =>
153
- buildDigestReducer.advance(acc, events.map(digestRow)),
154
- finish: buildDigestReducer.finish,
155
- },
156
- logIndex: logIndexReducer,
157
- openExecution: openExecutionReducer,
158
- openBuildWorkspace: openBuildWorkspaceReducer,
159
- publicationState: publicationStateReducer,
160
- lastExecutionOutcome: lastExecutionOutcomeReducer,
161
- // The operator dashboard's row facts (`cli/dashboard/facts.ts`) and the
162
- // auto-merge deferral ledger, so a row derives from the snapshot too.
163
- dashboardFacts: dashboardFactsReducer,
164
- currentDeferralObservation: currentDeferralObservationReducer,
165
- } as const
166
- type ExtraName = keyof typeof EXTRA_REDUCERS
167
- const EXTRA_NAMES = Object.keys(EXTRA_REDUCERS) as ExtraName[]
168
- type ExtraAccs = Record<ExtraName, unknown>
169
-
170
- /** The one build-scope snapshot row a work build is restored from. Every
171
- * accumulator rides in it at a single cursor, so one `putReducerSnapshot` —
172
- * atomic per row on every adapter — publishes them together and a reader in
173
- * another process sees either the old bundle or the new, never a mix (a mixed
174
- * restore would fall back to a full replay). */
175
- const SNAPSHOT_WORK = 'work'
176
- /** Bump when the bundle layout changes. */
177
- const WORK_BUNDLE_LAYOUT = 1
178
- /** Derived from the component versions (they only ever increase), so a bundle
179
- * written under any other reducer version misses instead of mixing shapes. */
180
- export const WORK_BUNDLE_VERSION =
181
- WORK_BUNDLE_LAYOUT +
182
- buildReducer.version +
183
- EXTRA_NAMES.reduce((total, name) => total + EXTRA_REDUCERS[name].version, 0)
184
- const WORK_BUNDLE_REDUCER = { version: WORK_BUNDLE_VERSION }
185
-
186
- /** The facts a quiet-path stage needs about one build, without its event array. */
187
- export interface BuildFacts {
188
- state: BuildState
189
- log: LogIndex
190
- open: OpenExecution | null
191
- /** The open workspace (the latest provisioned, not yet released). */
192
- workspace: OpenBuildWorkspace | null
193
- publication: PublicationView
194
- lastExecution: ExecutionOutcome
195
- /** What a dashboard row reads beyond the build state. */
196
- dashboard: DashboardFacts
197
- deferral: DeferralLedger
67
+ export interface RepoViewOptions {
68
+ repo: string
69
+ resident?: boolean
70
+ now?: () => number
198
71
  }
199
-
200
- /** A build that still has tick duties: nonterminal, aborted (awaiting its
201
- * cleanup's `build.completed`), a `done` build with an unreclaimed release
202
- * asset, or one with an execution the provider has not been seen to end. A
203
- * terminal, settled build costs no event reads. */
72
+ export type WindowSource = { build: string } | 'journal'
73
+ export type BuildFacts = CurrentBuildFacts
204
74
  export function isWorkDigest(digest: Omit<BuildDigest, 'slug'>): boolean {
205
75
  return (
206
76
  digest.terminal === undefined ||
@@ -209,1047 +79,326 @@ export function isWorkDigest(digest: Omit<BuildDigest, 'slug'>): boolean {
209
79
  digest.executionOpen === true
210
80
  )
211
81
  }
212
-
213
- function digestRow(event: AbEvent): DigestEventRow {
214
- return {
215
- type: event.type,
216
- seq: event.seq,
217
- ts: event.ts,
218
- ...(event.type === 'pr-attachment.reclaimed' ? { hostedSeq: event.payload.hostedSeq } : {}),
219
- ...(event.type === 'execution.started' || event.type === 'execution.ended'
220
- ? { instance: event.payload.instance }
221
- : {}),
222
- }
223
- }
224
-
225
- /** One build log held in memory: events with `seq > from`, contiguous through
226
- * `cursor`. Work logs are held from 0. A window created for a cursor-bearing
227
- * reader may start higher; in a resident view it then also carries `acc`, the
228
- * reduced state of the history it does not retain. */
229
- interface Log {
230
- from: number
231
- events: AbEvent[]
232
- cursor: number
233
- dirty: boolean
234
- /** The refresh epoch this log was last brought current in. */
235
- epoch: number
236
- acc?: BuildAcc
237
- /** The other work-build accumulators ride beside `acc`, so the facts a stage
238
- * derives never need the event array. */
239
- extra?: ExtraAccs
240
- /** The cursor last read from, or written to, the snapshot store. */
241
- persisted: number
242
- /** Created by a cursor-bearing request: survives refreshes while unneeded. */
243
- pinned: boolean
244
- digest?: { cursor: number; value: Omit<BuildDigest, 'slug'> }
245
- }
246
-
247
- interface JournalWindow {
248
- from: number
249
- events: RepositoryEvent[]
250
- /** Highest seq held (≥ from). */
251
- last: number
252
- }
253
-
254
- /** Every retained journal entry is an event envelope, in increasing seq order
255
- * and no newer than the snapshot's cursor. */
256
- function retainedShape(retained: unknown[], cursor: number): boolean {
257
- let last = 0
258
- for (const event of retained) {
259
- if (!isObject(event)) return false
260
- if (typeof event.type !== 'string' || typeof event.ts !== 'string') return false
261
- if (typeof event.repo !== 'string' || !isObject(event.actor)) return false
262
- if (!Number.isInteger(event.seq) || (event.seq as number) <= last) return false
263
- last = event.seq as number
264
- // The payload must satisfy the repository event contract for its type, so a
265
- // reducer never meets an entry a full replay would not have produced.
266
- try {
267
- validateRepositoryEventWrite({
268
- actor: event.actor,
269
- type: event.type,
270
- payload: event.payload,
271
- } as never)
272
- } catch {
273
- return false
274
- }
275
- }
276
- return last <= cursor
277
- }
278
-
279
- /** Structural checks on the small accumulators a later fold dereferences. */
280
- function extraAccsShape(extra: ExtraAccs): boolean {
281
- const field = (acc: unknown, key: string): unknown => (isObject(acc) ? acc[key] : undefined)
282
- const open = field(extra.openExecution, 'open')
283
- if (open !== null && !(isObject(open) && typeof open.instance === 'string')) return false
284
- const workspace = field(extra.openBuildWorkspace, 'open')
285
- if (workspace !== null && !(isObject(workspace) && typeof workspace.ref === 'string')) {
286
- return false
287
- }
288
- if (typeof field(extra.lastExecutionOutcome, 'state') !== 'string') return false
289
- if (!Array.isArray(field(extra.publicationState, 'ledger'))) return false
290
- const dashboard = extra.dashboardFacts
291
- if (
292
- !(
293
- isObject(dashboard) &&
294
- Array.isArray(dashboard.sessionEvents) &&
295
- isObject(dashboard.intervals) &&
296
- isObject(dashboard.produced)
297
- )
298
- ) {
299
- return false
300
- }
301
- if (!Array.isArray(field(extra.currentDeferralObservation, 'entries'))) return false
302
- const digest = extra.buildDigest
303
- return (
304
- isObject(digest) &&
305
- Array.isArray(digest.observations) &&
306
- Array.isArray(digest.hosted) &&
307
- Array.isArray(digest.reclaimed) &&
308
- (digest.openInstance === null || typeof digest.openInstance === 'string')
309
- )
310
- }
311
-
312
- function hasAccs(log: Log): boolean {
313
- return log.acc !== undefined && log.extra !== undefined
314
- }
315
-
316
- function isObject(value: unknown): value is Record<string, unknown> {
317
- return typeof value === 'object' && value !== null && !Array.isArray(value)
318
- }
319
-
320
- export interface RepoViewOptions {
321
- repo: string
322
- /** A long-running dispatcher: windows are seeded from a full read so the
323
- * reduced state of unretained history stays available (one pass over the log
324
- * at process start). One-shot invocations leave this off and read each
325
- * window from its cursor, exactly as the wake pass always did. */
326
- resident?: boolean
327
- /** Injectable millisecond clock for the persist throttle (tests). */
328
- now?: () => number
329
- }
330
-
331
- /** Runs tasks strictly one after another; `onIdle` fires when the queue drains. */
332
82
  class Chain {
333
- private tail: Promise<void> = Promise.resolve()
334
- private pending = 0
335
-
336
- constructor(private readonly onIdle?: () => void) {}
337
-
83
+ private tail: Promise<unknown> = Promise.resolve()
338
84
  run<T>(task: () => Promise<T>): Promise<T> {
339
- this.pending += 1
340
85
  const result = this.tail.then(task)
341
- this.tail = result
342
- .then(
343
- () => undefined,
344
- () => undefined,
345
- )
346
- .then(() => {
347
- this.pending -= 1
348
- if (this.pending === 0) this.onIdle?.()
349
- })
86
+ this.tail = result.catch(() => {})
350
87
  return result
351
88
  }
352
89
  }
353
-
354
- export type WindowSource = { build: string } | 'journal'
355
-
90
+ interface RawWindow<E> {
91
+ from: number
92
+ cursor: number
93
+ events: E[]
94
+ epoch: number
95
+ dirty: boolean
96
+ }
356
97
  export class RepoViewStore implements BuildStore {
357
- private readonly repo: string
358
- private readonly resident: boolean
359
98
  private epoch = 0
99
+ private readonly windows = new Map<string, RawWindow<AbEvent>>()
100
+ private journalWindow?: RawWindow<RepositoryEvent>
101
+ private readonly repo: string
102
+ private readonly builds = new Map<string, Chain>()
103
+ private readonly journalChain = new Chain()
104
+ private readonly metaChain = new Chain()
360
105
  private initialized = false
361
- /** The journal alone is loaded (`startJournal`): own journal appends fold. */
362
- private journalReady = false
363
- private initializing: Promise<void> | undefined
364
-
365
- // Journal.
366
106
  private recorded = false
367
- private journal: RepositoryEvent[] = []
368
- private anchor: number | undefined
369
- private journalCursor = 0
370
- private journalDirty = false
371
- private journalEpoch = -1
372
- private journalWindow: JournalWindow | undefined
373
- private persistedJournal = 0
374
- private lastPersistAt: number | undefined
375
-
376
- // Builds.
377
107
  private records: BuildRecord[] = []
378
- private recordsDirty = false
108
+ private recordsDirty = true
379
109
  private discovery = new Map<string, BuildDigest>()
380
- private discoveryDirty = false
381
- private readonly logs = new Map<string, Log>()
382
- private readonly work = new Set<string>()
383
- private readonly buildChains = new Map<string, Chain>()
384
- private readonly journalChain = new Chain()
385
- /** Serializes the build listing and the discovery digests. */
386
- private readonly metaChain = new Chain()
387
-
388
- private readonly now: () => number
389
-
110
+ private readonly dirtyDigests = new Set<string>()
111
+ private discoveryDirty = true
112
+ private repoFacts = repositoryReadFactsReducer.finish(repositoryReadFactsReducer.initial())
390
113
  constructor(
391
114
  private readonly backing: BuildStore,
392
115
  opts: RepoViewOptions,
393
116
  ) {
394
117
  this.repo = opts.repo
395
- this.resident = opts.resident === true
396
- this.now = opts.now ?? Date.now
397
118
  }
398
-
399
- // ── Refresh ────────────────────────────────────────────────────────────────
400
-
401
- /** One build's transitions, strictly in order. */
402
119
  private build<T>(slug: string, task: () => Promise<T>): Promise<T> {
403
- let chain = this.buildChains.get(slug)
404
- if (chain === undefined) {
405
- const created: Chain = new Chain(() => {
406
- if (this.buildChains.get(slug) === created) this.buildChains.delete(slug)
407
- })
408
- chain = created
409
- this.buildChains.set(slug, created)
120
+ let chain = this.builds.get(slug)
121
+ if (!chain) {
122
+ chain = new Chain()
123
+ this.builds.set(slug, chain)
410
124
  }
411
125
  return chain.run(task)
412
126
  }
413
-
414
- /** Cold start once, shared by every reader that arrives before it finishes. */
415
- private ensureInit(): Promise<void> {
416
- if (this.initialized) return Promise.resolve()
417
- this.initializing ??= this.refresh().finally(() => {
418
- this.initializing = undefined
419
- })
420
- return this.initializing
421
- }
422
-
423
- /** The once-per-tick entry and lazy initializer. Cold (first call): the
424
- * journal's high-water mark, the bounded journal read, the build listing and
425
- * digests, and a full read of every work build. Warm: only what is newer. */
426
127
  async refresh(): Promise<void> {
427
- this.epoch += 1
428
- await this.journalChain.run(() => this.syncOrLoadJournal())
429
- await this.refreshRecords()
430
- await this.refreshDiscovery()
431
-
432
- const own = this.records.filter((record) => record.repo === this.repo)
433
- const known = new Set(own.map((record) => record.slug))
434
- // Every build held at refresh start reads its tail first, regardless of
435
- // what discovery says; only then may a now-settled build be evicted.
436
- for (const slug of [...this.work]) {
437
- await this.build(slug, async () => {
438
- if (!this.work.has(slug)) return
439
- if (!known.has(slug)) {
440
- this.work.delete(slug)
441
- this.logs.delete(slug)
442
- return
443
- }
444
- const log = this.logs.get(slug)
445
- if (log === undefined) {
446
- await this.loadWork(slug)
447
- return
448
- }
449
- await this.ensureCurrent(slug, log)
450
- const current = this.logs.get(slug) ?? log
451
- if (!isWorkDigest(this.digestOf(slug, current))) {
452
- // Its last events are in hand; leave the final state behind.
453
- await this.persistLog(slug, current)
454
- this.work.delete(slug)
455
- if (!current.pinned) this.logs.delete(slug)
456
- }
457
- })
458
- }
459
- for (const record of own) {
460
- await this.build(record.slug, async () => {
461
- if (this.work.has(record.slug)) return
462
- const digest = this.discovery.get(record.slug)
463
- if (digest !== undefined && !isWorkDigest(digest)) return
464
- await this.loadWork(record.slug)
465
- this.work.add(record.slug)
466
- })
467
- }
468
- // Unpinned logs read for one request do not outlive the epoch.
469
- for (const slug of [...this.logs.keys()]) {
470
- await this.build(slug, async () => {
471
- const log = this.logs.get(slug)
472
- if (log !== undefined && !this.work.has(slug) && !log.pinned) this.logs.delete(slug)
473
- })
474
- }
475
- // The cold start's replay is the expensive part to lose: leave it behind
476
- // now rather than only at the tick's end. Later epochs persist at the end.
477
- if (this.epoch === 1) await this.persistSnapshots({ cold: true })
478
- }
479
-
480
- private refreshRecords(): Promise<void> {
481
- return this.metaChain.run(async () => {
482
- // Cleared before the read: an own write that lands during it re-marks.
128
+ this.epoch++
129
+ await this.refreshJournal()
130
+ await this.metaChain.run(async () => {
483
131
  this.recordsDirty = false
484
- try {
485
- this.records = await this.backing.listBuilds()
486
- } catch (error) {
487
- this.recordsDirty = true
488
- throw error
489
- }
490
- })
491
- }
492
-
493
- private refreshDiscovery(): Promise<void> {
494
- return this.metaChain.run(async () => {
495
132
  this.discoveryDirty = false
496
- try {
497
- this.discovery = await this.backing.getRepoBuildDigests(this.repo)
498
- } catch (error) {
499
- this.discoveryDirty = true
500
- throw error
501
- }
133
+ this.dirtyDigests.clear()
134
+ this.records = await this.backing.listBuilds()
135
+ this.discovery = await readBuildDigests(this.backing, this.repo)
136
+ this.initialized = true
502
137
  })
503
138
  }
504
-
505
- /** The journal-only delta, for a stage that needs foreign journal appends
506
- * mid-tick (the ready scan) without paying for discovery. */
507
- async refreshJournal(): Promise<void> {
508
- if (!this.initialized) return this.ensureInit()
509
- // The build epoch is untouched: build logs stay current for this tick.
510
- await this.journalChain.run(() => this.syncJournal(true))
139
+ async startJournal(): Promise<void> {
140
+ await this.refreshJournal()
511
141
  }
512
-
513
- // ── Journal ────────────────────────────────────────────────────────────────
514
-
515
- private resetJournalState(): void {
516
- this.journal = []
517
- this.anchor = undefined
518
- this.journalCursor = 0
519
- this.journalWindow = undefined
520
- this.journalDirty = false
521
- this.persistedJournal = 0
522
- }
523
-
524
- /** Cold journal load. The true high-water mark is read before the bounded
525
- * subset: an event the subset excludes is never needed (a later anchor has a
526
- * higher seq and cannot pull earlier events into the tail), and everything
527
- * committed between the two reads is either returned or irrelevant. */
528
- private async loadJournal(opts: { snapshot?: boolean } = {}): Promise<void> {
529
- this.resetJournalState()
530
- if ((await this.backing.getRepo(this.repo)) === null) {
531
- this.recorded = false
532
- return
533
- }
534
- this.recorded = true
535
- if (opts.snapshot === true && (await this.restoreJournal())) return
536
- const high = await this.backing.getRepoHighWater(this.repo)
537
- const subset = await this.backing.getRepoStateEvents(this.repo)
538
- this.foldJournal(subset)
539
- this.journalCursor = Math.max(high, this.journalCursor)
540
- this.journalEpoch = this.epoch
541
- }
542
-
543
- /** Install the stored journal view and read only the events newer than its
544
- * cursor. Any shortfall — no snapshot, a malformed one, a failed or
545
- * non-contiguous delta — leaves the state reset and answers `false`, and the
546
- * caller replays in full. */
547
- private async restoreJournal(): Promise<boolean> {
548
- const scope: SnapshotScope = { kind: 'repo', repo: this.repo }
549
- const snapshot = await loadReducerSnapshot(
550
- this.backing,
551
- scope,
552
- SNAPSHOT_JOURNAL,
553
- journalViewReducer,
554
- )
555
- if (snapshot === null) return false
556
- const state = snapshot.state
557
- if (
558
- !isObject(state) ||
559
- !Array.isArray(state.retained) ||
560
- (state.anchor !== undefined && typeof state.anchor !== 'number') ||
561
- !retainedShape(state.retained, snapshot.cursor)
562
- ) {
563
- return false
564
- }
565
- try {
566
- const delta = (await this.backing.getRepoEvents(this.repo, snapshot.cursor)).filter(
567
- (event) => event.seq > snapshot.cursor,
568
- )
569
- if (!contiguousFrom(delta, snapshot.cursor)) return false
570
- this.journal = [...(state.retained as RepositoryEvent[])]
571
- this.anchor = state.anchor as number | undefined
572
- this.journalCursor = snapshot.cursor
573
- this.persistedJournal = snapshot.cursor
574
- this.foldJournal(delta)
575
- this.journalEpoch = this.epoch
576
- return true
577
- } catch {
578
- this.resetJournalState()
579
- this.recorded = true
580
- return false
581
- }
582
- }
583
-
584
- /** Restore the journal alone, ahead of the first `refresh()`, so the
585
- * lifecycle appends a process makes before its first tick fold into the view
586
- * and are not read back. Costs no listing, digest, or build read. */
587
- startJournal(): Promise<void> {
588
- return this.journalChain.run(async () => {
589
- if (this.journalReady || this.initialized) return
590
- await this.loadJournal({ snapshot: true })
591
- this.journalReady = true
142
+ async refreshJournal(): Promise<void> {
143
+ if (this.journalWindow) this.journalWindow.dirty = true
144
+ await this.journalChain.run(async () => {
145
+ this.recorded = (await this.backing.getRepo(this.repo)) !== null
146
+ this.repoFacts = (await readRepositoryFacts(this.backing, this.repo)).value
592
147
  })
593
148
  }
594
-
595
- private async syncOrLoadJournal(): Promise<void> {
596
- if (!this.initialized) {
597
- if (this.journalReady) await this.syncJournal(true)
598
- else await this.loadJournal({ snapshot: true })
599
- this.initialized = true
600
- } else {
601
- await this.syncJournal(true)
602
- }
603
- }
604
-
605
- /** Bring the journal current: one delta read from the cursor (a repository
606
- * no tick has recorded yet is re-probed instead). Runs on the journal chain. */
607
- private async syncJournal(force = false): Promise<void> {
608
- if (!this.recorded) {
609
- if ((await this.backing.getRepo(this.repo)) === null) return
610
- await this.loadJournal({ snapshot: true })
611
- return
612
- }
613
- if (!force && !this.journalDirty && this.journalEpoch === this.epoch) return
614
- const read = await this.backing.getRepoEvents(this.repo, this.journalCursor)
615
- this.journalDirty = false
616
- this.journalEpoch = this.epoch
617
- const delta = read.filter((event) => event.seq > this.journalCursor)
618
- if (delta.length === 0) return
619
- if (!contiguousFrom(delta, this.journalCursor)) {
620
- // A hole: discard and re-read from scratch rather than skip an event.
621
- await this.loadJournal()
622
- return
623
- }
624
- this.foldJournal(delta)
149
+ repositoryFacts(): RepositoryReadFacts {
150
+ return structuredClone(this.repoFacts)
625
151
  }
626
-
627
- /** Fold events newer than the cursor into the bounded subset and the window. */
628
- private foldJournal(events: readonly RepositoryEvent[]): void {
629
- for (const event of events) {
630
- if (event.seq <= this.journalCursor) continue
631
- this.journalCursor = event.seq
632
- // The same prune-on-arrival fold the registered `journalView` reducer
633
- // runs, so a snapshot of this state resumes identically.
634
- const acc: JournalViewAcc = { retained: this.journal }
635
- if (this.anchor !== undefined) acc.anchor = this.anchor
636
- foldJournalView(acc, [event])
637
- this.journal = acc.retained
638
- this.anchor = acc.anchor
639
- const window = this.journalWindow
640
- if (window !== undefined && event.seq > window.last) {
641
- window.events.push(event)
642
- window.last = event.seq
643
- }
644
- }
645
- }
646
-
647
- /** The bounded journal subset, `[]` for a repository no one has recorded. */
648
- recordedJournal(): RepositoryEvent[] {
649
- return [...this.journal]
650
- }
651
-
652
- /** Whether the repository's journal record exists. */
653
152
  journalRecorded(): boolean {
654
153
  return this.recorded
655
154
  }
656
-
657
- // ── Builds ─────────────────────────────────────────────────────────────────
658
-
659
- private digestOf(slug: string, log: Log): Omit<BuildDigest, 'slug'> {
660
- if (log.digest === undefined || log.digest.cursor !== log.cursor) {
661
- if (log.extra !== undefined) {
662
- const value = buildDigestReducer.finish(log.extra.buildDigest as never)
663
- log.digest = { cursor: log.cursor, value }
664
- return value
665
- }
666
- if (log.from > 0) {
667
- // A trimmed window cannot derive a digest; discovery owns that slug.
668
- const found = this.discovery.get(slug)
669
- if (found !== undefined) return found
670
- }
671
- log.digest = { cursor: log.cursor, value: reduceBuildDigest(log.events.map(digestRow)) }
672
- }
673
- return log.digest.value
674
- }
675
-
676
- /** Full read, installed as the slug's log. Runs on the build's chain. */
677
- private async loadFull(slug: string): Promise<Log> {
678
- const events = await this.backing.getEvents(slug)
679
- const previous = this.logs.get(slug)
680
- const log: Log = {
681
- from: 0,
682
- events,
683
- cursor: events.at(-1)?.seq ?? 0,
684
- dirty: false,
685
- epoch: this.epoch,
686
- persisted: previous?.persisted ?? 0,
687
- pinned: previous?.pinned ?? false,
688
- }
689
- this.logs.set(slug, log)
690
- return log
691
- }
692
-
693
- /** Install a work build's log, from its stored reducer state when there is
694
- * one. Runs on the build's chain. The snapshot-backed log holds only the
695
- * events newer than the snapshot, plus the accumulators for everything
696
- * before; any shortfall is a full read. */
697
- private async loadWork(slug: string): Promise<Log> {
698
- const restored = await this.restoreLog(slug)
699
- return restored ?? (await this.loadFull(slug))
700
- }
701
-
702
- private async restoreLog(slug: string): Promise<Log | null> {
703
- const scope: SnapshotScope = { kind: 'build', slug }
704
- const bundle = await loadReducerSnapshot(
705
- this.backing,
706
- scope,
707
- SNAPSHOT_WORK,
708
- WORK_BUNDLE_REDUCER,
709
- )
710
- if (bundle === null || !isObject(bundle.state)) return null
711
- const buildState = bundle.state.build
712
- const extra = bundle.state.extra
713
- if (!isObject(extra)) return null
714
- if (!buildAccShape({ ...bundle, state: buildState })) return null
715
- for (const name of EXTRA_NAMES) if (!isObject(extra[name])) return null
716
- if (!extraAccsShape(extra as ExtraAccs)) return null
717
- if (!indexAccShape(extra.logIndex)) return null
718
- const build = { cursor: bundle.cursor, state: buildState }
719
- const cursor = build.cursor
720
- try {
721
- const delta = (await this.backing.getEvents(slug, cursor)).filter(
722
- (event) => event.seq > cursor,
723
- )
724
- if (!contiguousFrom(delta, cursor)) return null
725
- const log: Log = {
726
- from: cursor,
727
- events: [],
728
- cursor,
729
- dirty: false,
730
- epoch: this.epoch,
731
- acc: build.state as BuildAcc,
732
- extra: extra as ExtraAccs,
733
- persisted: cursor,
734
- pinned: this.logs.get(slug)?.pinned ?? false,
735
- }
736
- // Exercise every projection a stage will derive, before the state is
737
- // installed: a structurally incomplete accumulator that passed the cheap
738
- // shape checks throws here and the build is replayed instead.
739
- this.probe(log)
740
- if (delta.length > 0) this.fold(log, delta)
741
- this.probe(log)
742
- this.logs.set(slug, log)
743
- return log
744
- } catch {
745
- return null
746
- }
747
- }
748
-
749
- /** Bring one log current: a single delta read from its cursor, skipped when
750
- * it was already brought current in this epoch and nothing marked it dirty.
751
- * Runs on the build's chain, so nothing marks the log while the read is in
752
- * flight. */
753
- private async ensureCurrent(slug: string, log: Log): Promise<void> {
754
- if (!log.dirty && log.epoch === this.epoch) return
755
- const read = await this.backing.getEvents(slug, log.cursor)
756
- log.dirty = false
757
- log.epoch = this.epoch
758
- const delta = read.filter((event) => event.seq > log.cursor)
759
- if (delta.length === 0) return
760
- if (!contiguousFrom(delta, log.cursor)) {
761
- await this.loadFull(slug)
762
- return
763
- }
764
- this.fold(log, delta)
155
+ async refreshBuild(slug: string): Promise<void> {
156
+ await this.build(slug, async () => {})
765
157
  }
766
-
767
- /** Derive every projection of a log's accumulators (throws on malformed state). */
768
- private probe(log: Log): void {
769
- const extra = log.extra!
770
- buildReducer.finish(log.acc!)
771
- logIndexReducer.finish(extra.logIndex as never)
772
- openExecutionReducer.finish(extra.openExecution as never)
773
- openBuildWorkspaceReducer.finish(extra.openBuildWorkspace as never)
774
- const publication = publicationStateReducer.finish(extra.publicationState as never)
775
- publication.pending()
776
- publication.abandonedPending()
777
- publication.latestUncompletedRequest()
778
- lastExecutionOutcomeReducer.finish(extra.lastExecutionOutcome as never)
779
- buildDigestReducer.finish(extra.buildDigest as never)
780
- dashboardFactsReducer.finish(extra.dashboardFacts as never)
781
- currentDeferralObservationReducer.finish(extra.currentDeferralObservation as never)
158
+ buildState(slug: string): Promise<BuildState> {
159
+ return this.build(slug, async () => (await readBuildFacts(this.backing, slug)).value.state)
782
160
  }
783
-
784
- private fold(log: Log, events: AbEvent[]): void {
785
- log.events.push(...events)
786
- log.cursor = events[events.length - 1]!.seq
787
- if (log.acc !== undefined) log.acc = buildReducer.advance(log.acc, events)
788
- if (log.extra !== undefined) {
789
- for (const name of EXTRA_NAMES) {
790
- // biome-ignore lint/suspicious/noExplicitAny: the table is heterogeneous
791
- const reducer = EXTRA_REDUCERS[name] as any
792
- log.extra[name] = reducer.advance(log.extra[name], events)
793
- }
794
- }
161
+ buildFacts(slug: string): Promise<CurrentBuildFacts> {
162
+ return this.build(slug, async () => (await readBuildFacts(this.backing, slug)).value)
795
163
  }
796
-
797
- /** Make the three accumulators exist. Needs the full history unless they are
798
- * already held; the caller guarantees `from === 0` or all three present. */
799
- private ensureAccs(log: Log): void {
800
- log.acc ??= buildReducer.advance(buildReducer.initial(), log.events)
801
- if (log.extra === undefined) {
802
- const extra = {} as ExtraAccs
803
- for (const name of EXTRA_NAMES) {
804
- // biome-ignore lint/suspicious/noExplicitAny: the table is heterogeneous
805
- const reducer = EXTRA_REDUCERS[name] as any
806
- extra[name] = reducer.advance(reducer.initial(), log.events)
164
+ async listBuilds(): Promise<BuildRecord[]> {
165
+ if (!this.initialized) await this.refresh()
166
+ return this.metaChain.run(async () => {
167
+ if (this.recordsDirty) {
168
+ this.recordsDirty = false
169
+ this.records = await this.backing.listBuilds()
807
170
  }
808
- log.extra = extra
809
- }
171
+ return structuredClone(this.records)
172
+ })
810
173
  }
811
-
812
- private async openWindow(slug: string, since: number): Promise<Log> {
813
- if (this.work.has(slug)) return this.loadFull(slug)
814
- let log: Log
815
- if (this.resident) {
816
- // One pass over the log: reduce all of it, retain only what the reader
817
- // has not consumed.
818
- const all = await this.backing.getEvents(slug)
819
- log = {
820
- from: since,
821
- events: all.filter((event) => event.seq > since),
822
- cursor: all.at(-1)?.seq ?? 0,
823
- dirty: false,
824
- epoch: this.epoch,
825
- acc: buildReducer.advance(buildReducer.initial(), all),
826
- persisted: 0,
827
- pinned: true,
174
+ async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
175
+ if (repo !== this.repo) return this.backing.getRepoBuildDigests(repo)
176
+ await this.listBuilds()
177
+ return this.metaChain.run(async () => {
178
+ if (this.discoveryDirty) {
179
+ this.discoveryDirty = false
180
+ this.discovery = await readBuildDigests(this.backing, repo)
828
181
  }
829
- } else {
830
- const events = await this.backing.getEvents(slug, since)
831
- log = {
832
- from: since,
833
- events,
834
- cursor: Math.max(since, events.at(-1)?.seq ?? 0),
835
- dirty: false,
836
- epoch: this.epoch,
837
- persisted: 0,
838
- pinned: true,
182
+ if (this.dirtyDigests.size) {
183
+ const records = this.records.filter((record) => this.dirtyDigests.has(record.slug))
184
+ this.dirtyDigests.clear()
185
+ try {
186
+ for (const [slug, digest] of await readPersistedBuildDigests(this.backing, repo, records))
187
+ this.discovery.set(slug, digest)
188
+ } catch (error) {
189
+ for (const record of records) this.dirtyDigests.add(record.slug)
190
+ throw error
191
+ }
839
192
  }
840
- }
841
- this.logs.set(slug, log)
842
- return log
193
+ const missing = this.records.filter(
194
+ (record) => record.repo === repo && !this.discovery.has(record.slug),
195
+ )
196
+ if (missing.length)
197
+ for (const [slug, digest] of await readPersistedBuildDigests(this.backing, repo, missing))
198
+ this.discovery.set(slug, digest)
199
+ return structuredClone(this.discovery)
200
+ })
843
201
  }
844
-
845
202
  async getEvents(
846
203
  slug: string,
847
- cursor?: number,
204
+ sinceSeq = 0,
848
205
  opts?: { waitSeconds?: number; signal?: AbortSignal },
849
206
  ): Promise<AbEvent[]> {
850
- const sinceSeq = cursor ?? 0
851
- if (opts?.waitSeconds !== undefined || opts?.signal !== undefined) {
207
+ if (opts?.waitSeconds !== undefined || opts?.signal !== undefined)
852
208
  return this.backing.getEvents(slug, sinceSeq, opts)
853
- }
854
- return this.build(slug, async () => {
855
- let log = this.logs.get(slug)
856
- if (log === undefined || sinceSeq < log.from) {
857
- log = await this.openWindow(slug, sinceSeq)
858
- } else if (cursor !== undefined) {
859
- // An explicit cursor, zero included, marks a cursor-bearing consumer
860
- // (the wake pass); an omitted one is a plain read.
861
- log.pinned = true
862
- }
863
- await this.ensureCurrent(slug, log)
864
- // `ensureCurrent` may have replaced the log after a gap.
865
- log = this.logs.get(slug) ?? log
866
- return log.events.filter((event) => event.seq > sinceSeq)
867
- })
868
- }
869
-
870
- /** Take one build's delta now, even if it was already read this epoch — for
871
- * the decisions that deliberately re-read at the last possible moment so a
872
- * concurrent writer's cancellation or request is honored. */
873
- async refreshBuild(slug: string): Promise<void> {
874
- await this.build(slug, async () => {
875
- const log = this.logs.get(slug)
876
- if (log === undefined) return
877
- log.dirty = true
878
- await this.ensureCurrent(slug, log)
879
- })
880
- }
881
-
882
- /** The reduced state of one build without replaying history the view already
883
- * folded: a held log is reduced in place; a resident window brings itself
884
- * current and finishes its accumulator; anything else is one full read,
885
- * then held. */
886
- buildState(slug: string): Promise<BuildState> {
887
209
  return this.build(slug, async () => {
888
- let log = this.logs.get(slug)
889
- if (log === undefined || (log.from > 0 && log.acc === undefined)) {
890
- log = await this.loadFull(slug)
891
- }
892
- await this.ensureCurrent(slug, log)
893
- log = this.logs.get(slug) ?? log
894
- return log.acc !== undefined ? buildReducer.finish(log.acc) : reduceBuild(log.events)
895
- })
896
- }
897
-
898
- /** The reduced facts of one build — state, log index, open execution —
899
- * brought current with one delta read, without its event array: a work
900
- * build restored from snapshots answers from the accumulators. A log that is
901
- * not snapshot-backed is reduced from its events (a full read the first
902
- * time), and keeps the accumulators from then on. */
903
- buildFacts(slug: string): Promise<BuildFacts> {
904
- return this.build(slug, async () => {
905
- let log = this.logs.get(slug)
906
- if (log === undefined) {
907
- log = await this.loadWork(slug)
908
- } else if (log.from > 0 && !hasAccs(log)) {
909
- log = await this.loadFull(slug)
910
- }
911
- await this.ensureCurrent(slug, log)
912
- log = this.logs.get(slug) ?? log
913
- this.ensureAccs(log)
914
- const extra = log.extra!
915
- return {
916
- state: buildReducer.finish(log.acc!),
917
- log: logIndexReducer.finish(extra.logIndex as never),
918
- open: openExecutionReducer.finish(extra.openExecution as never),
919
- workspace: openBuildWorkspaceReducer.finish(extra.openBuildWorkspace as never),
920
- publication: publicationStateReducer.finish(extra.publicationState as never),
921
- lastExecution: lastExecutionOutcomeReducer.finish(extra.lastExecutionOutcome as never),
922
- dashboard: dashboardFactsReducer.finish(extra.dashboardFacts as never),
923
- deferral: currentDeferralObservationReducer.finish(
924
- extra.currentDeferralObservation as never,
925
- ),
926
- }
927
- })
928
- }
929
-
930
- // ── Snapshots ──────────────────────────────────────────────────────────────
931
-
932
- /** Write one work log's reducer state back to the snapshot store, if it has
933
- * advanced past what was last read or written. Runs on the build's chain.
934
- * Best effort: a failure leaves the cursor so the next call retries. A log
935
- * that is dirty (a foreign append not yet read) or trimmed without its
936
- * accumulators is skipped — only a cursor read from the log is persisted. */
937
- private async persistLog(slug: string, log: Log): Promise<void> {
938
- if (log.dirty || log.cursor <= log.persisted) return
939
- if (log.from > 0 && !hasAccs(log)) return
940
- this.ensureAccs(log)
941
- const scope: SnapshotScope = { kind: 'build', slug }
942
- const cursor = log.cursor
943
- await persistReducerSnapshot(this.backing, scope, SNAPSHOT_WORK, {
944
- version: WORK_BUNDLE_VERSION,
945
- cursor,
946
- state: { build: log.acc, extra: log.extra },
947
- })
948
- log.persisted = Math.max(log.persisted, cursor)
949
- }
950
-
951
- /** Write the journal view back when it has advanced. Runs on the journal chain. */
952
- private async persistJournal(): Promise<void> {
953
- if (!this.recorded || this.journalDirty || this.journalCursor <= this.persistedJournal) return
954
- const cursor = this.journalCursor
955
- const state: JournalViewAcc = {
956
- ...(this.anchor !== undefined ? { anchor: this.anchor } : {}),
957
- retained: [...this.journal],
958
- }
959
- await persistReducerSnapshot(
960
- this.backing,
961
- { kind: 'repo', repo: this.repo },
962
- SNAPSHOT_JOURNAL,
963
- { version: JOURNAL_VIEW_REDUCER_VERSION, cursor, state },
964
- )
965
- this.persistedJournal = Math.max(this.persistedJournal, cursor)
966
- }
967
-
968
- /** Persist every source that advanced since it was read or last written: the
969
- * journal view and each work build. A resident view throttles repeated
970
- * persists; `force` and a process's first persist always run. The snapshots
971
- * are a cache — nothing here can fail the caller. */
972
- async persistSnapshots(
973
- opts: { force?: boolean; cold?: boolean; held?: boolean } = {},
974
- ): Promise<void> {
975
- if (!this.initialized && !this.journalReady) return
976
- const now = this.now()
977
- if (opts.cold !== true) {
978
- if (
979
- opts.force !== true &&
980
- this.lastPersistAt !== undefined &&
981
- now - this.lastPersistAt < PERSIST_MIN_INTERVAL_MS
982
- ) {
983
- return
984
- }
985
- this.lastPersistAt = now
986
- }
987
- try {
988
- await this.journalChain.run(() => this.persistJournal())
989
- } catch {
990
- // A cache: the next call retries.
991
- }
992
- // `held` also writes a log read for one request (a settled build the view
993
- // never tracks as work), so its cold full read is paid once.
994
- const candidates = opts.held === true ? [...this.logs.keys()] : [...this.work]
995
- const slugs = candidates.filter((slug) => {
996
- const log = this.logs.get(slug)
997
- return log !== undefined && log.cursor > log.persisted
998
- })
999
- let next = 0
1000
- const worker = async (): Promise<void> => {
1001
- while (next < slugs.length) {
1002
- const slug = slugs[next++]!
1003
- try {
1004
- await this.build(slug, async () => {
1005
- const log = this.logs.get(slug)
1006
- if (log !== undefined) await this.persistLog(slug, log)
1007
- })
1008
- } catch {
1009
- // A cache: the next call retries.
210
+ let window = this.windows.get(slug)
211
+ if (!window || sinceSeq < window.from) {
212
+ const events = await this.backing.getEvents(slug, sinceSeq)
213
+ window = {
214
+ from: sinceSeq,
215
+ cursor: Math.max(sinceSeq, events.at(-1)?.seq ?? 0),
216
+ events,
217
+ epoch: this.epoch,
218
+ dirty: false,
1010
219
  }
1011
- }
1012
- }
1013
- await Promise.all(Array.from({ length: Math.min(PERSIST_CONCURRENCY, slugs.length) }, worker))
1014
- }
1015
-
1016
- /** Drop retained events at or below `minCursor` for a windowed source — the
1017
- * wake pass calls this once its sessions have consumed them. Only sources
1018
- * that carry reduced state for what they drop can be trimmed. */
1019
- releaseWindowsBelow(source: WindowSource, minCursor: number): void {
1020
- if (source === 'journal') {
1021
- const window = this.journalWindow
1022
- if (window === undefined || minCursor <= window.from) return
1023
- window.events = window.events.filter((event) => event.seq > minCursor)
1024
- window.from = minCursor
1025
- window.last = Math.max(window.last, minCursor)
1026
- return
1027
- }
1028
- const log = this.logs.get(source.build)
1029
- if (log === undefined || log.acc === undefined || this.work.has(source.build)) return
1030
- if (minCursor <= log.from) return
1031
- log.events = log.events.filter((event) => event.seq > minCursor)
1032
- log.from = minCursor
1033
- }
1034
-
1035
- async listBuilds(): Promise<BuildRecord[]> {
1036
- await this.ensureInit()
1037
- if (this.recordsDirty) await this.refreshRecords()
1038
- return this.records.map((record) => structuredClone(record))
1039
- }
1040
-
1041
- async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
1042
- if (repo !== this.repo) return this.backing.getRepoBuildDigests(repo)
1043
- const records = (await this.listBuilds()).filter((record) => record.repo === repo)
1044
- const missing = (): boolean =>
1045
- records.some((record) => !this.work.has(record.slug) && !this.discovery.has(record.slug))
1046
- if (this.discoveryDirty || missing()) await this.refreshDiscovery()
1047
- const digests = new Map<string, BuildDigest>()
1048
- for (const record of records) {
1049
- const slug = record.slug
1050
- const digest = await this.build(slug, async () => {
1051
- // A log invalidated by an own write resolves before it feeds a digest.
1052
- const held = this.logs.get(slug)
1053
- if (held?.dirty === true) await this.ensureCurrent(slug, held)
1054
- const log = this.logs.get(slug)
1055
- if (
1056
- this.work.has(slug) &&
1057
- log !== undefined &&
1058
- (log.from === 0 || log.extra !== undefined)
1059
- ) {
1060
- return { slug, ...this.digestOf(slug, log) }
220
+ this.windows.set(slug, window)
221
+ } else if (window.dirty || window.epoch !== this.epoch) {
222
+ let delta = await this.backing.getEvents(slug, window.cursor)
223
+ if (delta.some((event, index) => event.seq !== window!.cursor + index + 1)) {
224
+ delta = await this.backing.getEvents(slug, window.from)
225
+ window.events = []
1061
226
  }
1062
- return this.discovery.get(slug)
1063
- })
1064
- if (digest !== undefined) digests.set(slug, digest)
1065
- }
1066
- return digests
227
+ window.events.push(...delta)
228
+ window.cursor = delta.at(-1)?.seq ?? window.cursor
229
+ window.epoch = this.epoch
230
+ window.dirty = false
231
+ }
232
+ return structuredClone(window.events.filter((event) => event.seq > sinceSeq))
233
+ })
1067
234
  }
1068
-
1069
- // ── Repository journal reads ───────────────────────────────────────────────
1070
-
1071
235
  async getRepoEvents(
1072
236
  repo: string,
1073
237
  sinceSeq = 0,
1074
238
  opts?: { waitSeconds?: number; signal?: AbortSignal },
1075
239
  ): Promise<RepositoryEvent[]> {
1076
- if (repo !== this.repo || opts?.waitSeconds !== undefined || opts?.signal !== undefined) {
240
+ if (repo !== this.repo || opts?.waitSeconds !== undefined || opts?.signal !== undefined)
1077
241
  return this.backing.getRepoEvents(repo, sinceSeq, opts)
1078
- }
1079
- await this.ensureInit()
1080
242
  return this.journalChain.run(async () => {
1081
- await this.syncJournal()
1082
- if (!this.recorded) return this.backing.getRepoEvents(repo, sinceSeq)
1083
243
  let window = this.journalWindow
1084
- if (window === undefined || sinceSeq < window.from) {
244
+ if (!window || sinceSeq < window.from) {
1085
245
  const events = await this.backing.getRepoEvents(repo, sinceSeq)
1086
246
  window = {
1087
247
  from: sinceSeq,
248
+ cursor: Math.max(sinceSeq, events.at(-1)?.seq ?? 0),
1088
249
  events,
1089
- last: Math.max(sinceSeq, events.at(-1)?.seq ?? 0),
250
+ epoch: this.epoch,
251
+ dirty: false,
1090
252
  }
1091
253
  this.journalWindow = window
1092
- // Anything newer than the cursor feeds the bounded subset too.
1093
- this.foldJournal(events)
254
+ } else if (window.dirty || window.epoch !== this.epoch) {
255
+ let delta = await this.backing.getRepoEvents(repo, window.cursor)
256
+ if (delta.some((event, index) => event.seq !== window!.cursor + index + 1)) {
257
+ delta = await this.backing.getRepoEvents(repo, window.from)
258
+ window.events = []
259
+ }
260
+ window.events.push(...delta)
261
+ window.cursor = delta.at(-1)?.seq ?? window.cursor
262
+ window.epoch = this.epoch
263
+ window.dirty = false
1094
264
  }
1095
- return window.events.filter((event) => event.seq > sinceSeq)
265
+ return structuredClone(window.events.filter((event) => event.seq > sinceSeq))
1096
266
  })
1097
267
  }
1098
-
1099
- async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
1100
- if (repo !== this.repo) return this.backing.getRepoStateEvents(repo)
1101
- await this.ensureInit()
1102
- return this.journalChain.run(async () => {
1103
- await this.syncJournal()
1104
- if (!this.recorded) return this.backing.getRepoStateEvents(repo)
1105
- return [...this.journal]
1106
- })
268
+ getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
269
+ return this.backing.getRepoStateEvents(repo)
270
+ }
271
+ getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue> {
272
+ return this.backing.getReducerValue(scope, request)
1107
273
  }
1108
-
1109
274
  getReducerSnapshot(
1110
275
  scope: SnapshotScope,
1111
- reducer: string,
276
+ name: string,
1112
277
  version: number,
1113
278
  ): Promise<ReducerSnapshot | null> {
1114
- return this.backing.getReducerSnapshot(scope, reducer, version)
279
+ return this.backing.getReducerSnapshot(scope, name, version)
1115
280
  }
1116
-
1117
281
  putReducerSnapshot(
1118
282
  scope: SnapshotScope,
1119
- reducer: string,
283
+ name: string,
1120
284
  snapshot: ReducerSnapshot,
1121
285
  ): Promise<boolean> {
1122
- return this.backing.putReducerSnapshot(scope, reducer, snapshot)
286
+ return this.backing.putReducerSnapshot(scope, name, snapshot)
1123
287
  }
1124
-
1125
- async getRepoHighWater(repo: string): Promise<number> {
288
+ getRepoHighWater(repo: string): Promise<number> {
1126
289
  return this.backing.getRepoHighWater(repo)
1127
290
  }
1128
-
1129
- // ── Writes: delegate, then fold ────────────────────────────────────────────
1130
-
1131
- /** Fold an own build append on the build's chain, after any read in flight:
1132
- * contiguous folds in place, a gap (a foreign append interleaved) marks the
1133
- * log dirty so the next read deltas. */
1134
- private foldBuildAppend(slug: string, envelope: AbEvent): Promise<void> {
1135
- return this.build(slug, async () => {
1136
- this.recordsDirty = true
1137
- const log = this.logs.get(slug)
1138
- if (log === undefined) {
1139
- // An append to a build the view holds no log for may change its digest.
1140
- this.discoveryDirty = true
1141
- return
1142
- }
1143
- if (envelope.seq <= log.cursor) return
1144
- if (envelope.seq === log.cursor + 1) this.fold(log, [envelope])
1145
- else log.dirty = true
1146
- })
291
+ // Raw windows are owned by their cursor consumers, and values persist at the adapter boundary.
292
+ releaseWindowsBelow(source: WindowSource, minCursor: number): void {
293
+ const trim = <E extends { seq: number }>(window: RawWindow<E> | undefined): void => {
294
+ if (!window || minCursor <= window.from) return
295
+ window.events = window.events.filter((event) => event.seq > minCursor)
296
+ window.from = minCursor
297
+ window.cursor = Math.max(window.cursor, minCursor)
298
+ }
299
+ if (source === 'journal') trim(this.journalWindow)
300
+ else trim(this.windows.get(source.build))
1147
301
  }
1148
302
 
1149
- /** Fold an own journal append on the journal chain. A seq gap means a foreign
1150
- * append interleaved: repair it now, because stages snapshot the journal
1151
- * synchronously through `recordedJournal()` and must see our own write. */
1152
- private foldJournalAppend(envelope: RepositoryEvent): Promise<void> {
1153
- return this.journalChain.run(async () => {
1154
- // Before the cold load there is no journal state to extend; it reads the
1155
- // journal, this append included.
1156
- if (!this.initialized && !this.journalReady) return
1157
- this.recorded = true
1158
- if (envelope.seq <= this.journalCursor) return
1159
- if (envelope.seq === this.journalCursor + 1) {
1160
- this.foldJournal([envelope])
1161
- return
1162
- }
1163
- this.journalDirty = true
1164
- await this.syncJournal()
1165
- })
303
+ private foldBuildAppend(slug: string, event: AbEvent): void {
304
+ const window = this.windows.get(slug)
305
+ if (!window || event.seq <= window.cursor) return
306
+ if (event.seq !== window.cursor + 1) {
307
+ window.dirty = true
308
+ return
309
+ }
310
+ window.events.push(event)
311
+ window.cursor = event.seq
312
+ }
313
+ private invalidate(slug?: string): void {
314
+ this.recordsDirty = true
315
+ if (slug) this.dirtyDigests.add(slug)
1166
316
  }
1167
-
1168
317
  async createBuild(input: NewBuildInput): Promise<BuildRecord> {
1169
318
  const record = await this.backing.createBuild(input)
1170
- this.recordsDirty = true
1171
- if (record.repo === this.repo) {
1172
- await this.build(record.slug, async () => {
1173
- await this.loadFull(record.slug)
1174
- this.work.add(record.slug)
1175
- })
1176
- }
319
+ this.invalidate(input.slug)
1177
320
  return record
1178
321
  }
1179
-
1180
- async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
1181
- const envelope = await this.backing.append(slug, event)
1182
- await this.foldBuildAppend(slug, envelope as unknown as AbEvent)
1183
- return envelope
322
+ append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
323
+ return this.build(slug, async () => {
324
+ const result = await this.backing.append(slug, event)
325
+ this.foldBuildAppend(slug, result as AbEvent)
326
+ this.invalidate(slug)
327
+ return result
328
+ })
1184
329
  }
1185
-
1186
- async appendIfCurrent<T extends EventType>(
330
+ appendIfCurrent<T extends EventType>(
1187
331
  slug: string,
1188
332
  expectedSeq: number,
1189
333
  event: EventWrite<T>,
1190
334
  ): Promise<EventEnvelope<T> | null> {
1191
- const envelope = await this.backing.appendIfCurrent(slug, expectedSeq, event)
1192
- if (envelope === null) {
1193
- await this.build(slug, async () => {
1194
- const log = this.logs.get(slug)
1195
- if (log !== undefined) log.dirty = true
1196
- })
1197
- return null
1198
- }
1199
- await this.foldBuildAppend(slug, envelope as unknown as AbEvent)
1200
- return envelope
335
+ return this.build(slug, async () => {
336
+ const result = await this.backing.appendIfCurrent(slug, expectedSeq, event)
337
+ if (result) this.foldBuildAppend(slug, result as AbEvent)
338
+ else {
339
+ const window = this.windows.get(slug)
340
+ if (window) window.dirty = true
341
+ }
342
+ this.invalidate(slug)
343
+ return result
344
+ })
1201
345
  }
1202
-
1203
- async appendWithArtifacts<T extends EventType>(
346
+ appendWithArtifacts<T extends EventType>(
1204
347
  slug: string,
1205
348
  artifacts: ArtifactInput[],
1206
349
  makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
1207
350
  ): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
1208
- const result = await this.backing.appendWithArtifacts(slug, artifacts, makeEvent)
1209
- await this.foldBuildAppend(slug, result.event as unknown as AbEvent)
1210
- return result
351
+ return this.build(slug, async () => {
352
+ const result = await this.backing.appendWithArtifacts(slug, artifacts, makeEvent)
353
+ this.foldBuildAppend(slug, result.event as AbEvent)
354
+ this.invalidate(slug)
355
+ return result
356
+ })
357
+ }
358
+ private async foldJournalAppend(event: RepositoryEvent): Promise<void> {
359
+ const window = this.journalWindow
360
+ if (window && event.seq > window.cursor) {
361
+ if (event.seq === window.cursor + 1) {
362
+ window.events.push(event)
363
+ window.cursor = event.seq
364
+ } else window.dirty = true
365
+ }
366
+ await this.refreshJournal()
1211
367
  }
1212
-
1213
368
  async appendRepo<T extends RepositoryEventType>(
1214
369
  repo: string,
1215
370
  event: RepositoryEventWrite<T>,
1216
371
  ): Promise<RepositoryEventEnvelope<T>> {
1217
- const envelope = await this.backing.appendRepo(repo, event)
1218
- if (repo === this.repo) await this.foldJournalAppend(envelope as unknown as RepositoryEvent)
1219
- return envelope
372
+ const result = await this.backing.appendRepo(repo, event)
373
+ if (repo === this.repo) await this.foldJournalAppend(result as RepositoryEvent)
374
+ return result
1220
375
  }
1221
-
1222
376
  async appendRepoWithArtifacts<T extends RepositoryEventType>(
1223
377
  repo: string,
1224
378
  artifacts: ArtifactInput[],
1225
379
  makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
1226
380
  ): Promise<{ event: RepositoryEventEnvelope<T>; artifacts: RepositoryArtifactMeta[] }> {
1227
381
  const result = await this.backing.appendRepoWithArtifacts(repo, artifacts, makeEvent)
1228
- if (repo === this.repo) await this.foldJournalAppend(result.event as unknown as RepositoryEvent)
382
+ if (repo === this.repo) await this.foldJournalAppend(result.event as RepositoryEvent)
1229
383
  return result
1230
384
  }
1231
-
1232
385
  async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
1233
- const claimed = await this.backing.claimLease(slug, holder, ttlMs)
1234
- this.recordsDirty = true
1235
- return claimed
386
+ const result = await this.backing.claimLease(slug, holder, ttlMs)
387
+ this.invalidate()
388
+ return result
1236
389
  }
1237
-
1238
390
  async heartbeat(slug: string, holder: string): Promise<boolean> {
1239
- const beat = await this.backing.heartbeat(slug, holder)
1240
- this.recordsDirty = true
1241
- return beat
391
+ const result = await this.backing.heartbeat(slug, holder)
392
+ this.invalidate()
393
+ return result
1242
394
  }
1243
-
1244
395
  async releaseLease(slug: string, holder: string): Promise<void> {
1245
396
  await this.backing.releaseLease(slug, holder)
1246
- this.recordsDirty = true
397
+ this.invalidate()
1247
398
  }
1248
-
1249
- async ensureRepo(repo: string): Promise<RepositoryRecord> {
399
+ ensureRepo(repo: string): Promise<RepositoryRecord> {
1250
400
  return this.backing.ensureRepo(repo)
1251
401
  }
1252
-
1253
402
  // ── Plain delegation ───────────────────────────────────────────────────────
1254
403
 
1255
404
  scopeBuild(slug: string): BuildScopedStore {
@@ -1327,6 +476,16 @@ export class RepoViewStore implements BuildStore {
1327
476
  ): Promise<TicketAssetMeta | null> {
1328
477
  return this.backing.removeTicketAsset(repo, ticketId, kind, name)
1329
478
  }
479
+ beginRepoTick(repo: string, holder: string) {
480
+ return this.backing.beginRepoTick(repo, holder)
481
+ }
482
+ async finishRepoTick(...args: Parameters<BuildStore['finishRepoTick']>) {
483
+ const result = await this.backing.finishRepoTick(...args)
484
+ if (result && args[0] === this.repo) {
485
+ for (const event of result.events) await this.foldJournalAppend(event)
486
+ }
487
+ return result
488
+ }
1330
489
  claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
1331
490
  return this.backing.claimRepoLease(repo, holder, ttlMs)
1332
491
  }
@@ -1406,32 +565,3 @@ export class RepoViewStore implements BuildStore {
1406
565
  return this.backing.close()
1407
566
  }
1408
567
  }
1409
-
1410
- /** Cheap structural checks on a stored accumulator: a snapshot that fails one
1411
- * is a miss. They guard a hand-edited or truncated row; a well-formed row of the
1412
- * right version is the reducer's own output. */
1413
- function buildAccShape(snapshot: ReducerSnapshot): boolean {
1414
- const state = snapshot.state
1415
- return (
1416
- isObject(state) && Array.isArray(state.openEscalations) && Array.isArray(state.observations)
1417
- )
1418
- }
1419
-
1420
- function indexAccShape(state: unknown): boolean {
1421
- return (
1422
- isObject(state) &&
1423
- Array.isArray(state.candidates) &&
1424
- isObject(state.maxRoundEver) &&
1425
- Array.isArray(state.guidanceDeliveries)
1426
- )
1427
- }
1428
-
1429
- /** Events are a contiguous run starting right after `cursor`. */
1430
- function contiguousFrom(events: readonly { seq: number }[], cursor: number): boolean {
1431
- let expected = cursor + 1
1432
- for (const event of events) {
1433
- if (event.seq !== expected) return false
1434
- expected += 1
1435
- }
1436
- return true
1437
- }