@defrex/autobuild 0.10.1 → 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 (120) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +237 -23
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/architecture.md +66 -4
  5. package/docs/configuration.md +137 -19
  6. package/docs/hosted-dispatcher.md +17 -2
  7. package/docs/hosted-service.md +214 -0
  8. package/docs/mcp.md +8 -2
  9. package/docs/operator-api.md +107 -7
  10. package/docs/remote-store-protocol.md +189 -144
  11. package/docs/remote-ticket-protocol.md +111 -18
  12. package/docs/setup.md +173 -16
  13. package/docs/ticket-dependencies.md +1 -1
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/binary.ts +3 -3
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +261 -0
  19. package/packages/core/src/cli/dashboard/model.ts +8 -5
  20. package/packages/core/src/cli/dashboard/render.ts +37 -7
  21. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  22. package/packages/core/src/cli/dispatch-process.ts +25 -7
  23. package/packages/core/src/cli/dispatch.ts +247 -47
  24. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  25. package/packages/core/src/cli/help.ts +45 -13
  26. package/packages/core/src/cli/init-validation.ts +27 -2
  27. package/packages/core/src/cli/main.ts +41 -15
  28. package/packages/core/src/cli/mcp.ts +7 -1
  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/repository-status.ts +45 -5
  34. package/packages/core/src/cli/self-update.ts +10 -0
  35. package/packages/core/src/cli/store-opening.ts +22 -14
  36. package/packages/core/src/cli/terminals.ts +0 -10
  37. package/packages/core/src/cli/ticket.ts +8 -1
  38. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  39. package/packages/core/src/cli/wait.ts +84 -17
  40. package/packages/core/src/cli/watch.ts +350 -68
  41. package/packages/core/src/config/live.ts +4 -0
  42. package/packages/core/src/config/schema.ts +65 -11
  43. package/packages/core/src/connection.ts +86 -0
  44. package/packages/core/src/events/catalog.ts +10 -2
  45. package/packages/core/src/events/globs.ts +8 -3
  46. package/packages/core/src/events/payloads.ts +44 -0
  47. package/packages/core/src/events/repository.ts +64 -2
  48. package/packages/core/src/integration/harness.ts +2 -1
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
  50. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  51. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  52. package/packages/core/src/kernel/engine.ts +24 -0
  53. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  54. package/packages/core/src/kernel/log-index.ts +17 -0
  55. package/packages/core/src/kernel/pr-summary.ts +89 -0
  56. package/packages/core/src/kernel/reducer-registry.ts +12 -0
  57. package/packages/core/src/kernel/reducer.ts +8 -0
  58. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  59. package/packages/core/src/ontology.ts +1 -0
  60. package/packages/core/src/operator/index.ts +7 -0
  61. package/packages/core/src/operator/query.ts +44 -19
  62. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  63. package/packages/core/src/operator/tickets.ts +6 -8
  64. package/packages/core/src/plugin-sdk/index.ts +1 -0
  65. package/packages/core/src/plugins/manifest.ts +5 -3
  66. package/packages/core/src/plugins/runtimes.ts +3 -1
  67. package/packages/core/src/ports/forge/fake.ts +13 -1
  68. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  69. package/packages/core/src/ports/forge/github.ts +29 -5
  70. package/packages/core/src/ports/forge/local-git.ts +12 -4
  71. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  72. package/packages/core/src/ports/runner/session-env.ts +1 -0
  73. package/packages/core/src/ports/tickets/contract.ts +25 -0
  74. package/packages/core/src/ports/tickets/create.ts +11 -3
  75. package/packages/core/src/ports/tickets/fake.ts +10 -8
  76. package/packages/core/src/ports/tickets/file.ts +1 -0
  77. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  78. package/packages/core/src/ports/tickets/linear.ts +241 -49
  79. package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
  80. package/packages/core/src/ports/tickets/remote.ts +1 -0
  81. package/packages/core/src/ports/types.ts +17 -0
  82. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  83. package/packages/core/src/ports/workspace/create.ts +11 -2
  84. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  85. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  86. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  87. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  88. package/packages/core/src/processes/build-execution-state.ts +1 -1
  89. package/packages/core/src/processes/build-runner.ts +87 -26
  90. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  91. package/packages/core/src/processes/dispatcher.ts +336 -83
  92. package/packages/core/src/processes/harvest-child.ts +1 -0
  93. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  94. package/packages/core/src/processes/harvest-runner.ts +142 -100
  95. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  96. package/packages/core/src/processes/publication-loss.ts +18 -1
  97. package/packages/core/src/processes/repo-view.ts +2 -1
  98. package/packages/core/src/store/adapter.ts +1 -0
  99. package/packages/core/src/store/contract.ts +109 -0
  100. package/packages/core/src/store/digest.ts +14 -2
  101. package/packages/core/src/store/local/store.ts +18 -5
  102. package/packages/core/src/store/remote/client.ts +1 -1
  103. package/packages/core/src/store/remote/compatibility.ts +90 -0
  104. package/packages/core/src/store/remote/credentials.ts +28 -0
  105. package/packages/core/src/store/remote/index.ts +14 -1
  106. package/packages/core/src/store/remote/protocol.ts +26 -0
  107. package/packages/core/src/store/remote/token.ts +74 -13
  108. package/packages/core/src/store/repo-state-events.ts +49 -59
  109. package/packages/core/src/store/types.ts +5 -1
  110. package/packages/core/src/testing/index.ts +7 -0
  111. package/skills/guide/SKILL.md +175 -26
  112. package/skills/guide/references/operator-api.md +135 -13
  113. package/skills/guide/references/plugin-authoring.md +10 -2
  114. package/skills/guide/references/remote-store-protocol.md +189 -144
  115. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  116. package/skills/guide/references/setup.md +173 -16
  117. package/skills/guide/references/ticket-dependencies.md +1 -1
  118. package/skills/operate/SKILL.md +159 -34
  119. package/templates/hosted-service/package.json +13 -0
  120. package/templates/hosted-service/vercel.json +7 -0
