@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
@@ -40,9 +40,10 @@ import {
40
40
  type SessionEventWrite,
41
41
  } from '../../events/sessions'
42
42
  import { createBuildScopedStore } from '../build-scope'
43
+ import type { ReducerSnapshot, SnapshotScope } from '../snapshots'
43
44
  import { validateCreatedEvent } from '../new-build'
44
45
  import { createSessionScopedStore } from '../session-handle'
45
- import { DIGEST_EVENT_TYPES, reduceBuildDigest } from '../digest'
46
+ import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
46
47
  import {
47
48
  REPOSITORY_STATE_EVENT_TYPES,
48
49
  readRepoStateEventsWithAnchorRecheck,
@@ -135,6 +136,18 @@ import {
135
136
  // Exported for the schema-parity test, which pins this DDL against the
136
137
  // drizzle schema; schema.ts stays the source of truth, this is its inlined form.
137
138
  export const BOOTSTRAP_DDL = [
139
+ // Reducer snapshots: additive and idempotent, so databases that predate the
140
+ // table gain it on open.
141
+ `CREATE TABLE IF NOT EXISTS reducer_snapshots (
142
+ scope_kind TEXT NOT NULL,
143
+ scope_key TEXT NOT NULL,
144
+ reducer TEXT NOT NULL,
145
+ version INTEGER NOT NULL,
146
+ cursor INTEGER NOT NULL,
147
+ state TEXT NOT NULL,
148
+ updated_at TEXT NOT NULL,
149
+ PRIMARY KEY (scope_kind, scope_key, reducer)
150
+ )`,
138
151
  `CREATE TABLE IF NOT EXISTS builds (
139
152
  slug TEXT PRIMARY KEY,
140
153
  repo TEXT NOT NULL,
@@ -491,7 +504,11 @@ export class SqliteBuildStore implements BuildStore {
491
504
  // per build, so the answer cannot drift from `reduceBuild`.
492
505
  const rows = this.sqlite
493
506
  .query(
494
- `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type
507
+ `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
508
+ CASE WHEN e.type = 'pr-attachment.reclaimed'
509
+ THEN json_extract(e.payload, '$.hostedSeq') END AS hostedSeq,
510
+ CASE WHEN e.type IN ('execution.started', 'execution.ended')
511
+ THEN json_extract(e.payload, '$.instance') END AS instance
495
512
  FROM builds b
496
513
  LEFT JOIN events e
497
514
  ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map(() => '?').join(', ')})
@@ -503,12 +520,20 @@ export class SqliteBuildStore implements BuildStore {
503
520
  seq: number | null
504
521
  ts: string | null
505
522
  type: string | null
523
+ hostedSeq: number | null
524
+ instance: string | null
506
525
  }[]
507
- const eventsByBuild = new Map<string, Pick<AbEvent, 'type' | 'seq' | 'ts'>[]>()
526
+ const eventsByBuild = new Map<string, DigestEventRow[]>()
508
527
  for (const row of rows) {
509
528
  if (row.seq === null || row.ts === null || row.type === null) continue
510
529
  const events = eventsByBuild.get(row.slug) ?? []
511
- events.push({ type: row.type as AbEvent['type'], seq: row.seq, ts: row.ts })
530
+ events.push({
531
+ type: row.type as AbEvent['type'],
532
+ seq: row.seq,
533
+ ts: row.ts,
534
+ ...(row.hostedSeq !== null ? { hostedSeq: row.hostedSeq } : {}),
535
+ ...(row.instance !== null ? { instance: row.instance } : {}),
536
+ })
512
537
  eventsByBuild.set(row.slug, events)
513
538
  }
514
539
  const digests = new Map<string, BuildDigest>()
@@ -1038,6 +1063,88 @@ export class SqliteBuildStore implements BuildStore {
1038
1063
  })
1039
1064
  }
1040
1065
 
1066
+ async getRepoHighWater(repo: string): Promise<number> {
1067
+ this.requireRepo(repo)
1068
+ const row = this.db
1069
+ .select({ seq: sql<number | null>`max(${repoEvents.seq})` })
1070
+ .from(repoEvents)
1071
+ .where(eq(repoEvents.repo, repo))
1072
+ .get()
1073
+ return row?.seq ?? 0
1074
+ }
1075
+
1076
+ /** The scope's log tail, or `undefined` for a scope that does not exist. */
1077
+ private snapshotTail(scope: SnapshotScope): number | undefined {
1078
+ if (scope.kind === 'build') {
1079
+ const row = this.sqlite
1080
+ .query(
1081
+ 'SELECT (SELECT COALESCE(MAX(seq), 0) FROM events WHERE build = ?1) AS tail FROM builds WHERE slug = ?1',
1082
+ )
1083
+ .get(scope.slug) as { tail: number } | null
1084
+ return row?.tail
1085
+ }
1086
+ const row = this.sqlite
1087
+ .query(
1088
+ 'SELECT (SELECT COALESCE(MAX(seq), 0) FROM repo_events WHERE repo = ?1) AS tail FROM repo_streams WHERE repo = ?1',
1089
+ )
1090
+ .get(scope.repo) as { tail: number } | null
1091
+ return row?.tail
1092
+ }
1093
+
1094
+ async getReducerSnapshot(
1095
+ scope: SnapshotScope,
1096
+ reducer: string,
1097
+ version: number,
1098
+ ): Promise<ReducerSnapshot | null> {
1099
+ const tail = this.snapshotTail(scope)
1100
+ if (tail === undefined) return null
1101
+ const row = this.sqlite
1102
+ .query(
1103
+ 'SELECT version, cursor, state FROM reducer_snapshots WHERE scope_kind = ?1 AND scope_key = ?2 AND reducer = ?3',
1104
+ )
1105
+ .get(scope.kind, scope.kind === 'build' ? scope.slug : scope.repo, reducer) as {
1106
+ version: number
1107
+ cursor: number
1108
+ state: string
1109
+ } | null
1110
+ if (row === null || row.version !== version || row.cursor > tail) return null
1111
+ try {
1112
+ return { version: row.version, cursor: row.cursor, state: JSON.parse(row.state) }
1113
+ } catch {
1114
+ return null
1115
+ }
1116
+ }
1117
+
1118
+ async putReducerSnapshot(
1119
+ scope: SnapshotScope,
1120
+ reducer: string,
1121
+ snapshot: ReducerSnapshot,
1122
+ ): Promise<boolean> {
1123
+ const tail = this.snapshotTail(scope)
1124
+ if (tail === undefined || snapshot.cursor > tail) return false
1125
+ // One statement: the stored cursor never decreases, whatever interleaves.
1126
+ const result = this.sqlite
1127
+ .query(
1128
+ `INSERT INTO reducer_snapshots (scope_kind, scope_key, reducer, version, cursor, state, updated_at)
1129
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1130
+ ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
1131
+ version = excluded.version, cursor = excluded.cursor,
1132
+ state = excluded.state, updated_at = excluded.updated_at
1133
+ WHERE excluded.cursor > reducer_snapshots.cursor
1134
+ OR (excluded.cursor = reducer_snapshots.cursor AND excluded.version > reducer_snapshots.version)`,
1135
+ )
1136
+ .run(
1137
+ scope.kind,
1138
+ scope.kind === 'build' ? scope.slug : scope.repo,
1139
+ reducer,
1140
+ snapshot.version,
1141
+ snapshot.cursor,
1142
+ JSON.stringify(snapshot.state ?? null),
1143
+ this.clock().toISOString(),
1144
+ )
1145
+ return result.changes > 0
1146
+ }
1147
+
1041
1148
  async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
1042
1149
  this.requireRepo(repo)
1043
1150
  // Two queries, mirroring the oracle's derivation: the latest
@@ -26,6 +26,12 @@ import {
26
26
  } from '../events/sessions'
27
27
  import { humanActor } from '../events/envelope'
28
28
  import { createBuildScopedStore } from './build-scope'
29
+ import {
30
+ type ReducerSnapshot,
31
+ type SnapshotScope,
32
+ snapshotScopeKey,
33
+ snapshotSupersedes,
34
+ } from './snapshots'
29
35
  import { validateCreatedEvent } from './new-build'
30
36
  import { createSessionScopedStore } from './session-handle'
31
37
  import { reduceBuildDigest } from './digest'
@@ -163,6 +169,8 @@ export class MemoryBuildStore implements BuildStore {
163
169
  * stream's close against its appends inside this process (AUT-348). */
164
170
  private readonly streamLocks = new StreamLocks()
165
171
  private readonly sessions = new Map<string, SessionState>()
172
+ /** Reducer snapshots (store/snapshots.ts), keyed by scope and reducer name. */
173
+ private readonly snapshots = new Map<string, ReducerSnapshot>()
166
174
  /** `[repo, ticketId, kind, name]` → rows in revision order. A removal is a
167
175
  * tombstone row (`removed`, empty manifest) taking the next revision. */
168
176
  private readonly ticketAssets = new Map<string, { meta: TicketAssetMeta; removed: boolean }[]>()
@@ -318,7 +326,22 @@ export class MemoryBuildStore implements BuildStore {
318
326
  const digests = new Map<string, BuildDigest>()
319
327
  for (const slug of slugs) {
320
328
  const state = this.builds.get(slug)!
321
- digests.set(slug, { slug, ...reduceBuildDigest(state.events) })
329
+ digests.set(slug, {
330
+ slug,
331
+ ...reduceBuildDigest(
332
+ state.events.map((event) => ({
333
+ type: event.type,
334
+ seq: event.seq,
335
+ ts: event.ts,
336
+ ...(event.type === 'pr-attachment.reclaimed'
337
+ ? { hostedSeq: event.payload.hostedSeq }
338
+ : {}),
339
+ ...(event.type === 'execution.started' || event.type === 'execution.ended'
340
+ ? { instance: event.payload.instance }
341
+ : {}),
342
+ })),
343
+ ),
344
+ })
322
345
  }
323
346
  return digests
324
347
  }
@@ -678,6 +701,50 @@ export class MemoryBuildStore implements BuildStore {
678
701
  })
679
702
  }
680
703
 
704
+ async getRepoHighWater(repo: string): Promise<number> {
705
+ return this.repoState(repo).events.at(-1)?.seq ?? 0
706
+ }
707
+
708
+ /** The scope's log tail, or `undefined` for a scope that does not exist. */
709
+ private snapshotTail(scope: SnapshotScope): number | undefined {
710
+ const events =
711
+ scope.kind === 'build'
712
+ ? this.builds.get(scope.slug)?.events
713
+ : this.repos.get(scope.repo)?.events
714
+ return events === undefined ? undefined : (events.at(-1)?.seq ?? 0)
715
+ }
716
+
717
+ async getReducerSnapshot(
718
+ scope: SnapshotScope,
719
+ reducer: string,
720
+ version: number,
721
+ ): Promise<ReducerSnapshot | null> {
722
+ const tail = this.snapshotTail(scope)
723
+ if (tail === undefined) return null
724
+ const stored = this.snapshots.get(`${snapshotScopeKey(scope)}\u0000${reducer}`)
725
+ if (stored === undefined || stored.version !== version || stored.cursor > tail) return null
726
+ return { version: stored.version, cursor: stored.cursor, state: structuredClone(stored.state) }
727
+ }
728
+
729
+ async putReducerSnapshot(
730
+ scope: SnapshotScope,
731
+ reducer: string,
732
+ snapshot: ReducerSnapshot,
733
+ ): Promise<boolean> {
734
+ const tail = this.snapshotTail(scope)
735
+ if (tail === undefined || snapshot.cursor > tail) return false
736
+ const key = `${snapshotScopeKey(scope)}\u0000${reducer}`
737
+ if (!snapshotSupersedes(snapshot, this.snapshots.get(key))) return false
738
+ // The JSON round trip is the stored form every adapter shares, so callers
739
+ // cannot alias what the store holds.
740
+ this.snapshots.set(key, {
741
+ version: snapshot.version,
742
+ cursor: snapshot.cursor,
743
+ state: JSON.parse(JSON.stringify(snapshot.state ?? null)),
744
+ })
745
+ return true
746
+ }
747
+
681
748
  async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
682
749
  // The oracle runs on the in-memory journal itself, which keeps the pure
683
750
  // derivation honest against real adapter data.
@@ -8,6 +8,7 @@
8
8
  * scope handles in `session-handle.ts` (hosted-only, AUT-339), which scope a
9
9
  * store to a single operator session's record.
10
10
  */
11
+ import type { ReducerSnapshot, SnapshotScope } from './snapshots'
11
12
  import type { EventType } from '../events/payloads'
12
13
  import type { EventWrite } from '../events/catalog'
13
14
  import type { RepositoryEventType, RepositoryEventWrite } from '../events/repository'
@@ -328,6 +329,28 @@ export function scopeLocalStoreToPhaseSession(
328
329
  own('getRepoStateEvents', 'repo', repo)
329
330
  return store.getRepoStateEvents(repo)
330
331
  },
332
+ async getRepoHighWater(repo: string): Promise<number> {
333
+ own('getRepoHighWater', 'repo', repo)
334
+ return store.getRepoHighWater(repo)
335
+ },
336
+ async getReducerSnapshot(
337
+ target: SnapshotScope,
338
+ reducer: string,
339
+ version: number,
340
+ ): Promise<ReducerSnapshot | null> {
341
+ if (target.kind === 'build') own('getReducerSnapshot', 'build', target.slug)
342
+ else own('getReducerSnapshot', 'repo', target.repo)
343
+ return store.getReducerSnapshot(target, reducer, version)
344
+ },
345
+ async putReducerSnapshot(
346
+ target: SnapshotScope,
347
+ reducer: string,
348
+ snapshot: ReducerSnapshot,
349
+ ): Promise<boolean> {
350
+ if (target.kind === 'build') own('putReducerSnapshot', 'build', target.slug)
351
+ else own('putReducerSnapshot', 'repo', target.repo)
352
+ return store.putReducerSnapshot(target, reducer, snapshot)
353
+ },
331
354
  async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
332
355
  own('getRepoBuildDigests', 'repo', repo)
333
356
  return store.getRepoBuildDigests(repo)
@@ -46,6 +46,7 @@ import {
46
46
  import { createBuildScopedStore } from '../build-scope'
47
47
  import { createSessionScopedStore } from '../session-handle'
48
48
  import { pollingSubscribe } from '../subscribe'
49
+ import type { ReducerSnapshot, SnapshotScope } from '../snapshots'
49
50
  import type { BuildDigest } from '../types'
50
51
  import type {
51
52
  StreamChunk,
@@ -92,8 +93,11 @@ import {
92
93
  eventEnvelopeWireSchema,
93
94
  eventListSchema,
94
95
  repositoryEventEnvelopeWireSchema,
96
+ repoHighWaterSchema,
95
97
  repositoryEventListSchema,
96
98
  okResponseSchema,
99
+ reducerSnapshotPutResponseSchema,
100
+ reducerSnapshotResponseSchema,
97
101
  placeholderRev,
98
102
  repoDepositsResponseSchema,
99
103
  repositoryArtifactGetResponseSchema,
@@ -193,7 +197,7 @@ export class RemoteBuildStore implements BuildStore {
193
197
  }
194
198
 
195
199
  private async raw(
196
- method: 'GET' | 'POST',
200
+ method: 'GET' | 'POST' | 'PUT',
197
201
  path: string,
198
202
  body?: unknown,
199
203
  signal?: AbortSignal,
@@ -535,6 +539,51 @@ export class RemoteBuildStore implements BuildStore {
535
539
  )
536
540
  }
537
541
 
542
+ private snapshotPath(scope: SnapshotScope, reducer: string): string {
543
+ const root = scope.kind === 'build' ? this.buildPath(scope.slug) : this.repoPath(scope.repo)
544
+ return `${root}/snapshots/${encodeURIComponent(reducer)}`
545
+ }
546
+
547
+ async getReducerSnapshot(
548
+ scope: SnapshotScope,
549
+ reducer: string,
550
+ version: number,
551
+ ): Promise<ReducerSnapshot | null> {
552
+ // Additive routes, no protocol bump. A store that predates them answers
553
+ // 404 or 405 for the route, which reads as "no snapshot": the caller
554
+ // replays the log. Auth and transport failures still surface.
555
+ const response = await this.raw(
556
+ 'GET',
557
+ `${this.snapshotPath(scope, reducer)}?version=${version}`,
558
+ )
559
+ if (response.status === 404 || response.status === 405) return null
560
+ if (!response.ok) throw await this.toError(response)
561
+ return reducerSnapshotResponseSchema.parse(await response.json())
562
+ }
563
+
564
+ async putReducerSnapshot(
565
+ scope: SnapshotScope,
566
+ reducer: string,
567
+ snapshot: ReducerSnapshot,
568
+ ): Promise<boolean> {
569
+ const response = await this.raw('PUT', this.snapshotPath(scope, reducer), {
570
+ version: snapshot.version,
571
+ cursor: snapshot.cursor,
572
+ state: snapshot.state ?? null,
573
+ })
574
+ if (response.status === 404 || response.status === 405) return false
575
+ if (!response.ok) throw await this.toError(response)
576
+ return reducerSnapshotPutResponseSchema.parse(await response.json()).written
577
+ }
578
+
579
+ async getRepoHighWater(repo: string): Promise<number> {
580
+ // Additive route beside `state-events`; the store service and client
581
+ // ship in version lockstep, so no protocol bump.
582
+ return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
583
+ (body) => (body as { seq: number }).seq,
584
+ )
585
+ }
586
+
538
587
  async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
539
588
  // The bounded repository-journal read (AUT-489): one additive route next
540
589
  // to `GET events`, reusing the same wire shape. No protocol-version bump
@@ -20,3 +20,5 @@ export { EventValidationError } from '../../events/catalog'
20
20
  export type { Via } from '../../events/envelope'
21
21
  export type { SessionEvent, SessionEventWrite } from '../../events/sessions'
22
22
  export type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
23
+ export { SNAPSHOT_REDUCER_NAME } from '../snapshots'
24
+ export type { ReducerSnapshot, SnapshotScope } from '../snapshots'
@@ -24,6 +24,7 @@
24
24
  * Arbitrary computation over the assigned revisions inside `makeEvent`
25
25
  * (e.g. `rev + 1`, string interpolation) is unsupported by design.
26
26
  */
27
+ import { reducerSnapshotWireSchema } from '../snapshots'
27
28
  import { z } from 'zod'
28
29
  import { actorSchema } from '../../events/envelope'
29
30
  import { ticketRefSchema } from '../../ontology'
@@ -120,6 +121,18 @@ export const repositoryEventEnvelopeWireSchema = z.object({
120
121
  })
121
122
  export const repositoryEventListSchema = z.array(repositoryEventEnvelopeWireSchema)
122
123
 
124
+ /** `GET repos/:repo/high-water`: the journal's greatest assigned seq. */
125
+ export const repoHighWaterSchema = z.object({ seq: z.number().int().nonnegative() })
126
+
127
+ /** `GET builds|repos/:id/snapshots/:reducer?version=N`: the snapshot or `null`. */
128
+ /** `PUT …/snapshots/:reducer` request body. */
129
+ export const reducerSnapshotPutBodySchema = reducerSnapshotWireSchema
130
+
131
+ export const reducerSnapshotResponseSchema = reducerSnapshotWireSchema.nullable()
132
+
133
+ /** `PUT builds|repos/:id/snapshots/:reducer`: whether the row was written. */
134
+ export const reducerSnapshotPutResponseSchema = z.object({ written: z.boolean() })
135
+
123
136
  export const repositoryArtifactMetaWireSchema = z.object({
124
137
  repo: z.string(),
125
138
  kind: z.string(),
@@ -272,6 +285,8 @@ export const buildDigestWireSchema = z.object({
272
285
  }),
273
286
  ),
274
287
  merged: z.string().min(1).optional(),
288
+ reclaimPending: z.literal(true).optional(),
289
+ executionOpen: z.literal(true).optional(),
275
290
  })
276
291
  export const buildDigestListSchema = z.array(buildDigestWireSchema)
277
292
 
@@ -47,6 +47,7 @@
47
47
  * re-ordered, and a replay from the start is untouched.
48
48
  */
49
49
  import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
50
+ import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
50
51
  import {
51
52
  dispatcherSettingEventPayloadSchemas,
52
53
  dispatcherStatusEventPayloadSchemas,
@@ -120,6 +121,76 @@ export async function readRepoStateEventsWithAnchorRecheck(
120
121
  return rechecked === undefined ? events : select(rechecked)
121
122
  }
122
123
 
124
+ /** Carried state for the bounded read: the latest run-started seq (the last
125
+ * in array order, as the whole-array pass overwrites it) and every event seen,
126
+ * in array order. Nothing is pruned: a later run-started may carry a lower seq
127
+ * than an earlier one and restore events below the earlier anchor, so only
128
+ * `finish` can apply the filter exactly. Pruning below a monotone anchor is
129
+ * valid only for seq-ordered input and belongs with the cached-state
130
+ * follow-up. */
131
+ export interface RepositoryStateEventsAcc {
132
+ anchor?: number
133
+ retained: RepositoryEvent[]
134
+ }
135
+
136
+ /** Bump when `RepositoryStateEventsAcc` or its fold changes. */
137
+ export const REPOSITORY_STATE_EVENTS_REDUCER_VERSION = 1
138
+
139
+ const keeps = (event: RepositoryEvent, anchor: number | undefined): boolean =>
140
+ STATE_TYPES.has(event.type) || (anchor !== undefined && event.seq >= anchor)
141
+
142
+ export const repositoryStateEventsReducer: IncrementalReducer<
143
+ RepositoryStateEventsAcc,
144
+ RepositoryEvent,
145
+ RepositoryEvent[]
146
+ > = defineReducer<RepositoryStateEventsAcc, RepositoryEvent, RepositoryEvent[]>({
147
+ version: REPOSITORY_STATE_EVENTS_REDUCER_VERSION,
148
+ initial: () => ({ retained: [] }),
149
+ fold(acc, events) {
150
+ for (const event of events) {
151
+ if (event.type === RUN_STARTED) acc.anchor = event.seq
152
+ acc.retained.push(event)
153
+ }
154
+ },
155
+ finish: (acc) => acc.retained.filter((event) => keeps(event, acc.anchor)),
156
+ })
157
+
158
+ /** Carried state for the pruned journal view: the latest run-started seq and
159
+ * the subset the bounded read would return as of the events folded so far. Unlike
160
+ * `RepositoryStateEventsAcc` it prunes on every `dispatcher.run-started`
161
+ * (everything before the new anchor that is not durable leaves the tail), which
162
+ * is exact for seq-ordered input — the only input a real journal produces — and
163
+ * keeps the accumulator bounded, so it is the one a snapshot stores. */
164
+ export interface JournalViewAcc {
165
+ anchor?: number
166
+ retained: RepositoryEvent[]
167
+ }
168
+
169
+ /** Bump when `JournalViewAcc` or its fold changes. */
170
+ export const JOURNAL_VIEW_REDUCER_VERSION = 1
171
+
172
+ /** Fold seq-ordered events into a journal view in place. */
173
+ export function foldJournalView(acc: JournalViewAcc, events: readonly RepositoryEvent[]): void {
174
+ for (const event of events) {
175
+ if (event.type === RUN_STARTED) {
176
+ acc.anchor = event.seq
177
+ acc.retained = acc.retained.filter((held) => STATE_TYPES.has(held.type))
178
+ }
179
+ if (STATE_TYPES.has(event.type) || acc.anchor !== undefined) acc.retained.push(event)
180
+ }
181
+ }
182
+
183
+ export const journalViewReducer: IncrementalReducer<
184
+ JournalViewAcc,
185
+ RepositoryEvent,
186
+ RepositoryEvent[]
187
+ > = defineReducer<JournalViewAcc, RepositoryEvent, RepositoryEvent[]>({
188
+ version: JOURNAL_VIEW_REDUCER_VERSION,
189
+ initial: () => ({ retained: [] }),
190
+ fold: foldJournalView,
191
+ finish: (acc) => [...acc.retained],
192
+ })
193
+
123
194
  /** The normative subset derivation every adapter implements and the contract
124
195
  * tests use as the oracle: durable types across the whole journal, plus —
125
196
  * only when the journal has one — every event from the latest
@@ -130,11 +201,5 @@ export async function readRepoStateEventsWithAnchorRecheck(
130
201
  export function projectRepositoryStateEvents(
131
202
  events: readonly RepositoryEvent[],
132
203
  ): RepositoryEvent[] {
133
- let anchor: number | undefined
134
- for (const event of events) {
135
- if (event.type === RUN_STARTED) anchor = event.seq
136
- }
137
- return events.filter(
138
- (event) => STATE_TYPES.has(event.type) || (anchor !== undefined && event.seq >= anchor),
139
- )
204
+ return repositoryStateEventsReducer.reduce(events)
140
205
  }
@@ -1,3 +1,4 @@
1
+ import type { ReducerSnapshot, SnapshotScope } from './snapshots'
1
2
  import type { EventType } from '../events/payloads'
2
3
  import type { EventWrite } from '../events/catalog'
3
4
  import type { RepositoryEventType, RepositoryEventWrite } from '../events/repository'
@@ -171,6 +172,35 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
171
172
  getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
172
173
  return Promise.reject(new SessionScopeError(scope, 'getRepoStateEvents', repo))
173
174
  },
175
+ getRepoHighWater(repo: string): Promise<number> {
176
+ return Promise.reject(new SessionScopeError(scope, 'getRepoHighWater', repo))
177
+ },
178
+ getReducerSnapshot(
179
+ target: SnapshotScope,
180
+ _reducer: string,
181
+ _version: number,
182
+ ): Promise<ReducerSnapshot | null> {
183
+ return Promise.reject(
184
+ new SessionScopeError(
185
+ scope,
186
+ 'getReducerSnapshot',
187
+ target.kind === 'build' ? target.slug : target.repo,
188
+ ),
189
+ )
190
+ },
191
+ putReducerSnapshot(
192
+ target: SnapshotScope,
193
+ _reducer: string,
194
+ _snapshot: ReducerSnapshot,
195
+ ): Promise<boolean> {
196
+ return Promise.reject(
197
+ new SessionScopeError(
198
+ scope,
199
+ 'putReducerSnapshot',
200
+ target.kind === 'build' ? target.slug : target.repo,
201
+ ),
202
+ )
203
+ },
174
204
  getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
175
205
  return Promise.reject(new SessionScopeError(scope, 'getRepoBuildDigests', repo))
176
206
  },
@@ -6,6 +6,7 @@
6
6
  * history; unknown facts cannot exist.
7
7
  */
8
8
  import type { SessionEvent } from '../events/sessions'
9
+ import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
9
10
 
10
11
  export type SessionStatus = 'idle' | 'running' | 'suspended' | 'awaiting-approval' | 'archived'
11
12
  export type SessionSuspensionCause = 'budget' | 'approval'
@@ -62,17 +63,28 @@ interface TurnEntry extends SessionTurn {
62
63
  const isTerminal = (state: SessionTurn['state']): boolean =>
63
64
  state === 'completed' || state === 'failed'
64
65
 
65
- /** Reduce a session's events into its derived state. `session.archived` is
66
- * terminal: every fact after it is ignored — the archived state is frozen. */
67
- export function reduceSession(events: SessionEvent[]): SessionState {
68
- const turns: TurnEntry[] = []
69
- let archived = false
70
- let pendingApproval: SessionState['pendingApproval']
71
- let wakeGlobs: string[] = []
72
- const wakeCursors: Record<string, number> = {}
73
- let journalWakeCursor = 0
66
+ /** The carried accumulator: the reduced fields plus the per-turn suspension
67
+ * cause the public state drops. Once `archived`, the fold ignores every later
68
+ * event, so an advance after archive stays frozen. */
69
+ export interface SessionAcc {
70
+ turns: TurnEntry[]
71
+ archived: boolean
72
+ pendingApproval: SessionState['pendingApproval']
73
+ wakeGlobs: string[]
74
+ wakeCursors: Record<string, number>
75
+ journalWakeCursor: number
76
+ }
77
+
78
+ /** Bump when `SessionAcc`'s shape or fold semantics change. */
79
+ export const SESSION_REDUCER_VERSION = 1
74
80
 
75
- const open = (): TurnEntry | undefined => turns.findLast((turn) => !isTerminal(turn.state))
81
+ function foldSession(acc: SessionAcc, events: readonly SessionEvent[]): void {
82
+ const turns = acc.turns
83
+ let archived = acc.archived
84
+ let pendingApproval = acc.pendingApproval
85
+ let wakeGlobs = acc.wakeGlobs
86
+ const wakeCursors = acc.wakeCursors
87
+ let journalWakeCursor = acc.journalWakeCursor
76
88
 
77
89
  for (const event of events) {
78
90
  if (archived) break
@@ -166,7 +178,15 @@ export function reduceSession(events: SessionEvent[]): SessionState {
166
178
  }
167
179
  }
168
180
 
169
- const openEntry = open()
181
+ acc.archived = archived
182
+ acc.pendingApproval = pendingApproval
183
+ acc.wakeGlobs = wakeGlobs
184
+ acc.journalWakeCursor = journalWakeCursor
185
+ }
186
+
187
+ function finishSession(acc: SessionAcc): SessionState {
188
+ const { turns, archived, pendingApproval, wakeGlobs, wakeCursors, journalWakeCursor } = acc
189
+ const openEntry = turns.findLast((turn) => !isTerminal(turn.state))
170
190
  // Status precedence: archived > awaiting-approval > suspended > running >
171
191
  // idle. An unanswered approval outranks its turn's suspension; the answer
172
192
  // that clears it hands status back to the turn.
@@ -202,3 +222,24 @@ export function reduceSession(events: SessionEvent[]): SessionState {
202
222
  turns: turns.map(({ suspensionCause: _suspensionCause, ...turn }) => turn),
203
223
  }
204
224
  }
225
+
226
+ export const sessionReducer: IncrementalReducer<SessionAcc, SessionEvent, SessionState> =
227
+ defineReducer({
228
+ version: SESSION_REDUCER_VERSION,
229
+ initial: (): SessionAcc => ({
230
+ turns: [],
231
+ archived: false,
232
+ pendingApproval: undefined,
233
+ wakeGlobs: [],
234
+ wakeCursors: {},
235
+ journalWakeCursor: 0,
236
+ }),
237
+ fold: foldSession,
238
+ finish: finishSession,
239
+ })
240
+
241
+ /** Reduce a session's events into its derived state. `session.archived` is
242
+ * terminal: every fact after it is ignored — the archived state is frozen. */
243
+ export function reduceSession(events: SessionEvent[]): SessionState {
244
+ return sessionReducer.reduce(events)
245
+ }