@defrex/autobuild 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
@@ -0,0 +1,110 @@
1
+ import type { RepositoryEvent } from '../events/repository'
2
+ import type { TickReport } from './dispatcher'
3
+
4
+ /** Every counter and diagnostic is significant, including standing counts.
5
+ * Queue depth alone may be unchanged and nonzero. */
6
+ export function shouldPublishTick(
7
+ report: TickReport,
8
+ events: readonly RepositoryEvent[],
9
+ run: string,
10
+ startedAt: string,
11
+ ): boolean {
12
+ if (
13
+ Object.entries(report).some(
14
+ ([key, value]) =>
15
+ key !== 'queued' && (typeof value === 'number' ? value !== 0 : value.length > 0),
16
+ )
17
+ )
18
+ return true
19
+ // Queue and diagnostic readers reduce per run, so each run must publish
20
+ // its own baseline before quiet ticks can be suppressed.
21
+ const previous = events.findLast(
22
+ (e) => e.type === 'dispatcher.tick-completed' && e.payload.run === run,
23
+ )
24
+ if (previous?.type !== 'dispatcher.tick-completed' || previous.payload.queued !== report.queued)
25
+ return true
26
+ if (
27
+ [
28
+ previous.payload.blockedDiagnostics ?? [],
29
+ previous.payload.janitorDiagnostics,
30
+ previous.payload.ticketDiagnostics,
31
+ previous.payload.creationDiagnostics ?? [],
32
+ previous.payload.dependencyDiagnostics,
33
+ ].some((list) => list.length > 0)
34
+ )
35
+ return true
36
+ const boundary = events.findLast(
37
+ (e) =>
38
+ (e.type === 'dispatcher.tick-completed' || e.type === 'dispatcher.tick-failed') &&
39
+ e.payload.run === run,
40
+ )
41
+ if (boundary?.type === 'dispatcher.tick-failed') return true
42
+
43
+ // Preserve the first completed retry after each outstanding source cooldown.
44
+ const sources = new Map<string, { deadline: number; requiresRetry: boolean; retried: boolean }>()
45
+ const starts = new Map<string, number>()
46
+ for (const event of events) {
47
+ if (event.type === 'dispatcher.ticket-source-degraded') {
48
+ const existing = sources.get(event.payload.source)
49
+ const requiresRetry = 'resetAt' in event.payload
50
+ const deadline =
51
+ 'resetAt' in event.payload
52
+ ? Date.parse(event.payload.resetAt)
53
+ : Date.parse(event.ts) + 15 * 60_000
54
+ if (existing) {
55
+ existing.deadline = Math.min(existing.deadline, deadline)
56
+ existing.requiresRetry ||= requiresRetry
57
+ } else sources.set(event.payload.source, { deadline, requiresRetry, retried: false })
58
+ } else if (event.type === 'dispatcher.ticket-source-recovered')
59
+ sources.delete(event.payload.source)
60
+ else if (event.type === 'dispatcher.run-started') starts.clear()
61
+ else if (event.type === 'dispatcher.run-stopped') starts.delete(event.payload.run)
62
+ else if (event.type === 'dispatcher.tick-started')
63
+ starts.set(event.payload.run, Date.parse(event.payload.startedAt ?? event.ts))
64
+ else if (
65
+ event.type === 'dispatcher.tick-completed' ||
66
+ event.type === 'dispatcher.tick-failed'
67
+ ) {
68
+ const start = starts.get(event.payload.run)
69
+ starts.delete(event.payload.run)
70
+ for (const source of sources.values())
71
+ if (start !== undefined && start >= source.deadline) source.retried = true
72
+ }
73
+ }
74
+ return [...sources.values()].some(
75
+ (source) => source.requiresRetry && !source.retried && Date.parse(startedAt) >= source.deadline,
76
+ )
77
+ }
78
+
79
+ export function shouldPublishTickFromFacts(
80
+ report: TickReport,
81
+ facts: import('../store/reduced-reads').RepositoryReadFacts,
82
+ run: import('../kernel/read-facts').DispatcherRunFacts,
83
+ startedAt: string,
84
+ ): boolean {
85
+ if (
86
+ Object.entries(report).some(
87
+ ([key, value]) =>
88
+ key !== 'queued' && (typeof value === 'number' ? value !== 0 : value.length > 0),
89
+ )
90
+ )
91
+ return true
92
+ const previous = run.completed
93
+ if (previous?.type !== 'dispatcher.tick-completed' || previous.payload.queued !== report.queued)
94
+ return true
95
+ if (
96
+ [
97
+ previous.payload.blockedDiagnostics ?? [],
98
+ previous.payload.janitorDiagnostics,
99
+ previous.payload.ticketDiagnostics,
100
+ previous.payload.creationDiagnostics ?? [],
101
+ previous.payload.dependencyDiagnostics,
102
+ ].some((list) => list.length > 0)
103
+ )
104
+ return true
105
+ if (run.tick?.type === 'dispatcher.tick-failed') return true
106
+ return facts.attention.sources.some(
107
+ ([, source]) =>
108
+ source.requiresRetry && !source.retryObserved && Date.parse(startedAt) >= source.deadline,
109
+ )
110
+ }
@@ -1,3 +1,9 @@
1
+ export {
2
+ validateTickFinish,
3
+ repoTickCompletionSchema,
4
+ repoTickObservationSchema,
5
+ } from './repo-tick'
6
+ export type { RepoTickCompletion, RepoTickObservation, RepoTickFinish } from './types'
1
7
  export { validateEventWrite } from '../events/catalog'
