@defrex/autobuild 0.9.0 → 0.10.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 (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
@@ -19,7 +19,7 @@ import {
19
19
  ticketListCriteria,
20
20
  updateTicket,
21
21
  } from '../ports/tickets/operations'
22
- import type { Ticket, TicketSource, TicketUpdate } from '../ports/types'
22
+ import type { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
23
23
  import { reduceBuild } from '../kernel/reducer'
24
24
  import type { BuildStore } from '../store/types'
25
25
  import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
@@ -140,7 +140,20 @@ export interface TicketMoveOpts extends TicketCommandOpts {
140
140
  json?: boolean
141
141
  }
142
142
 
143
+ export interface TicketStatesOpts extends TicketCommandOpts {
144
+ json?: boolean
145
+ }
146
+
147
+ export interface TicketStateCreateOpts extends TicketCommandOpts {
148
+ name: string
149
+ /** One-line purpose; absent leaves an existing purpose untouched. */
150
+ about?: string
151
+ json?: boolean
152
+ }
153
+
143
154
  type TicketCommandName =
155
+ | 'states'
156
+ | 'state create'
144
157
  | 'create'
145
158
  | 'update'
146
159
  | 'block'
@@ -525,6 +538,108 @@ export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
525
538
  opts.stdout(`ticket moved: ${ticketSummary(moved)}`)
526
539
  }
527
540
 
541
+ export type TicketStateRole = 'create' | 'ready' | 'claimed' | 'triage' | 'proposal' | 'done'
542
+
543
+ export interface TicketStateRow extends TicketStateInfo {
544
+ roles: TicketStateRole[]
545
+ }
546
+
547
+ /**
548
+ * The lifecycle roles each state plays under `config`, for a source whose
549
+ * states are directories (names match case-insensitively, as `ab ticket move`
550
+ * does). The file adapter always claims into `doing`, so that is shown as
551
+ * claimed whatever `[tickets].claimedState` says. Roles naming no existing
552
+ * state are omitted.
553
+ */
554
+ export function stateRoles(
555
+ tickets: TicketsConfig,
556
+ names: string[],
557
+ ): Map<string, TicketStateRole[]> {
558
+ const triage = tickets.triageState ?? 'triage'
559
+ const named: Array<[TicketStateRole, string]> = [
560
+ ['create', tickets.createState ?? 'triage'],
561
+ ['ready', tickets.readyState],
562
+ ['claimed', 'doing'],
563
+ ['triage', triage],
564
+ ['proposal', tickets.proposalState ?? triage],
565
+ ['done', 'done'],
566
+ ]
567
+ const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
568
+ for (const [role, target] of named) {
569
+ const match =
570
+ names.find((name) => name === target) ??
571
+ names.find((name) => name.toLowerCase() === target.toLowerCase())
572
+ if (match !== undefined) roles.get(match)?.push(role)
573
+ }
574
+ return roles
575
+ }
576
+
577
+ function stateRow(info: TicketStateInfo, roles: Map<string, TicketStateRole[]>): TicketStateRow {
578
+ return {
579
+ name: info.name,
580
+ roles: roles.get(info.name) ?? [],
581
+ tickets: info.tickets,
582
+ ...(info.about !== undefined ? { about: info.about } : {}),
583
+ }
584
+ }
585
+
586
+ function unsupported(source: TicketSource, operation: string): Error {
587
+ return new Error(`ticket source "${source.name}" does not support ${operation}`)
588
+ }
589
+
590
+ /** `ab ticket states` — every state of the source, its roles, counts, purpose. */
591
+ export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
592
+ const { config, source } = await resolveTicketCommand(opts, 'states')
593
+ if (typeof source.listStates !== 'function') {
594
+ throw unsupported(source, 'state discovery (ab ticket states)')
595
+ }
596
+ const infos = await source.listStates()
597
+ const roles = stateRoles(
598
+ config.tickets,
599
+ infos.map((info) => info.name),
600
+ )
601
+ const rows = infos.map((info) => stateRow(info, roles))
602
+ if (opts.json === true) {
603
+ opts.stdout(JSON.stringify(rows, null, 2))
604
+ return
605
+ }
606
+ const nameWidth = Math.max(...rows.map((row) => row.name.length))
607
+ const roleText = rows.map((row) => (row.roles.length > 0 ? row.roles.join(',') : 'none'))
608
+ const roleWidth = Math.max(...roleText.map((text) => text.length))
609
+ rows.forEach((row, index) => {
610
+ const count = `${row.tickets} ${row.tickets === 1 ? 'ticket' : 'tickets'}`
611
+ const line = `${row.name.padEnd(nameWidth)} ${(roleText[index] as string).padEnd(roleWidth)} ${count}`
612
+ opts.stdout(row.about === undefined ? line : `${line} ${row.about}`)
613
+ })
614
+ }
615
+
616
+ /** `ab ticket state create <name> [--about <text>]` — idempotent. */
617
+ export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
618
+ const { config, source } = await resolveTicketCommand(opts, 'state create')
619
+ if (typeof source.addState !== 'function') {
620
+ throw unsupported(source, 'state creation (ab ticket state create)')
621
+ }
622
+ const aboutBefore =
623
+ opts.about === undefined
624
+ ? undefined
625
+ : (await source.listStates?.())?.find((info) => info.name === opts.name)?.about
626
+ const { state, created } = await source.addState(opts.name, {
627
+ ...(opts.about !== undefined ? { about: opts.about } : {}),
628
+ })
629
+ const names = (await source.listStates?.())?.map((info) => info.name) ?? [state.name]
630
+ const row = stateRow(state, stateRoles(config.tickets, names))
631
+ if (opts.json === true) {
632
+ opts.stdout(JSON.stringify({ ...row, created }, null, 2))
633
+ return
634
+ }
635
+ const purposeChanged = !created && opts.about !== undefined && state.about !== aboutBefore
636
+ opts.stdout(
637
+ created
638
+ ? `ticket state created: ${state.name}`
639
+ : `ticket state already exists: ${state.name}${purposeChanged ? ' (purpose updated)' : ''}`,
640
+ )
641
+ }
642
+
528
643
  const CREATE_USAGE =
