@defrex/autobuild 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { gzipSync, gunzipSync } from 'node:zlib'
3
+ import { z } from 'zod'
4
+ import type { TicketAssetStore } from '../ports/types'
5
+ import type { PinnedAsset } from '../events/payloads'
6
+ import { contentHash, type BuildStore } from './types'
7
+ import {
8
+ pinnedAssetOf,
9
+ summarizeTicketAsset,
10
+ validateTicketAssetInput,
11
+ validateTicketAssetStructure,
12
+ type TicketAsset,
13
+ } from './ticket-assets'
14
+
15
+ const CHUNK_BYTES = 256 * 1024
16
+ const refSchema = z.strictObject({
17
+ kind: z.string().regex(/^ticket-snapshot-[a-f0-9-]+$/),
18
+ revision: z.number().int().nonnegative(),
19
+ })
20
+ const manifestSchema = z.strictObject({
21
+ version: z.literal(1),
22
+ meta: z.strictObject({
23
+ repo: z.string(),
24
+ ticketId: z.string(),
25
+ kind: z.string(),
26
+ name: z.string(),
27
+ revision: z.number().int().nonnegative(),
28
+ layout: z.enum(['file', 'tree']),
29
+ size: z.number().int().nonnegative(),
30
+ createdAt: z.string(),
31
+ entries: z.array(
32
+ z.discriminatedUnion('type', [
33
+ z.strictObject({ type: z.literal('dir'), path: z.string() }),
34
+ z.strictObject({
35
+ type: z.literal('file'),
36
+ path: z.string(),
37
+ size: z.number().int().nonnegative(),
38
+ blobRef: z.string(),
39
+ }),
40
+ ]),
41
+ ),
42
+ }),
43
+ files: z.array(
44
+ z.strictObject({
45
+ path: z.string(),
46
+ chunks: z.array(
47
+ refSchema.extend({ hash: z.string(), size: z.number().int().min(1).max(CHUNK_BYTES) }),
48
+ ),
49
+ }),
50
+ ),
51
+ })
52
+
53
+ function verifyMetadata(
54
+ meta: TicketAsset['meta'],
55
+ repo: string,
56
+ ticketId: string,
57
+ pin: PinnedAsset,
58
+ ): void {
59
+ const summary = pinnedAssetOf(summarizeTicketAsset(meta))
60
+ if (
61
+ meta.repo !== repo ||
62
+ meta.ticketId !== ticketId ||
63
+ Object.entries(summary).some(([key, value]) => pin[key as keyof typeof summary] !== value) ||
64
+ validateTicketAssetStructure(meta) !== meta.size
65
+ )
66
+ throw new Error('inconsistent ticket asset snapshot metadata')
67
+ }
68
+
69
+ function verify(asset: TicketAsset, repo: string, ticketId: string, pin: PinnedAsset): void {
70
+ const { meta, entries } = asset
71
+ verifyMetadata(meta, repo, ticketId, pin)
72
+ if (
73
+ validateTicketAssetInput({ ...meta, entries }) !== meta.size ||
74
+ entries.length !== meta.entries.length
75
+ ) {
76
+ throw new Error('inconsistent ticket asset snapshot metadata')
77
+ }
78
+ for (let i = 0; i < entries.length; i++) {
79
+ const entry = entries[i]!
80
+ const stored = meta.entries[i]!
81
+ if (
82
+ entry.path !== stored.path ||
83
+ entry.type !== stored.type ||
84
+ (entry.type === 'file' &&
85
+ stored.type === 'file' &&
86
+ (entry.content.length !== stored.size || contentHash(entry.content) !== stored.blobRef))
87
+ ) {
88
+ throw new Error('inconsistent ticket asset snapshot content')
89
+ }
90
+ }
91
+ }
92
+
93
+ /** Copy exact sampled revisions before publishing any pins. No current ticket rows
94
+ * are written to the destination; their revision sequences remain independent. */
95
+ export async function prepareAssetSnapshots(
96
+ source: Pick<TicketAssetStore, 'listTicketAssets' | 'getTicketAsset'>,
97
+ store: Pick<BuildStore, 'putRepoArtifact' | 'ensureRepo'>,
98
+ repo: string,
99
+ ticketId: string,
100
+ canonicalTicketId: string = ticketId,
101
+ ): Promise<PinnedAsset[]> {
102
+ const pins = (await source.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
103
+ if (pins.length > 0) await store.ensureRepo(repo)
104
+ for (const pin of pins) {
105
+ const asset = await source.getTicketAsset(repo, ticketId, pin.kind, pin.name, pin.revision)
106
+ if (!asset) throw new Error('sampled ticket asset revision is missing')
107
+ manifestSchema.shape.meta.parse(asset.meta)
108
+ verify(asset, repo, canonicalTicketId, pin)
109
+ const files = []
110
+ for (const entry of asset.entries) {
111
+ if (entry.type !== 'file') continue
112
+ const chunks = []
113
+ for (let start = 0; start < entry.content.length; start += CHUNK_BYTES) {
114
+ const bytes = entry.content.slice(start, start + CHUNK_BYTES)
115
+ const meta = await store.putRepoArtifact(repo, {
116
+ kind: `ticket-snapshot-${randomUUID()}`,
117
+ content: bytes,
118
+ })
119
+ chunks.push({
120
+ kind: meta.kind,
121
+ revision: meta.revision,
122
+ size: bytes.length,
123
+ hash: contentHash(bytes),
124
+ })
125
+ }
126
+ files.push({ path: entry.path, chunks })
127
+ }
128
+ const meta = await store.putRepoArtifact(repo, {
129
+ kind: `ticket-snapshot-${randomUUID()}`,
130
+ // Compression also bounds manifests with many maximum-length escaped paths.
131
+ content: gzipSync(JSON.stringify({ version: 1, meta: { ...asset.meta, ticketId }, files })),
132
+ })
133
+ pin.snapshot = { kind: meta.kind, revision: meta.revision }
134
+ }
135
+ return pins
136
+ }
137
+
138
+ export async function readAssetSnapshot(
139
+ store: Pick<BuildStore, 'getRepoArtifact'>,
140
+ repo: string,
141
+ ticketId: string,
142
+ pin: PinnedAsset,
143
+ historicalTicketId?: string,
144
+ ): Promise<TicketAsset> {
145
+ if (!pin.snapshot) throw new Error('missing snapshot reference')
146
+ const artifact = await store.getRepoArtifact(repo, pin.snapshot.kind, pin.snapshot.revision)
147
+ if (!artifact) throw new Error('ticket asset snapshot manifest is missing')
148
+ const manifest = manifestSchema.parse(
149
+ JSON.parse(gunzipSync(artifact.content, { maxOutputLength: 4 * 1024 * 1024 }).toString('utf8')),
150
+ )
151
+ // BuildStore projections can display a readable ID while the immutable
152
+ // build.created event and its older snapshots retain the storage alias.
153
+ const snapshotTicketId =
154
+ manifest.meta.ticketId === historicalTicketId ? historicalTicketId : ticketId
155
+ verifyMetadata(manifest.meta, repo, snapshotTicketId, pin)
156
+ const entries: TicketAsset['entries'] = []
157
+ const files = new Map(manifest.files.map((file) => [file.path, file]))
158
+ if (
159
+ files.size !== manifest.files.length ||
160
+ files.size !== manifest.meta.entries.filter((entry) => entry.type === 'file').length
161
+ ) {
162
+ throw new Error('inconsistent ticket asset snapshot files')
163
+ }
164
+ for (const entry of manifest.meta.entries) {
165
+ if (entry.type === 'dir') {
166
+ entries.push(entry)
167
+ continue
168
+ }
169
+ const file = files.get(entry.path)
170
+ if (!file || file.chunks.reduce((size, chunk) => size + chunk.size, 0) !== entry.size) {
171
+ throw new Error('inconsistent ticket asset snapshot chunks')
172
+ }
173
+ const content = new Uint8Array(entry.size)
174
+ let offset = 0
175
+ for (const chunk of file.chunks) {
176
+ const artifact = await store.getRepoArtifact(repo, chunk.kind, chunk.revision)
177
+ if (
178
+ !artifact ||
179
+ artifact.content.length !== chunk.size ||
180
+ contentHash(artifact.content) !== chunk.hash
181
+ ) {
182
+ throw new Error('ticket asset snapshot chunk is missing or corrupt')
183
+ }
184
+ content.set(artifact.content, offset)
185
+ offset += chunk.size
186
+ }
187
+ entries.push({ type: 'file', path: entry.path, content })
188
+ }
189
+ const asset = { meta: manifest.meta, entries }
190
+ verify(asset, repo, snapshotTicketId, pin)
191
+ return asset
192
+ }
@@ -1,13 +1,16 @@
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
- * ticket and kept in the BuildStore, keyed by `(repo, ticketId, kind, name)`.
4
- * The ticket source never stores the contents; it only gets a note through
4
+ * ticket and kept in its service (hosted) or BuildStore (other sources), keyed by `(repo, ticketId, kind, name)`.
5
+ * Sources also get a human-readable note through
5
6
  * `TicketSource.comment()` (SPEC §13).
6
7
  *
7
8
  * This module is the shared, adapter-independent half: the types, the limits,
8
9
  * and the one validator every adapter calls before any write.
9
10
  */
10
11
 
12
+ import { readAssetSnapshot, prepareAssetSnapshots } from './ticket-asset-snapshots'
13
+ import type { TicketSource, TicketAssetStore } from '../ports/types'
11
14
  import type { AbEvent } from '../events/catalog'
12
15
  import type { PinnedAsset } from '../events/payloads'
13
16
  import { defineReducer } from '../kernel/incremental'
@@ -56,7 +59,8 @@ export interface TicketAssetMeta {
56
59
  ticketId: string
57
60
  kind: string
58
61
  name: string
59
- /** 0-based per `(repo, ticketId, kind, name)`. A removal consumes a number
62
+ /** Increasing per `(repo, ticketId, kind, name)` (normally from zero; native
63
+ * file revisions reserve a separate range for legacy compatibility). A removal consumes a number
60
64
  * too (a tombstone), so revisions may skip. */
61
65
  revision: number
62
66
  layout: TicketAssetLayout
@@ -380,7 +384,27 @@ export async function samplePinnedAssets(
380
384
  return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
381
385
  }
382
386
 
383
- export const PINNED_ASSETS_REDUCER_VERSION = 1
387
+ export async function preparePinnedAssets(
388
+ source: TicketSource,
389
+ store: BuildStore,
390
+ repo: string,
391
+ ticketId: string,
392
+ ): Promise<PinnedAsset[]> {
393
+ if (!source.assets) {
394
+ if (source.name === 'hosted') throw new Error('hosted ticket source has no asset capability')
395
+ return samplePinnedAssets(store, repo, ticketId)
396
+ }
397
+ const ticket = await source.get(ticketId)
398
+ return prepareAssetSnapshots(
399
+ composeTicketAssets(source, store),
400
+ store,
401
+ repo,
402
+ ticketId,
403
+ ticket?.ref.id ?? ticketId,
404
+ )
405
+ }
406
+
407
+ export const PINNED_ASSETS_REDUCER_VERSION = 2
384
408
 
385
409
  /** The asset sets recorded by every `build.created`/`spec.revised` pin event
386
410
  * that carries one, in array order. Each pin event replaces the set, so the
@@ -443,7 +467,7 @@ export function findPinnedRevision(
443
467
  * needed, so a finished build still answers. Null when the build never pinned
444
468
  * `kind/name` (or the revision). Rejects an unknown build. */
445
469
  export async function resolvePinnedAsset(
446
- store: Pick<BuildStore, 'getBuild' | 'getEvents' | 'getTicketAsset'>,
470
+ store: Pick<BuildStore, 'getBuild' | 'getReducerValue' | 'getTicketAsset' | 'getRepoArtifact'>,
447
471
  slug: string,
448
472
  kind: string,
449
473
  name: string,
@@ -452,7 +476,61 @@ export async function resolvePinnedAsset(
452
476
  const record = await store.getBuild(slug)
453
477
  if (record === null) throw new Error(`unknown build "${slug}"`)
454
478
  if (record.ticket === undefined) return null
455
- const revision = findPinnedRevision(await store.getEvents(slug), kind, name, rev)
456
- if (revision === undefined) return null
457
- return store.getTicketAsset(record.repo, record.ticket.id, kind, name, revision)
479
+ const sets = (await readPinnedAssets(store, slug)).value.pins
480
+ const pin =
481
+ rev === undefined
482
+ ? sets.at(-1)?.find((a) => a.kind === kind && a.name === name)
483
+ : sets.flat().find((a) => a.kind === kind && a.name === name && a.revision === rev)
484
+ if (!pin) return null
485
+ if (pin.snapshot) {
486
+ const created = (await readBuildFacts(store, slug)).value.created
487
+ const asset = await readAssetSnapshot(
488
+ store,
489
+ record.repo,
490
+ record.ticket.id,
491
+ pin,
492
+ created?.type === 'build.created' ? created.payload.ticket?.id : undefined,
493
+ )
494
+ return { ...asset, meta: { ...asset.meta, ticketId: record.ticket.id } }
495
+ }
496
+ return store.getTicketAsset(record.repo, record.ticket.id, kind, name, pin.revision)
497
+ }
498
+
499
+ /** Native writes; merged reads preserve legacy file attachments and recorded revisions. */
500
+ export function composeTicketAssets(
501
+ source: TicketSource,
502
+ legacy: TicketAssetStore,
503
+ ): TicketAssetStore {
504
+ const native = source.assets
505
+ if (!native) return legacy
506
+ if (!native.hasTicketAssetHistory) return native
507
+ const owns = (repo: string, id: string, kind: string, name: string) =>
508
+ native.hasTicketAssetHistory!(repo, id, kind, name)
509
+ return {
510
+ ticketAssetLimits: native.ticketAssetLimits.bind(native),
511
+ putTicketAsset: native.putTicketAsset.bind(native),
512
+ async getTicketAsset(repo, id, kind, name, rev) {
513
+ if (rev !== undefined)
514
+ return (
515
+ (await native.getTicketAsset(repo, id, kind, name, rev)) ??
516
+ legacy.getTicketAsset(repo, id, kind, name, rev)
517
+ )
518
+ return (await owns(repo, id, kind, name))
519
+ ? native.getTicketAsset(repo, id, kind, name)
520
+ : legacy.getTicketAsset(repo, id, kind, name)
521
+ },
522
+ async listTicketAssets(repo, id) {
523
+ const current = await native.listTicketAssets(repo, id)
524
+ for (const item of await legacy.listTicketAssets(repo, id)) {
525
+ if (!(await owns(repo, id, item.kind, item.name))) current.push(item)
526
+ }
527
+ return current
528
+ },
529
+ async removeTicketAsset(repo, id, kind, name) {
530
+ const previous = await this.getTicketAsset(repo, id, kind, name)
531
+ if (!previous) return null
532
+ await native.removeTicketAsset(repo, id, kind, name)
533
+ return previous?.meta ?? null
534
+ },
535
+ }
458
536
  }
@@ -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,
@@ -412,7 +439,7 @@ export interface BuildStore {
412
439
  listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]>
413
440
  // ── Ticket assets (SPEC §7.1 — files attached to a ticket) ─────────────
414
441
  // Keyed by (repo, ticketId, kind, name); revisions are 0-based. The store
415
- // owns the bytes, the ticket source only ever gets a note (SPEC §13).
442
+ // owns these bytes; hosted current assets use the ticket service store (SPEC §13).
416
443
  /** The effective ceilings this store accepts for `repo` (a remote
417
444
  * deployment may advertise lower ones than the defaults). */
418
445
  ticketAssetLimits(repo: string): Promise<TicketAssetLimits>
@@ -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>
@@ -0,0 +1,215 @@
1
+ import { expect } from 'bun:test'
2
+ import { DispatchFrontend } from '../cli/dispatch-frontend'
3
+ import type { DispatchChildResult } from '../cli/dispatch-process'
4
+ import { createTerminalModeController } from '../cli/terminal-restore'
5
+ import { DISPATCHER, humanActor } from '../events/envelope'
6
+ import type { BuildStore } from '../store/types'
7
+
8
+ const tickCounters = {
9
+ merged: 0,
10
+ closed: 0,
11
+ conflicted: 0,
12
+ abandoned: 0,
13
+ discarded: 0,
14
+ janitorFailed: 0,
15
+ recovered: 0,
16
+ dispatchFailed: 0,
17
+ resumed: 0,
18
+ swept: 0,
19
+ dispatched: 0,
20
+ authored: 0,
21
+ bounced: 0,
22
+ claimRaces: 0,
23
+ invalidTickets: 0,
24
+ dependencyBlocked: 0,
25
+ harvestStarted: 0,
26
+ harvestResumed: 0,
27
+ harvestCompleted: 0,
28
+ harvestEscalated: 0,
29
+ harvestFailed: 0,
30
+ }
31
+
32
+ /** Run the actual frontend lifecycle with a deterministic child and manual
33
+ * refreshes. Instrument the backing boundary, including HTTP server reads. */
34
+ export async function dispatchReadBudget(store: BuildStore, backing: BuildStore): Promise<void> {
35
+ const repo = '/dashboard-read-budget'
36
+ await backing.ensureRepo(repo)
37
+ const metrics = {
38
+ rows: 0,
39
+ events: 0,
40
+ state: 0,
41
+ snapshots: 0,
42
+ digests: 0,
43
+ records: 0,
44
+ high: 0,
45
+ puts: 0,
46
+ }
47
+ const putSnapshot = backing.putReducerSnapshot.bind(backing)
48
+ backing.putReducerSnapshot = async (...args) => {
49
+ metrics.puts++
50
+ return putSnapshot(...args)
51
+ }
52
+ const getEvents = backing.getRepoEvents.bind(backing)
53
+ const getState = backing.getRepoStateEvents.bind(backing)
54
+ const getSnapshot = backing.getReducerSnapshot.bind(backing)
55
+ const getDigests = backing.getRepoBuildDigests.bind(backing)
56
+ const getRepo = backing.getRepo.bind(backing)
57
+ const getHigh = backing.getRepoHighWater.bind(backing)
58
+ backing.getRepoEvents = async (repo, since) => {
59
+ const events = await getEvents(repo, since)
60
+ metrics.events++
61
+ metrics.rows += events.length
62
+ return events
63
+ }
64
+ backing.getRepoStateEvents = async (repo) => {
65
+ metrics.state++
66
+ return getState(repo)
67
+ }
68
+ backing.getReducerSnapshot = async (...args) => {
69
+ metrics.snapshots++
70
+ return getSnapshot(...args)
71
+ }
72
+ backing.getRepoBuildDigests = async (repo) => {
73
+ metrics.digests++
74
+ return getDigests(repo)
75
+ }
76
+ backing.getRepo = async (repo) => {
77
+ metrics.records++
78
+ return getRepo(repo)
79
+ }
80
+ backing.getRepoHighWater = async (repo) => {
81
+ metrics.high++
82
+ return getHigh(repo)
83
+ }
84
+ let now = 0
85
+ let finish!: (result: DispatchChildResult) => void
86
+ let started!: () => void
87
+ let childRun = ''
88
+ const startup = new Promise<void>((resolve) => {
89
+ started = resolve
90
+ })
91
+ const frontend = new DispatchFrontend({
92
+ repo,
93
+ storeRef: 'test',
94
+ store,
95
+ env: {},
96
+ once: true,
97
+ clock: () => new Date(now),
98
+ input: { start: () => () => {} },
99
+ terminal: {
100
+ columns: 100,
101
+ rows: 24,
102
+ interactive: true,
103
+ write: () => {},
104
+ modes: createTerminalModeController(
105
+ () => {},
106
+ () => {},
107
+ ),
108
+ },
109
+ launchChild: ({ run }) => {
110
+ childRun = run
111
+ const completed = new Promise<DispatchChildResult>((resolve) => {
112
+ finish = resolve
113
+ })
114
+ void backing
115
+ .appendRepoWithArtifacts(
116
+ repo,
117
+ [
118
+ {
119
+ kind: 'config',
120
+ content: JSON.stringify({
121
+ capacity: 4,
122
+ roles: { default: { runtime: 'claude' } },
123
+ tickets: { source: 'file', readyState: 'ready' },
124
+ }),
125
+ },
126
+ ],
127
+ (artifacts) => ({
128
+ actor: DISPATCHER,
129
+ type: 'dispatcher.run-started',
130
+ payload: {
131
+ run,
132
+ pid: 1,
133
+ roleWarnings: [],
134
+ effectiveConfig: { kind: artifacts[0]!.kind, rev: artifacts[0]!.revision },
135
+ },
136
+ }),
137
+ )
138
+ .then(started)
139
+ return { completed, stop: async () => {} }
140
+ },
141
+ })
142
+ const running = frontend.run()
143
+ await startup
144
+ // Exercise exactly the controller operation used by timers and actions,
145
+ // without a wall-clock timer determining the batch boundaries.
146
+ const refresh = () => (frontend as unknown as { renderOnce(): Promise<void> }).renderOnce()
147
+ await refresh()
148
+ const initialSize = JSON.stringify(
149
+ (await getSnapshot({ kind: 'repo', repo }, 'dispatchFrontend', 2))?.state,
150
+ ).length
151
+ let recordReads: number | undefined
152
+ try {
153
+ for (let batch = 0; batch < 24; batch++) {
154
+ for (let i = 0; i < 128; i++) {
155
+ await backing.appendRepo(repo, {
156
+ actor: DISPATCHER,
157
+ type: 'dispatcher.tick-completed',
158
+ payload: {
159
+ run: childRun,
160
+ queued: 0,
161
+ counters: tickCounters,
162
+ janitorDiagnostics: [],
163
+ ticketDiagnostics: [],
164
+ creationDiagnostics: [],
165
+ dependencyDiagnostics: [],
166
+ },
167
+ })
168
+ }
169
+ const before = { ...metrics }
170
+ now += 6000
171
+ await refresh()
172
+ expect(metrics.puts - before.puts).toBe(1)
173
+ expect(metrics.rows - before.rows).toBe(128)
174
+ expect(metrics.events - before.events).toBe(1)
175
+ expect(metrics.state - before.state).toBe(0)
176
+ expect(metrics.snapshots - before.snapshots).toBe(0)
177
+ expect(metrics.digests - before.digests).toBe(1)
178
+ expect(metrics.high - before.high).toBe(0)
179
+ recordReads ??= metrics.records - before.records
180
+ expect(metrics.records - before.records).toBe(recordReads)
181
+ const size = JSON.stringify(
182
+ (await getSnapshot({ kind: 'repo', repo }, 'dispatchFrontend', 2))?.state,
183
+ ).length
184
+ // First advancement adds the child's identity; ticks add nothing.
185
+ expect(size).toBeLessThan(initialSize + 1000)
186
+ const rows = metrics.rows
187
+ await refresh()
188
+ expect(metrics.rows).toBe(rows)
189
+ }
190
+ // Advance the clock across rejected equal-cursor saves. A standby can
191
+ // remain quiet indefinitely; each 500 ms frame must not upload its cache.
192
+ const beforeQuiet = { ...metrics }
193
+ for (let poll = 0; poll < 40; poll++) {
194
+ now += 500
195
+ await refresh()
196
+ }
197
+ expect(metrics.puts - beforeQuiet.puts).toBe(4)
198
+ expect(metrics.rows).toBe(beforeQuiet.rows)
199
+ } finally {
200
+ finish({ outcome: 'normal', exitCode: 0 })
201
+ await running
202
+ }
203
+ await backing.appendRepo(repo, {
204
+ actor: humanActor('test'),
205
+ type: 'dispatcher.pause-set',
206
+ payload: { enabled: true },
207
+ })
208
+ // A new invocation restores persisted state without any bounded/full seed.
209
+ const { DispatchRepositoryFeed } = await import('../cli/dispatch-repository-feed')
210
+ const feed = new DispatchRepositoryFeed(store, repo, () => new Date(now))
211
+ const before = { ...metrics }
212
+ expect((await feed.refresh()).length).toBe(1)
213
+ expect(metrics.rows - before.rows).toBe(1)
214
+ expect(metrics.state - before.state).toBe(0)
215
+ }
@@ -64,3 +64,11 @@ export { resolveHarvestCliEnv } from '../cli/env'
64
64
  export { buildHarvestContext, submitHarvestProposals, submitHarvestVerdict } from '../cli/harvest'
65
65
  export { abConnect, abDisconnect } from '../cli/connect'
66
66
  export { ProjectCredentials } from '../cli/project-credentials'
67
+
68
+ export { dispatchReadBudget } from './dispatch-read-budget'
69
+
70
+ export {
71
+ reducedReadBudget,
72
+ countHistoricalRows,
73
+ type ReducedReadCosts,
74
+ } from './reduced-read-budget'