2
8
  export { humanActor } from '../events/envelope'
3
9
  export type { AbEvent, EventEnvelope, EventWrite } from '../events/catalog'
@@ -120,3 +126,20 @@ export {
120
126
  snapshotSupersedes,
121
127
  } from './snapshots'
122
128
  export type { ReducerSnapshot, SnapshotScope } from './snapshots'
129
+
130
+ export {
131
+ readReducerValue,
132
+ resolveReducerValue,
133
+ reducerValueRequestSchema,
134
+ reducerValueSchema,
135
+ } from './reducer-values'
136
+ export type {
137
+ ReducerValue,
138
+ ReducerValueRequest,
139
+ ReducerValueStorage,
140
+ ValueCacheRow,
141
+ } from './reducer-values'
142
+
143
+ export { assertPublicSnapshotReducer } from './snapshots'
144
+
145
+ export { readPersistedBuildDigests } from './reduced-reads'
@@ -1,3 +1,4 @@
1
+ import type { ReducerValueRequest, ReducerValue } from './reducer-values'
1
2
  import type { ReducerSnapshot, SnapshotScope } from './snapshots'
2
3
  import type { EventType } from '../events/payloads'
3
4
  import type { EventWrite } from '../events/catalog'
@@ -194,6 +195,16 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
194
195
  getRepoHighWater(repo: string): Promise<number> {
195
196
  return Promise.reject(new BuildScopeError(scope, 'getRepoHighWater', repo))
196
197
  },
