@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
@@ -24,6 +24,7 @@ import type {
24
24
  VerifyOutcome,
25
25
  } from '../ontology'
26
26
  import { verifyPhase } from '../ontology'
27
+ import { defineReducer, type IncrementalReducer } from './incremental'
27
28
 
28
29
  /** An `escalation.raised` without a matching `escalation.answered` — the
29
30
  * definition of `blocked` (§15.5). Matched by id, so answers may arrive in
@@ -299,49 +300,133 @@ export function discardInFlight(state: Pick<BuildState, 'discardRequest'>): bool
299
300
  return state.discardRequest !== undefined
300
301
  }
301
302
 
302
- export function reduceBuild(events: AbEvent[]): BuildState {
303
- let attached = false
304
- let pausedFlag = false
305
- let terminal: 'done' | 'aborted' | undefined
306
- let outcome: BuildOutcome | undefined
307
- let round = 0
308
- let currentPhase: PhaseContext | undefined
309
- let lastCompletedPhase: PhaseContext | undefined
310
- const openEscalations = new Map<string, OpenEscalation>()
311
- const answeredEscalations: AnsweredEscalation[] = []
312
- const reviewRoundCeilings: BuildState['reviewRoundCeilings'] = {}
313
- let pr: BuildState['pr']
314
- let prState: PrLifecycle | undefined
315
- const autoMerge: AutoMergeProjection = { requested: false }
316
- let specRev: number | undefined
317
- let restartSince = 0
318
- let pinnedAssets: PinnedAsset[] = []
319
- let finalizeCompletedSeq = 0
320
- const finalizeSteps: BuildState['finalizeSteps'] = []
321
- const plan: BuildState['plan'] = { round: 0, approved: false }
322
- const implement: BuildState['implement'] = { round: 0 }
323
- let codeReviewApproved = false
324
- let codeReviewApproval: BuildState['codeReviewApproval']
325
- const planReviewFindings: Finding[][] = []
326
- const codeReviewFindings: Finding[][] = []
327
- const verify: BuildState['verify'] = { maxAttemptSeen: 0, results: [], cycleSince: 0 }
328
- let reconcileAttempts = 0
329
- const observations: ObservationRecord[] = []
330
- const setupFailures: BuildState['setupFailures'] = []
331
- let setupFailure: BuildState['setupFailure']
332
- const dispatchFailures: BuildState['dispatchFailures'] = []
333
- const executions: BuildState['executions'] = []
334
- const infrastructureFailures: BuildState['infrastructureFailures'] = []
335
- let infrastructureFailure: BuildState['infrastructureFailure']
336
- const cleanupAttempts: BuildState['cleanupAttempts'] = []
337
- let discardRequest: BuildState['discardRequest']
338
- const pending: Record<PendingCommand['command'], PendingCommand[]> = {
339
- pause: [],
340
- resume: [],
341
- abort: [],
303
+ /** The reducer's carried accumulator: everything `reduceBuild` folds, before
304
+ * the final status derivation. Plain JSON-shaped data (the escalation and
305
+ * session maps are insertion-ordered entry arrays) so it survives a round
306
+ * trip through `JSON.stringify` — the foundation for incremental advancement
307
+ * and cached state. */
308
+ export interface BuildAcc {
309
+ attached: boolean
310
+ pausedFlag: boolean
311
+ terminal?: 'done' | 'aborted'
312
+ outcome?: BuildOutcome
313
+ round: number
314
+ currentPhase?: PhaseContext
315
+ lastCompletedPhase?: PhaseContext
316
+ openEscalations: OpenEscalation[]
317
+ answeredEscalations: AnsweredEscalation[]
318
+ reviewRoundCeilings: BuildState['reviewRoundCeilings']
319
+ pr: BuildState['pr']
320
+ prState?: PrLifecycle
321
+ autoMerge: AutoMergeProjection
322
+ specRev?: number
323
+ restartSince: number
324
+ pinnedAssets: PinnedAsset[]
325
+ finalizeCompletedSeq: number
326
+ finalizeSteps: BuildState['finalizeSteps']
327
+ plan: BuildState['plan']
328
+ implement: BuildState['implement']
329
+ codeReviewApproved: boolean
330
+ codeReviewApproval: BuildState['codeReviewApproval']
331
+ planReviewFindings: Finding[][]
332
+ codeReviewFindings: Finding[][]
333
+ verify: BuildState['verify']
334
+ reconcileAttempts: number
335
+ observations: ObservationRecord[]
336
+ setupFailures: BuildState['setupFailures']
337
+ setupFailure: BuildState['setupFailure']
338
+ dispatchFailures: BuildState['dispatchFailures']
339
+ executions: BuildState['executions']
340
+ infrastructureFailures: BuildState['infrastructureFailures']
341
+ infrastructureFailure: BuildState['infrastructureFailure']
342
+ cleanupAttempts: BuildState['cleanupAttempts']
343
+ discardRequest: BuildState['discardRequest']
344
+ pending: Record<PendingCommand['command'], PendingCommand[]>
345
+ openSessions: OpenSession[]
346
+ failures: Record<string, number>
347
+ lastEvent?: AbEvent
348
+ }
349
+
350
+ /** Bump when `BuildAcc`'s shape or the fold's semantics change. */
351
+ export const BUILD_REDUCER_VERSION = 1
352
+
353
+ function initialBuildAcc(): BuildAcc {
354
+ return {
355
+ attached: false,
356
+ pausedFlag: false,
357
+ round: 0,
358
+ openEscalations: [],
359
+ answeredEscalations: [],
360
+ reviewRoundCeilings: {},
361
+ pr: undefined,
362
+ autoMerge: { requested: false },
363
+ restartSince: 0,
364
+ pinnedAssets: [],
365
+ finalizeCompletedSeq: 0,
366
+ finalizeSteps: [],
367
+ plan: { round: 0, approved: false },
368
+ implement: { round: 0 },
369
+ codeReviewApproved: false,
370
+ codeReviewApproval: undefined,
371
+ planReviewFindings: [],
372
+ codeReviewFindings: [],
373
+ verify: { maxAttemptSeen: 0, results: [], cycleSince: 0 },
374
+ reconcileAttempts: 0,
375
+ observations: [],
376
+ setupFailures: [],
377
+ setupFailure: undefined,
378
+ dispatchFailures: [],
379
+ executions: [],
380
+ infrastructureFailures: [],
381
+ infrastructureFailure: undefined,
382
+ cleanupAttempts: [],
383
+ discardRequest: undefined,
384
+ pending: { pause: [], resume: [], abort: [] },
385
+ openSessions: [],
386
+ failures: {},
342
387
  }
343
- const openSessions = new Map<string, OpenSession>()
344
- const failures: Record<string, number> = {}
388
+ }
389
+
390
+ /** Fold `events` into `acc` in place. */
391
+ function foldBuild(acc: BuildAcc, events: readonly AbEvent[]): void {
392
+ let attached = acc.attached
393
+ let pausedFlag = acc.pausedFlag
394
+ let terminal = acc.terminal
395
+ let outcome = acc.outcome
396
+ let round = acc.round
397
+ let currentPhase = acc.currentPhase
398
+ let lastCompletedPhase = acc.lastCompletedPhase
399
+ const openEscalations = new Map(acc.openEscalations.map((e) => [e.id, e]))
400
+ const answeredEscalations = acc.answeredEscalations
401
+ const reviewRoundCeilings = acc.reviewRoundCeilings
402
+ let pr = acc.pr
403
+ let prState = acc.prState
404
+ const autoMerge = acc.autoMerge
405
+ let specRev = acc.specRev
406
+ let restartSince = acc.restartSince
407
+ let pinnedAssets = acc.pinnedAssets
408
+ let finalizeCompletedSeq = acc.finalizeCompletedSeq
409
+ const finalizeSteps = acc.finalizeSteps
410
+ const plan = acc.plan
411
+ const implement = acc.implement
412
+ let codeReviewApproved = acc.codeReviewApproved
413
+ let codeReviewApproval = acc.codeReviewApproval
414
+ const planReviewFindings = acc.planReviewFindings
415
+ const codeReviewFindings = acc.codeReviewFindings
416
+ const verify = acc.verify
417
+ let reconcileAttempts = acc.reconcileAttempts
418
+ const observations = acc.observations
419
+ const setupFailures = acc.setupFailures
420
+ let setupFailure = acc.setupFailure
421
+ const dispatchFailures = acc.dispatchFailures
422
+ const executions = acc.executions
423
+ const infrastructureFailures = acc.infrastructureFailures
424
+ let infrastructureFailure = acc.infrastructureFailure
425
+ const cleanupAttempts = acc.cleanupAttempts
426
+ let discardRequest = acc.discardRequest
427
+ const pending = acc.pending
428
+ const openSessions = new Map(acc.openSessions.map((s) => [s.session, s]))
429
+ const failures = acc.failures
345
430
 
346
431
  const start = (ctx: PhaseContext): void => {
347
432
  currentPhase = ctx
@@ -362,7 +447,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
362
447
  per[r - 1] = findings
363
448
  }
364
449
 
365
- let lastEvent: AbEvent | undefined
450
+ let lastEvent = acc.lastEvent
366
451
  for (const event of events) {
367
452
  switch (event.type) {
368
453
  // Facts the projection does not need (workspace liveness is the
@@ -737,55 +822,112 @@ export function reduceBuild(events: AbEvent[]): BuildState {
737
822
  lastEvent = event
738
823
  }
739
824
 
825
+ acc.attached = attached
826
+ acc.pausedFlag = pausedFlag
827
+ acc.terminal = terminal
828
+ acc.outcome = outcome
829
+ acc.round = round
830
+ acc.currentPhase = currentPhase
831
+ acc.lastCompletedPhase = lastCompletedPhase
832
+ acc.openEscalations = [...openEscalations.values()]
833
+ acc.pr = pr
834
+ acc.prState = prState
835
+ acc.specRev = specRev
836
+ acc.restartSince = restartSince
837
+ acc.pinnedAssets = pinnedAssets
838
+ acc.finalizeCompletedSeq = finalizeCompletedSeq
839
+ acc.codeReviewApproved = codeReviewApproved
840
+ acc.codeReviewApproval = codeReviewApproval
841
+ acc.reconcileAttempts = reconcileAttempts
842
+ acc.setupFailure = setupFailure
843
+ acc.infrastructureFailure = infrastructureFailure
844
+ acc.discardRequest = discardRequest
845
+ acc.openSessions = [...openSessions.values()]
846
+ acc.lastEvent = lastEvent
847
+ }
848
+
849
+ function finishBuild(acc: BuildAcc): BuildState {
850
+ const {
851
+ terminal,
852
+ pausedFlag,
853
+ attached,
854
+ currentPhase,
855
+ lastCompletedPhase,
856
+ lastEvent,
857
+ discardRequest,
858
+ pending,
859
+ } = acc
860
+
740
861
  // §15.5 precedence: aborted/done (terminal, latest wins) > paused >
741
862
  // blocked > running > queued. See BuildState.status for the paused+blocked
742
863
  // overlap rule.
743
864
  const status: BuildStatus =
744
865
  terminal ??
745
- (pausedFlag ? 'paused' : openEscalations.size > 0 ? 'blocked' : attached ? 'running' : 'queued')
866
+ (pausedFlag
867
+ ? 'paused'
868
+ : acc.openEscalations.length > 0
869
+ ? 'blocked'
870
+ : attached
871
+ ? 'running'
872
+ : 'queued')
746
873
 
747
874
  const active = currentPhase ?? lastCompletedPhase
748
875
 
749
876
  return {
750
877
  status,
751
- outcome,
878
+ outcome: acc.outcome,
752
879
  phase: active?.phase,
753
- round,
880
+ round: acc.round,
754
881
  currentPhase,
755
882
  lastCompletedPhase,
756
- openEscalations: [...openEscalations.values()],
757
- answeredEscalations,
758
- reviewRoundCeilings,
759
- pr,
760
- prState,
761
- autoMerge,
762
- finalizeCompletedSeq,
763
- finalizeSteps,
883
+ openEscalations: [...acc.openEscalations],
884
+ answeredEscalations: acc.answeredEscalations,
885
+ reviewRoundCeilings: acc.reviewRoundCeilings,
886
+ pr: acc.pr,
887
+ prState: acc.prState,
888
+ autoMerge: acc.autoMerge,
889
+ finalizeCompletedSeq: acc.finalizeCompletedSeq,
890
+ finalizeSteps: acc.finalizeSteps,
764
891
  lastEvent,
765
892
  lastSeq: lastEvent?.seq ?? 0,
766
- pinnedAssets,
767
- specRev,
768
- restartSince,
769
- plan,
770
- implement,
771
- codeReviewApproved,
772
- codeReviewApproval,
773
- reviewFindings: { planReview: planReviewFindings, codeReview: codeReviewFindings },
774
- verify,
775
- reconcileAttempts,
776
- observations,
777
- setupFailures,
778
- setupFailure,
779
- dispatchFailures,
780
- executions,
781
- infrastructureFailures,
782
- infrastructureFailure,
783
- cleanupAttempts,
893
+ pinnedAssets: acc.pinnedAssets,
894
+ specRev: acc.specRev,
895
+ restartSince: acc.restartSince,
896
+ plan: acc.plan,
897
+ implement: acc.implement,
898
+ codeReviewApproved: acc.codeReviewApproved,
899
+ codeReviewApproval: acc.codeReviewApproval,
900
+ reviewFindings: { planReview: acc.planReviewFindings, codeReview: acc.codeReviewFindings },
901
+ verify: acc.verify,
902
+ reconcileAttempts: acc.reconcileAttempts,
903
+ observations: acc.observations,
904
+ setupFailures: acc.setupFailures,
905
+ setupFailure: acc.setupFailure,
906
+ dispatchFailures: acc.dispatchFailures,
907
+ executions: acc.executions,
908
+ infrastructureFailures: acc.infrastructureFailures,
909
+ infrastructureFailure: acc.infrastructureFailure,
910
+ cleanupAttempts: acc.cleanupAttempts,
784
911
  ...(terminal === undefined && discardRequest !== undefined ? { discardRequest } : {}),
785
912
  pendingCommands: [...pending.pause, ...pending.resume, ...pending.abort].sort(
786
913
  (a, b) => a.seq - b.seq,
787
914
  ),
788
- sessions: { open: [...openSessions.values()] },
789
- failures,
915
+ sessions: { open: [...acc.openSessions] },
916
+ failures: acc.failures,
790
917
  }
791
918
  }
919
+
920
+ /** The incremental form of `reduceBuild`: advance a carried accumulator with
921
+ * only the events newer than it, then derive the state. */
922
+ export const buildReducer: IncrementalReducer<BuildAcc, AbEvent, BuildState> = defineReducer({
923
+ version: BUILD_REDUCER_VERSION,
924
+ initial: initialBuildAcc,
925
+ fold: foldBuild,
926
+ finish: finishBuild,
927
+ })
928
+
929
+ export function reduceBuild(events: AbEvent[]): BuildState {
930
+ const acc = initialBuildAcc()
931
+ foldBuild(acc, events)
932
+ return finishBuild(acc)
933
+ }
@@ -7,13 +7,20 @@ import {
7
7
  parseEffectiveBuildConfig,
8
8
  } from '../processes/build-execution-state'
9
9
  import type { Artifact } from '../store/types'
10
- import { detail, statusFilter, summarize, type BuildDetail, type BuildSummary } from '../cli/status'
10
+ import {
11
+ detail,
12
+ statusFilter,
13
+ summarizeFrom,
14
+ type BuildDetail,
15
+ type BuildSummary,
16
+ } from '../cli/status'
11
17
  import { projectRepositoryStatus, type RepositoryStatus } from '../cli/repository-status'
12
18
  import { projectHarvestStatus, type HarvestStatusView } from '../cli/harvest'
13
19
  import {
14
20
  buildDashboardFromProjected,
15
21
  effectiveStatus,
16
22
  projectBuild,
23
+ projectBuildFromFacts,
17
24
  type DashboardBuild,
18
25
  type DashboardModel,
19
26
  } from '../cli/dashboard/model'
@@ -21,6 +28,8 @@ import { reduceBuild, type BuildState } from '../kernel/reducer'
21
28
  import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
22
29
  import { reduceDispatchStatus } from '../kernel/dispatch-status'
23
30
  import { readRepoEventsIfRecorded, unclaimedObservationCount } from '../processes/harvest'
31
+ import type { RepoViewStore } from '../processes/repo-view'
32
+ import { withOperatorView } from './read-view'
24
33
  import type { RepositoryEvent } from '../events/repository'
25
34
  import type { BuildStore, Clock } from '../store/types'
26
35
 
@@ -42,6 +51,13 @@ export async function listOperatorBuilds(opts: {
42
51
  scope: BuildListScope
43
52
  now: Date
44
53
  }): Promise<BuildSummary[]> {
54
+ return withOperatorView(opts.store, opts.repo, 'builds', (view) => listFromView(view, opts))
55
+ }
56
+
57
+ async function listFromView(
58
+ view: RepoViewStore,
59
+ opts: { repo: string; scope: BuildListScope; now: Date },
60
+ ): Promise<BuildSummary[]> {
45
61
  const statuses = new Set(statusFilter(opts.scope === 'all', opts.scope === 'queued'))
46
62
  // Records are read and held before the digest read: builds are never
47
63
  // deleted, so every record iterated below is guaranteed an entry in the
@@ -50,14 +66,14 @@ export async function listOperatorBuilds(opts: {
50
66
  // check can only ever catch a genuine adapter bug, never a concurrent
51
67
  // dispatch. The reverse order would let a concurrent creation abort the
52
68
  // whole listing (AUT-488 finding f_3cb67aba).
53
- const records = await opts.store.listBuilds()
54
- // One digest read gates the per-build history reads (AUT-488): the active
55
- // and queued scopes never show a terminal build, so a build whose digest
56
- // already carries a terminal fact skips its `getEvents` round trip and the
57
- // listing's store cost stays flat as finished builds accumulate. The `--all`
58
- // scope includes terminal statuses, so it takes no digest read and skips
59
- // nothing — byte-for-byte the old loop.
60
- const digests = opts.scope === 'all' ? undefined : await opts.store.getRepoBuildDigests(opts.repo)
69
+ const records = await view.listBuilds()
70
+ // One digest read gates the per-build reads (AUT-488): the active and queued
71
+ // scopes never show a terminal build, so a build whose digest already
72
+ // carries a terminal fact is skipped and the listing's cost stays flat as
73
+ // finished builds accumulate. The `--all` scope includes terminal statuses,
74
+ // so it takes no digest read and skips nothing; its terminal builds are
75
+ // restored from the reducer snapshots the dispatcher leaves at settlement.
76
+ const digests = opts.scope === 'all' ? undefined : await view.getRepoBuildDigests(opts.repo)
61
77
  const output: BuildSummary[] = []
62
78
  for (const record of records) {
63
79
  if (record.repo !== opts.repo) continue
@@ -74,10 +90,10 @@ export async function listOperatorBuilds(opts: {
74
90
  // cleared — pinned against `reduceBuild` in store/digest.test.ts), so
75
91
  // `terminal !== undefined` is exactly reduced status `done`/`aborted`,
76
92
  // which the active and queued status sets never include. Skipping the
77
- // history read therefore cannot change the emitted summaries.
93
+ // build therefore cannot change the emitted summaries.
78
94
  if (digest.terminal !== undefined) continue
79
95
  }
80
- const projected = summarize(record, await opts.store.getEvents(record.slug), opts.now)
96
+ const projected = summarizeFrom(record, (await view.buildFacts(record.slug)).state, opts.now)
81
97
  if (statuses.has(projected.status)) output.push(projected)
82
98
  }
83
99
  return output.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
@@ -95,12 +111,15 @@ async function requireBuild(store: BuildStore, repo: string, slug: string) {
95
111
  export async function effectiveConfig(
96
112
  store: BuildStore,
97
113
  repo: string,
114
+ /** The bounded journal, when the caller already holds it (a repository
115
+ * view); otherwise it is read from the store. */
116
+ journal?: RepositoryEvent[],
98
117
  ): Promise<{
99
118
  config: Config
100
119
  repositoryEvents: RepositoryEvent[]
101
120
  status: ReturnType<typeof reduceDispatchStatus>
102
121
  }> {
103
- const repositoryEvents = await readRepoEventsIfRecorded(store, repo)
122
+ const repositoryEvents = journal ?? (await readRepoEventsIfRecorded(store, repo))
104
123
  let latestRun: string | undefined
105
124
  for (const event of repositoryEvents) {
106
125
  if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
@@ -237,14 +256,18 @@ export async function getRepositoryStatus(
237
256
  store: BuildStore,
238
257
  repo: string,
239
258
  ): Promise<RepositoryStatus> {
240
- return projectRepositoryStatus(repo, await readRepoEventsIfRecorded(store, repo))
259
+ return withOperatorView(store, repo, 'journal', async (view) =>
260
+ projectRepositoryStatus(repo, view.recordedJournal()),
261
+ )
241
262
  }
242
263
 
243
264
  export async function getHarvestStatus(
244
265
  store: BuildStore,
245
266
  repo: string,
246
267
  ): Promise<HarvestStatusView> {
247
- return projectHarvestStatus(repo, await readRepoEventsIfRecorded(store, repo))
268
+ return withOperatorView(store, repo, 'journal', async (view) =>
269
+ projectHarvestStatus(repo, view.recordedJournal()),
270
+ )
248
271
  }
249
272
 
250
273
  export interface OperatorDashboardSnapshot {
@@ -263,15 +286,26 @@ export async function getOperatorDashboard(opts: {
263
286
  repo: string
264
287
  clock: Clock
265
288
  }): Promise<OperatorDashboardSnapshot> {
266
- const { config, repositoryEvents, status } = await effectiveConfig(opts.store, opts.repo)
267
- const records = await opts.store.listBuilds()
289
+ return withOperatorView(opts.store, opts.repo, 'builds', (view) => dashboardFromView(view, opts))
290
+ }
291
+
292
+ async function dashboardFromView(
293
+ view: RepoViewStore,
294
+ opts: { repo: string; clock: Clock },
295
+ ): Promise<OperatorDashboardSnapshot> {
296
+ const { config, repositoryEvents, status } = await effectiveConfig(
297
+ view,
298
+ opts.repo,
299
+ view.recordedJournal(),
300
+ )
301
+ const records = await view.listBuilds()
268
302
  // One digest read covers every build of the repository (AUT-487): the
269
- // row gate (a `done` digest means no row) and the observation count no
270
- // longer need per-build histories, so the snapshot's store traffic stays
271
- // flat as finished builds accumulate. Row-rendering builds still get their
272
- // full history below, and the digest itself is derived from the event log
273
- // on every call — nothing is persisted or cached between snapshots.
274
- const digests = await opts.store.getRepoBuildDigests(opts.repo)
303
+ // row gate (a `done` digest means no row) and the observation count need no
304
+ // per-build histories, so the snapshot's traffic stays flat as finished
305
+ // builds accumulate. Row-rendering builds derive from the repository view's
306
+ // reducer snapshots plus a delta read (AUT-650); every snapshot is a cache
307
+ // the event log regenerates, so the response equals a full replay's.
308
+ const digests = await view.getRepoBuildDigests(opts.repo)
275
309
  const projected: DashboardBuild[] = []
276
310
  let activeCount = 0
277
311
  for (const record of records) {
@@ -295,12 +329,18 @@ export async function getOperatorDashboard(opts: {
295
329
  // could never be surfaced. The digest already excluded `done` builds
296
330
  // above; the gate stays as the authority for the row projection so the
297
331
  // digest and the reducer cannot silently diverge. Every row-rendering
298
- // build still gets its full history and pinned pipeline.
299
- const events = await opts.store.getEvents(record.slug)
300
- const state = reduceBuild(events)
301
- if (projectsNoDashboardRow(state)) continue
302
- const pinned = await readPinnedConfig(opts.store, record.slug, config)
303
- const row = projectBuild(record, state, config, events, undefined, pinned.config)
332
+ // build still gets its facts and pinned pipeline.
333
+ const facts = await view.buildFacts(record.slug)
334
+ if (projectsNoDashboardRow(facts.state)) continue
335
+ const pinned = await readPinnedConfig(view, record.slug, config)
336
+ const row = projectBuildFromFacts(
337
+ record,
338
+ facts.state,
339
+ { dashboard: facts.dashboard, log: facts.log, deferral: facts.deferral },
340
+ config,
341
+ undefined,
342
+ pinned.config,
343
+ )
304
344
  if (row !== null) projected.push(decorateWithPinnedMeta(row, pinned))
305
345
  }
306
346
  // The unclaimed-observation count comes from the digests and the journal
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The operator read paths' repository view: every poll-driven route (dashboard,
3
+ * repository and harvest status, build listings) derives its state from a
4
+ * `RepoViewStore` cold-started from stored reducer snapshots and advanced with
5
+ * delta reads, so a poll's event-row cost follows the events appended since the
6
+ * last one, never the history, the open tabs, or the watchers.
7
+ *
8
+ * Passes are single-flight per `(store, repo)` within one serving process: at
9
+ * most one pass runs at a time, and requests arriving while it runs join the
10
+ * one trailing pass (each needs state at least as new as its arrival, which the
11
+ * running pass cannot promise). That trailing pass restores from the snapshot
12
+ * the previous pass persisted, so N concurrent pollers cost one delta read per
13
+ * pass, not N. The coordination is scheduling inside this process; every datum
14
+ * still comes from durable state, and a second serving process simply reads the
15
+ * same delta once itself (duplicate reads are bounded by the process count).
16
+ */
17
+ import { RepoViewStore } from '../processes/repo-view'
18
+ import type { BuildStore } from '../store/types'
19
+
20
+ /** What a request reads: the journal alone, or the builds beside it. */
21
+ export type OperatorViewNeed = 'journal' | 'builds'
22
+
23
+ interface Waiter {
24
+ need: OperatorViewNeed
25
+ run: (view: RepoViewStore) => Promise<unknown>
26
+ resolve: (value: unknown) => void
27
+ reject: (error: unknown) => void
28
+ }
29
+
30
+ interface Flight {
31
+ running: boolean
32
+ queue: Waiter[]
33
+ }
34
+
35
+ const flights = new WeakMap<BuildStore, Map<string, Flight>>()
36
+
37
+ function flightFor(store: BuildStore, repo: string): Flight {
38
+ let byRepo = flights.get(store)
39
+ if (byRepo === undefined) {
40
+ byRepo = new Map()
41
+ flights.set(store, byRepo)
42
+ }
43
+ let flight = byRepo.get(repo)
44
+ if (flight === undefined) {
45
+ flight = { running: false, queue: [] }
46
+ byRepo.set(repo, flight)
47
+ }
48
+ return flight
49
+ }
50
+
51
+ async function pass(store: BuildStore, repo: string, waiters: Waiter[]): Promise<void> {
52
+ const view = new RepoViewStore(store, { repo })
53
+ try {
54
+ if (waiters.some((waiter) => waiter.need === 'builds')) await view.refresh()
55
+ else await view.startJournal()
56
+ } catch (error) {
57
+ for (const waiter of waiters) waiter.reject(error)
58
+ return
59
+ }
60
+ await Promise.all(waiters.map((waiter) => waiter.run(view).then(waiter.resolve, waiter.reject)))
61
+ // Leave the advanced state behind for the next pass (this process's or
62
+ // another's). `held` also writes a settled build read for a listing. The
63
+ // cache never fails a read.
64
+ try {
65
+ await view.persistSnapshots({ force: true, held: true })
66
+ } catch {
67
+ // A cache: the next pass retries.
68
+ }
69
+ }
70
+
71
+ async function drain(store: BuildStore, repo: string, flight: Flight): Promise<void> {
72
+ flight.running = true
73
+ try {
74
+ while (flight.queue.length > 0) {
75
+ const waiters = flight.queue
76
+ flight.queue = []
77
+ await pass(store, repo, waiters)
78
+ }
79
+ } finally {
80
+ flight.running = false
81
+ }
82
+ }
83
+
84
+ /** Run `run` against a freshly advanced repository view. The view is only valid
85
+ * inside `run`; the response must not hold it. */
86
+ export function withOperatorView<T>(
87
+ store: BuildStore,
88
+ repo: string,
89
+ need: OperatorViewNeed,
90
+ run: (view: RepoViewStore) => Promise<T>,
91
+ ): Promise<T> {
92
+ const flight = flightFor(store, repo)
93
+ const result = new Promise<T>((resolve, reject) => {
94
+ flight.queue.push({ need, run, resolve: resolve as (value: unknown) => void, reject })
95
+ })
96
+ if (!flight.running) void drain(store, repo, flight)
97
+ return result
98
+ }
@@ -79,7 +79,11 @@ export class FakeTicketSource implements TicketSource {
79
79
  this.tickets.set(ticket.ref.id, cloneTicket(ticket))
80
80
  }
81
81
 
82
- async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
82
+ async listReady(criteria: {
83
+ labels?: string[]
84
+ state?: string
85
+ assignee?: string
86
+ }): Promise<TicketListing> {
83
87
  const labels = criteria.labels ?? []
84
88
  return {
85
89
  tickets: [...this.tickets.values()]