@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -46,6 +46,7 @@ import { defaultProposalState } from './dispatcher'
46
46
  import {
47
47
  artifactRef,
48
48
  evaluateHarvestPressure,
49
+ evaluateHarvestPressureFromDigests,
49
50
  HARVEST_REPORT_ARTIFACT,
50
51
  HARVEST_SCAN_ARTIFACT,
51
52
  HARVEST_TRANSCRIPT_ARTIFACT,
@@ -230,7 +231,9 @@ export class HarvestRunner {
230
231
  try {
231
232
  await this.startHeartbeat()
232
233
  await this.ensureLease()
233
- let events = await store.getRepoEvents(repo)
234
+ // Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
235
+ // below consume durable types only, so the subset is replay-equivalent.
236
+ let events = await store.getRepoStateEvents(repo)
234
237
  let state = reduceHarvest(events)
235
238
  run = actionableHarvestRun(state)
236
239
 
@@ -238,19 +241,48 @@ export class HarvestRunner {
238
241
  // selected run comes from the full journal, so a shadowed failure or
239
242
  // exhaustion barrier is recovered/reported before any later open run.
240
243
  await this.controlBoundary(run?.run)
241
- events = await store.getRepoEvents(repo)
244
+ events = await store.getRepoStateEvents(repo)
242
245
  state = reduceHarvest(events)
243
246
  run = openHarvestRun(state)
244
247
 
245
248
  if (!run) {
246
249
  await this.controlBoundary()
247
- const scan = await scanUnclaimedObservations(store, repo)
248
- // Scanning may span many build streams. Treat its completion as a
249
- // boundary even when the threshold is not met, so a request arriving
250
- // during the read is acknowledged before this runner returns idle.
250
+ // Flat-cost gate (AUT-521): the pressure trigger is evaluated from
251
+ // the journal and one repo-scoped digest batch read — no per-build
252
+ // history reads — so the idle evaluation no longer grows with
253
+ // accumulated finished builds. Only a tripped gate pays for the full
254
+ // unclaimed-observation scan, because the packet artifact needs the
255
+ // observation payloads no bounded source carries.
256
+ // Bounded read (AUT-489): the gate's journal input is consumed only
257
+ // through reduceHarvest, so the subset is replay-equivalent here too.
258
+ const [gateEvents, digests] = await Promise.all([
259
+ store.getRepoStateEvents(repo),
260
+ store.getRepoBuildDigests(repo),
261
+ ])
262
+ const pressure = evaluateHarvestPressureFromDigests({
263
+ digests,
264
+ harvestEvents: gateEvents,
265
+ policy: this.currentConfig().policy,
266
+ })
267
+ if (pressure.trigger === undefined) {
268
+ // Treat the read's completion as a boundary even when the gate did
269
+ // not trip, so a request arriving during the read is acknowledged
270
+ // before this runner returns idle.
271
+ await this.controlBoundary()
272
+ return { outcome: 'idle' }
273
+ }
251
274
  await this.controlBoundary()
252
- const pressure = evaluateHarvestPressure(scan, this.currentConfig().policy)
253
- if (pressure.trigger === undefined) return { outcome: 'idle' }
275
+ const scan = await scanUnclaimedObservations(store, repo)
276
+ // The trigger came from an earlier read than this scan, and a claim
277
+ // may have landed in the window. Re-confirm the gate against the
278
+ // fresh scan (pure, free): an emptied window returns idle exactly as
279
+ // if the gate had not tripped, so a packet is only ever built from a
280
+ // scan that itself triggered.
281
+ const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
282
+ if (confirmed.trigger === undefined) {
283
+ await this.controlBoundary()
284
+ return { outcome: 'idle' }
285
+ }
254
286
  const runId = this.deps.ids('harvest')
255
287
  const packet = await makeHarvestScanPacket({
256
288
  store,
@@ -280,14 +312,15 @@ export class HarvestRunner {
280
312
  run: runId,
281
313
  observations: packet.observations.map((item) => item.occurrence),
282
314
  scan: artifactRef(deposited[0]!),
283
- trigger: pressure.trigger,
315
+ trigger: confirmed.trigger,
284
316
  ...(this.deps.environment !== undefined
285
317
  ? { environment: this.deps.environment }
286
318
  : {}),
287
319
  },
288
320
  }),
289
321
  )
290
- events = await store.getRepoEvents(repo)
322
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
323
+ events = await store.getRepoStateEvents(repo)
291
324
  run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
292
325
  if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
293
326
  // A request racing the claim parks this exact run; its snapshot stays
@@ -448,7 +481,9 @@ export class HarvestRunner {
448
481
  existing.verdict,
449
482
  existing.artifact,
450
483
  )
451
- const event = (await this.deps.store.getRepoEvents(this.deps.repo)).find(
484
+ // Bounded read (AUT-489): only harvest.review.verdict facts are
485
+ // filtered here, and those are durable.
486
+ const event = (await this.deps.store.getRepoStateEvents(this.deps.repo)).find(
452
487
  (
453
488
  candidate,
454
489
  ): candidate is Extract<RepositoryEvent, { type: 'harvest.review.verdict' }> =>
@@ -515,7 +550,9 @@ export class HarvestRunner {
515
550
  event.actor.kind === 'agent' &&
516
551
  event.actor.session === session,
517
552
  })
518
- const events = await this.deps.store.getRepoEvents(this.deps.repo)
553
+ // Bounded read (AUT-489): only harvest.review.verdict facts are filtered
554
+ // here, and those are durable.
555
+ const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
519
556
  const verdict = [...events]
520
557
  .reverse()
521
558
  .find(
@@ -565,7 +602,9 @@ export class HarvestRunner {
565
602
  ): Promise<void> {
566
603
  const { store, repo, ids, workspacePath } = this.deps
567
604
  await this.ensureLease()
568
- const events = await store.getRepoEvents(repo)
605
+ // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
606
+ // consume durable types only.
607
+ const events = await store.getRepoStateEvents(repo)
569
608
  const matchingFailures = events.filter(
570
609
  (event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
571
610
  event.type === 'harvest.failed' &&
@@ -823,7 +862,9 @@ export class HarvestRunner {
823
862
  }): Promise<void> {
824
863
  const { store, repo, ids, workspacePath } = this.deps
825
864
  await this.ensureLease()
826
- const events = await store.getRepoEvents(repo)
865
+ // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
866
+ // consume durable types only.
867
+ const events = await store.getRepoStateEvents(repo)
827
868
  const matchingFailures = events.filter(
828
869
  (event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
829
870
  event.type === 'harvest.failed' &&
@@ -1296,7 +1337,9 @@ export class HarvestRunner {
1296
1337
 
1297
1338
  private async recordWorkflowFailure(run: string, error: unknown): Promise<void> {
1298
1339
  await this.ensureLease()
1299
- const events = await this.deps.store.getRepoEvents(this.deps.repo)
1340
+ // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
1341
+ // consume durable types only.
1342
+ const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
1300
1343
  const state = reduceHarvest(events)
1301
1344
  const current = state.runs.find((candidate) => candidate.run === run)
1302
1345
  if (current?.status !== 'running') return
@@ -1349,7 +1392,8 @@ export class HarvestRunner {
1349
1392
  * outer budget and therefore remain recoverable. */
1350
1393
  private async settleRecoveryExhaustion(run: string): Promise<void> {
1351
1394
  await this.ensureLease()
1352
- const state = reduceHarvest(await this.deps.store.getRepoEvents(this.deps.repo))
1395
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1396
+ const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1353
1397
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1354
1398
  if (decision.kind !== 'exhaust-recovery' || decision.run !== run) return
1355
1399
  await this.finalizeRecoveryExhaustion(decision.run, decision.attempts, decision.limit)
@@ -1374,7 +1418,8 @@ export class HarvestRunner {
1374
1418
  })
1375
1419
 
1376
1420
  await this.ensureLease()
1377
- const state = reduceHarvest(await this.deps.store.getRepoEvents(this.deps.repo))
1421
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1422
+ const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1378
1423
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1379
1424
  if (
1380
1425
  decision.kind !== 'exhaust-recovery' ||
@@ -1410,7 +1455,8 @@ export class HarvestRunner {
1410
1455
  private async controlBoundary(run?: string): Promise<void> {
1411
1456
  while (true) {
1412
1457
  await this.ensureLease()
1413
- const state = reduceHarvest(await this.deps.store.getRepoEvents(this.deps.repo))
1458
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1459
+ const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1414
1460
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1415
1461
  if (decision.kind === 'proceed') return
1416
1462
  if (decision.kind === 'park') {
@@ -1453,7 +1499,8 @@ export class HarvestRunner {
1453
1499
  }
1454
1500
 
1455
1501
  private async refreshRun(run: string): Promise<HarvestRunState> {
1456
- const state = reduceHarvest(await this.deps.store.getRepoEvents(this.deps.repo))
1502
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1503
+ const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1457
1504
  const found = state.runs.find((candidate) => candidate.run === run)
1458
1505
  if (!found) throw new Error(`unknown harvest run "${run}"`)
1459
1506
  return found
@@ -1,5 +1,6 @@
1
1
  /** Deterministic halves of observation harvest: scan, dedup, validation, and
2
2
  * proposal rendering. Agent judgment is deliberately absent from this file. */
3
+ import type { AbEvent } from '../events/catalog'
3
4
  import type { ArtifactRef } from '../ontology'
4
5
  import {
5
6
  harvestProposalSetSchema,
@@ -26,7 +27,14 @@ import {
26
27
  } from '../kernel/harvest'
27
28
  import type { TicketSource } from '../ports/types'
28
29
  import { specConformance } from '../spec-standard'
29
- import { contentHash, toBytes, type BuildStore } from '../store/types'
30
+ import type { RepositoryEvent } from '../events/repository'
31
+ import {
32
+ contentHash,
33
+ toBytes,
34
+ type BuildDigest,
35
+ type BuildRecord,
36
+ type BuildStore,
37
+ } from '../store/types'
30
38
 
31
39
  export const HARVEST_SCAN_ARTIFACT = 'harvest-scan'
32
40
  export const HARVEST_PROPOSALS_ARTIFACT = 'harvest-proposals'
@@ -52,21 +60,33 @@ export interface HarvestPressure {
52
60
  trigger?: HarvestTrigger
53
61
  }
54
62
 
55
- /** Pure two-dimensional Harvest gate. Drift is measured from the oldest
56
- * unclaimed observation and excludes that observation's own build. */
57
- export function evaluateHarvestPressure(
58
- scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
63
+ /** The build- and occurrence-shaped facts both pressure evaluations project
64
+ * onto: the unclaimed occurrences with their event timestamps, and the merge
65
+ * facts. Keeping the oldest-observation selection and drift arithmetic in one
66
+ * place makes the scan-based and digest-based evaluations equal by
67
+ * construction rather than by parallel maintenance (AUT-521). */
68
+ interface HarvestPressureFacts {
69
+ unclaimed: { build: string; seq: number; ts: string }[]
70
+ merges: { build: string; ts: string }[]
71
+ }
72
+
73
+ function evaluateHarvestPressureFacts(
74
+ facts: HarvestPressureFacts,
59
75
  policy: { harvestThreshold: number; harvestMaxDrift: number },
60
76
  ): HarvestPressure {
61
- const observationCount = scan.observations.length
77
+ const observationCount = facts.unclaimed.length
62
78
  if (observationCount === 0) return { observationCount, drift: 0 }
63
79
 
64
- const oldest = scan.observations.reduce((candidate, observation) =>
65
- observation.ts < candidate.ts ? observation : candidate,
66
- )
80
+ // The oldest unclaimed occurrence: min ts, then build ascending, then seq
81
+ // ascending — exactly what the scan projection's sort-by-(build,seq) then
82
+ // strict-`<`-reduce produced, now stated as one comparator.
83
+ const oldest = [...facts.unclaimed].sort(
84
+ (a, b) =>
85
+ (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0) || a.build.localeCompare(b.build) || a.seq - b.seq,
86
+ )[0]!
67
87
  const mergedBuilds = new Set<string>()
68
- for (const merge of scan.merges) {
69
- if (merge.build === oldest.occurrence.build || merge.ts <= oldest.ts) continue
88
+ for (const merge of facts.merges) {
89
+ if (merge.build === oldest.build || merge.ts <= oldest.ts) continue
70
90
  mergedBuilds.add(merge.build)
71
91
  }
72
92
  const drift = mergedBuilds.size
@@ -87,23 +107,94 @@ export function evaluateHarvestPressure(
87
107
  }
88
108
  }
89
109
 
90
- /** Raw structured `observation.recorded` envelopes across this repository.
91
- * The pair `{build, seq}` is the occurrence key; payload ids are not assumed
92
- * globally unique. */
93
- export async function scanUnclaimedObservations(
94
- store: BuildStore,
95
- repo: string,
96
- ): Promise<HarvestScanResult> {
97
- await store.ensureRepo(repo)
98
- const harvestEvents = await store.getRepoEvents(repo)
99
- const state = reduceHarvest(harvestEvents)
110
+ /** Pure two-dimensional Harvest gate. Drift is measured from the oldest
111
+ * unclaimed observation and excludes that observation's own build. */
112
+ export function evaluateHarvestPressure(
113
+ scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
114
+ policy: { harvestThreshold: number; harvestMaxDrift: number },
115
+ ): HarvestPressure {
116
+ return evaluateHarvestPressureFacts(
117
+ {
118
+ unclaimed: scan.observations.map((observation) => ({
119
+ build: observation.occurrence.build,
120
+ seq: observation.occurrence.seq,
121
+ ts: observation.ts,
122
+ })),
123
+ merges: scan.merges,
124
+ },
125
+ policy,
126
+ )
127
+ }
128
+
129
+ /** The same gate from build digests and the repository journal alone
130
+ * (AUT-521): claimedness comes from the journal, unclaimed occurrences and
131
+ * merge facts from the digests — no per-build history reads. The merge facts
132
+ * use each digest's latest `pr.merged` ts; drift counts a build when any of
133
+ * its merges is strictly after the oldest unclaimed observation, and
134
+ * `max(merge.ts) > oldest.ts` is equivalent to `any(merge.ts) > oldest.ts`,
135
+ * so latest-wins carries exactly the drift fact the scan collects. */
136
+ export function evaluateHarvestPressureFromDigests(input: {
137
+ digests: Map<string, BuildDigest>
138
+ harvestEvents: RepositoryEvent[]
139
+ policy: { harvestThreshold: number; harvestMaxDrift: number }
140
+ }): HarvestPressure {
141
+ const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
142
+ const unclaimed: HarvestPressureFacts['unclaimed'] = []
143
+ const merges: HarvestPressureFacts['merges'] = []
144
+ for (const digest of input.digests.values()) {
145
+ for (const observation of digest.observations) {
146
+ if (claimed.has(occurrenceKey({ build: digest.slug, seq: observation.seq }))) continue
147
+ unclaimed.push({ build: digest.slug, seq: observation.seq, ts: observation.ts })
148
+ }
149
+ if (digest.merged !== undefined) merges.push({ build: digest.slug, ts: digest.merged })
150
+ }
151
+ return evaluateHarvestPressureFacts({ unclaimed, merges }, input.policy)
152
+ }
153
+
154
+ /** The dispatcher harvest-launch gate's read sequence as one named seam
155
+ * (AUT-521): `ensureRepo`, one repo-scoped digest batch read, and the pure
156
+ * digest evaluation with the caller's policy. Per-evaluation cost is one
157
+ * journal read (passed in by the caller, which already holds it) plus one
158
+ * bounded digest read — flat in the number of accumulated finished builds,
159
+ * where the previous scan read every build's full history per evaluation. */
160
+ export async function evaluateHarvestPressureFromStore(input: {
161
+ store: BuildStore
162
+ repo: string
163
+ harvestEvents: RepositoryEvent[]
164
+ policy: { harvestThreshold: number; harvestMaxDrift: number }
165
+ }): Promise<HarvestPressure> {
166
+ await input.store.ensureRepo(input.repo)
167
+ const digests = await input.store.getRepoBuildDigests(input.repo)
168
+ return evaluateHarvestPressureFromDigests({
169
+ digests,
170
+ harvestEvents: input.harvestEvents,
171
+ policy: input.policy,
172
+ })
173
+ }
174
+
175
+ /** The deterministic core of the unclaimed-observation scan, over
176
+ * already-loaded streams: exactly the reduce/claim/collect/sort the
177
+ * store-reading scan performs, with no store calls. The dashboard consumes
178
+ * this directly with the journal and per-build event arrays it has already
179
+ * fetched (AUT-486), while `scanUnclaimedObservations` keeps the
180
+ * ensure/read/delegate shape its other callers depend on. */
181
+ export function collectUnclaimedObservations(input: {
182
+ repo: string
183
+ records: BuildRecord[]
184
+ eventsByBuild: Map<string, AbEvent[]>
185
+ harvestEvents: RepositoryEvent[]
186
+ }): HarvestScanResult {
187
+ const state = reduceHarvest(input.harvestEvents)
100
188
  const claimed = claimedOccurrenceKeys(state)
101
189
  const observations: HarvestObservation[] = []
102
190
  const merges: HarvestMerge[] = []
103
191
 
104
- for (const record of await store.listBuilds()) {
105
- if (record.repo !== repo) continue
106
- const events = await store.getEvents(record.slug)
192
+ for (const record of input.records) {
193
+ if (record.repo !== input.repo) continue
194
+ const events = input.eventsByBuild.get(record.slug)
195
+ if (events === undefined) {
196
+ throw new Error(`eventsByBuild is missing an entry for build "${record.slug}"`)
197
+ }
107
198
  for (const event of events) {
108
199
  if (event.type === 'pr.merged') {
109
200
  merges.push({ build: record.slug, ts: event.ts })
@@ -132,6 +223,87 @@ export async function scanUnclaimedObservations(
132
223
  return { observations, merges, state }
133
224
  }
134
225
 
226
+ /** The unclaimed-observation count from build digests and the repository
227
+ * journal alone (AUT-487): the same reduce/claim/count the store-reading scan
228
+ * performs, with no per-build history reads. The operator query consumes this
229
+ * directly, and the terminal dashboards consume it through
230
+ * `sampleUnclaimedObservationCount` (AUT-524), while `scanUnclaimedObservations`
231
+ * keeps the full-scan shape its remaining callers depend on (the harvest
232
+ * runner's packet scan — the only source of observation payloads — and its
233
+ * gate re-confirmation; the dispatcher's harvest-launch gate itself evaluates
234
+ * from digests, AUT-521). Occurrences are keyed `{build, seq}`; payload ids
235
+ * are not assumed globally unique. */
236
+ export function unclaimedObservationCount(input: {
237
+ digests: Map<string, BuildDigest>
238
+ harvestEvents: RepositoryEvent[]
239
+ }): number {
240
+ const claimed = claimedOccurrenceKeys(reduceHarvest(input.harvestEvents))
241
+ let count = 0
242
+ for (const digest of input.digests.values()) {
243
+ for (const observation of digest.observations) {
244
+ if (!claimed.has(occurrenceKey({ build: digest.slug, seq: observation.seq }))) count += 1
245
+ }
246
+ }
247
+ return count
248
+ }
249
+
250
+ /** The shared missing-record journal read (AUT-524): a repository whose
251
+ * journal record does not yet exist has an empty journal by definition, so
252
+ * the probe answers `[]` instead of letting `getRepoStateEvents` reject. The
253
+ * read writes nothing — a display path must not `ensureRepo` — while
254
+ * `scanUnclaimedObservations` keeps its materializing `ensureRepo` for its
255
+ * remaining callers.
256
+ *
257
+ * The journal read is bounded (AUT-489): every consumer — the terminal
258
+ * dashboards' observation-pressure sample, the operator query
259
+ * (operator/query.ts), the dispatcher's repository-wide controls and ready
260
+ * scans, and the operator sandbox service's journal reads — reduces durable
261
+ * event types plus the latest-run tail only, so the subset is
262
+ * replay-equivalent to a full journal replay. */
263
+ export async function readRepoEventsIfRecorded(
264
+ store: BuildStore,
265
+ repo: string,
266
+ ): Promise<RepositoryEvent[]> {
267
+ return (await store.getRepo(repo)) === null ? [] : store.getRepoStateEvents(repo)
268
+ }
269
+
270
+ /** The terminal dashboards' observation-pressure sample (AUT-487): build
271
+ * digests plus the repository journal, reduced to the unclaimed-observation
272
+ * count. Store traffic is flat in the finished-build count — one journal read
273
+ * plus one repo-scoped digest read, plus the journal-record probe inside
274
+ * `readRepoEventsIfRecorded`. Its missing-record treatment (AUT-524) is the
275
+ * shared helper's, above. */
276
+ export async function sampleUnclaimedObservationCount(
277
+ store: BuildStore,
278
+ repo: string,
279
+ ): Promise<number> {
280
+ const [digests, harvestEvents] = await Promise.all([
281
+ store.getRepoBuildDigests(repo),
282
+ readRepoEventsIfRecorded(store, repo),
283
+ ])
284
+ return unclaimedObservationCount({ digests, harvestEvents })
285
+ }
286
+
287
+ /** Raw structured `observation.recorded` envelopes across this repository.
288
+ * The pair `{build, seq}` is the occurrence key; payload ids are not assumed
289
+ * globally unique. */
290
+ export async function scanUnclaimedObservations(
291
+ store: BuildStore,
292
+ repo: string,
293
+ ): Promise<HarvestScanResult> {
294
+ await store.ensureRepo(repo)
295
+ // Bounded read (AUT-489): only harvest facts are consumed here; the
296
+ // per-build history reads stay as AUT-487 left them.
297
+ const harvestEvents = await store.getRepoStateEvents(repo)
298
+ const records = await store.listBuilds()
299
+ const eventsByBuild = new Map<string, AbEvent[]>()
300
+ for (const record of records) {
301
+ if (record.repo !== repo) continue
302
+ eventsByBuild.set(record.slug, await store.getEvents(record.slug))
303
+ }
304
+ return collectUnclaimedObservations({ repo, records, eventsByBuild, harvestEvents })
305
+ }
306
+
135
307
  /** Distinct previously filed/joined proposal tickets, reconciled through the
136
308
  * TicketSource's native lifecycle semantics. Resolved and missing entries stay
137
309
  * in the packet as tombstones rather than disappearing and being re-filed. */