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