@defrex/autobuild 0.9.0 → 0.10.0

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