@defrex/autobuild 0.11.0 → 0.13.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -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
  }),
@@ -211,7 +211,7 @@ export class DashboardBuildPollCache {
211
211
  // both the cached reduction and projected row untouched.
212
212
  next.set(
213
213
  record.slug,
214
- configChanged
214
+ configChanged || current.build?.ticketId !== record.ticket?.id
215
215
  ? {
216
216
  ...current,
217
217
  build: await this.projectRow(record, current.state, current.events, config),
@@ -1,3 +1,9 @@
1
+ import { issueTicketGrant, ticketGrantRequest } from '../ports/tickets/grants'
2
+ import { RemoteBuildStore } from '../store/remote/client'
3
+ import { delegateCredential } from '../store/remote/credentials'
4
+ import { isRemoteStoreRef } from './repo-state'
5
+ import { projectToken } from './project-credentials'
6
+ import { settleSummaryRequests } from './pr-summary-publication'
1
7
  import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
2
8
  import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
3
9
  /**
@@ -182,18 +188,6 @@ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
182
188
  // Local uses within this module.
183
189
  import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
184
190
 
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
191
  /** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
198
192
  * owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
199
193
  export function slugNamingPrompt(spec: string): string {
@@ -212,7 +206,8 @@ export function slugNamingPrompt(spec: string): string {
212
206
  function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
213
207
  const defined: Record<string, string> = {}
214
208
  for (const [key, value] of Object.entries(env)) {
215
- if (value !== undefined) defined[key] = value
209
+ if (value !== undefined && !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key))
210
+ defined[key] = value
216
211
  }
217
212
  return defined
218
213
  }
@@ -264,6 +259,7 @@ interface ResumePrompt {
264
259
  /** The real adapters the loop drives — resolved by `wire` (default: the
265
260
  * production ports; tests inject fakes). */
266
261
  export interface DispatchWiring {
262
+ credentialIssuer?: typeof delegateCredential
267
263
  store: BuildStore
268
264
  tickets: TicketSource
269
265
  forge: Forge
@@ -282,7 +278,7 @@ export interface DispatchWiring {
282
278
  * same store as `store`, so an agent's `ab` commands write where the
283
279
  * dispatcher reads. */
284
280
  storeRef: string
285
- /** Scoped token for a remote store (D8, `AB_TOKEN`); passed to sessions. */
281
+ /** Private parent authority for remote exchange; never passed to execution. */
286
282
  token?: string
287
283
  ids: IdSource
288
284
  uuids: UuidSource
@@ -817,6 +813,7 @@ class DispatchLoop {
817
813
  forge: wiring.forge,
818
814
  config,
819
815
  getConfig: () => this.liveConfig.current().config,
816
+ repairBuildConfig: (slug) => this.publishBuildConfig(slug),
820
817
  repo: this.repoIdentity,
821
818
  // Origin mode: the forge answers remote base-branch questions; no
822
819
  // checkout git is probed for the served repository's origin.
@@ -2231,7 +2228,7 @@ class DispatchLoop {
2231
2228
  }
2232
2229
 
2233
2230
  const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
2234
- const runner = new HarvestRunner({
2231
+ const harvestDeps = {
2235
2232
  store,
2236
2233
  tickets,
2237
2234
  config: this.currentConfig().config,
@@ -2246,16 +2243,175 @@ class DispatchLoop {
2246
2243
  ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2247
2244
  sessionEnv: {
2248
2245
  AB_STORE: storeRef,
2249
- ...(token !== undefined ? { AB_TOKEN: token } : {}),
2250
2246
  },
2251
2247
  opts: {
2252
2248
  maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
2253
2249
  },
2254
- })
2250
+ }
2255
2251
 
2256
2252
  let tracked: Promise<void>
2257
- tracked = runner
2258
- .run()
2253
+ tracked = (async () => {
2254
+ const ticketUrl =
2255
+ harvestDeps.config.tickets.source === 'hosted'
2256
+ ? (harvestDeps.config.tickets.url ?? (/^https?:/i.test(storeRef) ? storeRef : undefined))
2257
+ : undefined
2258
+ if (ticketUrl && ticketUrl.replace(/\/+$/, '') !== storeRef.replace(/\/+$/, '')) {
2259
+ const ticketToken = projectToken(ticketUrl, this.repoIdentity, {
2260
+ ...this.opts.env,
2261
+ AB_TOKEN: undefined,
2262
+ })
2263
+ if (!ticketToken)
2264
+ throw new Error('Independent hosted harvest requires a ticket-service project credential')
2265
+ const issued = await issueTicketGrant(
2266
+ ticketUrl,
2267
+ ticketToken,
2268
+ this.repoIdentity,
2269
+ harvestDeps.instance,
2270
+ 'runner',
2271
+ )
2272
+ let remoteRunnerStore: RemoteBuildStore | undefined
2273
+ let preparedRun: { run: string } | undefined
2274
+ let buildScoped: string | undefined
2275
+ let renew: ReturnType<typeof setInterval> | undefined
2276
+ try {
2277
+ if (/^https?:/i.test(storeRef)) {
2278
+ const prepared = await new HarvestRunner(harvestDeps).run(true)
2279
+ if (prepared.outcome !== 'prepared') {
2280
+ return prepared
2281
+ }
2282
+ preparedRun = { run: prepared.run }
2283
+ await this.repoView.appendRepo(this.repoIdentity, {
2284
+ actor: DISPATCHER,
2285
+ type: 'harvest.execution.authorized',
2286
+ payload: {
2287
+ execution: harvestDeps.instance,
2288
+ run: prepared.run,
2289
+ launch: prepared.launch,
2290
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2291
+ },
2292
+ })
2293
+ buildScoped = await (this.wiring.credentialIssuer ?? delegateCredential)(
2294
+ storeRef,
2295
+ token,
2296
+ {
2297
+ kind: 'harvest-runner',
2298
+ execution: harvestDeps.instance,
2299
+ repo: this.repoIdentity,
2300
+ id: prepared.run,
2301
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2302
+ },
2303
+ )
2304
+ remoteRunnerStore = new RemoteBuildStore({ url: storeRef, token: buildScoped })
2305
+ }
2306
+ const sessionEnv = {
2307
+ ...harvestDeps.sessionEnv,
2308
+ ...(buildScoped ? { AB_TOKEN: buildScoped } : {}),
2309
+ AB_TICKET_URL: ticketUrl,
2310
+ AB_TICKET_TOKEN: issued.token,
2311
+ }
2312
+ let renewalFailure: unknown
2313
+ renew = setInterval(() => {
2314
+ void ticketGrantRequest(ticketUrl, issued.token, {
2315
+ action: 'renew',
2316
+ repo: this.repoIdentity,
2317
+ id: issued.grant.id,
2318
+ }).catch((error) => {
2319
+ renewalFailure = error
2320
+ })
2321
+ }, 60_000)
2322
+ const runnerTickets = await createTicketSource(
2323
+ harvestDeps.config.tickets,
2324
+ { ...this.opts.env, ...sessionEnv, AB_TOKEN: undefined },
2325
+ this.opts.targetRepo,
2326
+ undefined,
2327
+ this.wiring.plugins,
2328
+ )
2329
+ const result = await new HarvestRunner({
2330
+ ...harvestDeps,
2331
+ ...(remoteRunnerStore ? { store: remoteRunnerStore } : {}),
2332
+ ...(preparedRun ? { preparedRun } : {}),
2333
+ tickets: runnerTickets,
2334
+ sessionEnv,
2335
+ }).run()
2336
+ if (renewalFailure) throw renewalFailure
2337
+ return result
2338
+ } finally {
2339
+ if (renew) clearInterval(renew)
2340
+ try {
2341
+ if (remoteRunnerStore && preparedRun) {
2342
+ await remoteRunnerStore.appendRepo(this.repoIdentity, {
2343
+ actor: DISPATCHER,
2344
+ type: 'harvest.execution.ended',
2345
+ payload: { execution: harvestDeps.instance, run: preparedRun.run },
2346
+ })
2347
+ await remoteRunnerStore.close()
2348
+ }
2349
+ } finally {
2350
+ await ticketGrantRequest(ticketUrl, ticketToken, {
2351
+ action: 'revoke',
2352
+ repo: this.repoIdentity,
2353
+ id: issued.grant.id,
2354
+ })
2355
+ }
2356
+ }
2357
+ }
2358
+ if (!/^https?:/i.test(storeRef)) return new HarvestRunner(harvestDeps).run()
2359
+ const prepared = await new HarvestRunner(harvestDeps).run(true)
2360
+ if (prepared.outcome !== 'prepared') return prepared
2361
+ await this.repoView.appendRepo(this.repoIdentity, {
2362
+ actor: DISPATCHER,
2363
+ type: 'harvest.execution.authorized',
2364
+ payload: {
2365
+ execution: harvestDeps.instance,
2366
+ run: prepared.run,
2367
+ launch: prepared.launch,
2368
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2369
+ },
2370
+ })
2371
+ const scoped = await (this.wiring.credentialIssuer ?? delegateCredential)(storeRef, token, {
2372
+ kind: 'harvest-runner',
2373
+ execution: harvestDeps.instance,
2374
+ repo: this.repoIdentity,
2375
+ id: prepared.run,
2376
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2377
+ })
2378
+ const runnerStore = new RemoteBuildStore({ url: storeRef, token: scoped })
2379
+ try {
2380
+ const runnerTickets =
2381
+ harvestDeps.config.tickets.source === 'hosted'
2382
+ ? await createTicketSource(
2383
+ harvestDeps.config.tickets,
2384
+ {
2385
+ ...this.opts.env,
2386
+ AB_STORE: storeRef,
2387
+ AB_TOKEN: scoped,
2388
+ AB_HARVEST: prepared.run,
2389
+ },
2390
+ this.opts.targetRepo,
2391
+ resolveRepoStatePaths({
2392
+ repo: this.repoIdentity,
2393
+ checkout: this.opts.targetRepo,
2394
+ storeRef,
2395
+ }).localStateRoot,
2396
+ this.wiring.plugins,
2397
+ )
2398
+ : harvestDeps.tickets
2399
+ return await new HarvestRunner({
2400
+ ...harvestDeps,
2401
+ store: runnerStore,
2402
+ tickets: runnerTickets,
2403
+ preparedRun: { run: prepared.run },
2404
+ sessionEnv: { AB_STORE: storeRef, AB_TOKEN: scoped },
2405
+ }).run()
2406
+ } finally {
2407
+ await runnerStore.appendRepo(this.repoIdentity, {
2408
+ actor: DISPATCHER,
2409
+ type: 'harvest.execution.ended',
2410
+ payload: { execution: harvestDeps.instance, run: prepared.run },
2411
+ })
2412
+ await runnerStore.close()
2413
+ }
2414
+ })()
2259
2415
  .then((result) => {
2260
2416
  this.recordHarvestResult(result)
2261
2417
  if (
@@ -2299,6 +2455,24 @@ class DispatchLoop {
2299
2455
  * contract: provisioning takes minutes and must never stall a tick; the
2300
2456
  * single-flight guard covers the whole span. Every failure is contained
2301
2457
  * and retried on a later threshold tick. */
2458
+ private async prepareHarvest(instance: string): Promise<HarvestRunnerResult> {
2459
+ return new HarvestRunner({
2460
+ store: this.wiring.store,
2461
+ tickets: this.wiring.tickets,
2462
+ config: this.currentConfig().config,
2463
+ getConfig: () => this.currentConfig().config,
2464
+ runtimes: this.wiring.runtimes,
2465
+ repo: this.repoIdentity,
2466
+ workspacePath: this.opts.targetRepo,
2467
+ ids: this.wiring.ids,
2468
+ uuids: this.wiring.uuids,
2469
+ clock: this.wiring.clock,
2470
+ instance,
2471
+ ...(this.repoLeaseHolder ? { leaseHolder: this.repoLeaseHolder } : {}),
2472
+ opts: { maxRecoveryAttempts: this.maxHarvestRecoveryAttempts },
2473
+ }).run(true)
2474
+ }
2475
+
2302
2476
  private async launchHostedHarvest(
2303
2477
  harvestExecution: HarvestExecution,
2304
2478
  execution: string,
@@ -2307,6 +2481,8 @@ class DispatchLoop {
2307
2481
  const repo = this.repoIdentity
2308
2482
  let handle: BuildExecutionHandle | undefined
2309
2483
  let startedSeq: number | undefined
2484
+ let ticketAuthority: { url: string; token: string; id: string } | undefined
2485
+ let renewTickets: ReturnType<typeof setInterval> | undefined
2310
2486
  // Shared with `stopHostedHarvest` through the `hostedHarvest` entry: a
2311
2487
  // detach must stay observable to the completion chain even after the
2312
2488
  // entry is cleared, or the chain would classify and reap a live guest.
@@ -2349,13 +2525,76 @@ class DispatchLoop {
2349
2525
  if (pressure.trigger === undefined) return
2350
2526
  }
2351
2527
 
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 } : {}),
2528
+ const prepared = await this.prepareHarvest(execution)
2529
+ if (prepared.outcome !== 'prepared') return
2530
+ await this.repoView.appendRepo(repo, {
2531
+ actor: DISPATCHER,
2532
+ type: 'harvest.execution.authorized',
2533
+ payload: {
2534
+ execution,
2535
+ run: prepared.run,
2536
+ launch: prepared.launch,
2537
+ leaseHolder: this.repoLeaseHolder ?? execution,
2538
+ },
2358
2539
  })
2540
+ const launchAuth = /^https?:/i.test(storeRef)
2541
+ ? {
2542
+ token: await (this.wiring.credentialIssuer ?? delegateCredential)(
2543
+ storeRef,
2544
+ this.wiring.token,
2545
+ {
2546
+ kind: 'harvest-runner',
2547
+ execution,
2548
+ repo,
2549
+ id: prepared.run,
2550
+ leaseHolder: this.repoLeaseHolder ?? execution,
2551
+ },
2552
+ ),
2553
+ }
2554
+ : undefined
2555
+ const ticketConfig = this.currentConfig().config.tickets
2556
+ if (
2557
+ ticketConfig.source === 'hosted' &&
2558
+ ticketConfig.url &&
2559
+ ticketConfig.url.replace(/\/+$/, '') !== storeRef.replace(/\/+$/, '')
2560
+ ) {
2561
+ const ticketToken = projectToken(ticketConfig.url, repo, {
2562
+ ...this.opts.env,
2563
+ AB_TOKEN: undefined,
2564
+ })
2565
+ if (!ticketToken || !launchAuth)
2566
+ throw new Error('Independent harvest requires scoped build and ticket credentials')
2567
+ const issued = await issueTicketGrant(
2568
+ ticketConfig.url,
2569
+ ticketToken,
2570
+ repo,
2571
+ execution,
2572
+ 'runner',
2573
+ )
2574
+ ticketAuthority = { url: ticketConfig.url, token: issued.token, id: issued.grant.id }
2575
+ renewTickets = setInterval(() => {
2576
+ void ticketGrantRequest(ticketConfig.url!, issued.token, {
2577
+ action: 'renew',
2578
+ repo,
2579
+ id: issued.grant.id,
2580
+ }).catch(() => {
2581
+ // Expiry fails subsequent reads closed; the guest cannot regain project authority.
2582
+ })
2583
+ }, 60_000)
2584
+ }
2585
+ handle = await harvestExecution.start(
2586
+ {
2587
+ preparedRun: { run: prepared.run },
2588
+ storeRef,
2589
+ repo,
2590
+ instance: execution,
2591
+ baseBranch: this.currentConfig().config.baseBranch,
2592
+ ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2593
+ },
2594
+ launchAuth && ticketAuthority
2595
+ ? { ...launchAuth, tickets: { url: ticketAuthority.url, token: ticketAuthority.token } }
2596
+ : launchAuth,
2597
+ )
2359
2598
  const identity = handle.identity
2360
2599
  if (
2361
2600
  identity === undefined ||
@@ -2432,6 +2671,13 @@ class DispatchLoop {
2432
2671
  )
2433
2672
  }
2434
2673
  } finally {
2674
+ if (renewTickets) clearInterval(renewTickets)
2675
+ if (ticketAuthority && !detached.value)
2676
+ await ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
2677
+ action: 'revoke',
2678
+ repo,
2679
+ id: ticketAuthority.id,
2680
+ })
2435
2681
  this.hostedHarvest = undefined
2436
2682
  }
2437
2683
  }
@@ -2628,7 +2874,7 @@ class DispatchLoop {
2628
2874
  return null
2629
2875
  }
2630
2876
 
2631
- private async settlePendingPublication(slug: string): Promise<void> {
2877
+ private async settlePendingPublication(slug: string, reservation?: string): Promise<void> {
2632
2878
  // The guest appended through its own handle; settle against fresh state.
2633
2879
  await this.repoView.refreshBuild(slug)
2634
2880
  await settleWorkspacePublication(
@@ -2645,6 +2891,16 @@ class DispatchLoop {
2645
2891
  },
2646
2892
  slug,
2647
2893
  )
2894
+ await settleSummaryRequests({
2895
+ store: this.repoView,
2896
+ forge: this.wiring.forge,
2897
+ slug,
2898
+ workspacePath: this.opts.targetRepo,
2899
+ storeRef: this.wiring.storeRef,
2900
+ clock: this.wiring.clock,
2901
+ holder: `summary:${this.runId}`,
2902
+ ...(reservation === undefined ? {} : { reservation }),
2903
+ })
2648
2904
  }
2649
2905
 
2650
2906
  private async recordInfrastructureFailure(input: {
@@ -2736,13 +2992,25 @@ class DispatchLoop {
2736
2992
  // A prior publication acknowledgement may have been lost after its push
2737
2993
  // reached the durable branch. Reconcile that fact before a replacement
2738
2994
  // 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
- })
2995
+ await this.settlePendingPublication(slug, instance)
2996
+ const launchAuth = /^https?:/i.test(this.wiring.storeRef)
2997
+ ? {
2998
+ token: await (this.wiring.credentialIssuer ?? delegateCredential)(
2999
+ this.wiring.storeRef,
3000
+ this.wiring.token,
3001
+ { kind: 'build-runner', id: slug },
3002
+ ),
3003
+ }
3004
+ : undefined
3005
+ const handle = await owning.execution.start(
3006
+ {
3007
+ slug,
3008
+ storeRef: this.wiring.storeRef,
3009
+ instance,
3010
+ workspaceRef,
3011
+ },
3012
+ launchAuth,
3013
+ )
2746
3014
  active.handle = handle
2747
3015
  const identity = handle.identity ?? {
2748
3016
  provider: owning.name,
@@ -3694,16 +3962,21 @@ function acceptableOriginModeStore(storeRef: string): boolean {
3694
3962
  * names the operator's own machine (local development, integration harnesses).
3695
3963
  * Requirements are validated here so a misconfigured origin-mode launch fails
3696
3964
  * before any side effect. */
3697
- async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePaths> {
3965
+ async function resolveOriginModeState(
3966
+ opts: DispatchOpts,
3967
+ configStore?: string,
3968
+ ): Promise<RepoStatePaths> {
3698
3969
  const identity = normalizeGitRemoteUrl(opts.repository!)
3699
- const selectedStore = opts.storeRef ?? opts.env.AB_STORE
3970
+ const selectedStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim() || configStore
3700
3971
  if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
3701
3972
  throw new Error(
3702
3973
  'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
3703
3974
  )
3704
3975
  }
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')
3976
+ if (!projectToken(selectedStore, identity, opts.env)) {
3977
+ throw new Error(
3978
+ 'origin-mode dispatch requires a project credential; run ab connect or set AB_TOKEN',
3979
+ )
3707
3980
  }
3708
3981
  // Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
3709
3982
  // no keyring, and the only workspace provider origin mode can run —
@@ -3721,6 +3994,7 @@ async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePath
3721
3994
  await mkdir(scratch, { recursive: true })
3722
3995
  return resolveRepoStatePaths({
3723
3996
  repo: identity,
3997
+ configStore,
3724
3998
  checkout: scratch,
3725
3999
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
3726
4000
  ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
@@ -3743,9 +4017,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3743
4017
  if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
3744
4018
  throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
3745
4019
  }
4020
+ const explicitOriginStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim()
4021
+ if (
4022
+ opts.repository !== undefined &&
4023
+ explicitOriginStore &&
4024
+ !acceptableOriginModeStore(explicitOriginStore)
4025
+ ) {
4026
+ throw new Error(
4027
+ 'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
4028
+ )
4029
+ }
4030
+ const originConfig =
4031
+ opts.repository !== undefined
4032
+ ? await fetchOriginModeConfig(opts, opts.originConfigTransport)
4033
+ : undefined
3746
4034
  const state =
3747
4035
  opts.repository !== undefined
3748
- ? await resolveOriginModeState(opts)
4036
+ ? await resolveOriginModeState(opts, originConfig?.config.store?.url)
3749
4037
  : await resolveRepoState({
3750
4038
  targetRepo: opts.targetRepo,
3751
4039
  exec: opts.exec,
@@ -3759,6 +4047,13 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3759
4047
  const resolvedOpts: DispatchOpts = {
3760
4048
  ...opts,
3761
4049
  targetRepo: state.checkout,
4050
+ env: {
4051
+ ...opts.env,
4052
+ AB_STORE: state.storeRef,
4053
+ ...(isRemoteStoreRef(state.storeRef)
4054
+ ? { AB_TOKEN: projectToken(state.storeRef, state.repo, opts.env) }
4055
+ : {}),
4056
+ },
3762
4057
  storeRef: state.storeRef,
3763
4058
  repo: opts.repo ?? state.repo,
3764
4059
  }
@@ -3816,7 +4111,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3816
4111
  if (opts.repository !== undefined) {
3817
4112
  // Origin mode startup config: the base branch's autobuild.toml, fetched
3818
4113
  // through the forge (default branch first — baseBranch itself is config).
3819
- const fetched = await fetchOriginModeConfig(opts, opts.originConfigTransport)
4114
+ const fetched = originConfig!
3820
4115
  configContent = fetched.content
3821
4116
  config = fetched.config
3822
4117
  if (config.forge !== 'github') {
@@ -3930,7 +4225,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3930
4225
  },
3931
4226
  {
3932
4227
  kind: _effectiveConfigKind,
3933
- content: effectiveConfigContent(effectiveConfig),
4228
+ content: effectiveBuildConfigContent(effectiveConfig),
3934
4229
  metadata: { run: runId, effectiveChanged },
3935
4230
  },
3936
4231
  ],
@@ -3995,7 +4290,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3995
4290
  [
3996
4291
  {
3997
4292
  kind: _effectiveConfigKind,
3998
- content: effectiveConfigContent(config),
4293
+ content: effectiveBuildConfigContent(config),
3999
4294
  metadata: { run: runId, revision: 0 },
4000
4295
  },
4001
4296
  ],
@@ -184,10 +184,20 @@ export async function buildHarvestContext(deps: HarvestCliDeps): Promise<Harvest
184
184
  }
185
185
 
186
186
  async function readJsonFile(path: string): Promise<unknown> {
187
- const file = Bun.file(path)
188
- if (!(await file.exists())) throw new Error(`file not found: ${path}`)
187
+ let text: string
189
188
  try {
190
- return JSON.parse(await file.text())
189
+ text = await Bun.file(path).text()
190
+ } catch (error) {
191
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
192
+ throw new Error(`file not found: ${path}`, { cause: error })
193
+ }
194
+ throw new Error(
195
+ `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
196
+ { cause: error },
197
+ )
198
+ }
199
+ try {
200
+ return JSON.parse(text)
191
201
  } catch (error) {
192
202
  throw new Error(
193
203
  `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,