@defrex/autobuild 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
@@ -0,0 +1,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 = '3'
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
- * keeps the accumulator bounded, so it is the one a snapshot stores. */
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' | 'getEvents' | 'getTicketAsset' | 'getRepoArtifact'>,
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 events = await store.getEvents(slug)
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 = events.find((event) => event.type === 'build.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>