@defrex/autobuild 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. package/templates/hosted-service/vercel.json +7 -0
@@ -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',
@@ -311,12 +325,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
311
325
  ' flushed as soon as it is written.',
312
326
  ' --store <ref> Override the BuildStore path or URL.',
313
327
  '',
314
- 'Default filter — the attention set. For builds: escalation.raised, phase.failed,',
315
- 'infrastructure.failed, runner.setup-failed, dispatch.failed, publication.lost,',
316
- 'finalize.completed, pr.conflicted, pr.merged, pr.closed, build.completed,',
317
- 'build.aborted. For the repository journal: harvest.escalated, harvest.failed,',
318
- 'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed,',
319
- 'dispatcher.ticket-source-degraded, dispatcher.ticket-source-recovered.',
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.',
320
345
  '',
321
346
  'Record shape (--json): build (slug, or null for a repository event), repo (repository',
322
347
  'id, present only for repository events), event (the complete durable envelope: build,',
@@ -364,19 +389,23 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
364
389
  'Conditions (--for, repeatable): blocked, paused, running, done, aborted, terminal (done',
365
390
  'or aborted), pr=open, pr=merged, pr=closed, pr=conflicted, phase=<phase> (a core phase —',
366
391
  'plan, plan-review, implement, code-review, finalize, reconcile — or verify:<step>), and',
367
- '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',
368
396
  'any store access. Repeatable --event globs follow the ab watch rules. Multiple --for',
369
397
  'values and --event globs are alternatives: the first one satisfied ends the wait — all',
370
398
  '--for values in supply order first (each checked against the state after an event and',
371
- 'the event’s attention membership), then all --event globs in supply order.',
399
+ 'the actionable build projection), then all --event globs in supply order.',
372
400
  '',
373
401
  'A status, PR-lifecycle, or phase condition is satisfied when a targeted build’s reduction',
374
402
  'has that status, PR state, or current phase. If any targeted build already satisfies',
375
403
  'such a condition when the command starts, the command returns immediately — in the',
376
404
  'manner of kubectl wait — with that build’s latest event; --since does not suppress this.',
377
405
  'Otherwise the condition is satisfied by the first later event whose reduction produces',
378
- 'it. attention and --event conditions are satisfied only by events appended after the',
379
- '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.',
380
409
  '',
381
410
  'Record shape (--json): the ab watch record — build, event (the complete durable',
382
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',
@@ -0,0 +1,132 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import {
3
+ constants,
4
+ fstatSync,
5
+ chmodSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ closeSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ renameSync,
13
+ unlinkSync,
14
+ } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { isAbsolute, join } from 'node:path'
17
+ import { normalizeServiceURL } from '../connection'
18
+ import { normalizeGitRemoteUrl } from '../kernel/origin'
19
+
20
+ export interface ProjectCredential {
21
+ service: string
22
+ repo: string
23
+ id: string
24
+ token: string
25
+ }
26
+ export class ProjectCredentials {
27
+ readonly directory: string
28
+ constructor(env: Record<string, string | undefined> = process.env) {
29
+ const root = env.XDG_CONFIG_HOME
30
+ if (root && !isAbsolute(root))
31
+ throw new Error('XDG_CONFIG_HOME must be absolute for project credentials')
32
+ this.directory = join(
33
+ root ||
34
+ (env.HOME && isAbsolute(env.HOME) ? join(env.HOME, '.config') : join(homedir(), '.config')),
35
+ 'autobuild',
36
+ 'credentials',
37
+ )
38
+ }
39
+ private path(service: string, repo: string): string {
40
+ return join(
41
+ this.directory,
42
+ `${createHash('sha256')
43
+ .update(JSON.stringify([normalizeServiceURL(service), normalizeGitRemoteUrl(repo)]))
44
+ .digest('hex')}.json`,
45
+ )
46
+ }
47
+ private guard(path: string): boolean {
48
+ try {
49
+ const info = lstatSync(path)
50
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe credential file')
51
+ return true
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
54
+ throw error
55
+ }
56
+ }
57
+ load(service: string, repo: string): ProjectCredential | undefined {
58
+ try {
59
+ const path = this.path(service, repo)
60
+ if (!this.guard(path)) return undefined
61
+ const directory = lstatSync(this.directory)
62
+ if (directory.isSymbolicLink() || (directory.mode & 0o777) !== 0o700) throw new Error()
63
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
64
+ let entry: ProjectCredential
65
+ try {
66
+ if ((fstatSync(fd).mode & 0o777) !== 0o600) throw new Error()
67
+ entry = JSON.parse(readFileSync(fd, 'utf8'))
68
+ } finally {
69
+ closeSync(fd)
70
+ }
71
+ if (
72
+ entry.service !== normalizeServiceURL(service) ||
73
+ entry.repo !== normalizeGitRemoteUrl(repo) ||
74
+ typeof entry.id !== 'string' ||
75
+ typeof entry.token !== 'string' ||
76
+ !/^[a-f0-9]{32}$/.test(entry.id) ||
77
+ !new RegExp(`^abp_1_${entry.id}_[a-f0-9]{64}$`).test(entry.token)
78
+ )
79
+ throw new Error()
80
+ return entry
81
+ } catch {
82
+ throw new Error(
83
+ 'Cannot read project credential; repair user credential storage and run ab connect',
84
+ )
85
+ }
86
+ }
87
+ save(entry: ProjectCredential): void {
88
+ const path = this.path(entry.service, entry.repo)
89
+ let temporary: string | undefined
90
+ try {
91
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 })
92
+ if (lstatSync(this.directory).isSymbolicLink()) throw new Error()
93
+ chmodSync(this.directory, 0o700)
94
+ this.guard(path)
95
+ temporary = `${path}.${randomUUID()}`
96
+ const fd = openSync(temporary, 'wx', 0o600)
97
+ try {
98
+ writeFileSync(fd, `${JSON.stringify(entry)}\n`)
99
+ } finally {
100
+ closeSync(fd)
101
+ }
102
+ renameSync(temporary, path)
103
+ } catch {
104
+ throw new Error('Cannot save project credential; check user credential directory permissions')
105
+ } finally {
106
+ if (temporary) {
107
+ try {
108
+ unlinkSync(temporary)
109
+ } catch {
110
+ /* renamed or absent */
111
+ }
112
+ }
113
+ }
114
+ }
115
+ remove(service: string, repo: string): void {
116
+ const path = this.path(service, repo)
117
+ if (this.guard(path)) unlinkSync(path)
118
+ }
119
+ }
120
+ export function projectToken(
121
+ service: string,
122
+ repo: string,
123
+ env: Record<string, string | undefined>,
124
+ ): string | undefined {
125
+ if (env.AB_TOKEN !== undefined && env.AB_TOKEN !== '') return env.AB_TOKEN
126
+ try {
127
+ normalizeServiceURL(service)
128
+ } catch {
129
+ return undefined
130
+ }
131
+ return new ProjectCredentials(env).load(service, repo)?.token
132
+ }