@@ -1,3 +1,10 @@
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'
6
+ import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
7
+ import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
1
8
  /**
2
9
  * `ab dispatch` — the operator's entry into the outer loop (SPEC §3.3, §12).
3
10
  * Runs OUTSIDE build sessions like init/upgrade/ticket (§16.3): it takes a
@@ -180,18 +187,6 @@ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
180
187
  // Local uses within this module.
181
188
  import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
182
189
 
183
- /** JSON encoding in the config schema's declarative input shape. Parsed Config
184
- * has normalized `{steps, stepConfigs}` sections; flattening named step tables
185
- * lets the frontend validate the artifact with the same strict configSchema. */
186
- function effectiveConfigContent(config: Config): string {
187
- const { verify, finalize, ...root } = config
188
- return JSON.stringify({
189
- ...root,
190
- verify: { steps: verify.steps, ...verify.stepConfigs },
191
- finalize: { steps: finalize.steps, ...finalize.stepConfigs },
192
- })
193
- }
194
-
195
190
  /** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
196
191
  * owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
197
192
  export function slugNamingPrompt(spec: string): string {
@@ -210,7 +205,7 @@ export function slugNamingPrompt(spec: string): string {
210
205
  function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
211
206
  const defined: Record<string, string> = {}
212
207
  for (const [key, value] of Object.entries(env)) {
213
- if (value !== undefined) defined[key] = value
208
+ if (value !== undefined && key !== 'AB_TOKEN') defined[key] = value
214
209
  }
215
210
  return defined
216
211
  }
@@ -262,6 +257,7 @@ interface ResumePrompt {
262
257
  /** The real adapters the loop drives — resolved by `wire` (default: the
263
258
  * production ports; tests inject fakes). */
