@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
@@ -9,6 +9,7 @@
9
9
  * orphaned environment.
10
10
  */
11
11
  import type { RepositoryEvent } from '../events/repository'
12
+ import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
12
13
 
13
14
  export type SandboxEnvironmentState = 'live' | 'stopped' | 'released'
14
15
 
@@ -45,7 +46,18 @@ type MutableSnapshot = {
45
46
  * derived from both). Latest fact wins; `released` environments stay in the
46
47
  * result with their closing evidence so callers can distinguish them. */
47
48
  export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnvironmentSnapshot[] {
48
- const byEnvironment = new Map<string, MutableSnapshot>()
49
+ return sandboxStatesReducer.reduce(events)
50
+ }
51
+
52
+ /** The carried accumulator: environments in `Map` insertion order (a re-set
53
+ * of an existing id keeps its position). */
54
+ export type SandboxStatesAcc = MutableSnapshot[]
55
+
56
+ /** Bump when `SandboxStatesAcc`'s shape or fold semantics change. */
57
+ export const SANDBOX_STATES_REDUCER_VERSION = 1
58
+
59
+ function foldSandboxStates(acc: SandboxStatesAcc, events: readonly RepositoryEvent[]): void {
60
+ const byEnvironment = new Map<string, MutableSnapshot>(acc.map((s) => [s.environmentId, s]))
49
61
  for (const event of events) {
50
62
  const ts = event.ts
51
63
  if (event.type === 'orchestrator.sandbox.provisioned') {
@@ -107,5 +119,17 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
107
119
  })
108
120
  }
109
121
  }
110
- return [...byEnvironment.values()]
122
+ acc.length = 0
123
+ acc.push(...byEnvironment.values())
111
124
  }
125
+
126
+ export const sandboxStatesReducer: IncrementalReducer<
127
+ SandboxStatesAcc,
128
+ RepositoryEvent,
129
+ SandboxEnvironmentSnapshot[]
130
+ > = defineReducer({
131
+ version: SANDBOX_STATES_REDUCER_VERSION,
132
+ initial: (): SandboxStatesAcc => [],
133
+ fold: foldSandboxStates,
134
+ finish: (acc) => acc.map((s) => ({ ...s })),
135
+ })
@@ -0,0 +1,33 @@
1
+ import type { RepositoryEvent } from '../events/repository'
2
+ import { defineReducer } from '../kernel/incremental'
3
+
4
+ export const OPEN_TICK_REDUCER_VERSION = 1
5
+
6
+ /** Whether the dispatcher run's latest tick boundary (started, completed or
7
+ * failed) is a `dispatcher.tick-started`. A factory because the run id is a
8
+ * parameter of the projection; the accumulator holds only the last boundary
9
+ * type seen for that run. */
10
+ export function openTickReducer(run: string) {
11
+ return defineReducer<{ boundary?: string }, RepositoryEvent, boolean>({
12
+ version: OPEN_TICK_REDUCER_VERSION,
13
+ initial: () => ({}),
14
+ fold(acc, events) {
15
+ for (const event of events) {
16
+ if (
17
+ 'run' in event.payload &&
18
+ event.payload.run === run &&
19
+ (event.type === 'dispatcher.tick-started' ||
20
+ event.type === 'dispatcher.tick-completed' ||
21
+ event.type === 'dispatcher.tick-failed')
22
+ ) {
23
+ acc.boundary = event.type
24
+ }
25
+ }
26
+ },
27
+ finish: (acc) => acc.boundary === 'dispatcher.tick-started',
28
+ })
29
+ }
30
+
31
+ export function hasOpenTick(events: readonly RepositoryEvent[], run: string): boolean {
32
+ return openTickReducer(run).reduce(events)
33
+ }
@@ -42,7 +42,7 @@ export type {
42
42
  export { validateCreatedEvent } from './new-build'
43
43
  export { createBuildScopedStore } from './build-scope'
44
44
  export { createSessionScopedStore } from './session-handle'
45
- export { DIGEST_EVENT_TYPES, reduceBuildDigest } from './digest'
45
+ export { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from './digest'
46
46
  export {
47
47
  projectRepositoryStateEvents,
48
48
  REPOSITORY_RUN_SCOPED_EVENT_TYPES,
@@ -110,3 +110,12 @@ export type {
110
110
  Unsubscribe,
111
111
  } from './types'
112
112
  export { normalizeOperator } from './types'
113
+ export {
114
+ loadReducerSnapshot,
115
+ persistReducerSnapshot,
116
+ reducerSnapshotWireSchema,
117
+ SNAPSHOT_REDUCER_NAME,
118
+ snapshotScopeKey,
119
+ snapshotSupersedes,
120
+ } from './snapshots'
121
+ export type { ReducerSnapshot, SnapshotScope } from './snapshots'
@@ -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'
@@ -190,6 +191,31 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
190
191
  getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
191
192
  return Promise.reject(new BuildScopeError(scope, 'getRepoStateEvents', repo))
192
193
  },
194
+ getRepoHighWater(repo: string): Promise<number> {
195
+ return Promise.reject(new BuildScopeError(scope, 'getRepoHighWater', repo))
196
+ },
197
+ async getReducerSnapshot(
198
+ target: SnapshotScope,
199
+ reducer: string,
200
+ version: number,
201
+ ): Promise<ReducerSnapshot | null> {
202
+ if (target.kind !== 'build') {
203
+ throw new BuildScopeError(scope, 'getReducerSnapshot', target.repo)
204
+ }
205
+ own('getReducerSnapshot', target.slug)
206
+ return store.getReducerSnapshot(target, reducer, version)
207
+ },
208
+ async putReducerSnapshot(
209
+ target: SnapshotScope,
210
+ reducer: string,
211
+ snapshot: ReducerSnapshot,
212
+ ): Promise<boolean> {
213
+ if (target.kind !== 'build') {
214
+ throw new BuildScopeError(scope, 'putReducerSnapshot', target.repo)
215
+ }
216
+ own('putReducerSnapshot', target.slug)
217
+ return store.putReducerSnapshot(target, reducer, snapshot)
218
+ },
193
219
  getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
194
220
  return Promise.reject(new BuildScopeError(scope, 'getRepoBuildDigests', repo))
195
221
  },
@@ -432,6 +432,81 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
432
432
  })
