@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,96 @@
|
|
|
1
|
+
import type { JsonValue } from './reducer-values'
|
|
2
|
+
import shapes from './reducer-value-shapes.json'
|
|
3
|
+
|
|
4
|
+
type Shape = {
|
|
5
|
+
kind: string
|
|
6
|
+
value?: unknown
|
|
7
|
+
items?: string | string[]
|
|
8
|
+
props?: Record<string, { type: string; optional: boolean }>
|
|
9
|
+
index?: string
|
|
10
|
+
}
|
|
11
|
+
const schemas: Record<string, Shape> = shapes.schemas
|
|
12
|
+
|
|
13
|
+
/** Generated structural shapes include nested event payload unions, records,
|
|
14
|
+
* optional fields and literal discriminants. No accumulator is trusted merely
|
|
15
|
+
* because finish() happens to accept it. */
|
|
16
|
+
export function validateAccumulator(name: string, value: unknown): boolean {
|
|
17
|
+
const root = (shapes.roots as Record<string, { acc: string }>)[name]
|
|
18
|
+
return root !== undefined && matches(root.acc, value)
|
|
19
|
+
}
|
|
20
|
+
export function validateReducerValue(name: string, value: unknown): boolean {
|
|
21
|
+
const root = (shapes.roots as Record<string, { value: string }>)[name]
|
|
22
|
+
return root !== undefined && matches(root.value, value)
|
|
23
|
+
}
|
|
24
|
+
function json(value: unknown): boolean {
|
|
25
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
|
26
|
+
if (typeof value === 'number') return Number.isFinite(value)
|
|
27
|
+
if (Array.isArray(value)) return value.every(json)
|
|
28
|
+
return (
|
|
29
|
+
typeof value === 'object' &&
|
|
30
|
+
Object.getPrototypeOf(value) === Object.prototype &&
|
|
31
|
+
Object.values(value).every(json)
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
function matches(id: string, value: unknown): boolean {
|
|
35
|
+
const shape = schemas[id]!
|
|
36
|
+
switch (shape.kind) {
|
|
37
|
+
case 'json':
|
|
38
|
+
return json(value)
|
|
39
|
+
case 'undefined':
|
|
40
|
+
return value === undefined
|
|
41
|
+
case 'literal':
|
|
42
|
+
return value === shape.value
|
|
43
|
+
case 'number':
|
|
44
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
45
|
+
case 'string':
|
|
46
|
+
return typeof value === 'string'
|
|
47
|
+
case 'boolean':
|
|
48
|
+
return typeof value === 'boolean'
|
|
49
|
+
case 'union':
|
|
50
|
+
return (shape.items as string[]).some((item) => matches(item, value))
|
|
51
|
+
case 'array':
|
|
52
|
+
return Array.isArray(value) && value.every((item) => matches(shape.items as string, item))
|
|
53
|
+
case 'object': {
|
|
54
|
+
if (
|
|
55
|
+
value === null ||
|
|
56
|
+
typeof value !== 'object' ||
|
|
57
|
+
Array.isArray(value) ||
|
|
58
|
+
Object.getPrototypeOf(value) !== Object.prototype
|
|
59
|
+
)
|
|
60
|
+
return false
|
|
61
|
+
const record = value as Record<string, unknown>
|
|
62
|
+
for (const [key, prop] of Object.entries(shape.props!)) {
|
|
63
|
+
if (!(key in record) && prop.optional) continue
|
|
64
|
+
if (!matches(prop.type, record[key])) return false
|
|
65
|
+
}
|
|
66
|
+
return Object.keys(record).every(
|
|
67
|
+
(key) =>
|
|
68
|
+
Object.hasOwn(shape.props!, key) ||
|
|
69
|
+
(shape.index !== undefined && matches(shape.index, record[key])),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Existing optional object fields keep their ordinary JSON omission semantics.
|
|
78
|
+
* Collections, functions, nonfinite numbers and undefined array entries cannot
|
|
79
|
+
* silently become a different public contract during JSON serialization. */
|
|
80
|
+
export function encodeReducerValue(value: unknown): JsonValue {
|
|
81
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
82
|
+
return value as JsonValue
|
|
83
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
84
|
+
if (Array.isArray(value)) return value.map(encodeReducerValue)
|
|
85
|
+
if (
|
|
86
|
+
value !== null &&
|
|
87
|
+
typeof value === 'object' &&
|
|
88
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
89
|
+
)
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.entries(value)
|
|
92
|
+
.filter(([, item]) => item !== undefined)
|
|
93
|
+
.map(([key, item]) => [key, encodeReducerValue(item)]),
|
|
94
|
+
)
|
|
95
|
+
throw new Error('reducer finished value is not plain JSON')
|
|
96
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Named JSON answers over persisted incremental state. The event log remains authoritative. */
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { AbEvent } from '../events/catalog'
|
|
4
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
5
|
+
import { REDUCERS } from '../kernel/reducer-registry'
|
|
6
|
+
import type { IncrementalReducer } from '../kernel/incremental'
|
|
7
|
+
import {
|
|
8
|
+
REDUCER_VALUE_SNAPSHOT_PREFIX,
|
|
9
|
+
type ReducerSnapshot,
|
|
10
|
+
type SnapshotScope,
|
|
11
|
+
} from './snapshots'
|
|
12
|
+
|
|
13
|
+
export const reducerValueRequestSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
reducer: z.string().min(1).max(64),
|
|
16
|
+
args: z.record(z.string(), z.json()).optional(),
|
|
17
|
+
})
|
|
18
|
+
.strict()
|
|
19
|
+
export type ReducerValueRequest = z.infer<typeof reducerValueRequestSchema>
|
|
20
|
+
export type JsonValue = z.infer<ReturnType<typeof z.json>>
|
|
21
|
+
export interface ReducerValue {
|
|
22
|
+
version: number
|
|
23
|
+
cursor: number
|
|
24
|
+
value: JsonValue
|
|
25
|
+
}
|
|
26
|
+
export const reducerValueSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
version: z.number().int().positive(),
|
|
29
|
+
cursor: z.number().int().nonnegative(),
|
|
30
|
+
value: z.json(),
|
|
31
|
+
})
|
|
32
|
+
.strict()
|
|
33
|
+
|
|
34
|
+
export interface ValueCatalogEntry {
|
|
35
|
+
scope: SnapshotScope['kind']
|
|
36
|
+
args: z.ZodType
|
|
37
|
+
validate(state: unknown): boolean
|
|
38
|
+
adapt(events: readonly (AbEvent | RepositoryEvent)[]): readonly unknown[]
|
|
39
|
+
value(state: unknown): JsonValue
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: heterogeneous executable catalog
|
|
41
|
+
create(args: any): IncrementalReducer<any, any, any>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canonical(value: unknown): string {
|
|
45
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
46
|
+
if (value !== null && typeof value === 'object')
|
|
47
|
+
return `{${Object.entries(value)
|
|
48
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
49
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
|
|
50
|
+
.join(',')}}`
|
|
51
|
+
return JSON.stringify(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveReducerValue(scope: SnapshotScope, input: ReducerValueRequest) {
|
|
55
|
+
const request = reducerValueRequestSchema.parse(input)
|
|
56
|
+
const registration = Object.hasOwn(REDUCERS, request.reducer)
|
|
57
|
+
? REDUCERS[request.reducer]
|
|
58
|
+
: undefined
|
|
59
|
+
if (!registration) throw new Error(`unknown reducer "${request.reducer}"`)
|
|
60
|
+
const entry = registration.valueRead
|
|
61
|
+
if ('excluded' in entry)
|
|
62
|
+
throw new Error(`excluded reducer "${request.reducer}": ${entry.excluded}`)
|
|
63
|
+
if (entry.scope !== scope.kind) throw new Error(`wrong scope for reducer "${request.reducer}"`)
|
|
64
|
+
const args = entry.args.parse(request.args ?? {})
|
|
65
|
+
const reducer = entry.create(args)
|
|
66
|
+
const identity = canonical({ scope, reducer: request.reducer, version: reducer.version, args })
|
|
67
|
+
// v2 ignores checkpoints from the former client-writable key format.
|
|
68
|
+
const key = `${REDUCER_VALUE_SNAPSHOT_PREFIX}v2.${new Bun.CryptoHasher('sha256').update(identity).digest('hex').slice(0, 54)}`
|
|
69
|
+
return { name: request.reducer, reducer, identity, key, entry }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Raw cache reads and conditional repairs are private adapter operations: clients
|
|
73
|
+
* cannot submit a new accumulator through the value API. `token` identifies the
|
|
74
|
+
* exact row read, including malformed bytes that could not be decoded. */
|
|
75
|
+
export interface ValueCacheRow {
|
|
76
|
+
snapshot: ReducerSnapshot
|
|
77
|
+
token: string
|
|
78
|
+
}
|
|
79
|
+
export interface ReducerValueStorage {
|
|
80
|
+
highWater(): Promise<number>
|
|
81
|
+
read(key: string): Promise<ValueCacheRow | null>
|
|
82
|
+
events(since: number, through: number): Promise<(AbEvent | RepositoryEvent)[]>
|
|
83
|
+
put(key: string, snapshot: ReducerSnapshot): Promise<boolean>
|
|
84
|
+
repair(key: string, observed: ValueCacheRow, snapshot: ReducerSnapshot): Promise<boolean>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readReducerValue(
|
|
88
|
+
scope: SnapshotScope,
|
|
89
|
+
request: ReducerValueRequest,
|
|
90
|
+
storage: ReducerValueStorage,
|
|
91
|
+
): Promise<ReducerValue> {
|
|
92
|
+
const { reducer, identity, key, entry } = resolveReducerValue(scope, request)
|
|
93
|
+
let boundary = await storage.highWater()
|
|
94
|
+
let row: ValueCacheRow | null = null
|
|
95
|
+
try {
|
|
96
|
+
row = await storage.read(key)
|
|
97
|
+
} catch {
|
|
98
|
+
/* unreadable cache is a miss */
|
|
99
|
+
}
|
|
100
|
+
// A concurrent reader may have persisted beyond our first boundary. Only a
|
|
101
|
+
// fresh authoritative boundary can establish ahead-of-log poison.
|
|
102
|
+
if (row && row.snapshot.cursor > boundary) boundary = await storage.highWater()
|
|
103
|
+
let acc = reducer.initial()
|
|
104
|
+
let cursor = 0
|
|
105
|
+
let valid = false
|
|
106
|
+
if (row) {
|
|
107
|
+
const { snapshot } = row
|
|
108
|
+
const state = snapshot.state as { identity?: unknown; acc?: unknown } | null
|
|
109
|
+
try {
|
|
110
|
+
valid =
|
|
111
|
+
snapshot.version === reducer.version &&
|
|
112
|
+
Number.isSafeInteger(snapshot.cursor) &&
|
|
113
|
+
snapshot.cursor >= 0 &&
|
|
114
|
+
snapshot.cursor <= boundary &&
|
|
115
|
+
state !== null &&
|
|
116
|
+
typeof state === 'object' &&
|
|
117
|
+
state.identity === identity &&
|
|
118
|
+
entry.validate(state.acc)
|
|
119
|
+
} catch {
|
|
120
|
+
valid = false // malformed nested cache data must never prevent replay
|
|
121
|
+
}
|
|
122
|
+
if (valid) {
|
|
123
|
+
acc = structuredClone(state!.acc)
|
|
124
|
+
cursor = snapshot.cursor
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const delta = (await storage.events(cursor, boundary)).filter((event) => event.seq <= boundary)
|
|
128
|
+
for (const event of delta) {
|
|
129
|
+
if (event.seq !== cursor + 1) throw new Error('noncontiguous reducer log')
|
|
130
|
+
cursor = event.seq
|
|
131
|
+
}
|
|
132
|
+
if (cursor !== boundary) throw new Error('incomplete reducer log')
|
|
133
|
+
acc = reducer.advance(acc, entry.adapt(delta))
|
|
134
|
+
const value = entry.value(reducer.finish(acc))
|
|
135
|
+
if (!valid || delta.length > 0) {
|
|
136
|
+
const next = { version: reducer.version, cursor, state: { identity, acc } }
|
|
137
|
+
try {
|
|
138
|
+
if (row && !valid) await storage.repair(key, row, next)
|
|
139
|
+
else await storage.put(key, next)
|
|
140
|
+
} catch {
|
|
141
|
+
/* best-effort cache; log and reduction errors propagate */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { version: reducer.version, cursor, value }
|
|
145
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveReducerValue,
|
|
3
|
+
reducerValueSchema,
|
|
4
|
+
type ReducerValueRequest,
|
|
5
|
+
type ReducerValue,
|
|
6
|
+
} from '../reducer-values'
|
|
1
7
|
/**
|
|
2
8
|
* RemoteBuildStore (SPEC §7.2 adapter 2): the client half of the remote
|
|
3
9
|
* store — a full BuildStore over the wire protocol in protocol.ts. The
|
|
@@ -13,6 +19,8 @@
|
|
|
13
19
|
* `subscribe` is the shared polling implementation over `getEvents(since)`
|
|
14
20
|
* (§7.2); `close()` is a no-op — the server owns the backing store.
|
|
15
21
|
*/
|
|
22
|
+
import { beginRepoTickResponseSchema, finishRepoTickResponseSchema } from './protocol'
|
|
23
|
+
import type { RepoTickCompletion, RepoTickFinish } from '../types'
|
|
16
24
|
import {
|
|
17
25
|
EventValidationError,
|
|
18
26
|
type AbEvent,
|
|
@@ -539,6 +547,17 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
539
547
|
)
|
|
540
548
|
}
|
|
541
549
|
|
|
550
|
+
async getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue> {
|
|
551
|
+
resolveReducerValue(scope, request)
|
|
552
|
+
const root = scope.kind === 'build' ? this.buildPath(scope.slug) : this.repoPath(scope.repo)
|
|
553
|
+
return this.requestJson(
|
|
554
|
+
'POST',
|
|
555
|
+
`${root}/reducer-values`,
|
|
556
|
+
reducerValueSchema,
|
|
557
|
+
request,
|
|
558
|
+
) as Promise<ReducerValue>
|
|
559
|
+
}
|
|
560
|
+
|
|
542
561
|
private snapshotPath(scope: SnapshotScope, reducer: string): string {
|
|
543
562
|
const root = scope.kind === 'build' ? this.buildPath(scope.slug) : this.repoPath(scope.repo)
|
|
544
563
|
return `${root}/snapshots/${encodeURIComponent(reducer)}`
|
|
@@ -822,6 +841,28 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
822
841
|
)
|
|
823
842
|
}
|
|
824
843
|
|
|
844
|
+
async beginRepoTick(repo: string, holder: string): Promise<string | null> {
|
|
845
|
+
return this.requestJson(
|
|
846
|
+
'POST',
|
|
847
|
+
`${this.repoPath(repo)}/tick/begin`,
|
|
848
|
+
beginRepoTickResponseSchema,
|
|
849
|
+
{ holder },
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
async finishRepoTick(
|
|
853
|
+
repo: string,
|
|
854
|
+
holder: string,
|
|
855
|
+
completion: RepoTickCompletion,
|
|
856
|
+
events: RepositoryEventWrite[],
|
|
857
|
+
): Promise<RepoTickFinish | null> {
|
|
858
|
+
return (await this.requestJson(
|
|
859
|
+
'POST',
|
|
860
|
+
`${this.repoPath(repo)}/tick/finish`,
|
|
861
|
+
finishRepoTickResponseSchema,
|
|
862
|
+
{ holder, completion, events },
|
|
863
|
+
)) as RepoTickFinish | null
|
|
864
|
+
}
|
|
865
|
+
|
|
825
866
|
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
826
867
|
const result = await this.requestJson(
|
|
827
868
|
'POST',
|
|
@@ -68,6 +68,8 @@ export const RELEASE_LINE_PROTOCOLS: Readonly<Record<string, string>> = {
|
|
|
68
68
|
'0.10': '3',
|
|
69
69
|
'0.11': '3',
|
|
70
70
|
'0.12': '3',
|
|
71
|
+
// v0.13.0 shipped protocol 3. Protocol 4 requires the next minor line.
|
|
72
|
+
'0.13': '3',
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export function clientIdentityError(
|
|
@@ -35,3 +35,22 @@ export { delegateCredential } from './credentials'
|
|
|
35
35
|
|
|
36
36
|
export { reduceBuild } from '../../kernel/reducer'
|
|
37
37
|
export { reduceHarvest } from '../../kernel/harvest'
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
readReducerValue,
|
|
41
|
+
resolveReducerValue,
|
|
42
|
+
reducerValueRequestSchema,
|
|
43
|
+
reducerValueSchema,
|
|
44
|
+
} from '../reducer-values'
|
|
45
|
+
export type {
|
|
46
|
+
ReducerValue,
|
|
47
|
+
ReducerValueRequest,
|
|
48
|
+
ReducerValueStorage,
|
|
49
|
+
ValueCacheRow,
|
|
50
|
+
} from '../reducer-values'
|
|
51
|
+
|
|
52
|
+
export type { BuildDelegationFacts, HarvestDelegationFacts } from '../../kernel/service-facts'
|
|
53
|
+
|
|
54
|
+
export { delegationFactKey } from '../../kernel/service-facts'
|
|
55
|
+
|
|
56
|
+
export { assertPublicSnapshotReducer } from '../snapshots'
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Arbitrary computation over the assigned revisions inside `makeEvent`
|
|
25
25
|
* (e.g. `rev + 1`, string interpolation) is unsupported by design.
|
|
26
26
|
*/
|
|
27
|
+
import { repoTickCompletionSchema, repoTickObservationSchema } from '../repo-tick'
|
|
27
28
|
import { reducerSnapshotWireSchema } from '../snapshots'
|
|
28
29
|
import { z } from 'zod'
|
|
29
30
|
import { actorSchema } from '../../events/envelope'
|
|
@@ -104,6 +105,7 @@ export const conditionalEventResponseSchema = eventEnvelopeWireSchema.nullable()
|
|
|
104
105
|
|
|
105
106
|
export const ensureRepoBodySchema = z.object({ repo: z.string().min(1) })
|
|
106
107
|
export const repositoryRecordWireSchema = z.object({
|
|
108
|
+
tick: repoTickObservationSchema.optional(),
|
|
107
109
|
repo: z.string().min(1),
|
|
108
110
|
createdAt: z.string(),
|
|
109
111
|
updatedAt: z.string(),
|
|
@@ -475,3 +477,17 @@ export const delegateCredentialBodySchema = z.discriminatedUnion('kind', [
|
|
|
475
477
|
])
|
|
476
478
|
export type DelegateCredentialTarget = z.infer<typeof delegateCredentialBodySchema>
|
|
477
479
|
export const delegateCredentialResponseSchema = z.strictObject({ token: z.string().min(1) })
|
|
480
|
+
|
|
481
|
+
export { reducerValueRequestSchema, reducerValueSchema } from '../reducer-values'
|
|
482
|
+
export const beginRepoTickResponseSchema = z.iso.datetime().nullable()
|
|
483
|
+
export const finishRepoTickBodySchema = z.object({
|
|
484
|
+
holder: z.string().min(1),
|
|
485
|
+
completion: repoTickCompletionSchema,
|
|
486
|
+
events: z.array(z.object({ actor: actorSchema, type: z.string(), payload: z.unknown() })),
|
|
487
|
+
})
|
|
488
|
+
export const finishRepoTickResponseSchema = z
|
|
489
|
+
.object({
|
|
490
|
+
observation: repoTickObservationSchema,
|
|
491
|
+
events: z.array(repositoryEventEnvelopeWireSchema),
|
|
492
|
+
})
|
|
493
|
+
.nullable()
|
|
@@ -4,11 +4,13 @@ import manifest from '../../../../../package.json'
|
|
|
4
4
|
export const AUTOBUILD_VERSION = manifest.version
|
|
5
5
|
|
|
6
6
|
/** Wire compatibility version for the remote BuildStore protocol.
|
|
7
|
+
* Version 4: adds required fenced begin/finish tick operations and bounded
|
|
8
|
+
* repository tick telemetry.
|
|
7
9
|
* Version 3 (AUT-521): the build-digest wire shape gains per-occurrence
|
|
8
10
|
* observation timestamps and the `merged` fact — a breaking shape change,
|
|
9
11
|
* so stale peers are rejected at handshake instead of failing mid-request
|
|
10
12
|
* at schema parse. */
|
|
11
|
-
export const REMOTE_STORE_PROTOCOL_VERSION = '
|
|
13
|
+
export const REMOTE_STORE_PROTOCOL_VERSION = '4'
|
|
12
14
|
|
|
13
15
|
export const AUTOBUILD_VERSION_HEADER = 'x-autobuild-version'
|
|
14
16
|
export const REMOTE_STORE_PROTOCOL_VERSION_HEADER = 'x-autobuild-protocol-version'
|
|
@@ -142,7 +142,7 @@ export const repositoryStateEventsReducer: IncrementalReducer<
|
|
|
142
142
|
* `RepositoryStateEventsAcc` it prunes on every `dispatcher.run-started`
|
|
143
143
|
* (everything before the new anchor that is not durable leaves the tail), which
|
|
144
144
|
* is exact for seq-ordered input — the only input a real journal produces — and
|
|
145
|
-
*
|
|
145
|
+
* holds the tail only in process memory. It is never persisted as a snapshot. */
|
|
146
146
|
export interface JournalViewAcc {
|
|
147
147
|
anchor?: number
|
|
148
148
|
retained: RepositoryEvent[]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { EventValidationError } from '../events/catalog'
|
|
3
|
+
import {
|
|
4
|
+
dispatcherStatusEventPayloadSchemas,
|
|
5
|
+
validateRepositoryEventWrite,
|
|
6
|
+
type RepositoryEventWrite,
|
|
7
|
+
} from '../events/repository'
|
|
8
|
+
import type { RepoTickCompletion } from './types'
|
|
9
|
+
|
|
10
|
+
export const repoTickCompletionSchema = z.discriminatedUnion('outcome', [
|
|
11
|
+
z.object({
|
|
12
|
+
outcome: z.literal('completed'),
|
|
13
|
+
counters: dispatcherStatusEventPayloadSchemas['dispatcher.tick-completed'].shape.counters,
|
|
14
|
+
}),
|
|
15
|
+
z.object({ outcome: z.literal('failed'), error: z.string().min(1) }),
|
|
16
|
+
])
|
|
17
|
+
export const repoTickObservationSchema = z.object({
|
|
18
|
+
run: z.string().min(1),
|
|
19
|
+
open: z.object({ startedAt: z.iso.datetime() }).optional(),
|
|
20
|
+
finished: z
|
|
21
|
+
.intersection(
|
|
22
|
+
repoTickCompletionSchema,
|
|
23
|
+
z.object({
|
|
24
|
+
run: z.string().min(1),
|
|
25
|
+
startedAt: z.iso.datetime(),
|
|
26
|
+
completedAt: z.iso.datetime(),
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
.optional(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** Only a matching start and terminal boundary may ride the atomic finish. */
|
|
33
|
+
export function validateTickFinish(
|
|
34
|
+
holder: string,
|
|
35
|
+
completion: RepoTickCompletion,
|
|
36
|
+
events: RepositoryEventWrite[],
|
|
37
|
+
) {
|
|
38
|
+
const parsed = repoTickCompletionSchema.parse(completion)
|
|
39
|
+
const validated = events.map(validateRepositoryEventWrite)
|
|
40
|
+
if (validated.length === 0 && completion.outcome === 'completed') return validated
|
|
41
|
+
if (validated.length !== 2)
|
|
42
|
+
throw new EventValidationError('tick finish requires start and terminal boundaries')
|
|
43
|
+
const [start, end] = validated
|
|
44
|
+
if (
|
|
45
|
+
start!.type !== 'dispatcher.tick-started' ||
|
|
46
|
+
end!.type !==
|
|
47
|
+
(completion.outcome === 'completed'
|
|
48
|
+
? 'dispatcher.tick-completed'
|
|
49
|
+
: 'dispatcher.tick-failed') ||
|
|
50
|
+
validated.some(
|
|
51
|
+
(e) => e.actor.kind !== 'dispatcher' || !('run' in e.payload) || e.payload.run !== holder,
|
|
52
|
+
)
|
|
53
|
+
) {
|
|
54
|
+
throw new EventValidationError('invalid tick finish boundaries')
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
end!.type === 'dispatcher.tick-failed' &&
|
|
58
|
+
completion.outcome === 'failed' &&
|
|
59
|
+
dispatcherStatusEventPayloadSchemas['dispatcher.tick-failed'].parse(end!.payload).error !==
|
|
60
|
+
completion.error
|
|
61
|
+
)
|
|
62
|
+
throw new EventValidationError('tick error mismatch')
|
|
63
|
+
if (
|
|
64
|
+
end!.type === 'dispatcher.tick-completed' &&
|
|
65
|
+
completion.outcome === 'completed' &&
|
|
66
|
+
JSON.stringify(
|
|
67
|
+
dispatcherStatusEventPayloadSchemas['dispatcher.tick-completed'].parse(end!.payload).counters,
|
|
68
|
+
) !== JSON.stringify(parsed.outcome === 'completed' ? parsed.counters : {})
|
|
69
|
+
)
|
|
70
|
+
throw new EventValidationError('tick counters mismatch')
|
|
71
|
+
return validated
|
|
72
|
+
}
|
|
@@ -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'
|
|
@@ -175,6 +176,18 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
|
|
|
175
176
|
getRepoHighWater(repo: string): Promise<number> {
|
|
176
177
|
return Promise.reject(new SessionScopeError(scope, 'getRepoHighWater', repo))
|
|
177
178
|
},
|
|
179
|
+
async getReducerValue(
|
|
180
|
+
target: SnapshotScope,
|
|
181
|
+
_request: ReducerValueRequest,
|
|
182
|
+
): Promise<ReducerValue> {
|
|
183
|
+
return Promise.reject(
|
|
184
|
+
new SessionScopeError(
|
|
185
|
+
scope,
|
|
186
|
+
'getReducerValue',
|
|
187
|
+
target.kind === 'build' ? target.slug : target.repo,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
},
|
|
178
191
|
getReducerSnapshot(
|
|
179
192
|
target: SnapshotScope,
|
|
180
193
|
_reducer: string,
|
|
@@ -262,6 +275,12 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
|
|
|
262
275
|
claimRepoLease(repo: string, _holder: string, _ttlMs: number): Promise<boolean> {
|
|
263
276
|
return Promise.reject(new SessionScopeError(scope, 'claimRepoLease', repo))
|
|
264
277
|
},
|
|
278
|
+
beginRepoTick(repo: string) {
|
|
279
|
+
return Promise.reject(new SessionScopeError(scope, 'beginRepoTick', repo))
|
|
280
|
+
},
|
|
281
|
+
finishRepoTick(repo: string) {
|
|
282
|
+
return Promise.reject(new SessionScopeError(scope, 'finishRepoTick', repo))
|
|
283
|
+
},
|
|
265
284
|
heartbeatRepo(repo: string, _holder: string): Promise<boolean> {
|
|
266
285
|
return Promise.reject(new SessionScopeError(scope, 'heartbeatRepo', repo))
|
|
267
286
|
},
|
|
@@ -36,6 +36,16 @@ export const reducerSnapshotWireSchema = z.object({
|
|
|
36
36
|
* path segment and a SQL key. */
|
|
37
37
|
export const SNAPSHOT_REDUCER_NAME = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/
|
|
38
38
|
|
|
39
|
+
/** Value checkpoints are written only by the adapter's private reduction path.
|
|
40
|
+
* Generic snapshot clients must never supply state used for named answers. */
|
|
41
|
+
export const REDUCER_VALUE_SNAPSHOT_PREFIX = 'value.'
|
|
42
|
+
|
|
43
|
+
export function assertPublicSnapshotReducer(reducer: string): void {
|
|
44
|
+
if (reducer.startsWith(REDUCER_VALUE_SNAPSHOT_PREFIX)) {
|
|
45
|
+
throw new Error('reserved reducer value snapshot namespace')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
export function snapshotScopeKey(scope: SnapshotScope): string {
|
|
40
50
|
return scope.kind === 'build' ? `build:${scope.slug}` : `repo:${scope.repo}`
|
|
41
51
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPinnedAssets, readBuildFacts } from './reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* Ticket assets (SPEC §7.1): named, versioned bundles of files attached to a
|
|
3
4
|
* ticket and kept in its service (hosted) or BuildStore (other sources), keyed by `(repo, ticketId, kind, name)`.
|
|
@@ -466,7 +467,7 @@ export function findPinnedRevision(
|
|
|
466
467
|
* needed, so a finished build still answers. Null when the build never pinned
|
|
467
468
|
* `kind/name` (or the revision). Rejects an unknown build. */
|
|
468
469
|
export async function resolvePinnedAsset(
|
|
469
|
-
store: Pick<BuildStore, 'getBuild' | '
|
|
470
|
+
store: Pick<BuildStore, 'getBuild' | 'getReducerValue' | 'getTicketAsset' | 'getRepoArtifact'>,
|
|
470
471
|
slug: string,
|
|
471
472
|
kind: string,
|
|
472
473
|
name: string,
|
|
@@ -475,15 +476,14 @@ export async function resolvePinnedAsset(
|
|
|
475
476
|
const record = await store.getBuild(slug)
|
|
476
477
|
if (record === null) throw new Error(`unknown build "${slug}"`)
|
|
477
478
|
if (record.ticket === undefined) return null
|
|
478
|
-
const
|
|
479
|
-
const sets = pinnedAssetsReducer.reduce(events).pins
|
|
479
|
+
const sets = (await readPinnedAssets(store, slug)).value.pins
|
|
480
480
|
const pin =
|
|
481
481
|
rev === undefined
|
|
482
482
|
? sets.at(-1)?.find((a) => a.kind === kind && a.name === name)
|
|
483
483
|
: sets.flat().find((a) => a.kind === kind && a.name === name && a.revision === rev)
|
|
484
484
|
if (!pin) return null
|
|
485
485
|
if (pin.snapshot) {
|
|
486
|
-
const created =
|
|
486
|
+
const created = (await readBuildFacts(store, slug)).value.created
|
|
487
487
|
const asset = await readAssetSnapshot(
|
|
488
488
|
store,
|
|
489
489
|
record.repo,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReducerValueRequest, ReducerValue } from './reducer-values'
|
|
1
2
|
/**
|
|
2
3
|
* The BuildStore seam (SPEC §7): the durable home of everything a build
|
|
3
4
|
* produces — events, artifacts, transcripts — one logical place whether
|
|
@@ -127,10 +128,31 @@ export interface BuildDigest {
|
|
|
127
128
|
executionOpen?: true
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
export type RepoTickCompletion =
|
|
132
|
+
| {
|
|
133
|
+
outcome: 'completed'
|
|
134
|
+
counters: RepositoryEventEnvelope<'dispatcher.tick-completed'>['payload']['counters']
|
|
135
|
+
}
|
|
136
|
+
| { outcome: 'failed'; error: string }
|
|
137
|
+
|
|
138
|
+
/** Bounded operational telemetry; never workflow or queue authority. */
|
|
139
|
+
export interface RepoTickObservation {
|
|
140
|
+
/** The run that last began or finished a tick. */
|
|
141
|
+
run: string
|
|
142
|
+
open?: { startedAt: string }
|
|
143
|
+
/** Retained across begin, even when the next run has a different holder. */
|
|
144
|
+
finished?: RepoTickCompletion & { run: string; startedAt: string; completedAt: string }
|
|
145
|
+
}
|
|
146
|
+
export interface RepoTickFinish {
|
|
147
|
+
observation: RepoTickObservation
|
|
148
|
+
events: RepositoryEvent[]
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
export interface RepositoryRecord {
|
|
131
152
|
repo: string
|
|
132
153
|
createdAt: string
|
|
133
154
|
updatedAt: string
|
|
155
|
+
tick?: RepoTickObservation
|
|
134
156
|
/** Repository workflow lease (harvest single-flight), separate from builds. */
|
|
135
157
|
lease?: { holder: string; expiresAt: string }
|
|
136
158
|
heartbeatAt?: string
|
|
@@ -384,12 +406,16 @@ export interface BuildStore {
|
|
|
384
406
|
* journal's trailing events. Unknown repo rejects like `getRepoEvents`;
|
|
385
407
|
* never an `ensureRepo` side effect. */
|
|
386
408
|
getRepoHighWater(repo: string): Promise<number>
|
|
409
|
+
/** A named JSON answer reduced from the authoritative log. Checkpoints are
|
|
410
|
+
* maintained privately by the adapter and cannot be supplied by callers. */
|
|
411
|
+
getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue>
|
|
387
412
|
/** The stored reducer snapshot for a scope (a build log or a repository
|
|
388
413
|
* journal), a cache beside the log and never an authority. `null` when
|
|
389
414
|
* absent, when the stored version differs from `version`, when the stored
|
|
390
415
|
* cursor is ahead of the scope's log tail, or when the scope is unknown (no
|
|
391
416
|
* `ensureRepo` side effect, no rejection). A store without snapshot storage
|
|
392
|
-
* answers `null`.
|
|
417
|
+
* answers `null`. Reserved `value.*` names reject: their checkpoints are
|
|
418
|
+
* internal to getReducerValue, not public snapshot resources. */
|
|
393
419
|
getReducerSnapshot(
|
|
394
420
|
scope: SnapshotScope,
|
|
395
421
|
reducer: string,
|
|
@@ -401,7 +427,8 @@ export interface BuildStore {
|
|
|
401
427
|
* past it), or when the cursor is equal and `version` is higher. Everything
|
|
402
428
|
* else, and any cursor beyond the log tail, is a no-op. Atomic, so
|
|
403
429
|
* concurrent writers cannot undo each other's progress. Resolves `true`
|
|
404
|
-
* when the row was written.
|
|
430
|
+
* when the row was written. Reserved `value.*` names reject before storage
|
|
431
|
+
* access; only getReducerValue may write these checkpoints. */
|
|
405
432
|
putReducerSnapshot(
|
|
406
433
|
scope: SnapshotScope,
|
|
407
434
|
reducer: string,
|
|
@@ -456,6 +483,16 @@ export interface BuildStore {
|
|
|
456
483
|
kind: string,
|
|
457
484
|
name: string,
|
|
458
485
|
): Promise<TicketAssetMeta | null>
|
|
486
|
+
/** Fenced by the unexpired repository lease. Null means authority was lost.
|
|
487
|
+
* Finish validates all boundaries before atomically depositing them and telemetry.
|
|
488
|
+
* Neither method renews the lease. */
|
|
489
|
+
beginRepoTick(repo: string, holder: string): Promise<string | null>
|
|
490
|
+
finishRepoTick(
|
|
491
|
+
repo: string,
|
|
492
|
+
holder: string,
|
|
493
|
+
completion: RepoTickCompletion,
|
|
494
|
+
events: RepositoryEventWrite[],
|
|
495
|
+
): Promise<RepoTickFinish | null>
|
|
459
496
|
claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean>
|
|
460
497
|
heartbeatRepo(repo: string, holder: string): Promise<boolean>
|
|
461
498
|
releaseRepoLease(repo: string, holder: string): Promise<void>
|