@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.
- package/README.md +1 -1
- package/SPEC.md +138 -16
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +155 -73
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +8 -1
- package/docs/remote-store-protocol.md +94 -2
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +23 -16
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +23 -79
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- 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 +304 -82
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +84 -42
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +23 -5
- package/packages/core/src/cli/mcp.ts +2 -1
- 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/self-update.ts +5 -1
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +166 -36
- 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/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +38 -4
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -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/build-log.ts +1 -1
- 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/incremental-fixtures.ts +13 -2
- 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 +139 -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/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +132 -22
- package/packages/core/src/plugin-sdk/index.ts +19 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- 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 +223 -205
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +58 -74
- package/packages/core/src/processes/harvest.ts +72 -29
- 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/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1099
- 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 +283 -1
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +180 -55
- 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-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +40 -3
- 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 +97 -12
- package/skills/guide/references/operator-api.md +8 -1
- package/skills/guide/references/remote-store-protocol.md +94 -2
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- 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:
|
|
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(
|