433
433
  })
434
434
 
435
+ test('executionOpen and reclaimPending follow the openExecution and pendingPrAttachmentReclaims rules', async () => {
436
+ await withStore(factory, undefined, async (store) => {
437
+ const repo = 'acme/dg-work'
438
+ const slug = 'dg-work'
439
+ await store.createBuild(sampleBuildInput(slug, { repo }))
440
+ await store.append(slug, buildCreatedWrite())
441
+ const flags = async (): Promise<{ executionOpen?: true; reclaimPending?: true }> => {
442
+ const digest = (await store.getRepoBuildDigests(repo)).get(slug)!
443
+ return {
444
+ ...(digest.executionOpen ? { executionOpen: true as const } : {}),
445
+ ...(digest.reclaimPending ? { reclaimPending: true as const } : {}),
446
+ }
447
+ }
448
+ const started = (instance: string): EventWrite<'execution.started'> => ({
449
+ actor: DISPATCHER,
450
+ type: 'execution.started',
451
+ payload: { provider: 'p', workspaceRef: 'w', instance },
452
+ })
453
+ const ended = (instance: string): EventWrite<'execution.ended'> => ({
454
+ actor: DISPATCHER,
455
+ type: 'execution.ended',
456
+ payload: { instance, workspaceRef: 'w', outcome: 'completed' },
457
+ })
458
+ expect(await flags()).toEqual({})
459
+
460
+ await store.append(slug, started('a'))
461
+ expect(await flags()).toEqual({ executionOpen: true })
462
+ // A mismatched end leaves the execution open.
463
+ await store.append(slug, ended('other'))
464
+ expect(await flags()).toEqual({ executionOpen: true })
465
+ await store.append(slug, ended('a'))
466
+ expect(await flags()).toEqual({})
467
+ await store.append(slug, started('b'))
468
+ expect(await flags()).toEqual({ executionOpen: true })
469
+ // Completion does not close an execution.
470
+ await store.append(slug, {
471
+ actor: DISPATCHER,
472
+ type: 'build.completed',
473
+ payload: { outcome: 'abandoned' },
474
+ })
475
+ expect(await flags()).toEqual({ executionOpen: true })
476
+ await store.append(slug, ended('b'))
477
+ expect(await flags()).toEqual({})
478
+
479
+ const hosted = await store.append(slug, {
480
+ actor: KERNEL,
481
+ type: 'pr-attachment.hosted',
482
+ payload: {
483
+ designationSeq: 1,
484
+ asset: {
485
+ provider: 'github-release',
486
+ repository: 'acme/assets',
487
+ releaseId: 1,
488
+ assetId: 2,
489
+ url: 'https://example.invalid/a.png',
490
+ },
491
+ },
492
+ })
493
+ expect(await flags()).toEqual({ reclaimPending: true })
494
+ // Unknown and backwards acks do not clear it.
495
+ await store.append(slug, {
496
+ actor: DISPATCHER,
497
+ type: 'pr-attachment.reclaimed',
498
+ payload: { hostedSeq: hosted.seq + 100 },
499
+ })
500
+ expect(await flags()).toEqual({ reclaimPending: true })
501
+ await store.append(slug, {
502
+ actor: DISPATCHER,
503
+ type: 'pr-attachment.reclaimed',
504
+ payload: { hostedSeq: hosted.seq },
505
+ })
506
+ expect(await flags()).toEqual({})
507
+ })
508
+ })
509
+
435
510
  test('the digest equals the full-log ground truth over every lifecycle shape', async () => {
436
511
  await withStore(factory, undefined, async (store) => {
437
512
  const repo = 'acme/dg-life'
@@ -803,6 +878,268 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
803
878
  })
804
879
  })
