@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,176 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+ import { DISPATCHER } from '../events/envelope'
3
+ import type { EventType } from '../events/payloads'
4
+ import type { IdSource } from '../ids'
5
+ import type { Config } from '../config/schema'
6
+ import { type Decision, decideNextFromFacts } from '../kernel/engine'
7
+ import { defineReducer } from '../kernel/incremental'
8
+ import type { RepoViewStore } from './repo-view'
9
+
10
+ /** Every new event must explicitly choose whether it advances build work. */
11
+ export const executionProgress = {
12
+ 'build.created': 'progress',
13
+ 'build.completed': 'progress',
14
+ 'runner.attached': 'bookkeeping',
15
+ 'runner.setup-failed': 'failure',
16
+ 'workspace.provisioned': 'bookkeeping',
17
+ 'workspace.provision-started': 'bookkeeping',
18
+ 'workspace.released': 'bookkeeping',
19
+ 'execution.started': 'bookkeeping',
20
+ 'execution.ended': 'bookkeeping',
21
+ 'infrastructure.failed': 'failure',
22
+ 'infrastructure.cleanup-attempted': 'bookkeeping',
23
+ 'pr-summary.requested': 'progress',
24
+ 'pr-summary.attempted': 'progress',
25
+ 'pr-summary.result': 'progress',
26
+ 'pr-summary.settled': 'progress',
27
+ 'finalize.step-started': 'progress',
28
+ 'publication.requested': 'progress',
29
+ 'publication.lost': 'progress',
30
+ 'abort.remote-branch-deleted': 'progress',
31
+ 'abort.local-branch-deleted': 'progress',
32
+ 'abort.ticket-returned': 'progress',
33
+ 'dispatch.failed': 'failure',
34
+ 'dispatch.comment-posted': 'bookkeeping',
35
+ 'build.pause-requested': 'progress',
36
+ 'build.resume-requested': 'progress',
37
+ 'build.abort-requested': 'progress',
38
+ 'build.discard-requested': 'progress',
39
+ 'build.auto-merge-requested': 'progress',
40
+ 'build.auto-merge-cancelled': 'progress',
41
+ 'build.auto-merge-default-observed': 'bookkeeping',
42
+ 'build.paused': 'progress',
43
+ 'build.resumed': 'progress',
44
+ 'build.aborted': 'progress',
45
+ 'spec.imported': 'progress',
46
+ 'spec.authored': 'progress',
47
+ 'spec.revised': 'progress',
48
+ 'session.started': 'progress',
49
+ 'session.ended': 'progress',
50
+ 'plan.started': 'progress',
51
+ 'plan.completed': 'progress',
52
+ 'plan-review.started': 'progress',
53
+ 'plan-review.verdict': 'progress',
54
+ 'implement.started': 'progress',
55
+ 'implement.completed': 'progress',
56
+ 'code-review.started': 'progress',
57
+ 'code-review.verdict': 'progress',
58
+ 'verify.reconcile-policy-captured': 'progress',
59
+ 'verify.started': 'progress',
60
+ 'verify.completed': 'progress',
61
+ 'finalize.started': 'progress',
62
+ 'finalize.completed': 'progress',
63
+ 'finalize.step-completed': 'progress',
64
+ 'pr-attachment.designated': 'progress',
65
+ 'pr-attachment.hosted': 'progress',
66
+ 'pr-attachment.reclaimed': 'progress',
67
+ 'pr-attachment.reclaim-failed': 'progress',
68
+ 'pr.auto-merge-enabled': 'progress',
69
+ 'pr.auto-merge-disabled': 'progress',
70
+ 'pr.merged': 'progress',
71
+ 'pr.closed': 'progress',
72
+ 'pr.conflicted': 'progress',
73
+ 'reconcile.progress-checked': 'progress',
74
+ 'reconcile.started': 'progress',
75
+ 'reconcile.completed': 'progress',
76
+ 'observation.recorded': 'progress',
77
+ 'escalation.raised': 'progress',
78
+ 'escalation.answered': 'progress',
79
+ 'phase.failed': 'failure',
80
+ } satisfies Record<EventType, 'bookkeeping' | 'failure' | 'progress'>
81
+
82
+ interface Accumulator {
83
+ active: { instance: string; seq: number; eligible: boolean } | null
84
+ count: number
85
+ open: string[]
86
+ latest: { instance: string; seq: number } | null
87
+ }
88
+
89
+ export const executionNoProgressReducer = defineReducer({
90
+ version: 1,
91
+ initial: (): Accumulator => ({ active: null, count: 0, open: [], latest: null }),
92
+ fold(acc, events: readonly AbEvent[]) {
93
+ for (const event of events) {
94
+ if (event.type === 'execution.started') {
95
+ // An abandoned start breaks the consecutive completed-execution streak.
96
+ if (acc.active !== null) acc.count = 0
97
+ acc.active = { instance: event.payload.instance, seq: event.seq, eligible: true }
98
+ } else if (event.type === 'execution.ended') {
99
+ if (acc.active?.instance !== event.payload.instance) continue
100
+ const clean = event.payload.outcome === 'completed' && event.payload.exitCode === 0
101
+ acc.count = clean && acc.active.eligible ? acc.count + 1 : 0
102
+ acc.latest = { instance: event.payload.instance, seq: event.seq }
103
+ acc.active = null
104
+ } else if (
105
+ event.type === 'escalation.raised' &&
106
+ event.payload.source === 'policy' &&
107
+ event.payload.policyCause === 'execution-no-progress'
108
+ ) {
109
+ if (!acc.open.includes(event.payload.id)) acc.open.push(event.payload.id)
110
+ } else if (event.type === 'escalation.answered') {
111
+ if (!acc.open.includes(event.payload.id)) continue
112
+ acc.open = acc.open.filter((id) => id !== event.payload.id)
113
+ acc.count = 0
114
+ acc.active = null
115
+ } else if (executionProgress[event.type] !== 'bookkeeping') {
116
+ acc.count = 0
117
+ if (acc.active !== null) acc.active.eligible = false
118
+ }
119
+ }
120
+ },
121
+ finish: (acc) => ({ ...acc, open: [...acc.open] }),
122
+ })
123
+
124
+ /** Describe routing without copying commands or phase feedback into the question. */
125
+ function expectedAction(decision: Decision): string {
126
+ if (decision.kind === 'run-phase') return `${decision.kind} ${decision.phase}@${decision.round}`
127
+ if ('step' in decision)
128
+ return `${decision.kind} ${decision.step} (attempt ${'attempt' in decision ? decision.attempt : 1})`
129
+ if (decision.kind === 'raise-escalation') return `${decision.kind} ${decision.phase}`
130
+ return decision.kind
131
+ }
132
+
133
+ /** Repair the third-completion/raise crash gap without provisioning execution four. */
134
+ export async function ensureExecutionProgress(
135
+ store: Pick<RepoViewStore, 'buildFacts' | 'appendIfCurrent' | 'refreshBuild'>,
136
+ ids: IdSource,
137
+ slug: string,
138
+ config: Config,
139
+ decide?: (
140
+ state: Parameters<typeof decideNextFromFacts>[0],
141
+ log: Parameters<typeof decideNextFromFacts>[1],
142
+ ) => Promise<Decision | undefined>,
143
+ ): Promise<boolean> {
144
+ for (;;) {
145
+ await store.refreshBuild(slug)
146
+ const facts = await store.buildFacts(slug)
147
+ const decision = decide
148
+ ? await decide(facts.state, facts.log)
149
+ : decideNextFromFacts(facts.state, facts.log, config)
150
+ if (decision === undefined) return false
151
+ // Commands must still be acknowledged even while this guard is parked.
152
+ if (decision.kind === 'acknowledge') return true
153
+ const streak = facts.executionNoProgress
154
+ if (streak.open.length > 0) return false
155
+ // Callers own wait routing, including publication-recovery launches. A
156
+ // legitimate wait is not dispatcher/runner disagreement.
157
+ if (decision.kind === 'wait') return true
158
+ if (streak.count < 3) return true
159
+ const raised = await store.appendIfCurrent(slug, facts.state.lastSeq, {
160
+ actor: DISPATCHER,
161
+ type: 'escalation.raised',
162
+ payload: {
163
+ id: ids('esc'),
164
+ phase: 'setup',
165
+ source: 'policy',
166
+ policyCause: 'execution-no-progress',
167
+ question: `${streak.count} consecutive executions ended normally without build progress. Dispatcher expected ${expectedAction(decision)}. Inspect the dispatcher/runner disagreement, then answer with guidance or retry.`,
168
+ refs:
169
+ streak.latest === null
170
+ ? []
171
+ : [`execution:${streak.latest.instance}`, `seq:${streak.latest.seq}`],
172
+ },
173
+ })
174
+ if (raised !== null) return false
175
+ }
176
+ }
@@ -1,3 +1,4 @@
1
+ import { ticketGrantRequest } from '../ports/tickets/grants'
1
2
  /**
2
3
  * Crash-safe repository-scoped harvest workflow: scan -> synthesize <-> review
3
4
  * -> file. It reuses `converge` for adversarial review semantics while every
@@ -56,6 +57,20 @@ export async function runHarvestChild(
56
57
  workspacePath: string = process.cwd(),
57
58
  ): Promise<void> {
58
59
  const fullStore: BuildStore = openStore(input.storeRef, env.AB_TOKEN)
60
+ const ticketAuthority =
61
+ env.AB_TICKET_URL && env.AB_TICKET_TOKEN
62
+ ? { url: env.AB_TICKET_URL, token: env.AB_TICKET_TOKEN }
63
+ : undefined
64
+ const renewTickets = ticketAuthority
65
+ ? setInterval(() => {
66
+ void ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
67
+ action: 'renew',
68
+ repo: input.repo,
69
+ }).catch(() => {
70
+ // Expiry denies subsequent ticket operations; never regain project authority.
71
+ })
72
+ }, 60_000)
73
+ : undefined
59
74
  try {
60
75
  const config = await loadConfig(join(workspacePath, 'autobuild.toml'))
61
76
  // Full build-child composition parity, in the same order: plugins load
@@ -80,7 +95,7 @@ export async function runHarvestChild(
80
95
  const { targetRepo, localStateRoot } = guestTicketSourceArgs(input, workspacePath)
81
96
  const tickets = await createTicketSource(
82
97
  config.tickets,
83
- env,
98
+ { ...env, AB_HARVEST: input.preparedRun?.run ?? input.instance },
84
99
  targetRepo,
85
100
  localStateRoot,
86
101
  plugins,
@@ -103,6 +118,8 @@ export async function runHarvestChild(
103
118
  ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
104
119
  ...(input.environment !== undefined ? { environment: input.environment } : {}),
105
120
  sessionEnv: {
121
+ ...(env.AB_TICKET_URL ? { AB_TICKET_URL: env.AB_TICKET_URL } : {}),
122
+ ...(env.AB_TICKET_TOKEN ? { AB_TICKET_TOKEN: env.AB_TICKET_TOKEN } : {}),
106
123
  AB_STORE: input.storeRef,
107
124
  ...(env.AB_TOKEN !== undefined && env.AB_TOKEN !== '' ? { AB_TOKEN: env.AB_TOKEN } : {}),
108
125
  },
@@ -112,10 +129,19 @@ export async function runHarvestChild(
112
129
  })
113
130
  await runner.run()
114
131
  } finally {
132
+ if (renewTickets) clearInterval(renewTickets)
115
133
  try {
116
- await fullStore.close()
117
- } catch {
118
- // Process teardown cannot change the already-durable runner outcome.
134
+ if (ticketAuthority)
135
+ await ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
136
+ action: 'revoke',
137
+ repo: input.repo,
138
+ })
139
+ } finally {
140
+ try {
141
+ await fullStore.close()
142
+ } catch {
143
+ // Process teardown cannot change the already-durable runner outcome.
144
+ }
119
145
  }
120
146
  }
121
147
  }
@@ -1,3 +1,4 @@
1
+ import type { RepositoryReadFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * Durable harvest execution state: the repository-journal projection that
3
4
  * hosted-harvest supervision (AUT-305) reads instead of process memory. The
@@ -88,22 +89,27 @@ export interface HarvestExecutionClassificationInput {
88
89
  * `held`; anything else is `idle`. The result feeds the same counters a
89
90
  * locally run harvest reports. */
