@defrex/autobuild 0.11.0 → 0.12.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 (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. package/templates/hosted-service/vercel.json +7 -0
@@ -0,0 +1,261 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
+ import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { constants } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { parse } from 'smol-toml'
6
+ import {
7
+ normalizeConnectionRepository,
8
+ normalizeServiceURL,
9
+ type ConnectionStarted,
10
+ type ConnectionOutcome,
11
+ } from '../connection'
12
+ import type { Exec } from '../ports/workspace/git-worktree'
13
+ import { resolveMainRepo } from './repo-state'
14
+ import { ProjectCredentials } from './project-credentials'
15
+
16
+ interface ConnectOptions {
17
+ cwd: string
18
+ env: Record<string, string | undefined>
19
+ exec: Exec
20
+ print: (message: string) => void
21
+ signal?: AbortSignal
22
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>
23
+ browser?: (url: string) => Promise<void>
24
+ sleep?: (ms: number) => Promise<void>
25
+ now?: () => number
26
+ }
27
+ function requireOperator(env: ConnectOptions['env']) {
28
+ if (
29
+ ['AB_BUILD', 'AB_PHASE', 'AB_SESSION', 'AB_HARVEST', 'AB_REPO'].some(
30
+ (key) => env[key] !== undefined,
31
+ )
32
+ )
33
+ throw new Error('connect and disconnect are unavailable inside build or harvest sessions')
34
+ }
35
+ async function project(opts: ConnectOptions) {
36
+ requireOperator(opts.env)
37
+ const checkout = await resolveMainRepo(opts.cwd, opts.exec)
38
+ const remote = await opts.exec(['git', 'remote', 'get-url', 'origin'], { cwd: checkout })
39
+ if (remote.exitCode !== 0 || !remote.stdout.trim())
40
+ throw new Error('A network origin remote is required; add an origin before running ab connect')
41
+ const repo = normalizeConnectionRepository(remote.stdout.trim())
42
+ const path = join(checkout, 'autobuild.toml')
43
+ const text = await readFile(path, 'utf8')
44
+ const table = parse(text)
45
+ const store = table.store as { url?: unknown } | undefined
46
+ if (store && (typeof store !== 'object' || Array.isArray(store)))
47
+ throw new Error('Invalid [store] configuration')
48
+ const service = store?.url === undefined ? undefined : normalizeServiceURL(String(store.url))
49
+ return { checkout, repo, path, text, table, service }
50
+ }
51
+ async function request(
52
+ opts: ConnectOptions,
53
+ service: string,
54
+ path: string,
55
+ body?: unknown,
56
+ token?: string,
57
+ ) {
58
+ const response = await (opts.fetch ?? fetch)(`${service}/connect/v1/${path}`, {
59
+ method: body === undefined ? 'GET' : 'POST',
60
+ headers: {
61
+ 'content-type': 'application/json',
62
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
63
+ },
64
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
65
+ signal: AbortSignal.timeout(15000),
66
+ redirect: 'error',
67
+ })
68
+ return response
69
+ }
70
+ async function revoke(opts: ConnectOptions, service: string, token: string) {
71
+ const response = await request(opts, service, 'revoke', {}, token)
72
+ if (!response.ok && response.status !== 401)
73
+ throw new Error('Remote revocation failed; retry ab disconnect when the service is reachable')
74
+ }
75
+ async function atomicConfig(path: string, before: string, after: string) {
76
+ const temporary = `${path}.${randomUUID()}.tmp`
77
+ try {
78
+ await writeFile(temporary, after, { flag: 'wx' })
79
+ if ((await readFile(path, 'utf8')) !== before)
80
+ throw new Error('autobuild.toml changed during connection; retry ab connect')
81
+ await rename(temporary, path)
82
+ } finally {
83
+ await unlink(temporary).catch(() => {})
84
+ }
85
+ }
86
+ export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
87
+ const p = await project(opts)
88
+ const credentials = new ProjectCredentials(opts.env)
89
+ if (raw === undefined) {
90
+ if (!p.service) {
91
+ opts.print(
92
+ `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
93
+ )
94
+ return
95
+ }
96
+ const entry = credentials.load(p.service, p.repo)
97
+ let status = 'missing; run ab connect'
98
+ if (entry) {
99
+ try {
100
+ const response = await request(opts, p.service, 'status', undefined, entry.token)
101
+ if (response.ok) {
102
+ const body = (await response.json()) as {
103
+ repo?: string
104
+ credentialId?: string
105
+ accepted?: boolean
106
+ }
107
+ if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
108
+ throw new Error('Invalid status response')
109
+ }
110
+ status = response.ok
111
+ ? 'accepted'
112
+ : response.status === 401 || response.status === 403
113
+ ? 'rejected; run ab connect'
114
+ : 'unreachable'
115
+ } catch {
116
+ status = 'unreachable'
117
+ }
118
+ }
119
+ opts.print(`Service: ${p.service}\nProject: ${p.repo}\nCredential: ${status}`)
120
+ return
121
+ }
122
+ const service = normalizeServiceURL(raw)
123
+ if (p.service && p.service !== service)
124
+ throw new Error(
125
+ 'Configured [store] url differs; update it explicitly before connecting to another service',
126
+ )
127
+ await access(p.path, constants.W_OK)
128
+ await access(dirname(p.path), constants.W_OK)
129
+ const previous = credentials.load(service, p.repo)
130
+ const id = randomBytes(16).toString('hex'),
131
+ secret = randomBytes(32).toString('hex')
132
+ const token = `abp_1_${id}_${secret}`
133
+ const response = await request(opts, service, 'requests', {
134
+ repo: p.repo,
135
+ credentialId: id,
136
+ digest: createHash('sha256').update(secret).digest('hex'),
137
+ })
138
+ if (!response.ok)
139
+ throw new Error('Could not start connection approval; verify the web-enabled service URL')
140
+ const started = (await response.json()) as ConnectionStarted
141
+ if (
142
+ !/^[a-f0-9]{32}$/.test(started.id) ||
143
+ !/^[a-f0-9]{64}$/.test(started.capability) ||
144
+ !/^[A-F0-9]{10}$/.test(started.code) ||
145
+ !Number.isFinite(Date.parse(started.expiresAt))
146
+ )
147
+ throw new Error('Invalid connection response')
148
+ const verification = `${service}/connect?code=${started.code}`
149
+ opts.print(`Approve project ${p.repo}\nOpen ${verification}\nCode: ${started.code}`)
150
+ try {
151
+ if (opts.browser) await opts.browser(verification)
152
+ else {
153
+ const command =
154
+ process.platform === 'darwin'
155
+ ? ['open', verification]
156
+ : process.platform === 'win32'
157
+ ? ['rundll32', 'url.dll,FileProtocolHandler', verification]
158
+ : ['xdg-open', verification]
159
+ const child = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' })
160
+ child.unref()
161
+ }
162
+ } catch {
163
+ opts.print('Browser could not be opened. Use the URL and code on another device.')
164
+ }
165
+ const capability = { id: started.id, capability: started.capability }
166
+ const now = opts.now ?? Date.now
167
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
168
+ const interval = Math.max(5, Math.min(30, started.interval || 5)) * 1000
169
+ let approved = false
170
+ let configChanged = false
171
+ let saved = false
172
+ try {
173
+ let outcome: ConnectionOutcome | undefined
174
+ while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
175
+ await sleep(interval)
176
+ const polled = await request(opts, service, 'poll', capability)
177
+ if (polled.status === 429) continue
178
+ if (!polled.ok) throw new Error('Approval polling failed; retry ab connect')
179
+ outcome = (await polled.json()) as ConnectionOutcome
180
+ if (outcome.status !== 'pending') break
181
+ }
182
+ if (!outcome || outcome.status === 'pending' || opts.signal?.aborted) {
183
+ const cancelled = await request(opts, service, 'cancel', capability)
184
+ if (!cancelled.ok)
185
+ throw new Error(
186
+ 'Could not confirm cancellation; use the owner credential list to check this connection',
187
+ )
188
+ opts.print(
189
+ opts.signal?.aborted
190
+ ? 'Connection cancelled; local configuration unchanged.'
191
+ : 'Connection timed out; local configuration unchanged.',
192
+ )
193
+ return
194
+ }
195
+ if (outcome.status !== 'approved') {
196
+ opts.print(`Connection ${outcome.status}; local configuration unchanged.`)
197
+ return
198
+ }
199
+ approved = true
200
+ if (outcome.repo !== p.repo || outcome.credentialId !== id)
201
+ throw new Error('Approval identity mismatch')
202
+ if (!p.service) {
203
+ // Dotted root key also supports an existing empty [store] table after it.
204
+ const after = `store.url = ${JSON.stringify(service)}\n${p.text}`
205
+ const parsed = parse(after)
206
+ if ((parsed.store as { url?: string })?.url !== service)
207
+ throw new Error('Cannot safely update [store] url; add it manually and retry')
208
+ await atomicConfig(p.path, p.text, after)
209
+ configChanged = true
210
+ }
211
+ credentials.save({ service, repo: p.repo, id, token })
212
+ saved = true
213
+ if (previous) {
214
+ try {
215
+ await revoke(opts, service, previous.token)
216
+ } catch {
217
+ opts.print(
218
+ `Connected, but the previous credential ${previous.id} could not be revoked; revoke it using the service administrative tools.`,
219
+ )
220
+ }
221
+ }
222
+ opts.print(`Connected ${p.repo} to ${service}.`)
223
+ if (configChanged) opts.print(`Updated ${p.path}; review and commit this non-secret setting.`)
224
+ } catch (error) {
225
+ if (!saved) {
226
+ try {
227
+ const cancelled = await request(opts, service, 'cancel', capability)
228
+ if (!cancelled.ok) throw new Error()
229
+ } catch {
230
+ opts.print(
231
+ `Could not confirm remote cleanup for credential ${id}; check the service administrative credential list.`,
232
+ )
233
+ }
234
+ if (configChanged) {
235
+ try {
236
+ await atomicConfig(p.path, `store.url = ${JSON.stringify(service)}\n${p.text}`, p.text)
237
+ } catch {
238
+ opts.print('Restore [store] url manually if needed; configuration changed concurrently.')
239
+ }
240
+ }
241
+ }
242
+ throw new Error(
243
+ approved
244
+ ? 'Connection could not be saved; previous credential retained. Repair local storage and run ab connect again.'
245
+ : (error as Error).message,
246
+ )
247
+ }
248
+ }
249
+ export async function abDisconnect(opts: ConnectOptions): Promise<void> {
250
+ const p = await project(opts)
251
+ if (!p.service) throw new Error('No configured service')
252
+ const credentials = new ProjectCredentials(opts.env)
253
+ const entry = credentials.load(p.service, p.repo)
254
+ if (!entry) {
255
+ opts.print('No stored credential; project configuration and service data retained.')
256
+ return
257
+ }
258
+ await revoke(opts, p.service, entry.token)
259
+ credentials.remove(p.service, p.repo)
260
+ opts.print('Disconnected; project registration, data, and [store] url retained.')
261
+ }
@@ -116,11 +116,9 @@ export interface PipelineStep {
116
116
  /** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
117
117
  * shown (AC 6). The renderer composes the elapsed segment from this. */
118
118
  timing?: StepTiming
119
- /** Display-only, like `qualifier`, and never consulted for routing: the
120
- * parked merge step's no-consent explanation — why nothing will happen
121
- * without operator action, and which control unblocks it. Attached when the
122
- * build is current at merge with effective auto-merge off; the terminal
123
- * row/detail and the web row/detail render the same words. */
119
+ /** Display-only policy skip reason or parked merge explanation. The shared
120
+ * row/detail renderers use this wrapping prose segment for longer reasons;
121
+ * it is never consulted for routing. */
124
122
  reason?: string
125
123
  }
126
124
 
@@ -752,6 +750,7 @@ export function projectBuildFromFacts(
752
750
  : stepResults.some((r) => r.outcome === 'skipped')
753
751
  ? 'skipped'
754
752
  : undefined,
753
+ reason: stepResults.find((r) => r.skipCause === 'post-reconcile-policy')?.reason,
755
754
  count,
756
755
  timing: timingFor(intervals, phase, cycleSince, frozenNow),
757
756
  }),
@@ -1,3 +1,8 @@
1
+ import { RemoteBuildStore } from '../store/remote/client'
2
+ import { delegateCredential } from '../store/remote/credentials'
3
+ import { isRemoteStoreRef } from './repo-state'
4
+ import { projectToken } from './project-credentials'
5
+ import { settleSummaryRequests } from './pr-summary-publication'
1
6
  import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
2
7
  import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
3
8
  /**
@@ -182,18 +187,6 @@ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
182
187
  // Local uses within this module.
183
188
  import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
184
189
 
185
- /** JSON encoding in the config schema's declarative input shape. Parsed Config
186
- * has normalized `{steps, stepConfigs}` sections; flattening named step tables
187
- * lets the frontend validate the artifact with the same strict configSchema. */
188
- function effectiveConfigContent(config: Config): string {
189
- const { verify, finalize, ...root } = config
190
- return JSON.stringify({
191
- ...root,
192
- verify: { steps: verify.steps, ...verify.stepConfigs },
193
- finalize: { steps: finalize.steps, ...finalize.stepConfigs },
194
- })
195
- }
196
-
197
190
  /** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
198
191
  * owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
199
192
  export function slugNamingPrompt(spec: string): string {
@@ -212,7 +205,7 @@ export function slugNamingPrompt(spec: string): string {
212
205
  function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
213
206
  const defined: Record<string, string> = {}
214
207
  for (const [key, value] of Object.entries(env)) {
215
- if (value !== undefined) defined[key] = value
208
+ if (value !== undefined && key !== 'AB_TOKEN') defined[key] = value
216
209
  }
217
210
  return defined
218
211
  }
@@ -264,6 +257,7 @@ interface ResumePrompt {
264
257
  /** The real adapters the loop drives — resolved by `wire` (default: the
265
258
  * production ports; tests inject fakes). */
266
259
  export interface DispatchWiring {
260
+ credentialIssuer?: typeof delegateCredential
267
261
  store: BuildStore
268
262
  tickets: TicketSource
269
263
  forge: Forge
@@ -282,7 +276,7 @@ export interface DispatchWiring {
282
276
  * same store as `store`, so an agent's `ab` commands write where the
283
277
  * dispatcher reads. */
284
278
  storeRef: string
285
- /** Scoped token for a remote store (D8, `AB_TOKEN`); passed to sessions. */
279
+ /** Private parent authority for remote exchange; never passed to execution. */
286
280
  token?: string
287
281
  ids: IdSource
288
282
  uuids: UuidSource
@@ -2231,7 +2225,7 @@ class DispatchLoop {
2231
2225
  }
2232
2226
 
2233
2227
  const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
2234
- const runner = new HarvestRunner({
2228
+ const harvestDeps = {
2235
2229
  store,
2236
2230
  tickets,
2237
2231
  config: this.currentConfig().config,
@@ -2246,16 +2240,66 @@ class DispatchLoop {
2246
2240
  ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2247
2241
  sessionEnv: {
2248
2242
  AB_STORE: storeRef,
2249
- ...(token !== undefined ? { AB_TOKEN: token } : {}),
2250
2243
  },
2251
2244
  opts: {
2252
2245
  maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
2253
2246
  },
2254
- })
2247
+ }
2255
2248
 
2256
2249
  let tracked: Promise<void>
2257
- tracked = runner
2258
- .run()
2250
+ tracked = (async () => {
2251
+ if (!/^https?:/i.test(storeRef)) return new HarvestRunner(harvestDeps).run()
2252
+ const prepared = await new HarvestRunner(harvestDeps).run(true)
2253
+ if (prepared.outcome !== 'prepared') return prepared
2254
+ await this.repoView.appendRepo(this.repoIdentity, {
2255
+ actor: DISPATCHER,
2256
+ type: 'harvest.execution.authorized',
2257
+ payload: {
2258
+ execution: harvestDeps.instance,
2259
+ run: prepared.run,
2260
+ launch: prepared.launch,
2261
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2262
+ },
2263
+ })
2264
+ const scoped = await (this.wiring.credentialIssuer ?? delegateCredential)(storeRef, token, {
2265
+ kind: 'harvest-runner',
2266
+ execution: harvestDeps.instance,
2267
+ repo: this.repoIdentity,
2268
+ id: prepared.run,
2269
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2270
+ })
2271
+ const runnerStore = new RemoteBuildStore({ url: storeRef, token: scoped })
2272
+ try {
2273
+ const runnerTickets =
2274
+ harvestDeps.config.tickets.source === 'hosted'
2275
+ ? await createTicketSource(
2276
+ harvestDeps.config.tickets,
2277
+ { ...this.opts.env, AB_STORE: storeRef, AB_TOKEN: scoped },
2278
+ this.opts.targetRepo,
2279
+ resolveRepoStatePaths({
2280
+ repo: this.repoIdentity,
2281
+ checkout: this.opts.targetRepo,
2282
+ storeRef,
2283
+ }).localStateRoot,
2284
+ this.wiring.plugins,
2285
+ )
2286
+ : harvestDeps.tickets
2287
+ return await new HarvestRunner({
2288
+ ...harvestDeps,
2289
+ store: runnerStore,
2290
+ tickets: runnerTickets,
2291
+ preparedRun: { run: prepared.run },
2292
+ sessionEnv: { AB_STORE: storeRef, AB_TOKEN: scoped },
2293
+ }).run()
2294
+ } finally {
2295
+ await runnerStore.appendRepo(this.repoIdentity, {
2296
+ actor: DISPATCHER,
2297
+ type: 'harvest.execution.ended',
2298
+ payload: { execution: harvestDeps.instance, run: prepared.run },
2299
+ })
2300
+ await runnerStore.close()
2301
+ }
2302
+ })()
2259
2303
  .then((result) => {
2260
2304
  this.recordHarvestResult(result)
2261
2305
  if (
@@ -2299,6 +2343,24 @@ class DispatchLoop {
2299
2343
  * contract: provisioning takes minutes and must never stall a tick; the
2300
2344
  * single-flight guard covers the whole span. Every failure is contained
2301
2345
  * and retried on a later threshold tick. */
2346
+ private async prepareHarvest(instance: string): Promise<HarvestRunnerResult> {
2347
+ return new HarvestRunner({
2348
+ store: this.wiring.store,
2349
+ tickets: this.wiring.tickets,
2350
+ config: this.currentConfig().config,
2351
+ getConfig: () => this.currentConfig().config,
2352
+ runtimes: this.wiring.runtimes,
2353
+ repo: this.repoIdentity,
2354
+ workspacePath: this.opts.targetRepo,
2355
+ ids: this.wiring.ids,
2356
+ uuids: this.wiring.uuids,
2357
+ clock: this.wiring.clock,
2358
+ instance,
2359
+ ...(this.repoLeaseHolder ? { leaseHolder: this.repoLeaseHolder } : {}),
2360
+ opts: { maxRecoveryAttempts: this.maxHarvestRecoveryAttempts },
2361
+ }).run(true)
2362
+ }
2363
+
2302
2364
  private async launchHostedHarvest(
2303
2365
  harvestExecution: HarvestExecution,
2304
2366
  execution: string,
@@ -2349,13 +2411,44 @@ class DispatchLoop {
2349
2411
  if (pressure.trigger === undefined) return
2350
2412
  }
2351
2413
 
2352
- handle = await harvestExecution.start({
2353
- storeRef,
2354
- repo,
2355
- instance: execution,
2356
- baseBranch: this.currentConfig().config.baseBranch,
2357
- ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2414
+ const prepared = await this.prepareHarvest(execution)
2415
+ if (prepared.outcome !== 'prepared') return
2416
+ await this.repoView.appendRepo(repo, {
2417
+ actor: DISPATCHER,
2418
+ type: 'harvest.execution.authorized',
2419
+ payload: {
2420
+ execution,
2421
+ run: prepared.run,
2422
+ launch: prepared.launch,
2423
+ leaseHolder: this.repoLeaseHolder ?? execution,
2424
+ },
2358
2425
  })
2426
+ const launchAuth = /^https?:/i.test(storeRef)
2427
+ ? {
2428
+ token: await (this.wiring.credentialIssuer ?? delegateCredential)(
2429
+ storeRef,
2430
+ this.wiring.token,
2431
+ {
2432
+ kind: 'harvest-runner',
2433
+ execution,
2434
+ repo,
2435
+ id: prepared.run,
2436
+ leaseHolder: this.repoLeaseHolder ?? execution,
2437
+ },
2438
+ ),
2439
+ }
2440
+ : undefined
2441
+ handle = await harvestExecution.start(
2442
+ {
2443
+ preparedRun: { run: prepared.run },
2444
+ storeRef,
2445
+ repo,
2446
+ instance: execution,
2447
+ baseBranch: this.currentConfig().config.baseBranch,
2448
+ ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2449
+ },
2450
+ launchAuth,
2451
+ )
2359
2452
  const identity = handle.identity
2360
2453
  if (
2361
2454
  identity === undefined ||
@@ -2628,7 +2721,7 @@ class DispatchLoop {
2628
2721
  return null
2629
2722
  }
2630
2723
 
2631
- private async settlePendingPublication(slug: string): Promise<void> {
2724
+ private async settlePendingPublication(slug: string, reservation?: string): Promise<void> {
2632
2725
  // The guest appended through its own handle; settle against fresh state.
2633
2726
  await this.repoView.refreshBuild(slug)
2634
2727
  await settleWorkspacePublication(
@@ -2645,6 +2738,16 @@ class DispatchLoop {
2645
2738
  },
2646
2739
  slug,
2647
2740
  )
2741
+ await settleSummaryRequests({
2742
+ store: this.repoView,
2743
+ forge: this.wiring.forge,
2744
+ slug,
2745
+ workspacePath: this.opts.targetRepo,
2746
+ storeRef: this.wiring.storeRef,
2747
+ clock: this.wiring.clock,
2748
+ holder: `summary:${this.runId}`,
2749
+ ...(reservation === undefined ? {} : { reservation }),
2750
+ })
2648
2751
  }
2649
2752
 
2650
2753
  private async recordInfrastructureFailure(input: {
@@ -2736,13 +2839,25 @@ class DispatchLoop {
2736
2839
  // A prior publication acknowledgement may have been lost after its push
2737
2840
  // reached the durable branch. Reconcile that fact before a replacement
2738
2841
  // runner can rerun the phase and create a non-fast-forward successor.
2739
- await this.settlePendingPublication(slug)
2740
- const handle = await owning.execution.start({
2741
- slug,
2742
- storeRef: this.wiring.storeRef,
2743
- instance,
2744
- workspaceRef,
2745
- })
2842
+ await this.settlePendingPublication(slug, instance)
2843
+ const launchAuth = /^https?:/i.test(this.wiring.storeRef)
2844
+ ? {
2845
+ token: await (this.wiring.credentialIssuer ?? delegateCredential)(
2846
+ this.wiring.storeRef,
2847
+ this.wiring.token,
2848
+ { kind: 'build-runner', id: slug },
2849
+ ),
2850
+ }
2851
+ : undefined
2852
+ const handle = await owning.execution.start(
2853
+ {
2854
+ slug,
2855
+ storeRef: this.wiring.storeRef,
2856
+ instance,
2857
+ workspaceRef,
2858
+ },
2859
+ launchAuth,
2860
+ )
2746
2861
  active.handle = handle
2747
2862
  const identity = handle.identity ?? {
2748
2863
  provider: owning.name,
@@ -3694,16 +3809,21 @@ function acceptableOriginModeStore(storeRef: string): boolean {
3694
3809
  * names the operator's own machine (local development, integration harnesses).
3695
3810
  * Requirements are validated here so a misconfigured origin-mode launch fails
3696
3811
  * before any side effect. */
3697
- async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePaths> {
3812
+ async function resolveOriginModeState(
3813
+ opts: DispatchOpts,
3814
+ configStore?: string,
3815
+ ): Promise<RepoStatePaths> {
3698
3816
  const identity = normalizeGitRemoteUrl(opts.repository!)
3699
- const selectedStore = opts.storeRef ?? opts.env.AB_STORE
3817
+ const selectedStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim() || configStore
3700
3818
  if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
3701
3819
  throw new Error(
3702
3820
  'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
3703
3821
  )
3704
3822
  }
3705
- if (opts.env.AB_TOKEN === undefined || opts.env.AB_TOKEN === '') {
3706
- throw new Error('origin-mode dispatch requires AB_TOKEN for the remote Store')
3823
+ if (!projectToken(selectedStore, identity, opts.env)) {
3824
+ throw new Error(
3825
+ 'origin-mode dispatch requires a project credential; run ab connect or set AB_TOKEN',
3826
+ )
3707
3827
  }
3708
3828
  // Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
3709
3829
  // no keyring, and the only workspace provider origin mode can run —
@@ -3721,6 +3841,7 @@ async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePath
3721
3841
  await mkdir(scratch, { recursive: true })
3722
3842
  return resolveRepoStatePaths({
3723
3843
  repo: identity,
3844
+ configStore,
3724
3845
  checkout: scratch,
3725
3846
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
3726
3847
  ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
@@ -3743,9 +3864,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3743
3864
  if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
3744
3865
  throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
3745
3866
  }
3867
+ const explicitOriginStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim()
3868
+ if (
3869
+ opts.repository !== undefined &&
3870
+ explicitOriginStore &&
3871
+ !acceptableOriginModeStore(explicitOriginStore)
3872
+ ) {
3873
+ throw new Error(
3874
+ 'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
3875
+ )
3876
+ }
3877
+ const originConfig =
3878
+ opts.repository !== undefined
3879
+ ? await fetchOriginModeConfig(opts, opts.originConfigTransport)
3880
+ : undefined
3746
3881
  const state =
3747
3882
  opts.repository !== undefined
3748
- ? await resolveOriginModeState(opts)
3883
+ ? await resolveOriginModeState(opts, originConfig?.config.store?.url)
3749
3884
  : await resolveRepoState({
3750
3885
  targetRepo: opts.targetRepo,
3751
3886
  exec: opts.exec,
@@ -3759,6 +3894,13 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3759
3894
  const resolvedOpts: DispatchOpts = {
3760
3895
  ...opts,
3761
3896
  targetRepo: state.checkout,
3897
+ env: {
3898
+ ...opts.env,
3899
+ AB_STORE: state.storeRef,
3900
+ ...(isRemoteStoreRef(state.storeRef)
3901
+ ? { AB_TOKEN: projectToken(state.storeRef, state.repo, opts.env) }
3902
+ : {}),
3903
+ },
3762
3904
  storeRef: state.storeRef,
3763
3905
  repo: opts.repo ?? state.repo,
3764
3906
  }
@@ -3816,7 +3958,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3816
3958
  if (opts.repository !== undefined) {
3817
3959
  // Origin mode startup config: the base branch's autobuild.toml, fetched
3818
3960
  // through the forge (default branch first — baseBranch itself is config).
3819
- const fetched = await fetchOriginModeConfig(opts, opts.originConfigTransport)
3961
+ const fetched = originConfig!
3820
3962
  configContent = fetched.content
3821
3963
  config = fetched.config
3822
3964
  if (config.forge !== 'github') {
@@ -3930,7 +4072,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3930
4072
  },
3931
4073
  {
3932
4074
  kind: _effectiveConfigKind,
3933
- content: effectiveConfigContent(effectiveConfig),
4075
+ content: effectiveBuildConfigContent(effectiveConfig),
3934
4076
  metadata: { run: runId, effectiveChanged },
3935
4077
  },
3936
4078
  ],
@@ -3995,7 +4137,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3995
4137
  [
3996
4138
  {
3997
4139
  kind: _effectiveConfigKind,
3998
- content: effectiveConfigContent(config),
4140
+ content: effectiveBuildConfigContent(config),
3999
4141
  metadata: { run: runId, revision: 0 },
4000
4142
  },
4001
4143
  ],