264
259
  export interface DispatchWiring {
260
+ credentialIssuer?: typeof delegateCredential
265
261
  store: BuildStore
266
262
  tickets: TicketSource
267
263
  forge: Forge
@@ -280,7 +276,7 @@ export interface DispatchWiring {
280
276
  * same store as `store`, so an agent's `ab` commands write where the
281
277
  * dispatcher reads. */
282
278
  storeRef: string
283
- /** Scoped token for a remote store (D8, `AB_TOKEN`); passed to sessions. */
279
+ /** Private parent authority for remote exchange; never passed to execution. */
284
280
  token?: string
285
281
  ids: IdSource
286
282
  uuids: UuidSource
@@ -691,6 +687,9 @@ class DispatchLoop {
691
687
  * discarded on Escape. All read semantics live in the shared feed module. */
692
688
  private sessionFeed: SessionStreamFeed | undefined
693
689
  private warningLine: string | undefined
690
+ /** Invocation-local ownership; independent of every notice slot. */
691
+ private standby: string | undefined
692
+ private standbyResolver: StandbyIdentityResolver | undefined
694
693
  /** Process-local, persistent release notice. It never shares the replaceable
695
694
  * warning slot and is re-applied to every store projection. */
696
695
  private availableUpgrade: string | undefined
@@ -1190,6 +1189,7 @@ class DispatchLoop {
1190
1189
  selection: _oldSelection,
1191
1190
  warningLines: _oldWarningLines,
1192
1191
  availableUpgrade: _oldAvailableUpgrade,
1192
+ standby: _oldStandby,
1193
1193
  resumeInput: _oldResumeInput,
1194
1194
  abortConfirmation: _oldAbortConfirmation,
1195
1195
  view: _oldView,
@@ -1204,6 +1204,7 @@ class DispatchLoop {
1204
1204
  ]
1205
1205
  let effective: DashboardModel = {
1206
1206
  ...base,
1207
+ ...(this.standby !== undefined ? { standby: this.standby } : {}),
1207
1208
  ...(this.availableUpgrade !== undefined ? { availableUpgrade: this.availableUpgrade } : {}),
1208
1209
  ...(warningLines.length > 0 ? { warningLines } : {}),
1209
1210
  ...(this.selection !== undefined ? { selection: this.selection } : {}),
@@ -1258,7 +1259,13 @@ class DispatchLoop {
1258
1259
  const limit =
1259
1260
  terminal === undefined
1260
1261
  ? 0
1261
- : sessionScrollLimit(view, terminal.columns, paintableRows(terminal.rows), upgrade)
1262
+ : sessionScrollLimit(
1263
+ view,
1264
+ terminal.columns,
1265
+ paintableRows(terminal.rows),
1266
+ upgrade,
1267
+ this.standby !== undefined,
1268
+ )
1262
1269
  const current = view.follow ? limit : view.scroll
1263
1270
  const scroll =
1264
1271
  terminal === undefined
@@ -1270,6 +1277,7 @@ class DispatchLoop {
1270
1277
  current,
1271
1278
  delta,
1272
1279
  upgrade,
1280
+ this.standby !== undefined,
1273
1281
  )
1274
1282
  let follow = view.follow
1275
1283
  if (delta < 0 && follow && scroll < limit) follow = false
@@ -1291,6 +1299,7 @@ class DispatchLoop {
1291
1299
  terminal.columns,
1292
1300
  paintableRows(terminal.rows),
1293
1301
  this.availableUpgrade !== undefined,
1302
+ this.standby !== undefined,
1294
1303
  )
1295
1304
  return { ...view, scroll: view.follow ? limit : Math.min(view.scroll, limit) }
1296
1305
  }
@@ -1350,6 +1359,7 @@ class DispatchLoop {
1350
1359
  this.view.scroll,
1351
1360
  delta,
1352
1361
  this.availableUpgrade !== undefined,
1362
+ this.standby !== undefined,
1353
1363
  ),
1354
1364
  }
1355
1365
  this.syncModelControls()
@@ -2215,7 +2225,7 @@ class DispatchLoop {
2215
2225
  }
2216
2226
 
2217
2227
  const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
2218
- const runner = new HarvestRunner({
2228
+ const harvestDeps = {
2219
2229
  store,
2220
2230
  tickets,
2221
2231
  config: this.currentConfig().config,
@@ -2230,16 +2240,66 @@ class DispatchLoop {
2230
2240
  ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2231
2241
  sessionEnv: {
2232
2242
  AB_STORE: storeRef,
2233
- ...(token !== undefined ? { AB_TOKEN: token } : {}),
2234
2243
  },
2235
2244
  opts: {
2236
2245
  maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
2237
2246
  },
2238
- })
2247
+ }
2239
2248
 
2240
2249
  let tracked: Promise<void>
2241
- tracked = runner
2242
- .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
+ })()
2243
2303
  .then((result) => {
2244
2304
  this.recordHarvestResult(result)
2245
2305
  if (
@@ -2283,6 +2343,24 @@ class DispatchLoop {
2283
2343
  * contract: provisioning takes minutes and must never stall a tick; the
2284
2344
  * single-flight guard covers the whole span. Every failure is contained
2285
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
+
2286
2364
  private async launchHostedHarvest(
2287
2365
  harvestExecution: HarvestExecution,
2288
2366
  execution: string,
@@ -2333,13 +2411,44 @@ class DispatchLoop {
2333
2411
  if (pressure.trigger === undefined) return
2334
2412
  }
2335
2413
 
2336
- handle = await harvestExecution.start({
2337
- storeRef,
2338
- repo,
2339
- instance: execution,
2340
- baseBranch: this.currentConfig().config.baseBranch,
2341
- ...(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
+ },
2342
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
+ )
2343
2452
  const identity = handle.identity
2344
2453
  if (
2345
2454
  identity === undefined ||
@@ -2612,7 +2721,7 @@ class DispatchLoop {
2612
2721
  return null
2613
2722
  }
2614
2723
 
2615
- private async settlePendingPublication(slug: string): Promise<void> {
2724
+ private async settlePendingPublication(slug: string, reservation?: string): Promise<void> {
2616
2725
  // The guest appended through its own handle; settle against fresh state.
2617
2726
  await this.repoView.refreshBuild(slug)
2618
2727
  await settleWorkspacePublication(
@@ -2629,6 +2738,16 @@ class DispatchLoop {
2629
2738
  },
2630
2739
  slug,
2631
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
+ })
2632
2751
  }
2633
2752
 
2634
2753
  private async recordInfrastructureFailure(input: {
@@ -2720,13 +2839,25 @@ class DispatchLoop {
2720
2839
  // A prior publication acknowledgement may have been lost after its push
2721
2840
  // reached the durable branch. Reconcile that fact before a replacement
2722
2841
  // runner can rerun the phase and create a non-fast-forward successor.
2723
- await this.settlePendingPublication(slug)
2724
- const handle = await owning.execution.start({
2725
- slug,
2726
- storeRef: this.wiring.storeRef,
2727
- instance,
2728
- workspaceRef,
2729
- })
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
+ )
2730
2861
  active.handle = handle
2731
2862
  const identity = handle.identity ?? {
2732
2863
  provider: owning.name,
@@ -3389,6 +3520,18 @@ class DispatchLoop {
3389
3520
  const claimed = await this.wiring.store
3390
3521
  .claimRepoLease(this.repoIdentity, holder, REPO_LEASE_TTL_MS)
3391
3522
  .catch(() => false)
3523
+ if (this.dashboard && !this.opts.once) {
3524
+ this.standbyResolver ??= new StandbyIdentityResolver(this.wiring.store, this.repoIdentity)
3525
+ this.standby = claimed
3526
+ ? undefined
3527
+ : await this.standbyResolver.refresh(
3528
+ this.runId,
3529
+ this.yieldedTo ?? 'another invocation',
3530
+ this.wiring.clock(),
3531
+ )
3532
+ this.syncModelControls()
3533
+ this.paint()
3534
+ }
3392
3535
  if (!claimed) {
3393
3536
  if (this.repoLeaseHolder !== undefined) {
3394
3537
  // We held the lease and the store says it is gone: a peer took over.
@@ -3474,10 +3617,17 @@ class DispatchLoop {
3474
3617
  const holder = this.runId
3475
3618
  if (!(await this.ensureRepoLease(holder))) {
3476
3619
  await this.recordTickYielded()
3477
- const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
3478
- ?.holder
3479
- const message = `tick yielded: repository held by ${peer ?? 'another invocation'}`
3480
- this.warn(message)
3620
+ if ((!this.dashboard && this.opts.kernelRunId === undefined) || this.opts.once) {
3621
+ const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
3622
+ ?.holder
3623
+ const liveness = await readDispatcherLiveness(
3624
+ this.wiring.store,
3625
+ this.repoIdentity,
3626
+ this.wiring.clock(),
3627
+ ).catch(() => null)
3628
+ const message = `tick yielded: repository held by ${liveness?.dispatcher.holder ? formatDispatcherIdentity(liveness.dispatcher.holder) : (peer ?? 'another invocation')}`
3629
+ this.warn(message)
3630
+ }
3481
3631
  if (this.opts.once === true) return
3482
3632
  // Watch mode stays alive and retries the claim at every tick.
3483
3633
  }
@@ -3498,7 +3648,17 @@ class DispatchLoop {
3498
3648
  // lease and any provisioning state are released for the next
3499
3649
  // invocation.
3500
3650
  if (this.pastDeadline()) return
3501
- const initial = await this.dispatcherTick(true)
3651
+ const initial = await this.dispatcherTick(true).catch(async (error: unknown) => {
3652
+ await this.appendStatus({
3653
+ actor: DISPATCHER,
3654
+ type: 'dispatcher.tick-failed',
3655
+ payload: {
3656
+ run: this.runId,
3657
+ error: error instanceof Error ? error.message : String(error),
3658
+ },
3659
+ })
3660
+ throw error
3661
+ })
3502
3662
  const initialPrinted = this.printReport(initial, false)
3503
3663
  await this.drainInFlight()
3504
3664
  const settledReports = await this.publishSettlementReports()
@@ -3649,16 +3809,21 @@ function acceptableOriginModeStore(storeRef: string): boolean {
3649
3809
  * names the operator's own machine (local development, integration harnesses).
3650
3810
  * Requirements are validated here so a misconfigured origin-mode launch fails
3651
3811
  * before any side effect. */
3652
- async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePaths> {
3812
+ async function resolveOriginModeState(
3813
+ opts: DispatchOpts,
3814
+ configStore?: string,
3815
+ ): Promise<RepoStatePaths> {
3653
3816
  const identity = normalizeGitRemoteUrl(opts.repository!)
3654
- const selectedStore = opts.storeRef ?? opts.env.AB_STORE
3817
+ const selectedStore = opts.storeRef?.trim() || opts.env.AB_STORE?.trim() || configStore
3655
3818
  if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
3656
3819
  throw new Error(
3657
3820
  'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
3658
3821
  )
3659
3822
  }
3660
- if (opts.env.AB_TOKEN === undefined || opts.env.AB_TOKEN === '') {
3661
- 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
+ )
3662
3827
  }
3663
3828
  // Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
3664
3829
  // no keyring, and the only workspace provider origin mode can run —
@@ -3676,6 +3841,7 @@ async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePath
3676
3841
  await mkdir(scratch, { recursive: true })
3677
3842
  return resolveRepoStatePaths({
3678
3843
  repo: identity,
3844
+ configStore,
3679
3845
  checkout: scratch,
3680
3846
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
3681
3847
  ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
@@ -3698,9 +3864,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3698
3864
  if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
3699
3865
  throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
3700
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
3701
3881
  const state =
3702
3882
  opts.repository !== undefined
3703
- ? await resolveOriginModeState(opts)
3883
+ ? await resolveOriginModeState(opts, originConfig?.config.store?.url)
3704
3884
  : await resolveRepoState({
3705
3885
  targetRepo: opts.targetRepo,
3706
3886
  exec: opts.exec,
@@ -3714,6 +3894,13 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3714
3894
  const resolvedOpts: DispatchOpts = {
3715
3895
  ...opts,
3716
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
+ },
3717
3904
  storeRef: state.storeRef,
3718
3905
  repo: opts.repo ?? state.repo,
3719
3906
  }
@@ -3771,7 +3958,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3771
3958
  if (opts.repository !== undefined) {
3772
3959
  // Origin mode startup config: the base branch's autobuild.toml, fetched
3773
3960
  // through the forge (default branch first — baseBranch itself is config).
3774
- const fetched = await fetchOriginModeConfig(opts, opts.originConfigTransport)
3961
+ const fetched = originConfig!
3775
3962
  configContent = fetched.content
3776
3963
  config = fetched.config
3777
3964
  if (config.forge !== 'github') {
@@ -3885,7 +4072,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3885
4072
  },
3886
4073
  {
3887
4074
  kind: _effectiveConfigKind,
3888
- content: effectiveConfigContent(effectiveConfig),
4075
+ content: effectiveBuildConfigContent(effectiveConfig),
3889
4076
  metadata: { run: runId, effectiveChanged },
3890
4077
  },
3891
4078
  ],
@@ -3950,7 +4137,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3950
4137
  [
3951
4138
  {
3952
4139
  kind: _effectiveConfigKind,
3953
- content: effectiveConfigContent(config),
4140
+ content: effectiveBuildConfigContent(config),
3954
4141
  metadata: { run: runId, revision: 0 },
3955
4142
  },
3956
4143
  ],
@@ -3963,6 +4150,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3963
4150
  payload: {
3964
4151
  run: runId,
3965
4152
  pid: process.pid,
4153
+ host: hostname(),
3966
4154
  effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
3967
4155
  roleWarnings: roleKeyWarnings(config),
3968
4156
  },
@@ -3973,6 +4161,17 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3973
4161
  const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
3974
4162
  try {
3975
4163
  await loop.run()
4164
+ const stopRequest = (
4165
+ resolvedOpts.kernelRunId && resolvedOpts.signal?.aborted
4166
+ ? await wiring.store.getRepoEvents(state.repo)
4167
+ : []
4168
+ ).find((event) => event.type === 'dispatcher.stop-requested' && event.payload.run === runId)
4169
+ const stoppedSignal =
4170
+ stopRequest?.type === 'dispatcher.stop-requested'
4171
+ ? stopRequest.payload.signal
4172
+ : typeof resolvedOpts.signal?.reason === 'string'
4173
+ ? resolvedOpts.signal.reason
4174
+ : undefined
3976
4175
  await view.appendRepo(state.repo, {
3977
4176
  actor: DISPATCHER,
3978
4177
  type: 'dispatcher.run-stopped',
@@ -3980,6 +4179,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3980
4179
  run: runId,
3981
4180
  outcome: 'normal',
3982
4181
  exitCode: 0,
4182
+ ...(stoppedSignal === undefined ? {} : { signal: stoppedSignal }),
3983
4183
  ...(loop.supersededByPeer ? { reason: 'superseded' as const } : {}),
3984
4184
  },
3985
4185
  })
@@ -0,0 +1,91 @@
1
+ import type { RepositoryEvent } from '../events/repository'
2
+ import type { BuildStore, RepositoryRecord } from '../store/types'
3
+ import { formatDispatcherIdentity, projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
4
+
5
+ /** Bounded first; a later yielding invocation can hide the owner's identity or
6
+ * an earlier stop. Only this correlation query falls back to full history. */
7
+ export async function readDispatcherLiveness(
8
+ store: BuildStore,
9
+ repo: string,
10
+ now = new Date(),
11
+ subset?: RepositoryEvent[],
12
+ ) {
13
+ const record = await store.getRepo(repo)
14
+ let events = record === null ? [] : (subset ?? (await store.getRepoStateEvents(repo)))
15
+ const bounded = projectDispatcherLiveness(record, events, now)
16
+ if (
17
+ record &&
18
+ ((record.lease &&
19
+ !events.some(
20
+ (e) => e.type === 'dispatcher.run-started' && e.payload.run === record.lease!.holder,
21
+ )) ||
22
+ (bounded.state !== 'held' &&
23
+ events.some((e) => e.type === 'dispatcher.run-started') &&
24
+ !events.some((e) => e.type === 'dispatcher.run-stopped')))
25
+ ) {
26
+ events = await store.getRepoEvents(repo)
27
+ }
28
+ return { record, events, dispatcher: projectDispatcherLiveness(record, events, now) }
29
+ }
30
+
31
+ /** Invocation-local identity cache. Only start facts survive refreshes; ages
32
+ * always come from the current lease record and clock. Failed correlations are
33
+ * negatively cached so a standby cannot repeatedly replay a large journal. */
34
+ export class StandbyIdentityResolver {
35
+ private readonly starts = new Map<string, RepositoryEvent>()
36
+ private readonly attempted = new Set<string>()
37
+ private readonly pending = new Map<string, Promise<void>>()
38
+
39
+ constructor(
40
+ private readonly store: BuildStore,
41
+ private readonly repo: string,
42
+ ) {}
43
+
44
+ ingest(events: readonly RepositoryEvent[]): void {
45
+ for (const event of events) {
46
+ if (event.type === 'dispatcher.run-started') this.starts.set(event.payload.run, event)
47
+ }
48
+ }
49
+
50
+ private async correlate(holder: string): Promise<void> {
51
+ const pending = this.pending.get(holder)
52
+ if (pending !== undefined) return pending
53
+ if (this.starts.has(holder) || this.attempted.has(holder)) return
54
+ this.attempted.add(holder)
55
+ const lookup = (async () => {
56
+ try {
57
+ this.ingest(await this.store.getRepoStateEvents(this.repo))
58
+ if (!this.starts.has(holder)) this.ingest(await this.store.getRepoEvents(this.repo, 0))
59
+ } catch {
60
+ // Identity is best effort; the sampled holder id remains sufficient.
61
+ }
62
+ })()
63
+ this.pending.set(holder, lookup)
64
+ try {
65
+ await lookup
66
+ } finally {
67
+ this.pending.delete(holder)
68
+ }
69
+ }
70
+
71
+ async refresh(
72
+ run: string,
73
+ yieldedHolder: string | undefined,
74
+ now: Date,
75
+ ): Promise<string | undefined> {
76
+ let record: RepositoryRecord | null
77
+ try {
78
+ record = await this.store.getRepo(this.repo)
79
+ } catch {
80
+ return yieldedHolder
81
+ }
82
+ const holder = record?.lease?.holder
83
+ if (holder === run) return undefined
84
+ if (holder === undefined) return yieldedHolder
85
+ await this.correlate(holder)
86
+ if (!this.starts.has(holder)) return holder
87
+ const projection = projectDispatcherLiveness(record, [...this.starts.values()], now)
88
+ const identity = projection.holder ?? projection.expiredHolder
89
+ return identity?.run === holder ? formatDispatcherIdentity(identity) : holder
90
+ }
91
+ }