805
880
 
881
+ describe('getRepoHighWater', () => {
882
+ test('unknown repo rejects like getRepoEvents and creates nothing', async () => {
883
+ await withStore(factory, undefined, async (store) => {
884
+ await expect(store.getRepoHighWater('acme/never-seen')).rejects.toThrow(/unknown repo/)
885
+ expect(await store.getRepo('acme/never-seen')).toBeNull()
886
+ })
887
+ })
888
+
889
+ test('an empty journal answers 0', async () => {
890
+ await withStore(factory, undefined, async (store) => {
891
+ await store.ensureRepo('acme/hw-empty')
892
+ expect(await store.getRepoHighWater('acme/hw-empty')).toBe(0)
893
+ })
894
+ })
895
+
896
+ test('equals the last appended seq regardless of event type', async () => {
897
+ await withStore(factory, undefined, async (store) => {
898
+ await store.ensureRepo('acme/hw')
899
+ await store.appendRepo('acme/hw', {
900
+ actor: humanActor('op'),
901
+ type: 'dispatcher.intake-set',
902
+ payload: { enabled: false },
903
+ })
904
+ expect(await store.getRepoHighWater('acme/hw')).toBe(1)
905
+ // A run-scoped, non-durable type must still move the mark.
906
+ const tick = await store.appendRepo('acme/hw', {
907
+ actor: DISPATCHER,
908
+ type: 'dispatcher.tick-started',
909
+ payload: { run: 'r1' },
910
+ })
911
+ expect(tick.seq).toBe(2)
912
+ expect(await store.getRepoHighWater('acme/hw')).toBe(2)
913
+ expect((await store.getRepoEvents('acme/hw')).at(-1)?.seq).toBe(2)
914
+ })
915
+ })
916
+ })
917
+
918
+ describe('reducer snapshots (a cache beside the log, never an authority)', () => {
919
+ const buildScope = (slug: string) => ({ kind: 'build' as const, slug })
920
+ const repoScope = (repo: string) => ({ kind: 'repo' as const, repo })
921
+
922
+ async function buildWithEvents(store: BuildStore, slug: string, count: number) {
923
+ await store.createBuild(sampleBuildInput(slug))
924
+ for (let index = 0; index < count; index += 1) {
925
+ await store.append(slug, sampleEventWrite(`event ${index}`))
926
+ }
927
+ }
928
+
929
+ /** Deterministic shuffle so a failure replays. */
930
+ function shuffled<T>(items: T[]): T[] {
931
+ const out = [...items]
932
+ let seed = 7
933
+ for (let index = out.length - 1; index > 0; index -= 1) {
934
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff
935
+ const swap = seed % (index + 1)
936
+ ;[out[index], out[swap]] = [out[swap]!, out[index]!]
937
+ }
938
+ return out
939
+ }
940
+
941
+ test('an absent snapshot reads null and an unknown scope reads null with no side effects', async () => {
942
+ await withStore(factory, undefined, async (store) => {
943
+ await buildWithEvents(store, 'snap-absent', 2)
944
+ expect(await store.getReducerSnapshot(buildScope('snap-absent'), 'build', 1)).toBeNull()
945
+ expect(await store.getReducerSnapshot(buildScope('snap-nobuild'), 'build', 1)).toBeNull()
946
+ expect(await store.getReducerSnapshot(repoScope('acme/snap-norepo'), 'j', 1)).toBeNull()
947
+ expect(
948
+ await store.putReducerSnapshot(buildScope('snap-nobuild'), 'build', {
949
+ version: 1,
950
+ cursor: 0,
951
+ state: {},
952
+ }),
953
+ ).toBe(false)
954
+ expect(
955
+ await store.putReducerSnapshot(repoScope('acme/snap-norepo'), 'j', {
956
+ version: 1,
957
+ cursor: 0,
958
+ state: {},
959
+ }),
960
+ ).toBe(false)
961
+ expect(await store.getRepo('acme/snap-norepo')).toBeNull()
962
+ expect(await store.getBuild('snap-nobuild')).toBeNull()
963
+ })
964
+ })
965
+
966
+ test('round-trips JSON state per scope and reducer, isolated from callers', async () => {
967
+ await withStore(factory, undefined, async (store) => {
968
+ await buildWithEvents(store, 'snap-rt', 3)
969
+ await store.ensureRepo('acme/snap-rt')
970
+ await store.appendRepo('acme/snap-rt', {
971
+ actor: humanActor('op'),
972
+ type: 'dispatcher.intake-set',
973
+ payload: { enabled: false },
974
+ })
975
+ const state = { counts: { a: 1 }, list: [1, 'two', null], flag: true }
976
+ expect(
977
+ await store.putReducerSnapshot(buildScope('snap-rt'), 'build', {
978
+ version: 2,
979
+ cursor: 3,
980
+ state,
981
+ }),
982
+ ).toBe(true)
983
+ state.counts.a = 99
984
+ const read = await store.getReducerSnapshot(buildScope('snap-rt'), 'build', 2)
985
+ expect(read).toEqual({
986
+ version: 2,
987
+ cursor: 3,
988
+ state: { counts: { a: 1 }, list: [1, 'two', null], flag: true },
989
+ })
990
+ ;(read!.state as { counts: { a: number } }).counts.a = 5
991
+ expect(
992
+ (await store.getReducerSnapshot(buildScope('snap-rt'), 'build', 2))?.state,
993
+ ).toEqual({ counts: { a: 1 }, list: [1, 'two', null], flag: true })
994
+ // Other reducers and the other scope kind are independent.
995
+ expect(await store.getReducerSnapshot(buildScope('snap-rt'), 'logIndex', 2)).toBeNull()
996
+ expect(await store.getReducerSnapshot(repoScope('acme/snap-rt'), 'build', 2)).toBeNull()
997
+ expect(
998
+ await store.putReducerSnapshot(repoScope('acme/snap-rt'), 'journalView', {
999
+ version: 1,
1000
+ cursor: 1,
1001
+ state: { retained: [] },
1002
+ }),
1003
+ ).toBe(true)
1004
+ expect(
1005
+ (await store.getReducerSnapshot(repoScope('acme/snap-rt'), 'journalView', 1))?.cursor,
1006
+ ).toBe(1)
1007
+ expect((await store.getReducerSnapshot(buildScope('snap-rt'), 'build', 2))?.cursor).toBe(
1008
+ 3,
1009
+ )
1010
+ })
1011
+ })
1012
+
1013
+ test('a stored snapshot of another version is ignored, then replaced by an advancing write', async () => {
1014
+ await withStore(factory, undefined, async (store) => {
1015
+ await buildWithEvents(store, 'snap-ver', 5)
1016
+ const scope = buildScope('snap-ver')
1017
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 3, state: 'v1' })
1018
+ expect(await store.getReducerSnapshot(scope, 'build', 2)).toBeNull()
1019
+ // Same cursor, lower version: no-op.
1020
+ expect(
1021
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 3, state: 'x' }),
1022
+ ).toBe(false)
1023
+ // Higher version at an unchanged cursor replaces the old shape.
1024
+ expect(
1025
+ await store.putReducerSnapshot(scope, 'build', { version: 2, cursor: 3, state: 'v2' }),
1026
+ ).toBe(true)
1027
+ expect(await store.getReducerSnapshot(scope, 'build', 1)).toBeNull()
1028
+ expect((await store.getReducerSnapshot(scope, 'build', 2))?.state).toBe('v2')
1029
+ // A lower version replaces once the cursor advances past the stored one.
1030
+ expect(
1031
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 4, state: 'v1b' }),
1032
+ ).toBe(true)
1033
+ expect(await store.getReducerSnapshot(scope, 'build', 2)).toBeNull()
1034
+ expect((await store.getReducerSnapshot(scope, 'build', 1))?.cursor).toBe(4)
1035
+ // But never at a lower cursor, whatever its version.
1036
+ expect(
1037
+ await store.putReducerSnapshot(scope, 'build', { version: 9, cursor: 2, state: 'old' }),
1038
+ ).toBe(false)
1039
+ expect((await store.getReducerSnapshot(scope, 'build', 1))?.state).toBe('v1b')
1040
+ })
1041
+ })
1042
+
1043
+ test('an equal or lower cursor is a no-op and the stored row is untouched', async () => {
1044
+ await withStore(factory, undefined, async (store) => {
1045
+ await buildWithEvents(store, 'snap-mono', 4)
1046
+ const scope = buildScope('snap-mono')
1047
+ expect(
1048
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 3, state: 'a' }),
1049
+ ).toBe(true)
1050
+ expect(
1051
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 3, state: 'b' }),
1052
+ ).toBe(false)
1053
+ expect(
1054
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 2, state: 'c' }),
1055
+ ).toBe(false)
1056
+ expect(await store.getReducerSnapshot(scope, 'build', 1)).toEqual({
1057
+ version: 1,
1058
+ cursor: 3,
1059
+ state: 'a',
1060
+ })
1061
+ expect(
1062
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 4, state: 'd' }),
1063
+ ).toBe(true)
1064
+ })
1065
+ })
1066
+
1067
+ test('a cursor ahead of the log tail is refused on write and hidden on read', async () => {
1068
+ await withStore(factory, undefined, async (store) => {
1069
+ await buildWithEvents(store, 'snap-ahead', 2)
1070
+ const scope = buildScope('snap-ahead')
1071
+ expect(
1072
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 3, state: 'x' }),
1073
+ ).toBe(false)
1074
+ expect(await store.getReducerSnapshot(scope, 'build', 1)).toBeNull()
1075
+ // Within the tail it is accepted, and stays readable as the log grows.
1076
+ expect(
1077
+ await store.putReducerSnapshot(scope, 'build', { version: 1, cursor: 2, state: 'ok' }),
1078
+ ).toBe(true)
1079
+ await store.append('snap-ahead', sampleEventWrite('more'))
1080
+ expect((await store.getReducerSnapshot(scope, 'build', 1))?.cursor).toBe(2)
1081
+ })
1082
+ })
1083
+
1084
+ test('two interleaved writers never regress the stored cursor', async () => {
1085
+ await withStore(factory, undefined, async (store) => {
1086
+ const tail = 12
1087
+ await buildWithEvents(store, 'snap-race', tail)
1088
+ const scope = buildScope('snap-race')
1089
+ const cursors = shuffled(Array.from({ length: tail }, (_, index) => index + 1))
1090
+ const writerA = cursors.filter((_, index) => index % 2 === 0)
1091
+ const writerB = cursors.filter((_, index) => index % 2 === 1)
1092
+ // Each writer reads back sequentially, so its own series is ordered;
1093
+ // reads from different writers overlap and complete in any order, so
1094
+ // only per-writer monotonicity is meaningful.
1095
+ const write = async (list: number[]): Promise<number[]> => {
1096
+ const seen: number[] = []
1097
+ for (const cursor of list) {
1098
+ await store.putReducerSnapshot(scope, 'build', {
1099
+ version: 1,
1100
+ cursor,
1101
+ state: { cursor },
1102
+ })
1103
+ const read = await store.getReducerSnapshot(scope, 'build', 1)
1104
+ if (read !== null) seen.push(read.cursor)
1105
+ }
1106
+ return seen
1107
+ }
1108
+ const series = await Promise.all([write(writerA), write(writerB)])
1109
+ const final = await store.getReducerSnapshot(scope, 'build', 1)
1110
+ expect(final?.cursor).toBe(tail)
1111
+ expect(final?.state).toEqual({ cursor: tail })
1112
+ for (const seen of series) {
1113
+ for (let index = 1; index < seen.length; index += 1) {
1114
+ expect(seen[index]!).toBeGreaterThanOrEqual(seen[index - 1]!)
1115
+ }
1116
+ }
1117
+ })
1118
+ })
1119
+
1120
+ test('writers on mixed versions still only ever raise the stored cursor', async () => {
1121
+ await withStore(factory, undefined, async (store) => {
1122
+ const tail = 9
1123
+ await buildWithEvents(store, 'snap-mixed', tail)
1124
+ const scope = buildScope('snap-mixed')
1125
+ const writes = shuffled(
1126
+ Array.from({ length: tail * 3 }, (_, index) => ({
1127
+ version: (index % 3) + 1,
1128
+ cursor: Math.floor(index / 3) + 1,
1129
+ })),
1130
+ )
1131
+ await Promise.all(
1132
+ writes.map((write) =>
1133
+ store.putReducerSnapshot(scope, 'build', { ...write, state: write }),
1134
+ ),
1135
+ )
1136
+ // The cursor is the max; at that cursor the highest version won.
1137
+ const final = await store.getReducerSnapshot(scope, 'build', 3)
1138
+ expect(final).toEqual({ version: 3, cursor: tail, state: { version: 3, cursor: tail } })
1139
+ })
1140
+ })
1141
+ })
1142
+
806
1143
  describe('getRepoStateEvents (bounded journal read, AUT-489)', () => {
807
1144
  /** One no-op hosted dispatcher invocation: the ~4 facts an invocation
808
1145
  * appends whether or not it did anything. */
@@ -10,6 +10,7 @@
10
10
  * history.
11
11
  */
12
12
  import type { AbEvent } from '../events/catalog'
13
+ import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
13
14
  import type { BuildDigest } from './types'
14
15
 
15
16
  /** The event types a digest is derived from. Adapters filter their event
@@ -20,30 +21,82 @@ export const DIGEST_EVENT_TYPES = [
20
21
  'build.aborted',
21
22
  'observation.recorded',
22
23
  'pr.merged',
24
+ 'pr-attachment.hosted',
25
+ 'pr-attachment.reclaimed',
26
+ 'execution.started',
27
+ 'execution.ended',
23
28
  ] as const
24
29
 
30
+ /** The digest-relevant slice of one event row. Adapters ship `hostedSeq` for
31
+ * `pr-attachment.reclaimed` rows and `instance` for `execution.started` /
32
+ * `execution.ended` rows instead of whole payloads. */
33
+ export type DigestEventRow = Pick<AbEvent, 'type' | 'seq' | 'ts'> & {
34
+ hostedSeq?: number
35
+ instance?: string
36
+ }
37
+
25
38
  /** The digest-relevant projection of one build's events, in log order.
26
39
  * `terminal` follows `reduceBuild` exactly: `build.completed` sets `done`,
27
40
  * `build.aborted` sets `aborted`, latest wins (in-order overwrite) — and
28
41
  * `merged` follows the same overwrite pattern for `pr.merged` timestamps.
29
42
  * The events carry no slug, so the result is slug-less; every adapter attaches
30
43
  * `slug` itself when grouping its per-build event rows. */
31
- export function reduceBuildDigest(
32
- events: Pick<AbEvent, 'type' | 'seq' | 'ts'>[],
33
- ): Omit<BuildDigest, 'slug'> {
34
- let terminal: BuildDigest['terminal']
35
- let merged: string | undefined
36
- const observations: { seq: number; ts: string }[] = []
37
- for (const event of events) {
38
- if (event.type === 'build.completed') terminal = 'done'
39
- else if (event.type === 'build.aborted') terminal = 'aborted'
40
- else if (event.type === 'pr.merged') merged = event.ts
41
- else if (event.type === 'observation.recorded')
42
- observations.push({ seq: event.seq, ts: event.ts })
43
- }
44
- return {
45
- observations,
46
- ...(merged !== undefined ? { merged } : {}),
47
- ...(terminal !== undefined ? { terminal } : {}),
48
- }
44
+ export type DigestEvent = DigestEventRow
45
+
46
+ /** The carried accumulator for `reduceBuildDigest`. */
47
+ export interface DigestAcc {
48
+ terminal?: BuildDigest['terminal']
49
+ merged?: string
50
+ observations: { seq: number; ts: string }[]
51
+ /** `pendingPrAttachmentReclaims`: seqs of hosted events. */
52
+ hosted: number[]
53
+ /** Hosted seqs named by a reclaimed event with a later seq. */
54
+ reclaimed: number[]
55
+ /** `openExecution`: the latest start's instance, cleared only by an instance-matched end. */
56
+ openInstance: string | null
57
+ }
58
+
59
+ /** Bump when `DigestAcc`'s shape or fold semantics change. */
60
+ export const BUILD_DIGEST_REDUCER_VERSION = 2
61
+
62
+ export const buildDigestReducer: IncrementalReducer<
63
+ DigestAcc,
64
+ DigestEvent,
65
+ Omit<BuildDigest, 'slug'>
66
+ > = defineReducer({
67
+ version: BUILD_DIGEST_REDUCER_VERSION,
68
+ initial: (): DigestAcc => ({ observations: [], hosted: [], reclaimed: [], openInstance: null }),
69
+ fold(acc, events) {
70
+ for (const event of events) {
71
+ if (event.type === 'build.completed') acc.terminal = 'done'
72
+ else if (event.type === 'build.aborted') acc.terminal = 'aborted'
73
+ else if (event.type === 'pr.merged') acc.merged = event.ts
74
+ else if (event.type === 'observation.recorded')
75
+ acc.observations.push({ seq: event.seq, ts: event.ts })
76
+ else if (event.type === 'pr-attachment.hosted') acc.hosted.push(event.seq)
77
+ else if (event.type === 'pr-attachment.reclaimed') {
78
+ if (event.hostedSeq !== undefined && event.seq > event.hostedSeq)
79
+ acc.reclaimed.push(event.hostedSeq)
80
+ } else if (event.type === 'execution.started') acc.openInstance = event.instance ?? null
81
+ else if (event.type === 'execution.ended') {
82
+ if (acc.openInstance !== null && event.instance === acc.openInstance)
83
+ acc.openInstance = null
84
+ }
85
+ }
86
+ },
87
+ finish: ({ terminal, merged, observations, hosted, reclaimed, openInstance }) => {
88
+ const reclaimedSet = new Set(reclaimed)
89
+ const reclaimPending = hosted.some((seq) => !reclaimedSet.has(seq))
90
+ return {
91
+ observations: [...observations],
92
+ ...(merged !== undefined ? { merged } : {}),
93
+ ...(terminal !== undefined ? { terminal } : {}),
94
+ ...(reclaimPending ? { reclaimPending: true as const } : {}),
95
+ ...(openInstance !== null ? { executionOpen: true as const } : {}),
96
+ }
97
+ },
98
+ })
99
+
100
+ export function reduceBuildDigest(events: DigestEvent[]): Omit<BuildDigest, 'slug'> {
101
+ return buildDigestReducer.reduce(events)
49
102
  }
@@ -105,6 +105,23 @@ export const ticketAssets = sqliteTable(
105
105
  (t) => [primaryKey({ columns: [t.repo, t.ticketId, t.kind, t.name, t.revision] })],
106
106
  )
107
107
 
108
+ /** Reducer snapshots (store/snapshots.ts): a cache of one reducer's state as of
109
+ * a log cursor, per scope (`scope_kind` build|repo, `scope_key` the slug or
110
+ * repo). Never an authority — the log regenerates any row. */
111
+ export const reducerSnapshots = sqliteTable(
112
+ 'reducer_snapshots',
113
+ {
114
+ scopeKind: text('scope_kind').notNull(),
115
+ scopeKey: text('scope_key').notNull(),
116
+ reducer: text('reducer').notNull(),
117
+ version: integer('version').notNull(),
118
+ cursor: integer('cursor').notNull(),
119
+ state: text('state').notNull(),
120
+ updatedAt: text('updated_at').notNull(),
121
+ },
122
+ (t) => [primaryKey({ columns: [t.scopeKind, t.scopeKey, t.reducer] })],
123
+ )
124
+
108
125
  /** Operator sessions (SPEC §7.1.1): hosted-only durable orchestrator
109
126
  * conversation state. Adapters implement the contract uniformly; nothing
110
127
  * local creates one. */