198
+ async getReducerValue(
199
+ target: SnapshotScope,
200
+ request: ReducerValueRequest,
201
+ ): Promise<ReducerValue> {
202
+ if (target.kind !== 'build') {
203
+ throw new BuildScopeError(scope, 'getReducerValue', target.repo)
204
+ }
205
+ own('getReducerValue', target.slug)
206
+ return store.getReducerValue(target, request)
207
+ },
197
208
  async getReducerSnapshot(
198
209
  target: SnapshotScope,
199
210
  reducer: string,
@@ -278,6 +289,12 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
278
289
  claimRepoLease(repo: string, _holder: string, _ttlMs: number): Promise<boolean> {
279
290
  return Promise.reject(new BuildScopeError(scope, 'claimRepoLease', repo))
280
291
  },
292
+ beginRepoTick(repo: string) {
293
+ return Promise.reject(new BuildScopeError(scope, 'beginRepoTick', repo))
294
+ },
295
+ finishRepoTick(repo: string) {
296
+ return Promise.reject(new BuildScopeError(scope, 'finishRepoTick', repo))
297
+ },
281
298
  heartbeatRepo(repo: string, _holder: string): Promise<boolean> {
282
299
  return Promise.reject(new BuildScopeError(scope, 'heartbeatRepo', repo))
283
300
  },
@@ -1,3 +1,5 @@
1
+ import { REDUCERS } from '../kernel/reducer-registry'
2
+ import { resolveReducerValue } from './reducer-values'
1
3
  import { prepareAssetSnapshots } from './ticket-asset-snapshots'
2
4
  import { MemoryBuildStore } from './memory'
3
5
  /**
@@ -704,6 +706,135 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
704
706
  })
705
707
 
706
708
  describe('repository journal (workflow and control paper trail)', () => {
709
+ test('tick observations are fenced, bounded, atomic and detached snapshots', async () => {
710
+ const clock = manualClock(CONTRACT_T0)
711
+ await withStore(factory, { clock }, async (store) => {
712
+ const repo = 'tick-contract'
713
+ await store.ensureRepo(repo)
714
+ expect(await store.beginRepoTick(repo, 'owner')).toBeNull()
715
+ await store.claimRepoLease(repo, 'owner', 60_000)
716
+ const startedAt = await store.beginRepoTick(repo, 'owner')
717
+ expect(startedAt).toBe(CONTRACT_T0)
718
+ const failure = { outcome: 'failed' as const, error: 'failure' }
719
+ const events: RepositoryEventWrite[] = [
720
+ {
721
+ actor: DISPATCHER,
722
+ type: 'dispatcher.tick-started',
723
+ payload: { run: 'owner', startedAt: startedAt! },
724
+ },
725
+ {
726
+ actor: DISPATCHER,
727
+ type: 'dispatcher.tick-failed',
728
+ payload: { run: 'owner', error: 'failure' },
729
+ },
730
+ ]
731
+ const before = await store.getRepo(repo)
732
+ expect(
733
+ await store.finishRepoTick(repo, 'foreign', failure, [
734
+ { ...events[0]!, payload: { run: 'foreign' } },
735
+ { ...events[1]!, payload: { run: 'foreign', error: 'failure' } },
736
+ ]),
737
+ ).toBeNull()
738
+ expect(await store.getRepo(repo)).toEqual(before)
739
+ await expect(
740
+ store.finishRepoTick(repo, 'owner', failure, [
741
+ events[0]!,
742
+ { ...events[1]!, payload: {} },
743
+ ]),
744
+ ).rejects.toThrow()
745
+ expect(await store.getRepoEvents(repo)).toEqual([])
746
+ expect(await store.getRepo(repo)).toEqual(before)
747
+ clock.advance(1000)
748
+ const finished = await store.finishRepoTick(repo, 'owner', failure, events)
749
+ expect(finished!.events.map((e) => e.seq)).toEqual([1, 2])
750
+ expect(finished!.observation.finished).toMatchObject({
751
+ startedAt,
752
+ completedAt: clock().toISOString(),
753
+ ...failure,
754
+ })
755
+ finished!.observation.finished!.startedAt = 'mutated'
756
+ expect((await store.getRepo(repo))!.tick!.finished!.startedAt).toBe(startedAt!)
757
+ await store.beginRepoTick(repo, 'owner')
758
+ expect((await store.getRepo(repo))!.tick!.finished!.outcome).toBe('failed')
759
+ clock.advance(60_000)
760
+ const expired = await store.getRepo(repo)
761
+ expect(await store.beginRepoTick(repo, 'owner')).toBeNull()
762
+ expect(await store.finishRepoTick(repo, 'owner', failure, events)).toBeNull()
763
+ expect(await store.getRepo(repo)).toEqual(expired)
764
+ await store.releaseRepoLease(repo, 'owner')
765
+ expect((await store.getRepo(repo))!.tick!.finished!.startedAt).toBe(startedAt!)
766
+ })
767
+ })
768
+
769
+ test('restricted handles deny tick mutations and arbitrary finish batches append nothing', async () => {
770
+ await withStore(factory, undefined, async (store) => {
771
+ const repo = 'tick-authority'
772
+ await store.ensureRepo(repo)
773
+ await store.createBuild(sampleBuildInput('tick-scope', { repo }))
774
+ const scoped = store.scopeBuild('tick-scope')
775
+ await expect(scoped.beginRepoTick(repo, 'owner')).rejects.toThrow()
776
+ const failure = { outcome: 'failed' as const, error: 'failed' }
777
+ await expect(scoped.finishRepoTick(repo, 'owner', failure, [])).rejects.toThrow()
778
+ const session = await store.createSession({ repo, operator: 'operator' })
779
+ await expect(
780
+ store.scopeSession(session.id).beginRepoTick(repo, 'owner'),
781
+ ).rejects.toThrow()
782
+ await expect(
783
+ store.scopeSession(session.id).finishRepoTick(repo, 'owner', failure, []),
784
+ ).rejects.toThrow()
785
+ await store.claimRepoLease(repo, 'owner', 60_000)
786
+ await store.beginRepoTick(repo, 'owner')
787
+ const before = await store.getRepo(repo)
788
+ await expect(
789
+ store.finishRepoTick(repo, 'owner', failure, [
790
+ { actor: DISPATCHER, type: 'dispatcher.tick-started', payload: { run: 'owner' } },
791
+ {
792
+ actor: humanActor('operator'),
793
+ type: 'dispatcher.intake-set',
794
+ payload: { enabled: false },
795
+ },
796
+ ]),
797
+ ).rejects.toThrow()
798
+ expect(await store.getRepoEvents(repo)).toEqual([])
799
+ expect(await store.getRepo(repo)).toEqual(before)
800
+ })
801
+ })
802
+
803
+ test('quiet finishes update completion without advancing the journal or heartbeat', async () => {
804
+ const clock = manualClock(CONTRACT_T0)
805
+ await withStore(factory, { clock }, async (store) => {
806
+ const repo = 'quiet-tick-contract'
807
+ await store.ensureRepo(repo)
808
+ await store.claimRepoLease(repo, 'owner', 60_000)
809
+ const { emptyTickReport } = await import('../processes/dispatcher')
810
+ const {
811
+ queued,
812
+ janitorDiagnostics,
813
+ blockedDiagnostics,
814
+ ticketDiagnostics,
815
+ creationDiagnostics,
816
+ dependencyDiagnostics,
817
+ ...counters
818
+ } = emptyTickReport()
819
+ for (let i = 0; i < 20; i++) {
820
+ await store.beginRepoTick(repo, 'owner')
821
+ clock.advance(1000)
822
+ const result = await store.finishRepoTick(
823
+ repo,
824
+ 'owner',
825
+ { outcome: 'completed', counters },
826
+ [],
827
+ )
828
+ expect(result!.events).toEqual([])
829
+ expect((await store.getRepo(repo))!.tick!.finished!.completedAt).toBe(
830
+ clock().toISOString(),
831
+ )
832
+ }
833
+ expect(await store.getRepoHighWater(repo)).toBe(0)
834
+ expect(await store.getRepoEvents(repo)).toEqual([])
835
+ expect((await store.getRepo(repo))!.heartbeatAt).toBeUndefined()
836
+ })
837
+ })
707
838
  test('ensureRepo is idempotent and repo event seq is independent', async () => {
708
839
  const clock = manualClock(CONTRACT_T0)
709
840
  await withStore(factory, { clock }, async (store) => {
@@ -922,6 +1053,126 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
922
1053
  })
923
1054
  })
924
1055
 
1056
+ describe('named reducer value reads', () => {
1057
+ test('all admitted reducers match replay on both scopes and isolate parameter variants', async () => {
1058
+ await withStore(factory, undefined, async (store) => {
1059
+ const slug = 'values'
1060
+ const repo = 'acme/values'
1061
+ await store.createBuild(sampleBuildInput(slug))
1062
+ await store.ensureRepo(repo)
1063
+ await store.append(slug, sampleEventWrite('hello'))
1064
+ await store.appendRepo(repo, {
1065
+ actor: humanActor('op'),
1066
+ type: 'dispatcher.intake-set',
1067
+ payload: { enabled: false },
1068
+ })
1069
+ const buildEvents = await store.getEvents(slug)
1070
+ const repoEvents = await store.getRepoEvents(repo)
1071
+ for (const [name, entry] of Object.entries(REDUCERS)) {
1072
+ const read = entry.valueRead
1073
+ const scope =
1074
+ read && !('excluded' in read) && read.scope === 'repo'
1075
+ ? { kind: 'repo' as const, repo }
1076
+ : { kind: 'build' as const, slug }
1077
+ if ('excluded' in read) {
1078
+ await expect(store.getReducerValue(scope, { reducer: name })).rejects.toThrow(
1079
+ 'excluded',
1080
+ )
1081
+ continue
1082
+ }
1083
+ const variants =
1084
+ name === 'terminalGuard'
1085
+ ? [
1086
+ { phase: 'plan', round: 1, session: 'session-a' },
1087
+ { phase: 'implement', round: 2, session: 'session-b' },
1088
+ ]
1089
+ : name === 'openTick' || name === 'dispatchStatus' || name === 'dispatcherRunFacts'
1090
+ ? [{ run: 'run-a' }, { run: 'run-b' }]
1091
+ : name === 'phaseFailures'
1092
+ ? [
1093
+ { phase: 'plan', round: 1 },
1094
+ { phase: 'implement', round: 2 },
1095
+ ]
1096
+ : name === 'dispatchFailureBudget'
1097
+ ? [
1098
+ { infrastructureOwnsWorkspace: false },
1099
+ { infrastructureOwnsWorkspace: true },
1100
+ ]
1101
+ : [{}]
1102
+ for (const args of variants) {
1103
+ const request = { reducer: name, args }
1104
+ const resolved = resolveReducerValue(scope, request)
1105
+ const events = scope.kind === 'repo' ? repoEvents : buildEvents
1106
+ const expected = JSON.parse(JSON.stringify(resolved.reducer.reduce(events)))
1107
+ const answer = await store.getReducerValue(scope, request)
1108
+ expect(answer).toEqual({
1109
+ version: resolved.reducer.version,
1110
+ cursor: events.at(-1)?.seq ?? 0,
1111
+ value: expected,
1112
+ })
1113
+ if (answer.value && typeof answer.value === 'object')
1114
+ Object.assign(answer.value, { poison: true })
1115
+ expect((await store.getReducerValue(scope, request)).value).toEqual(expected)
1116
+ }
1117
+ }
1118
+ })
1119
+ })
1120
+ test('public snapshots cannot supply value checkpoints and unknown scopes create nothing', async () => {
1121
+ await withStore(factory, undefined, async (store) => {
1122
+ const scope = { kind: 'build' as const, slug: 'value-repair' }
1123
+ await store.createBuild(sampleBuildInput(scope.slug))
1124
+ await store.append(scope.slug, sampleEventWrite('first'))
1125
+ const request = { reducer: 'build' }
1126
+ const resolved = resolveReducerValue(scope, request)
1127
+ await expect(
1128
+ store.putReducerSnapshot(scope, resolved.key, {
1129
+ version: resolved.reducer.version,
1130
+ cursor: 1,
1131
+ state: { identity: resolved.identity, acc: resolved.reducer.initial() },
1132
+ }),
1133
+ ).rejects.toThrow('reserved')
1134
+ await expect(
1135
+ store.getReducerSnapshot(scope, resolved.key, resolved.reducer.version),
1136
+ ).rejects.toThrow('reserved')
1137
+ // Block the whole namespace, including the former writable format.
1138
+ for (const target of [scope, { kind: 'repo' as const, repo: 'value-repair' }]) {
1139
+ await store.ensureRepo('value-repair')
1140
+ await expect(
1141
+ store.putReducerSnapshot(target, 'value.legacy', {
1142
+ version: 1,
1143
+ cursor: 0,
1144
+ state: {},
1145
+ }),
1146
+ ).rejects.toThrow('reserved')
1147
+ }
1148
+ const answer = await store.getReducerValue(scope, request)
1149
+ expect(answer.value).toEqual(
1150
+ JSON.parse(JSON.stringify(reduceBuild(await store.getEvents(scope.slug)))),
1151
+ )
1152
+ await expect(
1153
+ store.getReducerValue({ kind: 'build', slug: 'unknown' }, request),
1154
+ ).rejects.toThrow()
1155
+ await expect(
1156
+ store.getReducerValue(
1157
+ { kind: 'repo', repo: 'unknown' },
1158
+ { reducer: 'dispatchSettings' },
1159
+ ),
1160
+ ).rejects.toThrow()
1161
+ expect(await store.getRepo('unknown')).toBeNull()
1162
+ await expect(
1163
+ store
1164
+ .scopeBuild(scope.slug)
1165
+ .getReducerValue({ kind: 'repo', repo: 'unknown' }, { reducer: 'dispatchSettings' }),
1166
+ ).rejects.toThrow()
1167
+ await expect(
1168
+ store
1169
+ .scopeBuild(scope.slug)
1170
+ .getReducerValue({ kind: 'build', slug: 'unknown' }, request),
1171
+ ).rejects.toThrow()
1172
+ })
1173
+ })
1174
+ })
1175
+
925
1176
  describe('reducer snapshots (a cache beside the log, never an authority)', () => {
926
1177
  const buildScope = (slug: string) => ({ kind: 'build' as const, slug })
927
1178
  const repoScope = (repo: string) => ({ kind: 'repo' as const, repo })
@@ -56,6 +56,7 @@ export const repoStreams = sqliteTable('repo_streams', {
56
56
  leaseExpiresAt: text('lease_expires_at'),
57
57
  leaseTtlMs: integer('lease_ttl_ms'),
58
58
  heartbeatAt: text('heartbeat_at'),
59
+ tick: text('tick', { mode: 'json' }).$type<import('../types').RepoTickObservation>(),
59
60
  })
60
61
 
61
62
  export const repoEvents = sqliteTable(