@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.
- package/README.md +1 -1
- package/SPEC.md +19 -1
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +96 -70
- package/docs/hosted-service.md +2 -2
- package/docs/operator-api.md +1 -1
- package/docs/remote-store-protocol.md +84 -2
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +11 -15
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/context.ts +16 -78
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +148 -79
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +71 -39
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/main.ts +3 -3
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket.ts +2 -2
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +1 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +137 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +2 -0
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/tickets.ts +2 -2
- package/packages/core/src/plugin-sdk/index.ts +13 -0
- package/packages/core/src/processes/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +113 -173
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +34 -70
- package/packages/core/src/processes/harvest.ts +38 -15
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1134
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +251 -0
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +179 -54
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- package/packages/core/src/store/ticket-assets.ts +4 -4
- package/packages/core/src/store/types.ts +39 -2
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +6 -1
- package/skills/guide/references/operator-api.md +1 -1
- package/skills/guide/references/remote-store-protocol.md +84 -2
- 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(
|