@defrex/autobuild 0.12.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. 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,7 @@
1
+ import { REDUCERS } from '../kernel/reducer-registry'
2
+ import { resolveReducerValue } from './reducer-values'
3
+ import { prepareAssetSnapshots } from './ticket-asset-snapshots'
4
+ import { MemoryBuildStore } from './memory'
1
5
  /**
2
6
  * Reusable contract suites for the BuildStore seam (SPEC §7). The in-memory
3
7
  * adapter's behavior *is* the contract; every adapter — SQLite (§7.2.1),
@@ -702,6 +706,135 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
702
706
  })
703
707
 
704
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
+ })
705
838
  test('ensureRepo is idempotent and repo event seq is independent', async () => {
706
839
  const clock = manualClock(CONTRACT_T0)
707
840
  await withStore(factory, { clock }, async (store) => {
@@ -920,6 +1053,126 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
920
1053
  })
921
1054
  })
922
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
+
923
1176
  describe('reducer snapshots (a cache beside the log, never an authority)', () => {
924
1177
  const buildScope = (slug: string) => ({ kind: 'build' as const, slug })
925
1178
  const repoScope = (repo: string) => ({ kind: 'repo' as const, repo })
@@ -2514,7 +2767,7 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2514
2767
  async function pinnedBuild(
2515
2768
  store: BuildStore,
2516
2769
  slug: string,
2517
- assets: ReturnType<typeof pinOf>[],
2770
+ assets: import('../events/payloads').PinnedAsset[],
2518
2771
  ): Promise<void> {
2519
2772
  const created = buildCreatedWrite()
2520
2773
  await store.createBuild({
@@ -2523,6 +2776,35 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2523
2776
  })
2524
2777
  }
2525
2778
 
2779
+ test('snapshot pins read independent bytes, historical revisions and scoped access', async () => {
2780
+ await withStore(factory, undefined, async (store) => {
2781
+ const source = new MemoryBuildStore()
2782
+ await source.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'remote one'))
2783
+ await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'local conflict'))
2784
+ const pins = await prepareAssetSnapshots(source, store, REPO, 'TICK-1')
2785
+ await pinnedBuild(store, 'snapshot-pin', pins)
2786
+ await source.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'remote two'))
2787
+ const newer = await prepareAssetSnapshots(source, store, REPO, 'TICK-1')
2788
+ await store.append('snapshot-pin', {
2789
+ actor: KERNEL,
2790
+ type: 'spec.revised',
2791
+ payload: { artifact: { kind: 'spec', rev: 1 }, escalation: 1, assets: newer },
2792
+ })
2793
+ await source.removeTicketAsset(REPO, 'TICK-1', 'design', 'a.txt')
2794
+ expect(text(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt'))).toBe(
2795
+ 'remote two',
2796
+ )
2797
+ expect(text(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt', 0))).toBe(
2798
+ 'remote one',
2799
+ )
2800
+ expect(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt', 2)).toBeNull()
2801
+ const scoped = store.scopeBuild('snapshot-pin')
2802
+ expect(text(await scoped.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt'))).toBe(
2803
+ 'remote two',
2804
+ )
2805
+ })
2806
+ })
2807
+
2526
2808
  test('reads the pinned revision after the ticket replaces and then removes the asset', async () => {
2527
2809
  await withStore(factory, undefined, async (store) => {
2528
2810
  const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
@@ -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(