@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
@@ -69,6 +69,20 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
69
69
  'locally. --validate cannot be combined with --force.',
70
70
  ]),
71
71
  },
72
+ {
73
+ name: 'connect',
74
+ audience: 'human',
75
+ summary: 'Connect this project to a hosted service with browser approval.',
76
+ detail:
77
+ 'Usage: ab connect [url]\nWith a URL, approve the printed URL and code in your browser. Stores the credential in user configuration and adds [store] url to autobuild.toml. Without a URL, reports the saved credential status. Runs outside sessions.',
78
+ },
79
+ {
80
+ name: 'disconnect',
81
+ audience: 'human',
82
+ summary: 'Revoke and remove this project’s saved credential.',
83
+ detail:
84
+ 'Usage: ab disconnect\nRevokes the saved credential remotely before removing it locally. Keeps project registration, data and [store] url. Runs outside sessions.',
85
+ },
72
86
  {
73
87
  name: 'upgrade',
74
88
  audience: 'human',
@@ -282,7 +296,7 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
282
296
  ' ab watch [<slug>...] [--repository] [--event <glob>]... [--since <cursor>] [--timeout <duration>] [--interval <duration>] [--count <n>] [--json] [--store <ref>]',
283
297
  '',
284
298
  'Follow the event logs of one, several, or all active builds in this repository —',
285
- 'optionally the repository journal — and emit one record per matching event as it',
299
+ 'the repository journal by default with no slugs — and emit one record per matching event as it',
286
300
  'lands. With no slug, every nonterminal build (queued, running, paused, blocked) is',
287
301
  'watched, and a build created while the watch runs joins it without a restart.',
288
302
  'Naming one or more slugs watches exactly those builds; an unknown slug is an error',
@@ -291,16 +305,18 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
291
305
  '',
292
306
  'Options:',
293
307
  ' --repository Also follow the repository journal (dispatcher and harvest',
294
- ' events). Without it, repository events are never emitted.',
308
+ ' events) for named watches. No-slug watches include it by default.',
295
309
  ' --event <glob> Repeatable glob over event type names: * matches any run of',
296
310
  ' characters, ? one character (escalation.*, pr.merged, *). Every',
297
311
  ' glob must match at least one type in the build catalog — or,',
298
- ' with --repository, the repository catalog — or the command fails',
312
+ ' with no slugs or --repository, the repository catalog — or the command fails',
299
313
  ' before any store read. Any --event replaces the default filter.',
300
314
  ' --since <cursor> Resume from a cursor emitted by an earlier record; every matching',
301
315
  ' event after that cursor is delivered exactly once, including',
302
316
  ' events that landed while no watch was running. A cursor from a',
303
- ' different repository or store is rejected.',
317
+ ' different repository or store is rejected. A legacy cursor without',
318
+ ' a repository position baselines that stream now; reconcile earlier',
319
+ ' source conditions with ab repository status.',
304
320
  ' --timeout <dur> End the watch after this duration (30, 45s, 5m, 1h). Default',
305
321
  ' 30 minutes; 0 means unbounded.',
306
322
  ' --interval <dur> Poll cadence. Default: 5s for an http(s) store, 1s for a local store.',
@@ -309,11 +325,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
309
325
  ' flushed as soon as it is written.',
310
326
  ' --store <ref> Override the BuildStore path or URL.',
311
327
  '',
312
- 'Default filter — the attention set. For builds: escalation.raised, phase.failed,',
313
- 'infrastructure.failed, runner.setup-failed, dispatch.failed, publication.lost,',
314
- 'finalize.completed, pr.conflicted, pr.merged, pr.closed, build.completed,',
315
- 'build.aborted. For the repository journal: harvest.escalated, harvest.failed,',
316
- 'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed.',
328
+ 'Default filter — actionable attention only. Builds: escalation.raised and',
329
+ 'build.merge-decision (finalized open PR, auto-merge off or currently deferred).',
330
+ 'Armed consent awaiting application is silent; one merge decision per build/PR.',
331
+ 'Repository: harvest.escalated, harvest.recovery-exhausted, dispatcher.config-rejected,',
332
+ 'dispatcher.config-reloaded with restartRequired, abnormal dispatcher.run-stopped,',
333
+ 'and dispatcher.lost. dispatcher.ticket-source-persistent reports degradation past',
334
+ 'resetAt after a tick started at or after reset completes or fails while still degraded,',
335
+ 'or 15 minutes without a reset; dispatcher.tick-failure-persistent reports',
336
+ 'five minutes of continuous tick failure outside source grace or cooldown. Each episode',
337
+ 'reports once; recovery ends it. Cooldown failures do not start the tick-failure clock.',
338
+ '--event replaces defaults with exactly matching catalog events (or dispatcher.lost).',
339
+ 'Routine failures, finalize and terminal outcomes require explicit --event globs.',
340
+ 'Derived records carry observation instead of event, with observedAt and PR/reason',
341
+ 'or source/cause/message/resetAt or run/firstFailure/error plus episodeSeq.',
342
+ 'Cursors suppress delivered observations, including timer-only records. Old v1',
343
+ 'cursors remain valid. Fresh watches baseline history and retain repository timers;',
344
+ 'reconcile existing build attention through status after arming.',
317
345
  '',
318
346
  'Record shape (--json): build (slug, or null for a repository event), repo (repository',
319
347
  'id, present only for repository events), event (the complete durable envelope: build,',
@@ -361,19 +389,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
361
389
  'Conditions (--for, repeatable): blocked, paused, running, done, aborted, terminal (done',
362
390
  'or aborted), pr=open, pr=merged, pr=closed, pr=conflicted, phase=<phase> (a core phase —',
363
391
  'plan, plan-review, implement, code-review, finalize, reconcile — or verify:<step>), and',
364
- 'attention (any event in the ab watch attention set). An unknown value is rejected before',
392
+ 'attention (an open escalation or a build waiting for a merge decision). Merge attention',
393
+ 'requires a finalized open PR with auto-merge off or a current actionable gate deferral;',
394
+ 'armed pending consent, routine outcomes and self-healing failures stay silent.',
395
+ 'An unknown condition value is rejected before',
365
396
  'any store access. Repeatable --event globs follow the ab watch rules. Multiple --for',
366
397
  'values and --event globs are alternatives: the first one satisfied ends the wait — all',
367
398
  '--for values in supply order first (each checked against the state after an event and',
368
- 'the event’s attention membership), then all --event globs in supply order.',
399
+ 'the actionable build projection), then all --event globs in supply order.',
369
400
  '',
370
401
  'A status, PR-lifecycle, or phase condition is satisfied when a targeted build’s reduction',
371
402
  'has that status, PR state, or current phase. If any targeted build already satisfies',
372
403
  'such a condition when the command starts, the command returns immediately — in the',
373
404
  'manner of kubectl wait — with that build’s latest event; --since does not suppress this.',
374
405
  'Otherwise the condition is satisfied by the first later event whose reduction produces',
375
- 'it. attention and --event conditions are satisfied only by events appended after the',
376
- 'command started (or, on a cursor-resumed backlog, after --since).',
406
+ 'it. attention also recognizes existing conditions immediately, even with --since.',
407
+ '--event conditions match only events appended after the command started, or a',
408
+ 'cursor-resumed backlog after --since; use --event to opt into other event types.',
377
409
  '',
378
410
  'Record shape (--json): the ab watch record — build, event (the complete durable',
379
411
  'envelope), state (the reduction after this event: status, phase, round, openEscalations,',
@@ -1,3 +1,5 @@
1
+ import { projectToken } from './project-credentials'
2
+ import { resolveRepoOrigin } from './repo-state'
1
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
2
4
  import { tmpdir } from 'node:os'
3
5
  import { join, resolve } from 'node:path'
@@ -390,7 +392,31 @@ export async function validateInitReadiness(opts: {
390
392
  const configPath = join(repo, 'autobuild.toml')
391
393
  const configBytes = await readFile(configPath, 'utf8')
392
394
  const config = await loadConfig(configPath)
393
- const state = resolveRepoStatePaths({ repo, envStore: opts.env.AB_STORE })
395
+ hostPreflight(config, opts.env)
396
+ const selectedRemote = opts.env.AB_STORE?.trim() || config.store?.url
397
+ const identity =
398
+ selectedRemote && isRemoteStoreRef(selectedRemote) && !opts.env.AB_TOKEN
399
+ ? ((await resolveRepoOrigin(repo, exec)) ?? repo)
400
+ : repo
401
+ const state = resolveRepoStatePaths({
402
+ repo: identity,
403
+ checkout: repo,
404
+ envStore: opts.env.AB_STORE,
405
+ configStore: config.store?.url,
406
+ })
407
+ opts = {
408
+ ...opts,
409
+ env: {
410
+ ...opts.env,
411
+ AB_STORE: state.storeRef,
412
+ ...(isRemoteStoreRef(state.storeRef)
413
+ ? { AB_TOKEN: projectToken(state.storeRef, identity, opts.env) }
414
+ : {}),
415
+ },
416
+ }
417
+ if (config.store?.url === state.storeRef && !opts.env.AB_TOKEN) {
418
+ throw new Error('No project credential for this service; run ab connect')
419
+ }
394
420
  // Pre-registry parse and redaction come from the builtin capability table;
395
421
  // after plugin load the registration's own declarations take over (AUT-516).
396
422
  const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
@@ -405,7 +431,6 @@ export async function validateInitReadiness(opts: {
405
431
  let caps: WorkspaceProviderCapabilities | undefined
406
432
  let assigneeLine: string | undefined
407
433
  try {
408
- hostPreflight(config, opts.env)
409
434
  // Ticket acquisition is a host responsibility for both workspace providers.
410
435
  // Exercise its read surface before allocating disposable infrastructure.
411
436
  const hostPlugins = await loadPlugins(config.plugins, repo, { packageRoot: repo })
@@ -1,3 +1,4 @@
1
+ import { abConnect, abDisconnect } from './connect'
1
2
  /**
2
3
  * `ab` — the only channel between agents and the store, and the enforcement
3
4
  * point of the entire ontology (SPEC §8). This module is pure routing: every
@@ -16,7 +17,7 @@ import type { Clock } from '../store/types'
16
17
  import type { BuildStore } from '../store/types'
17
18
  import { textContent } from '../store/types'
18
19
  import type { Forge } from '../ports/types'
19
- import type { Exec } from '../ports/workspace/git-worktree'
20
+ import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
20
21
  import type { IdSource } from '../ids'
21
22
  import { reduceBuild } from '../kernel/reducer'
22
23
  import { artifactDownload, artifactDownloadStream, artifactGet, artifactPut } from './artifact'
@@ -44,7 +45,6 @@ import { recognizeHelpRequest, renderCommandHelp, renderTopLevelHelp } from './h
44
45
  import { observe } from './observe'
45
46
  import { abPlugin, type PluginContractSubprocess } from './plugin'
46
47
  import { preparePrAttachments } from './pr-attachments'
47
- import { renderPrSummary } from './pr-summary'
48
48
  import { abBuildAssetGet, abBuilds, abBuildStatus } from './status'
49
49
  import { abRepositoryStatus } from './repository-status'
50
50
  import { abWatch, WATCH_USAGE } from './watch'
@@ -53,6 +53,7 @@ import { abMcp, AB_MCP_USAGE } from './mcp'
53
53
  import type { StoreOpener } from './store-opening'
54
54
  import { done, escalate, verdict } from './terminals'
55
55
  import { abTicket, openTicketSource } from './ticket'
56
+ import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
56
57
  import { abUpgrade, type ResolveConflict } from './upgrade'
57
58
  import { withUpgradeProgress } from './upgrade-progress'
58
59
  import {
@@ -95,6 +96,8 @@ import {
95
96
  */
96
97
  export const SESSIONLESS_COMMANDS = new Set([
97
98
  'init',
99
+ 'connect',
100
+ 'disconnect',
98
101
  'upgrade',
99
102
  'update',
100
103
  'ticket',
@@ -186,6 +189,7 @@ export interface SessionlessCliDeps {
186
189
  * to the running module, independent of the current repository. */
187
190
  distributionRoot?: string
188
191
  /** Self-update seams keep release/package-manager orchestration testable. */
192
+ compatibilityFetch?: CompatibilityFetch
189
193
  selfUpdateCommand?: SelfUpdateCommand
190
194
  selfUpdate?: (options: SelfUpdateOptions) => Promise<SelfUpdateResult>
191
195
  /** Production supplies the tool-free upgrade agent; tests inject a fake.
@@ -444,6 +448,22 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
444
448
 
445
449
  // init and upgrade run OUTSIDE build sessions (§16.3): they operate on a
446
450
  // repo, not a build, so they route before any store/env requirement.
451
+ case 'connect':
452
+ case 'disconnect': {
453
+ const usage = command === 'connect' ? 'usage: ab connect [url]' : 'usage: ab disconnect'
454
+ const parsed = parseArgs(rest, {}, usage)
455
+ if (parsed.positionals.length > (command === 'connect' ? 1 : 0)) throw new Error(usage)
456
+ const options = {
457
+ cwd: deps.workspacePath,
458
+ env: deps.processEnv ?? process.env,
459
+ exec: deps.exec ?? spawnExec,
460
+ print: stdout,
461
+ ...(deps.signal ? { signal: deps.signal } : {}),
462
+ }
463
+ if (command === 'connect') await abConnect(parsed.positionals[0], options)
464
+ else await abDisconnect(options)
465
+ return 0
466
+ }
447
467
  case 'init': {
448
468
  const usage = 'usage: ab init [target] [--force | --validate] (§16.3)'
449
469
  const removed = [
@@ -515,6 +535,14 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
515
535
  }
516
536
  const targetRepo = parsed.positionals[0] ?? deps.workspacePath
517
537
  const noCommit = parsed.flags.has('no-commit')
538
+ const compatibilityWarnings = new Set<string>()
539
+ const upgradeStderr = (line: string): void => {
540
+ if (line.startsWith('warning:')) {
541
+ if (compatibilityWarnings.has(line)) return
542
+ compatibilityWarnings.add(line)
543
+ }
544
+ stderr(line)
545
+ }
518
546
  const handoff = deps.processEnv?.[SELF_UPDATE_HANDOFF_ENV] === '1'
519
547
  let commitContext: UpgradeCommitContext | undefined
520
548
  if (!noCommit) {
@@ -543,9 +571,10 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
543
571
  if (!parsed.flags.has('no-self-update') && !handoff) {
544
572
  const update = await (deps.selfUpdate ?? selfUpdate)({
545
573
  targetRepo,
574
+ compatibilityFetch: deps.compatibilityFetch,
546
575
  stdout,
547
- stderr,
548
- env: deps.processEnv ?? {},
576
+ stderr: upgradeStderr,
577
+ env: deps.processEnv ?? process.env,
549
578
  noCommit: noCommit || commitContext === undefined,
550
579
  ...(commitContext === undefined
551
580
  ? {}
@@ -559,6 +588,13 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
559
588
  if (update.kind === 'handoff') return update.exitCode
560
589
  if (update.kind === 'failed') return 1
561
590
  }
591
+ await checkUpgradeCompatibility({
592
+ targetRepo,
593
+ distRoot: deps.distributionRoot,
594
+ env: deps.processEnv,
595
+ stderr: upgradeStderr,
596
+ fetch: deps.compatibilityFetch,
597
+ })
562
598
  const resolverFactory = deps.upgradeResolverFactory
563
599
  let resolver: ResolveConflict | undefined
564
600
  const resolveConflict: ResolveConflict | undefined =
@@ -1314,7 +1350,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1314
1350
  const meta = await artifactPut(session, kind, file, { attach })
1315
1351
 
1316
1352
  // A designation made after the PR exists (verify after reconcile or a
1317
- // finalize post-step) republishes the same complete projection. Every
1353
+ // finalize post-step) prepares hosted attachments. Every
1318
1354
  // external side effect is best-effort after the atomic designation.
1319
1355
  if (attach) {
1320
1356
  try {
@@ -1328,16 +1364,6 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1328
1364
  // asset identity before throwing. Keep going so its hosting
1329
1365
  // failure cannot suppress the complete text projection.
1330
1366
  }
1331
- try {
1332
- await session.forge.commentOnPr(
1333
- session.workspacePath,
1334
- pr.number,
1335
- renderPrSummary(session.env, await session.store.getEvents(session.env.build)),
1336
- )
1337
- } catch {
1338
- // The exact artifact and designation are already durable. A
1339
- // comment refresh cannot turn the agent's deposit into failure.
1340
- }
1341
1367
  }
1342
1368
  } catch {
1343
1369
  // Reading the optional PR projection is best-effort too.
@@ -107,12 +107,13 @@ export async function buildMcpTicketBackend(
107
107
  config.tickets.readyState,
108
108
  config.tickets.claimedState,
109
109
  config.tickets.createState,
110
+ config.tickets.doneState,
110
111
  ].filter((state): state is string => state !== undefined),
111
112
  ),
112
113
  ]
113
114
  return {
114
115
  sourceFor: async (context) => {
115
- if (config.tickets.teamKey !== context.teamKey) {
116
+ if ((config.tickets.teamKey ?? '') !== context.teamKey) {
116
117
  throw new Error(
117
118
  `the repository's effective ticket config names team "${context.teamKey}", but this checkout's autobuild.toml names team "${String(config.tickets.teamKey)}"; refusing to serve tickets across that drift — run ab mcp from the checkout dispatch uses`,
118
119
  )
@@ -200,6 +201,11 @@ export async function abMcp(opts: AbMcpOpts): Promise<number> {
200
201
  try {
201
202
  const backend = await buildMcpTicketBackend({
202
203
  ...opts,
204
+ env: {
205
+ ...opts.env,
206
+ AB_STORE: context.storeRef,
207
+ ...(context.token !== undefined ? { AB_TOKEN: context.token } : {}),
208
+ },
203
209
  checkout: context.checkout,
204
210
  localStateRoot: context.localStateRoot,
205
211
  })
@@ -0,0 +1,220 @@
1
+ import { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
2
+ export { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
3
+ import type { AbEvent } from '../events/catalog'
4
+ import { KERNEL } from '../events/envelope'
5
+ import { isRemoteWorkspace } from '../events/workspace-remote'
6
+ import { reduceBuild } from '../kernel/reducer'
7
+ import { currentPrAttachments } from '../kernel/pr-attachments'
8
+ import { openExecution } from '../processes/execution-settlement'
9
+ import type { Forge } from '../ports/types'
10
+ import { githubRateLimitUntil } from '../ports/forge/github-transport'
11
+ import { summaryMarker } from '../ports/forge/summary-marker'
12
+ import type { BuildStore, Clock } from '../store/types'
13
+ import { html, shellQuote, renderPrSummary } from './pr-summary'
14
+
15
+ export const SUMMARY_LIMIT = 65_536
16
+
17
+ interface SummaryDeps {
18
+ store: BuildStore
19
+ forge: Forge
20
+ slug: string
21
+ workspacePath: string
22
+ storeRef: string
23
+ clock: Clock
24
+ }
25
+
26
+ /** No credentials are accepted by the renderer. The full oversized projection
27
+ * is deliberately not designated, avoiding recursive growth. */
28
+ export async function boundedSummary(
29
+ deps: SummaryDeps,
30
+ events: readonly AbEvent[],
31
+ ): Promise<string> {
32
+ const env = {
33
+ build: deps.slug,
34
+ store: deps.storeRef,
35
+ phase: 'finalize' as const,
36
+ round: 1,
37
+ session: '',
38
+ }
39
+ const full = renderPrSummary(env, events)
40
+ if (full.length <= SUMMARY_LIMIT) return full
41
+ const meta = await deps.store.putArtifact(deps.slug, {
42
+ kind: 'pr-summary',
43
+ content: full,
44
+ metadata: {},
45
+ })
46
+ const command = `ab artifact download ${shellQuote(deps.slug)} ${shellQuote(`pr-summary@${meta.revision}`)} --store ${shellQuote(deps.storeRef)} --output pr-summary.md`
47
+ const compact = [
48
+ summaryMarker(deps.slug),
49
+ `## Autobuild: ${html(deps.slug)}`,
50
+ '',
51
+ `The full summary exceeds the comment limit. It includes ${currentPrAttachments(events).length} PR attachments.`,
52
+ `Store: <code>${html(deps.storeRef)}</code>`,
53
+ '',
54
+ 'Download the complete summary, including all pinned artifact commands and hosted links:',
55
+ `<pre><code>${html(command)}</code></pre>`,
56
+ ].join('\n')
57
+ if (compact.length > SUMMARY_LIMIT) throw new Error('Summary locator exceeds comment limit')
58
+ return compact
59
+ }
60
+
61
+ /** Caller owns the exclusive build lease and has fenced guest execution.
62
+ * Claim-before-effect plus CAS also fences overlapping refresh triggers. */
63
+ export async function publishSummary(
64
+ deps: SummaryDeps,
65
+ boundary: { occurrence: number; boundary: number },
66
+ ): Promise<'attempted' | 'skipped' | 'deferred'> {
67
+ try {
68
+ const events = await deps.store.getEvents(deps.slug)
69
+ const pr = reduceBuild(events).pr
70
+ if (pr === undefined) return 'skipped'
71
+ if (
72
+ events.some(
73
+ (event) =>
74
+ event.type === 'pr-summary.attempted' && event.payload.occurrence === boundary.occurrence,
75
+ )
76
+ )
77
+ return 'attempted'
78
+ if (
79
+ events.some(
80
+ (event) =>
81
+ event.type === 'pr-summary.result' &&
82
+ event.payload.eligibleAfter !== undefined &&
83
+ Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
84
+ )
85
+ )
86
+ return 'deferred'
87
+ const claim = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
88
+ actor: KERNEL,
89
+ type: 'pr-summary.attempted',
90
+ payload: { ...boundary, pr: pr.number },
91
+ })
92
+ if (claim === null) return 'skipped'
93
+ let outcome: 'success' | 'failure' | 'rate-limited' = 'success'
94
+ let eligibleAfter: string | undefined
95
+ try {
96
+ const body = await boundedSummary(deps, await deps.store.getEvents(deps.slug))
97
+ await deps.forge.commentOnPr(deps.workspacePath, pr.number, body)
98
+ } catch (error) {
99
+ eligibleAfter = githubRateLimitUntil(error, deps.clock().getTime())
100
+ outcome = eligibleAfter === undefined ? 'failure' : 'rate-limited'
101
+ }
102
+ await deps.store.append(deps.slug, {
103
+ actor: KERNEL,
104
+ type: 'pr-summary.result',
105
+ payload: {
106
+ attempt: claim.seq,
107
+ outcome,
108
+ ...(eligibleAfter === undefined ? {} : { eligibleAfter }),
109
+ },
110
+ })
111
+ return 'attempted'
112
+ } catch {
113
+ return 'skipped'
114
+ }
115
+ }
116
+
117
+ /** Local boundary or durable guest handoff. Optional refresh failures never
118
+ * prevent pipeline progress, including a failed request deposit. */
119
+ export async function refreshSummaryBoundary(deps: SummaryDeps): Promise<boolean> {
120
+ try {
121
+ const events = await deps.store.getEvents(deps.slug)
122
+ const boundary = summaryBoundary(events)
123
+ if (boundary === undefined) return false
124
+ const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
125
+ if (workspace?.type === 'workspace.provisioned' && isRemoteWorkspace(workspace.payload)) {
126
+ const request = events.find(
127
+ (event) =>
128
+ event.type === 'pr-summary.requested' && event.payload.occurrence === boundary.occurrence,
129
+ )
130
+ if (request !== undefined) {
131
+ // A lost result/disposition append must not repeatedly park a guest.
132
+ if (
133
+ events.some(
134
+ (event) =>
135
+ event.type === 'pr-summary.attempted' &&
136
+ event.payload.occurrence === boundary.occurrence,
137
+ )
138
+ )
139
+ return false
140
+ if (
141
+ events.some(
142
+ (event) =>
143
+ event.type === 'pr-summary.result' &&
144
+ event.payload.eligibleAfter !== undefined &&
145
+ Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
146
+ )
147
+ )
148
+ return false
149
+ return pendingSummaryRequests(events).some((event) => event.seq === request.seq)
150
+ }
151
+ if (
152
+ reduceBuild(events).pr === undefined &&
153
+ !events.some(
154
+ (event) =>
155
+ event.type === 'publication.requested' && event.payload.operation === 'finalize',
156
+ )
157
+ )
158
+ return false
159
+ const written = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
160
+ actor: KERNEL,
161
+ type: 'pr-summary.requested',
162
+ payload: boundary,
163
+ })
164
+ return written !== null
165
+ }
166
+ await publishSummary(deps, boundary)
167
+ } catch {
168
+ /* Optional publication cannot fail phase execution. */
169
+ }
170
+ return false
171
+ }
172
+
173
+ /** Host-only, after execution settlement. Reuse a launch reservation only
174
+ * before execution.start; otherwise acquire and release our own lease. */
175
+ export async function settleSummaryRequests(
176
+ deps: SummaryDeps & { holder: string; reservation?: string },
177
+ ): Promise<void> {
178
+ let acquired = false
179
+ let heartbeat: ReturnType<typeof setInterval> | undefined
180
+ try {
181
+ let events = await deps.store.getEvents(deps.slug)
182
+ if (openExecution(events) !== null) return
183
+ // Repair a crash after terminal/session completion but before guest handoff.
184
+ const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
185
+ if (workspace?.type !== 'workspace.provisioned' || !isRemoteWorkspace(workspace.payload)) return
186
+ const holder = deps.reservation ?? deps.holder
187
+ acquired = await deps.store.claimLease(deps.slug, holder, 60_000)
188
+ if (!acquired) return
189
+ heartbeat = setInterval(() => {
190
+ void deps.store.heartbeat(deps.slug, holder).catch(() => {})
191
+ }, 15_000)
192
+ events = await deps.store.getEvents(deps.slug)
193
+ if (openExecution(events) !== null) return
194
+ await refreshSummaryBoundary(deps)
195
+ events = await deps.store.getEvents(deps.slug)
196
+ const requests = pendingSummaryRequests(events)
197
+ const latest = requests.at(-1)
198
+ for (const request of requests) {
199
+ if (request.type !== 'pr-summary.requested') continue
200
+ const disposition =
201
+ request === latest ? await publishSummary(deps, request.payload) : 'skipped'
202
+ await deps.store.append(deps.slug, {
203
+ actor: KERNEL,
204
+ type: 'pr-summary.settled',
205
+ payload: { request: request.seq, disposition },
206
+ })
207
+ }
208
+ } catch {
209
+ /* Best effort, separate from branch publication and phase outcome. */
210
+ } finally {
211
+ if (heartbeat !== undefined) clearInterval(heartbeat)
212
+ if (acquired && deps.reservation === undefined) {
213
+ try {
214
+ await deps.store.releaseLease(deps.slug, deps.holder)
215
+ } catch {
216
+ /* lease expires */
217
+ }
218
+ }
219
+ }
220
+ }
@@ -1,3 +1,4 @@
1
+ import { summaryMarker } from '../ports/forge/summary-marker'
1
2
  import type { AbEvent } from '../events/catalog'
2
3
  import {
3
4
  currentPrAttachments,
@@ -106,6 +107,7 @@ export function renderPrSummary(env: CliEnv, events: readonly AbEvent[]): string
106
107
  }
107
108
 
108
109
  return [
110
+ summaryMarker(env.build),
109
111
  `## Autobuild: ${html(env.build)}`,
110
112
  '',
111
113
  '### Verdict history',