@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
@@ -18,58 +18,80 @@ import type {
18
18
  BuildExecutionIdentity,
19
19
  ExecutionObservation,
20
20
  } from '../ports/workspace/build-execution'
21
+ import { defineReducer } from '../kernel/incremental'
21
22
  import type { BuildStore } from '../store/types'
22
23
 
23
- /** The latest `execution.started` not followed by an instance-matched
24
- * `execution.ended`, or null when no execution is open. */
25
- export function openExecution(events: AbEvent[]): {
24
+ export interface OpenExecution {
26
25
  instance: string
27
26
  workspaceRef: string
28
27
  provider: string
29
28
  environmentId?: string
30
29
  sessionId?: string
31
30
  commandId?: string
32
- } | null {
33
- let open: {
34
- instance: string
35
- workspaceRef: string
36
- provider: string
37
- environmentId?: string
38
- sessionId?: string
39
- commandId?: string
40
- } | null = null
41
- for (const event of events) {
42
- if (event.type === 'execution.started') {
43
- const payload = event.payload
44
- open = {
45
- instance: payload.instance,
46
- workspaceRef: payload.workspaceRef,
47
- provider: payload.provider,
48
- ...(payload.environmentId !== undefined ? { environmentId: payload.environmentId } : {}),
49
- ...(payload.sessionId !== undefined ? { sessionId: payload.sessionId } : {}),
50
- ...(payload.commandId !== undefined ? { commandId: payload.commandId } : {}),
31
+ }
32
+
33
+ export const OPEN_EXECUTION_REDUCER_VERSION = 1
34
+
35
+ /** The latest `execution.started` not followed by an instance-matched
36
+ * `execution.ended`, or null when no execution is open. */
37
+ export const openExecutionReducer = defineReducer<
38
+ { open: OpenExecution | null },
39
+ AbEvent,
40
+ OpenExecution | null
41
+ >({
42
+ version: OPEN_EXECUTION_REDUCER_VERSION,
43
+ initial: () => ({ open: null }),
44
+ fold(acc, events) {
45
+ for (const event of events) {
46
+ if (event.type === 'execution.started') {
47
+ const payload = event.payload
48
+ acc.open = {
49
+ instance: payload.instance,
50
+ workspaceRef: payload.workspaceRef,
51
+ provider: payload.provider,
52
+ ...(payload.environmentId !== undefined ? { environmentId: payload.environmentId } : {}),
53
+ ...(payload.sessionId !== undefined ? { sessionId: payload.sessionId } : {}),
54
+ ...(payload.commandId !== undefined ? { commandId: payload.commandId } : {}),
55
+ }
56
+ } else if (event.type === 'execution.ended' && acc.open !== null) {
57
+ if (event.payload.instance === acc.open.instance) acc.open = null
51
58
  }
52
- } else if (event.type === 'execution.ended' && open !== null) {
53
- if (event.payload.instance === open.instance) open = null
54
59
  }
55
- }
56
- return open
60
+ },
61
+ finish: (acc) => (acc.open === null ? null : { ...acc.open }),
62
+ })
63
+
64
+ export function openExecution(events: AbEvent[]): OpenExecution | null {
65
+ return openExecutionReducer.reduce(events)
57
66
  }
58
67
 
68
+ export type ExecutionOutcome = 'open' | 'completed' | 'stopped' | 'lost' | 'none'
69
+
70
+ export const LAST_EXECUTION_OUTCOME_REDUCER_VERSION = 1
71
+
59
72
  /** The lifecycle state of the latest recorded execution: `open` (no matching
60
73
  * end yet), `completed`/`stopped`/`lost` (its recorded end), or `none`. */
61
- export function lastExecutionOutcome(
62
- events: AbEvent[],
63
- ): 'open' | 'completed' | 'stopped' | 'lost' | 'none' {
64
- let state: 'open' | 'completed' | 'stopped' | 'lost' | 'none' = 'none'
65
- for (const event of events) {
66
- if (event.type === 'execution.started') {
67
- state = 'open'
68
- } else if (event.type === 'execution.ended' && state === 'open') {
69
- state = event.payload.outcome
74
+ export const lastExecutionOutcomeReducer = defineReducer<
75
+ { state: ExecutionOutcome },
76
+ AbEvent,
77
+ ExecutionOutcome
78
+ >({
79
+ version: LAST_EXECUTION_OUTCOME_REDUCER_VERSION,
80
+ initial: () => ({ state: 'none' }),
81
+ fold(acc, events) {
82
+ for (const event of events) {
83
+ if (event.type === 'execution.started') {
84
+ acc.state = 'open'
85
+ } else if (event.type === 'execution.ended' && acc.state === 'open') {
86
+ acc.state = event.payload.outcome
87
+ }
70
88
  }
71
- }
72
- return state
89
+ },
90
+ finish: (acc) => acc.state,
91
+ })
92
+
93
+ export function lastExecutionOutcome(events: AbEvent[]): ExecutionOutcome {
94
+ return lastExecutionOutcomeReducer.reduce(events)
73
95
  }
74
96
 
75
97
  function observationIdentity(open: {
@@ -124,12 +146,23 @@ async function releaseSettledLease(
124
146
  * Observation errors are contained and read as `running` (conservative: never
125
147
  * reap on an unknown); the next tick retries.
126
148
  */
127
- export async function settleExecution(
149
+ export function settleExecution(
128
150
  deps: ExecutionSettlementDeps,
129
151
  slug: string,
130
152
  events: AbEvent[],
131
153
  ): Promise<ExecutionSettlement> {
132
- const open = openExecution(events)
154
+ return settleOpenExecution(deps, slug, openExecution(events))
155
+ }
156
+
157
+ /** `settleExecution` over an already-derived open execution, for callers that
158
+ * hold the projection (a reducer snapshot advanced to the log tail) and so
159
+ * never need the event array. A `running` result or an observation error
160
+ * touches no event rows. */
161
+ export async function settleOpenExecution(
162
+ deps: ExecutionSettlementDeps,
163
+ slug: string,
164
+ open: OpenExecution | null,
165
+ ): Promise<ExecutionSettlement> {
133
166
  if (open === null) return 'settled'
134
167
  if (open.commandId === undefined || deps.execution.observe === undefined) return 'running'
135
168
  let observation: ExecutionObservation
@@ -8,6 +8,7 @@
8
8
  * supervisor died.
9
9
  */
10
10
  import type { RepositoryEvent } from '../events/repository'
11
+ import { defineReducer } from '../kernel/incremental'
11
12
  import {
12
13
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
13
14
  actionableHarvestRun,
@@ -16,42 +17,53 @@ import {
16
17
  } from '../kernel/harvest'
17
18
  import type { HarvestRunnerResult } from './harvest-runner'
18
19
 
19
- /** Open harvest executions: every `harvest.execution.started` not followed by
20
- * a matching `harvest.execution.released`, in journal order. */
21
- export function openHarvestExecutions(events: readonly RepositoryEvent[]): Array<{
20
+ export interface OpenHarvestExecution {
22
21
  execution: string
23
22
  provider: string
24
23
  environmentId: string
25
24
  sessionId?: string
26
25
  commandId?: string
27
26
  seq: number
28
- }> {
29
- const open = new Map<
30
- string,
31
- {
32
- execution: string
33
- provider: string
34
- environmentId: string
35
- sessionId?: string
36
- commandId?: string
37
- seq: number
38
- }
39
- >()
40
- for (const event of events) {
41
- if (event.type === 'harvest.execution.started') {
42
- open.set(event.payload.execution, {
43
- execution: event.payload.execution,
44
- provider: event.payload.provider,
45
- environmentId: event.payload.environmentId,
46
- ...(event.payload.sessionId !== undefined ? { sessionId: event.payload.sessionId } : {}),
47
- ...(event.payload.commandId !== undefined ? { commandId: event.payload.commandId } : {}),
48
- seq: event.seq,
49
- })
50
- } else if (event.type === 'harvest.execution.released') {
51
- open.delete(event.payload.execution)
27
+ }
28
+
29
+ export const OPEN_HARVEST_EXECUTIONS_REDUCER_VERSION = 1
30
+
31
+ /** Open harvest executions: every `harvest.execution.started` not followed by
32
+ * a matching `harvest.execution.released`, in journal order. The accumulator is
33
+ * an ordered array: a re-started execution keeps its original position, as it
34
+ * did in the `Map` this replaces. */
35
+ export const openHarvestExecutionsReducer = defineReducer<
36
+ { open: OpenHarvestExecution[] },
37
+ RepositoryEvent,
38
+ OpenHarvestExecution[]
39
+ >({
40
+ version: OPEN_HARVEST_EXECUTIONS_REDUCER_VERSION,
41
+ initial: () => ({ open: [] }),
42
+ fold(acc, events) {
43
+ for (const event of events) {
44
+ if (event.type === 'harvest.execution.started') {
45
+ const entry: OpenHarvestExecution = {
46
+ execution: event.payload.execution,
47
+ provider: event.payload.provider,
48
+ environmentId: event.payload.environmentId,
49
+ ...(event.payload.sessionId !== undefined ? { sessionId: event.payload.sessionId } : {}),
50
+ ...(event.payload.commandId !== undefined ? { commandId: event.payload.commandId } : {}),
51
+ seq: event.seq,
52
+ }
53
+ const index = acc.open.findIndex((item) => item.execution === entry.execution)
54
+ if (index === -1) acc.open.push(entry)
55
+ else acc.open[index] = entry
56
+ } else if (event.type === 'harvest.execution.released') {
57
+ const index = acc.open.findIndex((item) => item.execution === event.payload.execution)
58
+ if (index !== -1) acc.open.splice(index, 1)
59
+ }
52
60
  }
53
- }
54
- return [...open.values()]
61
+ },
62
+ finish: (acc) => acc.open.map((item) => ({ ...item })),
63
+ })
64
+
65
+ export function openHarvestExecutions(events: readonly RepositoryEvent[]): OpenHarvestExecution[] {
66
+ return openHarvestExecutionsReducer.reduce(events)
55
67
  }
56
68
 
57
69
  export interface HarvestExecutionClassificationInput {
@@ -2,6 +2,7 @@ import type { AbEvent } from '../events/catalog'
2
2
  import { DISPATCHER } from '../events/envelope'
3
3
  import type { EventPayload } from '../events/payloads'
4
4
  import type { IdSource } from '../ids'
5
+ import { defineReducer } from '../kernel/incremental'
5
6
  import type { BuildStore } from '../store/types'
6
7
 
7
8
  export type InfrastructureOperation = EventPayload<'infrastructure.failed'>['operation']
@@ -26,27 +27,39 @@ export interface InfrastructureFailureDependencies {
26
27
  ids: IdSource
27
28
  }
28
29
 
30
+ export const INFRASTRUCTURE_FAILURE_RESET_REDUCER_VERSION = 1
31
+
29
32
  /** Infrastructure retries are re-armed only by a confirmed execution boundary
30
33
  * or a retry answer to this policy's own previously-raised exhaustion escalation. */
31
- export function infrastructureFailureResetSeq(events: readonly AbEvent[]): number {
32
- const infrastructureEscalations = new Set<string>()
33
- let resetSeq = 0
34
- for (const event of events) {
35
- if (
36
- event.type === 'escalation.raised' &&
37
- event.payload.policyCause === 'infrastructure-failure-limit'
38
- ) {
39
- infrastructureEscalations.add(event.payload.id)
40
- } else if (
41
- event.type === 'execution.ended' ||
42
- (event.type === 'escalation.answered' &&
43
- event.payload.resolution === 'retry' &&
44
- infrastructureEscalations.has(event.payload.id))
45
- ) {
46
- resetSeq = event.seq
34
+ export const infrastructureFailureResetReducer = defineReducer<
35
+ { escalations: string[]; resetSeq: number },
36
+ AbEvent,
37
+ number
38
+ >({
39
+ version: INFRASTRUCTURE_FAILURE_RESET_REDUCER_VERSION,
40
+ initial: () => ({ escalations: [], resetSeq: 0 }),
41
+ fold(acc, events) {
42
+ for (const event of events) {
43
+ if (
44
+ event.type === 'escalation.raised' &&
45
+ event.payload.policyCause === 'infrastructure-failure-limit'
46
+ ) {
47
+ if (!acc.escalations.includes(event.payload.id)) acc.escalations.push(event.payload.id)
48
+ } else if (
49
+ event.type === 'execution.ended' ||
50
+ (event.type === 'escalation.answered' &&
51
+ event.payload.resolution === 'retry' &&
52
+ acc.escalations.includes(event.payload.id))
53
+ ) {
54
+ acc.resetSeq = event.seq
55
+ }
47
56
  }
48
- }
49
- return resetSeq
57
+ },
58
+ finish: (acc) => acc.resetSeq,
59
+ })
60
+
61
+ export function infrastructureFailureResetSeq(events: readonly AbEvent[]): number {
62
+ return infrastructureFailureResetReducer.reduce(events)
50
63
  }
51
64
 
52
65
  export function normalizeInfrastructureError(error: unknown): string {
@@ -31,6 +31,7 @@ import { reduceSession, type SessionTurnTrigger } from '../store/session-reducer
31
31
  import { reduceBuild } from '../kernel/reducer'
32
32
  import type { AbEvent } from '../events/catalog'
33
33
  import type { BuildStore, Clock } from '../store/types'
34
+ import type { RepoViewStore } from './repo-view'
34
35
  import type { IdSource } from '../ids'
35
36
  import type { LanguageModel } from 'ai'
36
37
  import {
@@ -69,6 +70,10 @@ export interface OrchestratorTickOptions {
69
70
  * Less than ORCHESTRATOR_MIN_TURN_SECONDS skips the whole step
70
71
  * (suspension state is durable — skipping is safe). */
71
72
  remainingBudgetSeconds?: number
73
+ /** The dispatcher's repository view, when the tick runs under one: the wake
74
+ * pass reads its full build state through it (no replay of history the view
75
+ * already folded) and releases consumed window history through it. */
76
+ view?: Pick<RepoViewStore, 'buildState' | 'releaseWindowsBelow'>
72
77
  log?: (message: string) => void
73
78
  }
74
79
 
@@ -133,6 +138,9 @@ export async function runOrchestratorTickStep(
133
138
  const sessions = await store.listSessions(repo)
134
139
 
135
140
  // ── Resume pass ─────────────────────────────────────────────────────────
141
+ // The lowest cursors only cover sessions visited; a budget break leaves later
142
+ // sessions (possibly with lower cursors) uncounted, so it skips the trim.
143
+ let visitedAll = true
136
144
  for (const record of sessions) {
137
145
  const events = await store.getSessionEvents(record.id)
138
146
  const state = reduceSession(events)
@@ -202,9 +210,28 @@ export async function runOrchestratorTickStep(
202
210
 
203
211
  // ── Wake pass ───────────────────────────────────────────────────────────
204
212
  const builds = (await store.listBuilds()).filter((candidate) => candidate.repo === repo)
213
+ /** The lowest wake cursor across every session that scans, per source: the
214
+ * view may drop window history at or below it once the pass ends. */
215
+ const lowestBuildCursor = new Map<string, number>()
216
+ let lowestJournalCursor: number | undefined
217
+ let scanners = 0
205
218
  for (const record of sessions) {
206
219
  const events = await store.getSessionEvents(record.id)
207
220
  const state = reduceSession(events)
221
+ if (state.wakeGlobs.length > 0) {
222
+ scanners += 1
223
+ for (const build of builds) {
224
+ const cursor = state.wakeCursors[build.slug] ?? 0
225
+ lowestBuildCursor.set(
226
+ build.slug,
227
+ Math.min(lowestBuildCursor.get(build.slug) ?? cursor, cursor),
228
+ )
229
+ }
230
+ lowestJournalCursor = Math.min(
231
+ lowestJournalCursor ?? state.journalWakeCursor,
232
+ state.journalWakeCursor,
233
+ )
234
+ }
208
235
  if (state.status !== 'idle') continue
209
236
  if (state.wakeGlobs.length === 0) continue
210
237
  let filters: RegExp[]
@@ -226,7 +253,7 @@ export async function runOrchestratorTickStep(
226
253
  | undefined
227
254
  for (const build of builds) {
228
255
  const cursor = state.wakeCursors[build.slug] ?? 0
229
- const buildEvents = await store.getEvents(build.slug)
256
+ const buildEvents = await store.getEvents(build.slug, cursor)
230
257
  for (const event of buildEvents) {
231
258
  if (event.seq <= cursor) continue
232
259
  if (!filters.some((regex) => regex.test(event.type))) continue
@@ -262,7 +289,10 @@ export async function runOrchestratorTickStep(
262
289
  // by the step's shared deadline; past the floor the wake defers to a
263
290
  // later tick.
264
291
  const remaining = remainingNow() - ORCHESTRATOR_MIN_TURN_SECONDS
265
- if (remaining < ORCHESTRATOR_MIN_TURN_SECONDS) break
292
+ if (remaining < ORCHESTRATOR_MIN_TURN_SECONDS) {
293
+ visitedAll = false
294
+ break
295
+ }
266
296
 
267
297
  // A journal wake delivers the frozen event record only — the turn's
268
298
  // operator registry already exposes bounded repository reads, so no
@@ -283,7 +313,9 @@ export async function runOrchestratorTickStep(
283
313
  ? { event }
284
314
  : {
285
315
  event,
286
- buildState: reduceBuild(await store.getEvents(newest.build)) as unknown as Record<
316
+ buildState: (options.view !== undefined
317
+ ? await options.view.buildState(newest.build)
318
+ : reduceBuild(await store.getEvents(newest.build))) as unknown as Record<
287
319
  string,
288
320
  unknown
289
321
  >,
@@ -301,6 +333,15 @@ export async function runOrchestratorTickStep(
301
333
  }
302
334
  }
303
335
 
336
+ if (options.view !== undefined && scanners > 0 && visitedAll) {
337
+ for (const [slug, cursor] of lowestBuildCursor) {
338
+ if (cursor > 0) options.view.releaseWindowsBelow({ build: slug }, cursor)
339
+ }
340
+ if (lowestJournalCursor !== undefined && lowestJournalCursor > 0) {
341
+ options.view.releaseWindowsBelow('journal', lowestJournalCursor)
342
+ }
343
+ }
344
+
304
345
  options.log?.(
305
346
  `orchestrator step: resumed=${report.resumed} reaped=${report.reaped} woken=${report.woken}`,
306
347
  )
@@ -1,32 +1,170 @@
1
1
  import type { AbEvent } from '../events/catalog'
2
+ import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
2
3
 
3
4
  export type PublicationRequest = Extract<AbEvent, { type: 'publication.requested' }>
4
5
 
6
+ /** One entry of the order-preserving publication ledger: the fields the
7
+ * publication selectors read from each relevant event, in array order. */
8
+ type LedgerItem =
9
+ | { t: 'request'; seq: number; event: PublicationRequest }
10
+ | { t: 'lost'; seq: number; request: number }
11
+ | { t: 'provisioned'; seq: number; ref: string }
12
+ | { t: 'released'; seq: number; ref?: string | null }
13
+ | { t: 'implement'; seq: number; round: number; base: string; head: string }
14
+ | { t: 'reconcile'; seq: number; mergeCommit: string }
15
+ | { t: 'finalize'; seq: number; headSha: string }
16
+ | { t: 'step'; seq: number; step: string; ok: boolean; headSha?: string }
17
+ /** A maximal run of consecutive irrelevant events. Only its max seq
18
+ * matters: the workspace scan's `seq > request.seq` break test. */
19
+ | { t: 'barrier'; seq: number }
20
+
21
+ /** The publication family's accumulator: the whole event sequence in array
22
+ * order, relevant events compacted and each run of irrelevant ones collapsed
23
+ * to a barrier. Filtering to relevant types alone would be wrong, because
24
+ * `publicationRequestSettled` stops its workspace scan at the first event (of
25
+ * any type) whose seq exceeds the request's. */
26
+ export interface PublicationLedger {
27
+ ledger: LedgerItem[]
28
+ }
29
+
30
+ /** Bump when `PublicationLedger` or its fold changes. */
31
+ export const PUBLICATION_STATE_REDUCER_VERSION = 1
32
+
33
+ function compact(event: AbEvent): LedgerItem | undefined {
34
+ switch (event.type) {
35
+ case 'publication.requested':
36
+ return { t: 'request', seq: event.seq, event }
37
+ case 'publication.lost':
38
+ return { t: 'lost', seq: event.seq, request: event.payload.request }
39
+ case 'workspace.provisioned':
40
+ return { t: 'provisioned', seq: event.seq, ref: event.payload.ref }
41
+ case 'workspace.released': {
42
+ const payload = event.payload as { ref?: string }
43
+ return 'ref' in payload
44
+ ? { t: 'released', seq: event.seq, ref: payload.ref ?? null }
45
+ : { t: 'released', seq: event.seq }
46
+ }
47
+ case 'implement.completed':
48
+ return {
49
+ t: 'implement',
50
+ seq: event.seq,
51
+ round: event.payload.round,
52
+ base: event.payload.commits.base,
53
+ head: event.payload.commits.head,
54
+ }
55
+ case 'reconcile.completed':
56
+ return { t: 'reconcile', seq: event.seq, mergeCommit: event.payload.mergeCommit }
57
+ case 'finalize.completed':
58
+ return { t: 'finalize', seq: event.seq, headSha: event.payload.pr.headSha }
59
+ case 'finalize.step-completed': {
60
+ const item: LedgerItem = {
61
+ t: 'step',
62
+ seq: event.seq,
63
+ step: event.payload.step,
64
+ ok: event.payload.ok,
65
+ }
66
+ const headSha = (event.payload as { headSha?: string }).headSha
67
+ if (headSha !== undefined) item.headSha = headSha
68
+ return item
69
+ }
70
+ default:
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ function itemCompletes(item: LedgerItem, request: PublicationRequest): boolean {
76
+ if (item.seq <= request.seq) return false
77
+ const payload = request.payload
78
+ if (payload.operation === 'implement')
79
+ return (
80
+ item.t === 'implement' &&
81
+ item.round === payload.round &&
82
+ item.base === payload.base &&
83
+ item.head === payload.sha
84
+ )
85
+ if (payload.operation === 'reconcile')
86
+ return item.t === 'reconcile' && item.mergeCommit === payload.sha
87
+ if (payload.operation === 'finalize') return item.t === 'finalize' && item.headSha === payload.sha
88
+ return (
89
+ item.t === 'step' && item.step === payload.step && (!item.ok || item.headSha === payload.sha)
90
+ )
91
+ }
92
+
93
+ /** The publication selectors, derived from one ledger. */
94
+ export interface PublicationView {
95
+ requestCompleted(request: PublicationRequest): boolean
96
+ requestSettled(request: PublicationRequest): boolean
97
+ latestUncompletedRequest(): PublicationRequest | undefined
98
+ lostRecorded(request: PublicationRequest): boolean
99
+ abandonedPending(): boolean
100
+ pending(): boolean
101
+ }
102
+
103
+ function viewOf(ledger: readonly LedgerItem[]): PublicationView {
104
+ const requestCompleted = (request: PublicationRequest): boolean =>
105
+ ledger.some((item) => itemCompletes(item, request))
106
+
107
+ const requestSettled = (request: PublicationRequest): boolean => {
108
+ let workspaceRef: string | undefined
109
+ for (const item of ledger) {
110
+ if (item.seq > request.seq) break
111
+ if (item.t === 'provisioned') workspaceRef = item.ref
112
+ else if (item.t === 'released') workspaceRef = undefined
113
+ }
114
+ return (
115
+ requestCompleted(request) ||
116
+ ledger.some(
117
+ (item) =>
118
+ item.seq > request.seq &&
119
+ item.t === 'released' &&
120
+ (item.ref === undefined || workspaceRef === undefined || item.ref === workspaceRef),
121
+ )
122
+ )
123
+ }
124
+
125
+ const latestUncompletedRequest = (): PublicationRequest | undefined => {
126
+ const item = ledger.findLast((entry) => entry.t === 'request' && !requestCompleted(entry.event))
127
+ return item?.t === 'request' ? item.event : undefined
128
+ }
129
+
130
+ return {
131
+ requestCompleted,
132
+ requestSettled,
133
+ latestUncompletedRequest,
134
+ lostRecorded: (request) =>
135
+ ledger.some((item) => item.t === 'lost' && item.request === request.seq),
136
+ abandonedPending() {
137
+ const request = latestUncompletedRequest()
138
+ return request !== undefined && requestSettled(request)
139
+ },
140
+ pending: () => ledger.some((item) => item.t === 'request' && !requestSettled(item.event)),
141
+ }
142
+ }
143
+
144
+ export const publicationStateReducer: IncrementalReducer<
145
+ PublicationLedger,
146
+ AbEvent,
147
+ PublicationView
148
+ > = defineReducer<PublicationLedger, AbEvent, PublicationView>({
149
+ version: PUBLICATION_STATE_REDUCER_VERSION,
150
+ initial: () => ({ ledger: [] }),
151
+ fold(acc, events) {
152
+ for (const event of events) {
153
+ const item = compact(event)
154
+ const last = acc.ledger[acc.ledger.length - 1]
155
+ if (item !== undefined) acc.ledger.push(item)
156
+ else if (last?.t === 'barrier') last.seq = Math.max(last.seq, event.seq)
157
+ else acc.ledger.push({ t: 'barrier', seq: event.seq })
158
+ }
159
+ },
160
+ finish: (acc) => viewOf(acc.ledger),
161
+ })
162
+
5
163
  export function publicationRequestCompleted(
6
164
  events: readonly AbEvent[],
7
165
  request: PublicationRequest,
8
166
  ): boolean {
9
- return events.some((event) => {
10
- if (event.seq <= request.seq) return false
11
- if (request.payload.operation === 'implement')
12
- return (
13
- event.type === 'implement.completed' &&
14
- event.payload.round === request.payload.round &&
15
- event.payload.commits.base === request.payload.base &&
16
- event.payload.commits.head === request.payload.sha
17
- )
18
- if (request.payload.operation === 'reconcile')
19
- return (
20
- event.type === 'reconcile.completed' && event.payload.mergeCommit === request.payload.sha
21
- )
22
- if (request.payload.operation === 'finalize')
23
- return event.type === 'finalize.completed' && event.payload.pr.headSha === request.payload.sha
24
- return (
25
- event.type === 'finalize.step-completed' &&
26
- event.payload.step === request.payload.step &&
27
- (!event.payload.ok || event.payload.headSha === request.payload.sha)
28
- )
29
- })
167
+ return publicationStateReducer.reduce(events).requestCompleted(request)
30
168
  }
31
169
 
32
170
  /** A request is no longer publishable after its workspace is released. The
@@ -36,23 +174,7 @@ export function publicationRequestSettled(
36
174
  events: readonly AbEvent[],
37
175
  request: PublicationRequest,
38
176
  ): boolean {
39
- let workspaceRef: string | undefined
40
- for (const event of events) {
41
- if (event.seq > request.seq) break
42
- if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
43
- else if (event.type === 'workspace.released') workspaceRef = undefined
44
- }
45
- return (
46
- publicationRequestCompleted(events, request) ||
47
- events.some(
48
- (event) =>
49
- event.seq > request.seq &&
50
- event.type === 'workspace.released' &&
51
- (!('ref' in event.payload) ||
52
- workspaceRef === undefined ||
53
- event.payload.ref === workspaceRef),
54
- )
55
- )
177
+ return publicationStateReducer.reduce(events).requestSettled(request)
56
178
  }
57
179
 
58
180
  /** The latest `publication.requested` with no settling completion fact — the
@@ -61,11 +183,7 @@ export function publicationRequestSettled(
61
183
  export function latestUncompletedPublicationRequest(
62
184
  events: readonly AbEvent[],
63
185
  ): PublicationRequest | undefined {
64
- const request = events.findLast(
65
- (event) =>
66
- event.type === 'publication.requested' && !publicationRequestCompleted(events, event),
67
- )
68
- return request?.type === 'publication.requested' ? request : undefined
186
+ return publicationStateReducer.reduce(events).latestUncompletedRequest()
69
187
  }
70
188
 
71
189
  /** Whether the durable loss record (AUT-328) has already been appended for
@@ -75,19 +193,13 @@ export function publicationLostRecorded(
75
193
  events: readonly AbEvent[],
76
194
  request: PublicationRequest,
77
195
  ): boolean {
78
- return events.some(
79
- (event) => event.type === 'publication.lost' && event.payload.request === request.seq,
80
- )
196
+ return publicationStateReducer.reduce(events).lostRecorded(request)
81
197
  }
82
198
 
83
199
  export function abandonedPublicationPending(events: readonly AbEvent[]): boolean {
84
- const request = latestUncompletedPublicationRequest(events)
85
- return request !== undefined && publicationRequestSettled(events, request)
200
+ return publicationStateReducer.reduce(events).abandonedPending()
86
201
  }
87
202
 
88
203
  export function publicationPending(events: readonly AbEvent[]): boolean {
89
- return events.some(
90
- (request) =>
91
- request.type === 'publication.requested' && !publicationRequestSettled(events, request),
92
- )
204
+ return publicationStateReducer.reduce(events).pending()
93
205
  }