529
644
  'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
530
645
  const UPDATE_USAGE =
@@ -540,6 +655,8 @@ const ASSET_GET_USAGE =
540
655
  const ASSET_RM_USAGE =
541
656
  'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
542
657
  const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
658
+ const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
659
+ const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
543
660
  export const TICKET_USAGE = [
544
661
  CREATE_USAGE,
545
662
  UPDATE_USAGE,
@@ -548,6 +665,8 @@ export const TICKET_USAGE = [
548
665
  LIST_USAGE,
549
666
  SHOW_USAGE,
550
667
  MOVE_USAGE,
668
+ STATES_USAGE,
669
+ STATE_CREATE_USAGE,
551
670
  ATTACH_USAGE,
552
671
  ASSET_GET_USAGE,
553
672
  ASSET_RM_USAGE,
@@ -757,6 +876,32 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
757
876
  throw new Error(TICKET_USAGE)
758
877
  }
759
878
 
879
+ case 'states': {
880
+ const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
881
+ if (parsed.positionals.length !== 0) throw new Error(STATES_USAGE)
882
+ await abTicketStates({ ...opts, json: parsed.flags.has('json') })
883
+ return
884
+ }
885
+
886
+ case 'state': {
887
+ const [action, ...stateArgs] = args
888
+ if (action !== 'create') throw new Error(STATE_CREATE_USAGE)
889
+ const parsed = parseArgs(stateArgs, { about: 'value', json: 'boolean' }, TICKET_USAGE)
890
+ const [name, ...extra] = parsed.positionals
891
+ if (name === undefined || name.trim() === '' || extra.length > 0) {
892
+ throw new Error(STATE_CREATE_USAGE)
893
+ }
894
+ const about = stringFlag(parsed, 'about')
895
+ if (about !== undefined && about.trim() === '') throw new Error(STATE_CREATE_USAGE)
896
+ await abTicketStateCreate({
897
+ ...opts,
898
+ name,
899
+ ...(about !== undefined ? { about } : {}),
900
+ json: parsed.flags.has('json'),
901
+ })
902
+ return
903
+ }
904
+
760
905
  case 'move': {
761
906
  const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
762
907
  const [id, state, ...extra] = parsed.positionals
@@ -416,6 +416,9 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
416
416
  if (satisfied === null) checkRegistrationState(stream)
417
417
  }
418
418
 
419
+ /** Builds discovery read in full and found terminal; never re-read. */
420
+ const retired = new Set<string>()
421
+
419
422
  /**
420
423
  * No-slug membership: every nonterminal build of this repository joins
421
424
  * the wait, baselined at its current maximum so no history replays, and
@@ -440,12 +443,17 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
440
443
  // diverge from reduceBuild.
441
444
  const digests = await store.getRepoBuildDigests(repo)
442
445
  for (const record of (await store.listBuilds()).filter(mine)) {
443
- if (streams.has(record.slug)) continue
446
+ if (streams.has(record.slug) || retired.has(record.slug)) continue
444
447
  const digest = digests.get(record.slug)
445
448
  if (digest !== undefined && digest.terminal !== undefined) continue
446
449
  const events = await store.getEvents(record.slug)
447
450
  const status = reduceBuild(events).status
448
- if (!NONTERMINAL_STATUSES.includes(status)) continue
451
+ if (!NONTERMINAL_STATUSES.includes(status)) {
452
+ // Terminal is permanent (reduceBuild never clears it): a legacy
453
+ // record the digest cannot retire is read once, not every pass.
454
+ retired.add(record.slug)
455
+ continue
456
+ }
449
457
  const stream = registerStream(record.slug, events, false)
450
458
  if (satisfied === null) checkRegistrationState(stream)
451
459
  }
@@ -597,6 +597,9 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
597
597
  return true
598
598
  }
599
599
 
600
+ /** Builds discovery read in full and found terminal; never re-read. */
601
+ const retired = new Set<string>()
602
+
600
603
  /**
601
604
  * No-slug membership: every nonterminal build of this repository joins
602
605
  * the watch, baselined at its current maximum so no history replays. A
@@ -624,12 +627,17 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
624
627
  // diverge from reduceBuild. Mirrors cli/wait.ts discoverBuilds.
625
628
  const digests = await store.getRepoBuildDigests(repo)
626
629
  for (const record of (await store.listBuilds()).filter(mine)) {
627
- if (streams.has(record.slug)) continue
630
+ if (streams.has(record.slug) || retired.has(record.slug)) continue
628
631
  const digest = digests.get(record.slug)
629
632
  if (digest !== undefined && digest.terminal !== undefined) continue
630
633
  const events = await store.getEvents(record.slug)
631
634
  const status = reduceBuild(events).status
632
- if (!NONTERMINAL_STATUSES.includes(status)) continue
635
+ if (!NONTERMINAL_STATUSES.includes(status)) {
636
+ // Terminal is permanent (reduceBuild never clears it): a legacy
637
+ // record the digest cannot retire is read once, not every pass.
638
+ retired.add(record.slug)
639
+ continue
640
+ }
633
641
  const stream: BuildStream = {
634
642
  kind: 'build',
635
643
  slug: record.slug,
@@ -31,6 +31,7 @@ export const CONFIG_RELOAD_CLASSIFICATION = {
31
31
  source: 'restart',
32
32
  readyLabels: 'hot',
33
33
  readyState: 'hot',
34
+ readyAssignee: 'hot',
34
35
  teamKey: 'restart',
35
36
  claimedState: 'restart',
36
37
  createState: 'restart',
@@ -10,6 +10,7 @@
10
10
  import { readFile } from 'node:fs/promises'
11
11
  import { join } from 'node:path'
12
12
  import type { AbEvent } from '../events/catalog'
13
+ import { defineReducer } from '../kernel/incremental'
13
14
  import type { Forge } from '../ports/types'
14
15
  import type { Exec } from '../ports/workspace/git-worktree'
15
16
  import type { BuildRecord } from '../store/types'
@@ -101,15 +102,26 @@ export function isActionablePipelineFailure(failure: PipelineSourceFailure): boo
101
102
  )
102
103
  }
103
104
 
105
+ export const RECORDED_BASE_SHA_REDUCER_VERSION = 1
106
+
104
107
  /** The recorded base commit of the build's latest workspace. The newest
105
108
  * `workspace.provisioned` wins: a re-provisioned workspace is what the build's
106
109
  * current environment was cut from. */
110
+ export const recordedBaseShaReducer = defineReducer<{ base?: string }, AbEvent, string | undefined>(
111
+ {
112
+ version: RECORDED_BASE_SHA_REDUCER_VERSION,
113
+ initial: () => ({}),
114
+ fold(acc, events) {
115
+ for (const event of events) {
116
+ if (event.type === 'workspace.provisioned') acc.base = event.payload.base.sha
117
+ }
118
+ },
119
+ finish: (acc) => acc.base,
120
+ },
121
+ )
122
+
107
123
  export function recordedBaseSha(events: readonly AbEvent[]): string | undefined {
108
- let base: string | undefined
109
- for (const event of events) {
110
- if (event.type === 'workspace.provisioned') base = event.payload.base.sha
111
- }
112
- return base
124
+ return recordedBaseShaReducer.reduce(events)
113
125
  }
114
126
 
115
127
  function buildBranch(record: Pick<BuildRecord, 'branch'>, slug: string): string {
@@ -556,6 +556,20 @@ export const ticketsSchema = z.strictObject({
556
556
  (s) => s.trim().length > 0,
557
557
  '[tickets].readyState must not be blank — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
558
558
  ),
559
+ /**
560
+ * Narrows readiness to tickets assigned to one Linear user — a third
561
+ * conjunctive gate beside readyState and readyLabels. `"me"` is the owner of
562
+ * the dispatcher's LINEAR_API_KEY; an email names a specific user (for a
563
+ * shared or bot key). Unassigned tickets are never ready while it is set.
564
+ * Linear only; absent leaves readiness unchanged.
565
+ */
566
+ readyAssignee: z
567
+ .string()
568
+ .refine(
569
+ (s) => s === 'me' || (s === s.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)),
570
+ '[tickets].readyAssignee must be "me" (the owner of the API key) or a user email address (e.g. "person@example.com")',
571
+ )
572
+ .optional(),
559
573
  /** Backend team key (e.g. "ENG") — required for linear and hosted. */
560
574
  teamKey: z.string().min(1).optional(),
561
575
  /** Workflow state claim() moves an issue to (§12); Linear and hosted. */
@@ -923,6 +937,13 @@ export const configSchema = configRootSchema.superRefine((config, ctx) => {
923
937
  }
924
938
  }
925
939
  }
940
+ if (tickets.source !== 'linear' && tickets.readyAssignee !== undefined) {
941
+ ctx.addIssue({
942
+ code: 'custom',
943
+ path: ['tickets', 'readyAssignee'],
944
+ message: `[tickets].readyAssignee applies only to source = "linear" — remove it or select that source (selected source: "${tickets.source}")`,
945
+ })
946
+ }
926
947
  // Plugin sources receive the existing ticket lifecycle/configuration fields
927
948
  // unchanged; adapter-specific validation belongs to their factory.
928
949
 
@@ -59,6 +59,13 @@ import { InProcessBuildExecution } from '../ports/workspace/in-process-build-exe
59
59
  import { BuildRunner, LeaseHeldError, SetupFailureError } from '../processes/build-runner'
60
60
  import { diagnosticArtifact } from '../processes/build-execution-state'
61
61
  import { Dispatcher, type LaunchRunnerResult } from '../processes/dispatcher'
62
+ import {
63
+ getHarvestStatus,
64
+ getOperatorDashboard,
65
+ getRepositoryStatus,
66
+ listOperatorBuilds,
67
+ } from '../operator/query'
68
+ import { RepoViewStore } from '../processes/repo-view'
62
69
  import { MemoryBuildStore } from '../store/memory'
63
70
  import type { BuildStore } from '../store/types'
64
71
  import { steppingClock } from '../testing/fixed'
@@ -279,6 +286,106 @@ export interface E2eHarness {
279
286
  cleanup(): Promise<void>
280
287
  }
281
288
 
289
+ /** The store with every reducer snapshot deleted: reads miss, writes no-op. */
290
+ export function withoutSnapshots<T extends BuildStore>(store: T): T {
291
+ return new Proxy(store, {
292
+ get(target, key) {
293
+ if (key === 'getReducerSnapshot') return async () => null
294
+ if (key === 'putReducerSnapshot') return async () => false
295
+ const value = Reflect.get(target, key, target)
296
+ return typeof value === 'function' ? value.bind(target) : value
297
+ },
298
+ })
299
+ }
300
+
301
+ /** A build's facts with the publication view (closures) reduced to its answers. */
302
+ async function plainFacts(view: RepoViewStore, slug: string) {
303
+ const { publication, ...rest } = await view.buildFacts(slug)
304
+ return {
305
+ ...rest,
306
+ publication: {
307
+ pending: publication.pending(),
308
+ abandoned: publication.abandonedPending(),
309
+ latest: publication.latestUncompletedRequest(),
310
+ },
311
+ }
312
+ }
313
+
314
+ /** JSON with sorted keys, so two structurally equal values compare equal. */
315
+ function canonical(value: unknown): string {
316
+ return JSON.stringify(value, (_key, item) =>
317
+ item !== null && typeof item === 'object' && !Array.isArray(item)
318
+ ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
319
+ : item,
320
+ )
321
+ }
322
+
323
+ async function repositoryReading(store: BuildStore, repo: string) {
324
+ const view = new RepoViewStore(store, { repo })
325
+ await view.refresh()
326
+ const slugs = (await view.listBuilds()).map((record) => record.slug).sort()
327
+ const builds: Record<string, unknown> = {}
328
+ for (const slug of slugs) {
329
+ builds[slug] = { facts: await plainFacts(view, slug), state: await view.buildState(slug) }
330
+ }
331
+ return {
332
+ journal: view.recordedJournal(),
333
+ digests: [...(await view.getRepoBuildDigests(repo)).entries()].sort(([a], [b]) =>
334
+ a.localeCompare(b),
335
+ ),
336
+ builds,
337
+ view,
338
+ }
339
+ }
340
+
341
+ /** Reducer snapshots are a cache the log regenerates (SPEC §7): whatever a
342
+ * scenario's dispatcher left behind, a cold view restored from it must read
343
+ * exactly what a cold view over a store with every snapshot deleted reads — and
344
+ * so must one restored from snapshots written at the final cursor. Run for
345
+ * every scenario at cleanup. */
346
+ export async function assertSnapshotEquivalence(store: BuildStore, repo: string): Promise<void> {
347
+ const replayed = await repositoryReading(withoutSnapshots(store), repo)
348
+ for (const round of ['as left by the scenario', 'written at the final cursor']) {
349
+ const restored = await repositoryReading(store, repo)
350
+ const { view: restoredView, ...got } = restored
351
+ const { view: _replayedView, ...want } = replayed
352
+ if (canonical(got) !== canonical(want)) {
353
+ throw new Error(`snapshot-restored reading differs from full replay (${round})`)
354
+ }
355
+ await restoredView.persistSnapshots({ force: true })
356
+ }
357
+ // The operator routes derive from the same view: each response with
358
+ // snapshots present equals the response with every snapshot deleted.
359
+ const at = new Date('2026-01-01T00:00:00.000Z')
360
+ const routes = async (target: BuildStore) => {
361
+ const attempt = async (read: () => Promise<unknown>): Promise<unknown> => {
362
+ try {
363
+ return await read()
364
+ } catch (error) {
365
+ return { error: error instanceof Error ? error.message : String(error) }
366
+ }
367
+ }
368
+ return {
369
+ dashboard: await attempt(() =>
370
+ getOperatorDashboard({ store: target, repo, clock: () => at }),
371
+ ),
372
+ status: await attempt(() => getRepositoryStatus(target, repo)),
373
+ harvest: await attempt(() => getHarvestStatus(target, repo)),
374
+ lists: await Promise.all(
375
+ (['active', 'queued', 'all'] as const).map((scope) =>
376
+ attempt(() => listOperatorBuilds({ store: target, repo, scope, now: at })),
377
+ ),
378
+ ),
379
+ }
380
+ }
381
+ const replayedRoutes = canonical(await routes(withoutSnapshots(store)))
382
+ for (const round of ['as left', 'warmed by a first poll']) {
383
+ if (canonical(await routes(store)) !== replayedRoutes) {
384
+ throw new Error(`snapshot-backed operator routes differ from full replay (${round})`)
385
+ }
386
+ }
387
+ }
388
+
282
389
  export async function makeHarness(opts: {
283
390
  handlers: SkillHandlers
284
391
  tickets?: Ticket[]
@@ -336,7 +443,11 @@ export async function makeHarness(opts: {
336
443
  const clock = steppingClock()
337
444
  const ids = sequentialIds()
338
445
  const uuids = sequentialUuids()
339
- const store = new MemoryBuildStore({ clock })
446
+ // `AB_E2E_SNAPSHOTS=off` runs every scenario with every snapshot deleted, the
447
+ // other arm of the "snapshots change no observable output" proof.
448
+ const snapshotsOff = process.env.AB_E2E_SNAPSHOTS === 'off'
449
+ const memory = new MemoryBuildStore({ clock })
450
+ const store = snapshotsOff ? withoutSnapshots(memory) : memory
340
451
  const adapted = await opts.storeAdapter?.(store)
341
452
  const activeStore = adapted?.store ?? store
342
453
  const config = parseConfig(configToml, 'e2e autobuild.toml')
@@ -612,6 +723,7 @@ export async function makeHarness(opts: {
612
723
  },
613
724
  events: (slug) => store.getEvents(slug),
614
725
  cleanup: async () => {
726
+ if (!snapshotsOff) await assertSnapshotEquivalence(store, origin)
615
727
  await activeStore.close()
616
728
  if (activeStore !== store) await store.close()
617
729
  await adapted?.cleanup?.()