90
91
  export function classifyHarvestOutcome(
91
- events: readonly RepositoryEvent[],
92
+ events: readonly RepositoryEvent[] | RepositoryReadFacts,
92
93
  input: HarvestExecutionClassificationInput,
93
94
  ): HarvestRunnerResult {
94
- const state = reduceHarvest(events)
95
- const started = events.find(
95
+ const facts = !Array.isArray(events) ? (events as RepositoryReadFacts) : undefined
96
+ const raw = Array.isArray(events) ? events : []
97
+ const state = facts?.harvest ?? reduceHarvest(raw)
98
+ const started = raw.find(
96
99
  (event) =>
97
100
  event.type === 'harvest.execution.started' && event.seq === input.executionStartedSeq,
98
101
  )
102
+ const execution =
103
+ facts?.harvestExecutionStarts[String(input.executionStartedSeq)] ??
104
+ (started?.type === 'harvest.execution.started' ? started.payload.execution : undefined)
99
105
  const authorization =
100
- started?.type === 'harvest.execution.started'
101
- ? events.find(
106
+ execution === undefined
107
+ ? undefined
108
+ : (facts?.harvestAuthorizations[JSON.stringify(execution)] ??
109
+ raw.find(
102
110
  (event) =>
103
- event.type === 'harvest.execution.authorized' &&
104
- event.payload.execution === started.payload.execution,
105
- )
106
- : undefined
111
+ event.type === 'harvest.execution.authorized' && event.payload.execution === execution,
112
+ ))
107
113
  const selected =
108
114
  authorization?.type === 'harvest.execution.authorized' ? authorization.payload : undefined
109
115
  const latestRun = selected
@@ -1,3 +1,6 @@
1
+ import { readRepositoryFacts } from '../store/reduced-reads'
2
+ import { ticketIdentity } from '../ports/tickets/identity'
3
+ import { issueTicketGrant } from '../ports/tickets/grants'
1
4
  import { delegateCredential } from '../store/remote/credentials'
2
5
  /**
3
6
  * Crash-safe repository-scoped harvest workflow:
@@ -20,7 +23,6 @@ import {
20
23
  decideHarvestControl,
21
24
  openHarvestRun,
22
25
  proposalArtifactForRound,
23
- reduceHarvest,
24
26
  type HarvestRunState,
25
27
  } from '../kernel/harvest'
26
28
  import type { ArtifactRef, Feedback, Verdict } from '../ontology'
@@ -237,28 +239,23 @@ export class HarvestRunner {
237
239
  // Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
238
240
  // below consume durable types only, so the subset is replay-equivalent.
239
241
  if (this.deps.preparedRun) {
240
- const events = await store.getRepoStateEvents(repo)
241
- const authorization = events.find(
242
- (event) =>
243
- event.type === 'harvest.execution.authorized' &&
244
- event.payload.execution === instance &&
245
- event.payload.run === this.deps.preparedRun!.run,
246
- )
242
+ const events = (await readRepositoryFacts(store, repo)).value
243
+ const authorization = events.harvestAuthorizations[JSON.stringify(instance)]
247
244
  if (authorization?.type !== 'harvest.execution.authorized')
248
245
  throw new Error('prepared harvest execution unavailable')
249
- run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
246
+ run = events.harvest.runs.find((run) => run.run === this.deps.preparedRun!.run)
250
247
  initial = authorization.payload.launch
251
248
  } else {
252
- let events = await store.getRepoStateEvents(repo)
253
- let state = reduceHarvest(events)
249
+ let events = (await readRepositoryFacts(store, repo)).value
250
+ let state = events.harvest
254
251
  run = actionableHarvestRun(state)
255
252
 
256
253
  // Settle durable operator control before scanning or resuming work. The
257
254
  // selected run comes from the full journal, so a shadowed failure or
258
255
  // exhaustion barrier is recovered/reported before any later open run.
259
256
  await this.controlBoundary(run?.run)
260
- events = await store.getRepoStateEvents(repo)
261
- state = reduceHarvest(events)
257
+ events = (await readRepositoryFacts(store, repo)).value
258
+ state = events.harvest
262
259
  run = openHarvestRun(state)
263
260
 
264
261
  if (!run) {
@@ -272,7 +269,7 @@ export class HarvestRunner {
272
269
  // Bounded read (AUT-489): the gate's journal input is consumed only
273
270
  // through reduceHarvest, so the subset is replay-equivalent here too.
274
271
  const [gateEvents, digests] = await Promise.all([
275
- store.getRepoStateEvents(repo),
272
+ readRepositoryFacts(store, repo).then((result) => result.value.harvest),
276
273
  store.getRepoBuildDigests(repo),
277
274
  ])
278
275
  const pressure = evaluateHarvestPressureFromDigests({
@@ -336,8 +333,8 @@ export class HarvestRunner {
336
333
  }),
337
334
  )
338
335
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
339
- events = await store.getRepoStateEvents(repo)
340
- run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
336
+ events = (await readRepositoryFacts(store, repo)).value
337
+ run = events.harvest.runs.find((candidate) => candidate.run === runId)
341
338
  if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
342
339
  // A request racing the claim parks this exact run; its snapshot stays
343
340
  // open and is resumed rather than scanned again.
@@ -501,14 +498,8 @@ export class HarvestRunner {
501
498
  )
502
499
  // Bounded read (AUT-489): only harvest.review.verdict facts are
503
500
  // filtered here, and those are durable.
504
- const event = (await this.deps.store.getRepoStateEvents(this.deps.repo)).find(
505
- (
506
- candidate,
507
- ): candidate is Extract<RepositoryEvent, { type: 'harvest.review.verdict' }> =>
508
- candidate.type === 'harvest.review.verdict' &&
509
- candidate.payload.run === run.run &&
510
- candidate.payload.round === round,
511
- )
501
+ const event = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value
502
+ .harvestVerdicts[JSON.stringify([run.run, round])]?.first
512
503
  if (!event) throw new Error('review reducer/event mismatch')
513
504
  await this.controlBoundary(run.run)
514
505
  return verdictFromEvent(event)
@@ -570,15 +561,8 @@ export class HarvestRunner {
570
561
  })
571
562
  // Bounded read (AUT-489): only harvest.review.verdict facts are filtered
572
563
  // here, and those are durable.
573
- const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
574
- const verdict = [...events]
575
- .reverse()
576
- .find(
577
- (event): event is Extract<RepositoryEvent, { type: 'harvest.review.verdict' }> =>
578
- event.type === 'harvest.review.verdict' &&
579
- event.payload.run === run.run &&
580
- event.payload.round === round,
581
- )
564
+ const events = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value
565
+ const verdict = events.harvestVerdicts[JSON.stringify([run.run, round])]?.latest
582
566
  if (!verdict) throw new Error(`review@${round} terminal is missing`)
583
567
  await this.completeStep(
584
568
  run.run,
@@ -622,22 +606,16 @@ export class HarvestRunner {
622
606
  await this.ensureLease()
623
607
  // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
624
608
  // consume durable types only.
625
- const events = await store.getRepoStateEvents(repo)
626
- const matchingFailures = events.filter(
627
- (event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
628
- event.type === 'harvest.failed' &&
629
- event.payload.run === spec.run &&
630
- event.payload.step === spec.step &&
631
- event.payload.round === spec.round,
632
- )
633
- const failures = matchingFailures.length
634
- const latestFailure = matchingFailures.at(-1)
635
- const current = reduceHarvest(events).runs.find((candidate) => candidate.run === spec.run)
609
+ const events = (await readRepositoryFacts(store, repo)).value
610
+ const failureFacts = events.harvestFailures[JSON.stringify([spec.run, spec.step, spec.round])]
611
+ const failures = failureFacts?.count ?? 0
612
+ const latestFailure = failureFacts?.latest
613
+ const current = events.harvest.runs.find((candidate) => candidate.run === spec.run)
636
614
  const resumedAfterTerminalFailure =
637
615
  latestFailure?.payload.willRetry === false &&
638
616
  current?.status === 'running' &&
639
617
  current.failure === undefined &&
640
- events.some((event) => event.type === 'harvest.resumed' && event.seq > latestFailure.seq)
618
+ events.latestHarvestResumedSeq > latestFailure.seq
641
619
  if (failures >= this.maxSessionAttempts && !resumedAfterTerminalFailure) {
642
620
  throw new SessionFailure(`${spec.step}@${spec.round} exhausted retries`)
643
621
  }
@@ -882,17 +860,11 @@ export class HarvestRunner {
882
860
  await this.ensureLease()
883
861
  // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
884
862
  // consume durable types only.
885
- const events = await store.getRepoStateEvents(repo)
886
- const matchingFailures = events.filter(
887
- (event): event is Extract<RepositoryEvent, { type: 'harvest.failed' }> =>
888
- event.type === 'harvest.failed' &&
889
- event.payload.run === spec.run &&
890
- event.payload.step === spec.step &&
891
- event.payload.round === spec.round,
892
- )
893
- const failures = matchingFailures.length
894
- const latestFailure = matchingFailures.at(-1)
895
- const current = reduceHarvest(events).runs.find((candidate) => candidate.run === spec.run)
863
+ const events = (await readRepositoryFacts(store, repo)).value
864
+ const failureFacts = events.harvestFailures[JSON.stringify([spec.run, spec.step, spec.round])]
865
+ const failures = failureFacts?.count ?? 0
866
+ const latestFailure = failureFacts?.latest
867
+ const current = events.harvest.runs.find((candidate) => candidate.run === spec.run)
896
868
  // A stopping failure may already have consumed the ordinary retry budget.
897
869
  // harvest.resumed deliberately clears that reduced stop and grants one real
898
870
  // re-entry without deleting history or resetting attempt numbers. The next
@@ -901,7 +873,7 @@ export class HarvestRunner {
901
873
  latestFailure?.payload.willRetry === false &&
902
874
  current?.status === 'running' &&
903
875
  current.failure === undefined &&
904
- events.some((event) => event.type === 'harvest.resumed' && event.seq > latestFailure.seq)
876
+ events.latestHarvestResumedSeq > latestFailure.seq
905
877
  if (failures >= this.maxSessionAttempts && !resumedAfterTerminalFailure) {
906
878
  throw new SessionFailure(`${spec.step}@${spec.round} exhausted retries`)
907
879
  }
@@ -1176,9 +1148,16 @@ export class HarvestRunner {
1176
1148
  )
1177
1149
  const packet = await loadScanPacket(store, repo, current.scan)
1178
1150
  const observations = packet.observations
1179
- const knownLedger = new Map(
1180
- packet.ledger.map((entry) => [`${entry.ticket.source}:${entry.ticket.id}`, entry]),
1181
- )
1151
+ const identity = ticketIdentity(tickets)
1152
+ const knownLedger = new Map<string, (typeof packet.ledger)[number]>()
1153
+ for (const entry of packet.ledger) {
1154
+ const current =
1155
+ entry.ticket.source === tickets.name ? await tickets.get(entry.ticket.id) : null
1156
+ knownLedger.set(await identity(entry.ticket), {
1157
+ ...entry,
1158
+ ticket: current?.ref ?? entry.ticket,
1159
+ })
1160
+ }
1182
1161
  const alreadyFiled = new Map(current.filed.map((entry) => [entry.proposalKey, entry]))
1183
1162
  const reservations = new Map(current.reservations.map((entry) => [entry.proposalKey, entry.id]))
1184
1163
  const dispositions: HarvestDisposition[] = []
@@ -1258,7 +1237,7 @@ export class HarvestRunner {
1258
1237
  ...(filed.blockers !== undefined ? { blockers: filed.blockers } : {}),
1259
1238
  })
1260
1239
  } else if (proposal.action === 'join') {
1261
- const known = knownLedger.get(`${proposal.ticket.source}:${proposal.ticket.id}`)
1240
+ const known = knownLedger.get(await identity(proposal.ticket))
1262
1241
  if (!known) {
1263
1242
  throw new Error(
1264
1243
  `approved join target ${proposal.ticket.source}:${proposal.ticket.id} is not in the ledger context`,
@@ -1357,8 +1336,8 @@ export class HarvestRunner {
1357
1336
  await this.ensureLease()
1358
1337
  // Bounded read (AUT-489): reduceHarvest and the harvest.failed filter
1359
1338
  // consume durable types only.
1360
- const events = await this.deps.store.getRepoStateEvents(this.deps.repo)
1361
- const state = reduceHarvest(events)
1339
+ const events = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value
1340
+ const state = events.harvest
1362
1341
  const current = state.runs.find((candidate) => candidate.run === run)
1363
1342
  if (current?.status !== 'running') return
1364
1343
  const open = [...current.steps]
@@ -1366,13 +1345,7 @@ export class HarvestRunner {
1366
1345
  .find((occurrence) => occurrence.completedSeq === undefined)
1367
1346
  const step = open?.step ?? 'file'
1368
1347
  const round = open?.round
1369
- const prior = events.filter(
1370
- (event) =>
1371
- event.type === 'harvest.failed' &&
1372
- event.payload.run === run &&
1373
- event.payload.step === step &&
1374
- event.payload.round === round,
1375
- ).length
1348
+ const prior = events.harvestFailures[JSON.stringify([run, step, round])]?.count ?? 0
1376
1349
  const attempt = prior + 1
1377
1350
  const willRetry = attempt < this.maxSessionAttempts
1378
1351
  const message = errorMessage(error)
@@ -1412,7 +1385,7 @@ export class HarvestRunner {
1412
1385
  if (this.deps.preparedRun) return
1413
1386
  await this.ensureLease()
1414
1387
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1415
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1388
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1416
1389
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1417
1390
  if (decision.kind !== 'exhaust-recovery' || decision.run !== run) return
1418
1391
  await this.finalizeRecoveryExhaustion(decision.run, decision.attempts, decision.limit)
@@ -1438,7 +1411,7 @@ export class HarvestRunner {
1438
1411
 
1439
1412
  await this.ensureLease()
1440
1413
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1441
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1414
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1442
1415
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1443
1416
  if (
1444
1417
  decision.kind !== 'exhaust-recovery' ||
@@ -1475,7 +1448,7 @@ export class HarvestRunner {
1475
1448
  while (true) {
1476
1449
  await this.ensureLease()
1477
1450
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1478
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1451
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1479
1452
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1480
1453
  if (decision.kind === 'proceed') return
1481
1454
  if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
@@ -1520,7 +1493,7 @@ export class HarvestRunner {
1520
1493
 
1521
1494
  private async refreshRun(run: string): Promise<HarvestRunState> {
1522
1495
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1523
- const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1496
+ const state = (await readRepositoryFacts(this.deps.store, this.deps.repo)).value.harvest
1524
1497
  const found = state.runs.find((candidate) => candidate.run === run)
1525
1498
  if (!found) throw new Error(`unknown harvest run "${run}"`)
1526
1499
  return found
@@ -1638,9 +1611,20 @@ export class HarvestRunner {
1638
1611
  session,
1639
1612
  })
1640
1613
  : undefined
1614
+ const ticketGrant =
1615
+ parent?.AB_TICKET_URL && parent.AB_TICKET_TOKEN
1616
+ ? await issueTicketGrant(
1617
+ parent.AB_TICKET_URL,
1618
+ parent.AB_TICKET_TOKEN,
1619
+ this.deps.repo,
1620
+ this.deps.instance,
1621
+ 'phase',
1622
+ )
1623
+ : undefined
1641
1624
  return {
1642
1625
  AB_STORE: 'local',
1643
1626
  ...this.deps.sessionEnv,
1627
+ ...(ticketGrant ? { AB_TICKET_TOKEN: ticketGrant.token } : {}),
1644
1628
  ...(token ? { AB_TOKEN: token } : {}),
1645
1629
  AB_REPO: this.deps.repo,
1646
1630
  AB_HARVEST: run,