@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,55 @@
1
+ import type { BuildState } from '../kernel/reducer'
2
+ import type { BuildRecord } from '../store/types'
3
+
4
+ /**
5
+ * Event silence must be long enough to distinguish ordinary agent turns from
6
+ * durable progress that has fallen behind a renewing runner lease.
7
+ */
8
+ export const PROGRESS_DIVERGENCE_MS = 60 * 60 * 1000
9
+
10
+ /** Exact, JSON-safe inputs for the presentation-only progress projection. */
11
+ export interface BuildProgress {
12
+ lastEventAt?: string
13
+ heartbeatAt?: string
14
+ leaseExpiresAt?: string
15
+ /** Reducer-terminal even when abort cleanup keeps a dashboard row visible. */
16
+ terminal: boolean
17
+ }
18
+
19
+ /** Project only retained facts. Relative ages and divergence depend on render time. */
20
+ export function buildProgress(record: BuildRecord, state: BuildState): BuildProgress {
21
+ return {
22
+ ...(state.lastEvent !== undefined ? { lastEventAt: state.lastEvent.ts } : {}),
23
+ ...(record.heartbeatAt !== undefined ? { heartbeatAt: record.heartbeatAt } : {}),
24
+ ...(record.lease !== undefined ? { leaseExpiresAt: record.lease.expiresAt } : {}),
25
+ terminal: state.status === 'done' || state.status === 'aborted',
26
+ }
27
+ }
28
+
29
+ function timestamp(value: string | undefined): number | undefined {
30
+ if (value === undefined) return undefined
31
+ const parsed = Date.parse(value)
32
+ return Number.isFinite(parsed) ? parsed : undefined
33
+ }
34
+
35
+ /**
36
+ * True only for a nonterminal build with a strictly live lease whose renewing
37
+ * heartbeat is at least one hour newer than its latest durable event.
38
+ */
39
+ export function isDiverged(progress: BuildProgress, now: Date | number): boolean {
40
+ if (progress.terminal) return false
41
+ const lastEventAt = timestamp(progress.lastEventAt)
42
+ const heartbeatAt = timestamp(progress.heartbeatAt)
43
+ const leaseExpiresAt = timestamp(progress.leaseExpiresAt)
44
+ const nowMs = typeof now === 'number' ? now : now.getTime()
45
+ if (
46
+ lastEventAt === undefined ||
47
+ heartbeatAt === undefined ||
48
+ leaseExpiresAt === undefined ||
49
+ !Number.isFinite(nowMs) ||
50
+ leaseExpiresAt <= nowMs
51
+ ) {
52
+ return false
53
+ }
54
+ return heartbeatAt - lastEventAt >= PROGRESS_DIVERGENCE_MS
55
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Repository-wide quiescence: one keypress that parks every running build and
3
+ * stops intake, and one that reverses it.
4
+ *
5
+ * This is deliberately NOT a per-build control, so it does not live in
6
+ * `build-control.ts`. Two contracts differ at their core:
7
+ *
8
+ * - `controlBuild` throws a typed `BuildControlError` when its one target is
9
+ * inapplicable. A bulk walk crosses builds the operator never named, so an
10
+ * ineligible build is SKIPPED silently instead.
11
+ * - `controlBuild`'s `dashboard-pause` deliberately toggles, appending a
12
+ * `resume` to cancel a pending pause. The bulk control must NEVER cancel a
13
+ * pending pause — a build already on its way to `paused` is exactly the state
14
+ * the operator is asking for, so it is skipped, not reversed.
15
+ *
16
+ * Every per-build write is a compare-and-set against the reduced stream tail.
17
+ * The walk reduces a stream and then writes to it, and nothing serializes those
18
+ * two operations against the rest of the world: the in-process runner fleet is
19
+ * fire-and-forget, a second `ab dispatch` may hold a dashboard, and
20
+ * `ab pause <slug>` runs sessionless. An unconditional append could add a
21
+ * second entry to `pending.pause` (the reducer pushes rather than replaces, so
22
+ * "exactly one pending pause" is observable), or write a pause to a build a
23
+ * runner has already acknowledged to `paused`, or completed. So each build goes
24
+ * through `appendIfCurrent` and retries on a miss, re-reading and re-evaluating
25
+ * eligibility every attempt — the idiom `recordAutoMergeDeferralObservation`
26
+ * already establishes in this codebase.
27
+ *
28
+ * The PAUSE FACT is written FIRST and ABSOLUTELY, with intake immediately
29
+ * after it. `dispatcher.pause-set` is the repository's quiescence boundary —
30
+ * it is what holds every queued build across the dispatcher's recovery,
31
+ * startup-resume, lease-sweep, and dispatch stages — while intake gates only
32
+ * the ready-ticket scan. Ordering them this way gives the prefix property that
33
+ * matters: every surviving prefix of a pause-all leaves the repository at
34
+ * least as quiesced as the operator asked for, and no prefix can leave a
35
+ * queued build launchable once any write has landed. Under the reverse order,
36
+ * an intake write that landed before a failing pause write would leave exactly
37
+ * the hole this control exists to close — recovery, startup resume, and the
38
+ * sweep all ignore intake and would still hand a queued build a runner. Resume
39
+ * benefits symmetrically: a pause-cleared/intake-still-off prefix is the
40
+ * conservative half, where the reverse order would re-open intake while every
41
+ * build stayed held.
42
+ *
43
+ * Both are written ABSOLUTELY — an unconditional append rather than a
44
+ * read-then-write on a difference — because they are setters, not toggles:
45
+ * last-write-wins is race-free and is exactly what the operator asked for, at
46
+ * the cost of one redundant repository event when the value already held.
47
+ *
48
+ * This is ordering, not atomicity. The per-build walk that follows is still N
49
+ * separate CAS writes, so a partial bulk pause remains representable and a
50
+ * failure midway leaves some running builds un-requested. The recovery is a
51
+ * second keypress, which the CAS loop and the predicates make idempotent: a
52
+ * build that already carries a pending pause fails the same predicate and is
53
+ * skipped.
54
+ *
55
+ * Eligibility is expressed against `BuildState` rather than by importing
56
+ * `effectiveStatus` from `dashboard/model.ts`, whose contract is
57
+ * "DISPLAY-ONLY — nothing consults this"; a durable write path must not become
58
+ * its first consumer. The predicates are nevertheless defined to coincide with
59
+ * what the operator sees, and a unit test pins that agreement.
60
+ */
61
+ import { humanActor, type Actor, type Via } from '../events/envelope'
62
+ import { reduceBuild, type BuildState } from '../kernel/reducer'
63
+ import type { Exec } from '../ports/workspace/git-worktree'
64
+ import type { BuildRecord, BuildStore } from '../store/types'
65
+ import { BuildControlError, buildControlUser } from './build-control'
66
+ import { withSessionlessStore, type StoreOpener } from './store-opening'
67
+
68
+ export type BulkDirection = 'pause' | 'resume'
69
+
70
+ export interface BulkControlSummary {
71
+ direction: BulkDirection
72
+ /** Slugs that received a durable request, in the order written. */
73
+ slugs: string[]
74
+ /** The durable repository-wide hold value this action wrote. */
75
+ paused: boolean
76
+ /** The durable intake value this action wrote. */
77
+ intake: boolean
78
+ }
79
+
80
+ /**
81
+ * Pausable for the bulk control: actually running, with neither pause nor
82
+ * abort intent already in flight. The pending-pause exclusion is the "never
83
+ * cancels a pending pause / still exactly one pending pause" rule, and it also
84
+ * means a row displaying `PAUSING` is left alone. Abort intent dominates pause
85
+ * regardless of whether it came from an explicit request or an escalation
86
+ * answer, so a row displaying `ABORTING` is also left alone. `queued`, `paused`,
87
+ * `blocked`, `done`, and `aborted` all fail the first clause, so they are
88
+ * skipped with no error. A `running` build carrying a pending *resume* (a
89
+ * just-cancelled pause) is eligible: the reducer clears `pending.resume` when
90
+ * the pause request lands, which is the existing opposing-command
91
+ * supersession.
92
+ */
93
+ export function bulkPausable(state: BuildState): boolean {
94
+ if (state.status !== 'running') return false
95
+ return !state.pendingCommands.some(
96
+ (command) => command.command === 'pause' || command.command === 'abort',
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Resumable for the bulk control: paused, unblocked, with no resume in flight.
102
+ * The escalation clause is what keeps bulk resume off display-`BLOCKED` builds:
103
+ * §15.5 gives `paused` precedence in the reducer, so a paused-and-blocked build
104
+ * reduces to `paused` while the dashboard shows `BLOCKED`. Clearing a blocker
105
+ * stays the per-build `r` prompt with its feedback field. Reducer-status
106
+ * `blocked` fails the first clause.
107
+ */
108
+ export function bulkResumable(state: BuildState): boolean {
109
+ if (state.status !== 'paused') return false
110
+ if (state.openEscalations.length > 0) return false
111
+ return !state.pendingCommands.some((command) => command.command === 'resume')
112
+ }
113
+
114
+ function eligible(state: BuildState, direction: BulkDirection): boolean {
115
+ return direction === 'pause' ? bulkPausable(state) : bulkResumable(state)
116
+ }
117
+
118
+ /**
119
+ * Request one direction against one build, compare-and-set. Returns whether a
120
+ * request was durably written.
121
+ *
122
+ * The read/reduce/evaluate lives INSIDE the loop on purpose: hoisting it would
123
+ * reintroduce exactly the stale-state defect the loop exists to remove. Any
124
+ * competing write that pauses, resumes, acknowledges, or terminates the build
125
+ * ends the loop by making the predicate false; only an unrelated append to a
126
+ * still-eligible build causes a true retry.
127
+ */
128
+ async function requestOne(
129
+ store: BuildStore,
130
+ slug: string,
131
+ actor: Actor,
132
+ direction: BulkDirection,
133
+ ): Promise<boolean> {
134
+ while (true) {
135
+ const state = reduceBuild(await store.getEvents(slug))
136
+ if (!eligible(state, direction)) return false
137
+ // The two branches are written out rather than selecting the event type
138
+ // into a variable so `appendIfCurrent`'s `T extends EventType` infers a
139
+ // concrete payload type in each — the shape `build-control.ts`'s own
140
+ // `appendCommand` switch uses.
141
+ const appended =
142
+ direction === 'pause'
143
+ ? await store.appendIfCurrent(slug, state.lastSeq, {
144
+ actor,
145
+ type: 'build.pause-requested',
146
+ payload: {},
147
+ })
148
+ : await store.appendIfCurrent(slug, state.lastSeq, {
149
+ actor,
150
+ type: 'build.resume-requested',
151
+ payload: {},
152
+ })
153
+ if (appended !== null) return true
154
+ }
155
+ }
156
+
157
+ export interface BulkControlOpts {
158
+ store: BuildStore
159
+ /** Canonical main-repository identity stored on BuildRecord.repo. */
160
+ repo: string
161
+ /** Explicit identity for API callers; CLI/dashboard may retain env. */
162
+ user?: string
163
+ env?: Record<string, string | undefined>
164
+ /** Delegated-write attribution marker threaded onto the actor. */
165
+ via?: Via
166
+ direction: BulkDirection
167
+ }
168
+
169
+ /** What a walk had already done when it failed. Every field is derived from the
170
+ * walk's own position, so a failure report cannot disagree with what was
171
+ * actually attempted. */
172
+ export interface BulkControlProgress {
173
+ direction: BulkDirection
174
+ /** The repository-wide hold value the walk was asked to write. */
175
+ paused: boolean
176
+ pausedWritten: boolean
177
+ /** The intake value the walk was asked to write. */
178
+ intake: boolean
179
+ intakeWritten: boolean
180
+ /** Slugs that received a durable request before the failure, in write order. */
181
+ slugs: string[]
182
+ /** The build whose request failed; absent when the walk failed before any. */
183
+ failedSlug?: string
184
+ /** Candidate slugs the walk never reached. */
185
+ remaining: string[]
186
+ }
187
+
188
+ /**
189
+ * A walk that failed partway, carrying what it had already written.
190
+ *
191
+ * `message` is deliberately EXACTLY the cause's message: the dashboard's
192
+ * `queueAction` catch renders `dashboard bulk-pause action failed:
193
+ * ${error.message}`, and that announcement must stay byte-identical. A surface
194
+ * that wants the partial progress reads `progress` instead.
195
+ */
196
+ export class BulkWalkError extends Error {
197
+ readonly progress: BulkControlProgress
198
+
199
+ constructor(cause: unknown, progress: BulkControlProgress) {
200
+ super(cause instanceof Error ? cause.message : String(cause), { cause })
201
+ this.name = 'BulkWalkError'
202
+ this.progress = progress
203
+ }
204
+ }
205
+
206
+ /** One operator-facing line describing what a bulk action durably did. */
207
+ export function bulkControlReport(summary: BulkControlSummary): string {
208
+ const label = summary.direction === 'pause' ? 'pause all' : 'resume all'
209
+ // Clauses follow write order. The hold clause is not decorative: in the
210
+ // zero-build case it is the only thing the action did, and it is the fact
211
+ // that makes the control trustworthy.
212
+ const hold = summary.paused ? 'queued builds held' : 'queued builds released'
213
+ const intake = `intake ${summary.intake ? 'ON' : 'OFF'}`
214
+ if (summary.slugs.length === 0) {
215
+ const nothing = summary.direction === 'pause' ? 'no pausable builds' : 'no paused builds'
216
+ return `${label}: ${nothing}; ${hold}; ${intake}`
217
+ }
218
+ const count = summary.slugs.length === 1 ? '1 build' : `${summary.slugs.length} builds`
219
+ const verb = summary.direction === 'pause' ? 'pause requested' : 'resume requested'
220
+ return `${label}: ${verb} for ${count}; ${hold}; ${intake}`
221
+ }
222
+
223
+ /**
224
+ * Put the repository into (or out of) quiescence. The repository-wide hold is
225
+ * set first, then intake, then every eligible build is requested one at a time.
226
+ *
227
+ * A per-build write that fails outright leaves both repository facts already
228
+ * set and some builds requested. That is not transactional and the store offers
229
+ * no way to make it so; the recovery is a second keypress, which the CAS loop
230
+ * and the predicates make idempotent — nothing is double-paused. The failure is
231
+ * rethrown as a `BulkWalkError` carrying that partial progress, so a surface
232
+ * with somewhere to print it can name what did not complete — including which
233
+ * of the two repository facts landed, since the prefix property above is only
234
+ * legible to an operator who is told where the walk stopped.
235
+ */
236
+ export async function bulkControlRepository(opts: BulkControlOpts): Promise<BulkControlSummary> {
237
+ const { store, repo, direction } = opts
238
+ const actor = humanActor(opts.user?.trim() || buildControlUser(opts.env ?? {}), opts.via)
239
+ const paused = direction === 'pause'
240
+ const intake = direction === 'resume'
241
+
242
+ let pausedWritten = false
243
+ let intakeWritten = false
244
+ let records: BuildRecord[] = []
245
+ let failedIndex: number | undefined
246
+ const slugs: string[] = []
247
+
248
+ try {
249
+ // `getRepoEvents`/`appendRepo` throw on a repository row no tick has created
250
+ // yet, and a keypress can precede the first tick. Mirrors toggleHarvestGate.
251
+ await store.ensureRepo(repo)
252
+ // The quiescence boundary, first — see the module doc's prefix property.
253
+ await store.appendRepo(repo, {
254
+ actor,
255
+ type: 'dispatcher.pause-set',
256
+ payload: { enabled: paused },
257
+ })
258
+ pausedWritten = true
259
+ await store.appendRepo(repo, {
260
+ actor,
261
+ type: 'dispatcher.intake-set',
262
+ payload: { enabled: intake },
263
+ })
264
+ intakeWritten = true
265
+
266
+ records = (await store.listBuilds())
267
+ .filter((record) => record.repo === repo)
268
+ // Slug order matches the dashboard's row order and makes the summary
269
+ // deterministic.
270
+ .sort((a, b) => a.slug.localeCompare(b.slug))
271
+
272
+ for (let index = 0; index < records.length; index += 1) {
273
+ const record = records[index]!
274
+ // Marked before the attempt and cleared after it, so the loop index is the
275
+ // single source for both `failedSlug` and `remaining`.
276
+ failedIndex = index
277
+ if (await requestOne(store, record.slug, actor, direction)) slugs.push(record.slug)
278
+ failedIndex = undefined
279
+ }
280
+ } catch (error) {
281
+ const failed = failedIndex === undefined ? undefined : records[failedIndex]
282
+ throw new BulkWalkError(error, {
283
+ direction,
284
+ paused,
285
+ pausedWritten,
286
+ intake,
287
+ intakeWritten,
288
+ slugs,
289
+ ...(failed !== undefined ? { failedSlug: failed.slug } : {}),
290
+ remaining:
291
+ failedIndex === undefined ? [] : records.slice(failedIndex + 1).map((later) => later.slug),
292
+ })
293
+ }
294
+
295
+ return { direction, slugs, paused, intake }
296
+ }
297
+
298
+ /**
299
+ * A phase agent may not quiesce the repository from inside its own build.
300
+ *
301
+ * `refuseOwnSessionControl` blocks a phase from controlling its own build, and a
302
+ * phase session's build is `running` by definition — so it is inside a pause
303
+ * walk, and `--all` without this guard would be a one-flag bypass of a stated
304
+ * product rule. Uniform across directions, like the per-build rule. No caller
305
+ * the repository-wide form exists for (a script, a deploy hook, a cron job, a
306
+ * non-TTY host) carries `AB_SESSION`; only a runner-spawned phase does.
307
+ */
308
+ export function refuseOwnSessionBulkControl(
309
+ direction: BulkDirection,
310
+ env: Record<string, string | undefined>,
311
+ ): void {
312
+ const session = env.AB_SESSION?.trim()
313
+ const build = env.AB_BUILD?.trim()
314
+ if (session === undefined || session === '') return
315
+ if (build === undefined || build === '') return
316
+ throw new BuildControlError(
317
+ 'own-session',
318
+ `cannot ${direction} every build from build "${build}"'s own phase session ` +
319
+ '(AB_SESSION/AB_BUILD conflict); run this command outside that build session',
320
+ )
321
+ }
322
+
323
+ export interface AbBulkControlOpts {
324
+ targetRepo: string
325
+ env: Record<string, string | undefined>
326
+ exec: Exec
327
+ direction: BulkDirection
328
+ /** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
329
+ storeRef?: string
330
+ /** Injectable adapter seam for unit tests. */
331
+ openStore?: StoreOpener
332
+ }
333
+
334
+ /** Sessionless command shell: resolve repository/store, walk, always close.
335
+ * The exact mirror of `abBuildControl`, over the bulk contract. */
336
+ export async function abBulkControl(opts: AbBulkControlOpts): Promise<BulkControlSummary> {
337
+ // Before opening a store, as in abBuildControl: the conflict is ambient and
338
+ // no read is needed to know the attempt is forbidden.
339
+ refuseOwnSessionBulkControl(opts.direction, opts.env)
340
+
341
+ return withSessionlessStore(opts, ({ store, repo }) =>
342
+ bulkControlRepository({ store, repo, env: opts.env, direction: opts.direction }),
343
+ )
344
+ }
345
+
346
+ /** Operator-facing success report: the shared announcement line the dashboard
347
+ * also prints, then one line per requested build in the exact shape
348
+ * `ab pause <slug>` already prints, so a script parsing per-build output parses
349
+ * this unchanged. */
350
+ export function bulkControlLines(summary: BulkControlSummary): string[] {
351
+ return [
352
+ bulkControlReport(summary),
353
+ ...summary.slugs.map((slug) => `build ${slug}: ${summary.direction} requested`),
354
+ ]
355
+ }
356
+
357
+ /** Operator-facing failure report: what the walk wrote, where it stopped, and
358
+ * what it never reached. */
359
+ export function bulkFailureLines(error: BulkWalkError): string[] {
360
+ const { direction, paused, pausedWritten, intake, intakeWritten, slugs, failedSlug, remaining } =
361
+ error.progress
362
+ const label = direction === 'pause' ? 'pause all' : 'resume all'
363
+ // Write order again, and the hold clause carries the same words as the
364
+ // success report. It is the clause that tells the operator whether the
365
+ // prefix that survived is the quiesced one: a failure with the hold written
366
+ // still holds every queued build, and a failure before it changed nothing.
367
+ const holdLine = pausedWritten
368
+ ? paused
369
+ ? 'queued builds held'
370
+ : 'queued builds released'
371
+ : 'hold not written'
372
+ const intakeLine = intakeWritten
373
+ ? `intake ${intake ? 'ON' : 'OFF'} written`
374
+ : 'intake not written'
375
+
376
+ const lines = [`${label}: failed; ${holdLine}; ${intakeLine}`]
377
+ if (slugs.length > 0) lines.push(`requested: ${slugs.join(', ')}`)
378
+ lines.push(
379
+ failedSlug === undefined
380
+ ? `failed before any build — ${error.message}`
381
+ : `failed at: ${failedSlug} — ${error.message}`,
382
+ )
383
+ if (remaining.length > 0) lines.push(`not attempted: ${remaining.join(', ')}`)
384
+ return lines
385
+ }