@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,289 @@
1
+ /**
2
+ * Shared behavioral contract for finite sessionless commands that own a
3
+ * BuildStore. Register this against distinct command shells so selection,
4
+ * credentials, production composition, and cleanup cannot drift per command.
5
+ */
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { existsSync } from 'node:fs'
8
+ import { mkdtemp, rm } from 'node:fs/promises'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import type { Exec } from '../ports/workspace/git-worktree'
12
+ import { MemoryBuildStore } from '../store/memory'
13
+ import { startStoreServer } from '../store/remote/server'
14
+ import { mintToken } from '../store/remote/token'
15
+ import type { BuildStore } from '../store/types'
16
+ import type { StoreOpener } from './store-opening'
17
+
18
+ const MAIN_REPO = '/main/repo'
19
+ const LINKED_REPO = '/linked/worktree'
20
+
21
+ const mainRepoExec: Exec = async (cmd) =>
22
+ cmd[1] === 'remote'
23
+ ? // No origin remote: identity falls back to the resolved checkout path.
24
+ { stdout: '', stderr: "error: No such remote 'origin'\n", exitCode: 2 }
25
+ : {
26
+ stdout: `${MAIN_REPO}/.git\n${MAIN_REPO}/.git\n${MAIN_REPO}\n`,
27
+ stderr: '',
28
+ exitCode: 0,
29
+ }
30
+
31
+ const noGit: Exec = async () => ({
32
+ stdout: '',
33
+ stderr: 'not a git repository',
34
+ exitCode: 128,
35
+ })
36
+
37
+ export interface StoreOpeningCommandOpts {
38
+ targetRepo: string
39
+ env: Record<string, string | undefined>
40
+ exec: Exec
41
+ stdout: (line: string) => void
42
+ storeRef?: string
43
+ openStore?: StoreOpener
44
+ }
45
+
46
+ export interface StoreOpeningCommandAdapter {
47
+ run: (opts: StoreOpeningCommandOpts) => Promise<void>
48
+ /** Extract a command-specific marker proving it used canonical main-repo
49
+ * identity (for example a filtered build slug or a projected repo path). */
50
+ canonicalMarker: (stdout: string[]) => string | undefined
51
+ expectedCanonicalMarker: string
52
+ }
53
+
54
+ function trackedStore(
55
+ store: MemoryBuildStore,
56
+ opts: { failOperations?: boolean; onClose: () => void },
57
+ ): BuildStore {
58
+ return new Proxy(store, {
59
+ get(target, prop) {
60
+ if (prop === 'close') {
61
+ return async () => opts.onClose()
62
+ }
63
+ const value = Reflect.get(target, prop, target) as unknown
64
+ if (typeof value !== 'function') return value
65
+ if (opts.failOperations === true) {
66
+ return async () => {
67
+ throw new Error('store operation failed')
68
+ }
69
+ }
70
+ return value.bind(target) as unknown
71
+ },
72
+ }) as unknown as BuildStore
73
+ }
74
+
75
+ async function capture(
76
+ adapter: StoreOpeningCommandAdapter,
77
+ opts: Omit<StoreOpeningCommandOpts, 'stdout'>,
78
+ ): Promise<string[]> {
79
+ const stdout: string[] = []
80
+ await adapter.run({ ...opts, stdout: (line) => stdout.push(line) })
81
+ return stdout
82
+ }
83
+
84
+ export function describeStoreOpeningContract(
85
+ name: string,
86
+ adapter: StoreOpeningCommandAdapter,
87
+ ): void {
88
+ describe(`sessionless store-opening contract: ${name}`, () => {
89
+ test('shares reference precedence, normalization, URL preservation, and opaque token forwarding', async () => {
90
+ const store = new MemoryBuildStore()
91
+ const opened: Array<{ ref: string; token?: string }> = []
92
+ const openStore: StoreOpener = (ref, token) => {
93
+ opened.push({ ref, ...(token !== undefined ? { token } : {}) })
94
+ return store
95
+ }
96
+ const base = {
97
+ targetRepo: LINKED_REPO,
98
+ exec: mainRepoExec,
99
+ openStore,
100
+ }
101
+
102
+ await capture(adapter, {
103
+ ...base,
104
+ env: { AB_STORE: 'environment' },
105
+ storeRef: 'flag/../selected',
106
+ })
107
+ await capture(adapter, {
108
+ ...base,
109
+ env: { AB_STORE: 'environment/../selected-env' },
110
+ })
111
+ await capture(adapter, {
112
+ ...base,
113
+ env: { AB_STORE: '/absolute/state' },
114
+ storeRef: ' ',
115
+ })
116
+ await capture(adapter, {
117
+ ...base,
118
+ env: { AB_STORE: ' \t' },
119
+ storeRef: '',
120
+ })
121
+ await capture(adapter, {
122
+ ...base,
123
+ env: {},
124
+ storeRef: 'http://store.example.test/api',
125
+ })
126
+ await capture(adapter, {
127
+ ...base,
128
+ env: {},
129
+ storeRef: 'https://store.example.test/api?x=1',
130
+ })
131
+ await capture(adapter, {
132
+ ...base,
133
+ env: { AB_TOKEN: '' },
134
+ storeRef: 'https://store.example.test/empty-token',
135
+ })
136
+ await capture(adapter, {
137
+ ...base,
138
+ env: { AB_TOKEN: ' opaque token ' },
139
+ storeRef: 'https://store.example.test/opaque-token',
140
+ })
141
+
142
+ expect(opened).toEqual([
143
+ { ref: `${MAIN_REPO}/selected` },
144
+ { ref: `${MAIN_REPO}/selected-env` },
145
+ { ref: '/absolute/state' },
146
+ { ref: `${MAIN_REPO}/.autobuild` },
147
+ { ref: 'http://store.example.test/api' },
148
+ { ref: 'https://store.example.test/api?x=1' },
149
+ { ref: 'https://store.example.test/empty-token' },
150
+ {
151
+ ref: 'https://store.example.test/opaque-token',
152
+ token: ' opaque token ',
153
+ },
154
+ ])
155
+ })
156
+
157
+ test('uses canonical main-repository identity', async () => {
158
+ const store = new MemoryBuildStore()
159
+ await store.createBuild({ slug: 'main-build', repo: MAIN_REPO })
160
+ await store.createBuild({ slug: 'other-build', repo: '/other/repo' })
161
+ await store.ensureRepo(MAIN_REPO)
162
+ const stdout = await capture(adapter, {
163
+ targetRepo: LINKED_REPO,
164
+ env: {},
165
+ exec: mainRepoExec,
166
+ openStore: () => store,
167
+ })
168
+ expect(adapter.canonicalMarker(stdout)).toBe(adapter.expectedCanonicalMarker)
169
+ })
170
+
171
+ test('closes exactly once on success, store failure, and output failure', async () => {
172
+ const run = async (mode: 'success' | 'store' | 'output'): Promise<number> => {
173
+ const store = new MemoryBuildStore()
174
+ let closes = 0
175
+ const tracked = trackedStore(store, {
176
+ failOperations: mode === 'store',
177
+ onClose: () => {
178
+ closes += 1
179
+ },
180
+ })
181
+ const command = adapter.run({
182
+ targetRepo: LINKED_REPO,
183
+ env: {},
184
+ exec: mainRepoExec,
185
+ openStore: () => tracked,
186
+ stdout:
187
+ mode === 'output'
188
+ ? () => {
189
+ throw new Error('output failed')
190
+ }
191
+ : () => {},
192
+ })
193
+ if (mode === 'success') await command
194
+ else {
195
+ await expect(command).rejects.toThrow(
196
+ mode === 'store' ? 'store operation failed' : 'output failed',
197
+ )
198
+ }
199
+ return closes
200
+ }
201
+
202
+ expect(await run('success')).toBe(1)
203
+ expect(await run('store')).toBe(1)
204
+ expect(await run('output')).toBe(1)
205
+ })
206
+
207
+ test('propagates opener failure without phantom cleanup', async () => {
208
+ let closes = 0
209
+ const unopened = new MemoryBuildStore()
210
+ unopened.close = async () => {
211
+ closes += 1
212
+ }
213
+ await expect(
214
+ adapter.run({
215
+ targetRepo: LINKED_REPO,
216
+ env: {},
217
+ exec: mainRepoExec,
218
+ stdout: () => {},
219
+ openStore: () => {
220
+ throw new Error('open failed')
221
+ },
222
+ }),
223
+ ).rejects.toThrow('open failed')
224
+ expect(closes).toBe(0)
225
+ })
226
+
227
+ test('production composition opens a local SQLite root', async () => {
228
+ const repo = await mkdtemp(join(tmpdir(), 'ab-store-opening-local-'))
229
+ try {
230
+ await adapter.run({
231
+ targetRepo: repo,
232
+ env: {},
233
+ exec: noGit,
234
+ stdout: () => {},
235
+ storeRef: 'state',
236
+ })
237
+ expect(existsSync(join(repo, 'state', 'autobuild.sqlite'))).toBe(true)
238
+ } finally {
239
+ await rm(repo, { recursive: true, force: true })
240
+ }
241
+ })
242
+
243
+ test('production composition forwards a token to remote HTTP and rejects missing or invalid credentials', async () => {
244
+ const secret = 'store-opening-secret'
245
+ const now = new Date('2026-07-15T12:00:00.000Z')
246
+ const backing = new MemoryBuildStore({ clock: () => now })
247
+ const server = startStoreServer({
248
+ store: backing,
249
+ secret,
250
+ clock: () => now,
251
+ })
252
+ const token = mintToken(secret, {
253
+ build: '*',
254
+ session: '*',
255
+ exp: now.getTime() + 60_000,
256
+ })
257
+ try {
258
+ await adapter.run({
259
+ targetRepo: LINKED_REPO,
260
+ env: { AB_TOKEN: token },
261
+ exec: mainRepoExec,
262
+ stdout: () => {},
263
+ storeRef: server.url,
264
+ })
265
+ await expect(
266
+ adapter.run({
267
+ targetRepo: LINKED_REPO,
268
+ env: {},
269
+ exec: mainRepoExec,
270
+ stdout: () => {},
271
+ storeRef: server.url,
272
+ }),
273
+ ).rejects.toThrow(/missing bearer token/)
274
+ await expect(
275
+ adapter.run({
276
+ targetRepo: LINKED_REPO,
277
+ env: { AB_TOKEN: 'invalid-token' },
278
+ exec: mainRepoExec,
279
+ stdout: () => {},
280
+ storeRef: server.url,
281
+ }),
282
+ ).rejects.toThrow(/invalid or expired token/)
283
+ } finally {
284
+ await server.stop()
285
+ await backing.close()
286
+ }
287
+ })
288
+ })
289
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Production BuildStore composition for CLI commands.
3
+ *
4
+ * `repo-state.ts` owns repository identity and reference precedence, while
5
+ * `store-ref.ts` remains the adapter-free local-vs-remote parser. This module
6
+ * joins those seams to the production remote adapter and owns finite-command
7
+ * store lifecycle.
8
+ */
9
+ import type { Exec } from '../ports/workspace/git-worktree'
10
+ import { RemoteBuildStore } from '../store/remote/client'
11
+ import { scopeLocalStoreToPhaseSession } from '../store/phase-session'
12
+ import type { BuildStore } from '../store/types'
13
+ import {
14
+ resolveAmbientReadSession,
15
+ type AmbientReadSession,
16
+ type CliEnv,
17
+ type HarvestCliEnv,
18
+ } from './env'
19
+ import { isRemoteStoreRef, resolveRepoState, type RepoStatePaths } from './repo-state'
20
+ import { resolveStore } from './store-ref'
21
+
22
+ /** Shared injection seam used by every command that opens a BuildStore. */
23
+ export type StoreOpener = (ref: string, token?: string) => BuildStore
24
+
25
+ function optionalToken(value: string | undefined): string | undefined {
26
+ // Bearer credentials are opaque. Only absence and the exact empty value mean
27
+ // "no token"; nonempty values are forwarded byte-for-byte.
28
+ return value !== undefined && value !== '' ? value : undefined
29
+ }
30
+
31
+ /** Production local/remote composition. The low-level parser stays injectable
32
+ * and does not import the HTTP adapter. */
33
+ export const openProductionStore: StoreOpener = (ref, token) => {
34
+ const forwardedToken = optionalToken(token)
35
+ return resolveStore(ref, {
36
+ remoteFactory: (url, remoteToken) => new RemoteBuildStore({ url, token: remoteToken }),
37
+ ...(forwardedToken !== undefined ? { token: forwardedToken } : {}),
38
+ })
39
+ }
40
+
41
+ /** Apply validated ambient authority to a selected Store handle. Remote
42
+ * handles retain token-backed server authorization; filesystem handles receive
43
+ * the equivalent in-process scope. Keeping this primitive shared prevents
44
+ * phase commands and ambient-aware query commands from drifting. */
45
+ function scopeSelectedStoreToPhaseSession(
46
+ store: BuildStore,
47
+ ref: string,
48
+ env: AmbientReadSession,
49
+ ): BuildStore {
50
+ if (isRemoteStoreRef(ref)) return store
51
+ return scopeLocalStoreToPhaseSession(
52
+ store,
53
+ 'build' in env
54
+ ? { kind: 'build', id: env.build, session: env.session }
55
+ : { kind: 'repo', id: env.repo, session: env.session },
56
+ )
57
+ }
58
+
59
+ /** Open the Store for a validated phase/Harvest ambient identity. */
60
+ export function openProductionSessionStore(env: CliEnv | HarvestCliEnv): BuildStore {
61
+ return scopeSelectedStoreToPhaseSession(openProductionStore(env.store, env.token), env.store, env)
62
+ }
63
+
64
+ export interface OpenedStoreContext extends RepoStatePaths {
65
+ store: BuildStore
66
+ token?: string
67
+ /** The validated ambient phase/Harvest identity this invocation carries, if
68
+ * any — the same value that scoped `store` on the local path. Exposed so a
69
+ * command core can extend own-resource authority to remote handles too,
70
+ * where no session wrapper exists. */
71
+ ambient?: AmbientReadSession
72
+ }
73
+
74
+ export interface OpenStoreForRepoStateOpts {
75
+ env: Record<string, string | undefined>
76
+ openStore?: StoreOpener
77
+ }
78
+
79
+ /** Open from an already canonicalized state selection. Dispatch uses this form
80
+ * so repository topology and relative overrides are decided exactly once. */
81
+ export function openStoreForRepoState(
82
+ state: RepoStatePaths,
83
+ opts: OpenStoreForRepoStateOpts,
84
+ ): OpenedStoreContext {
85
+ const token = optionalToken(opts.env.AB_TOKEN)
86
+ const store = (opts.openStore ?? openProductionStore)(state.storeRef, token)
87
+ return {
88
+ ...state,
89
+ store,
90
+ ...(token !== undefined ? { token } : {}),
91
+ }
92
+ }
93
+
94
+ export interface SessionlessStoreOpts extends OpenStoreForRepoStateOpts {
95
+ targetRepo: string
96
+ exec: Exec
97
+ /** Explicit --store; precedence is applied by repo-state.ts. */
98
+ storeRef?: string
99
+ }
100
+
101
+ /** Resolve the main checkout and sessionless reference precedence, then open
102
+ * the selected production or injected store. The caller owns the handle. */
103
+ export async function openSessionlessStore(
104
+ opts: SessionlessStoreOpts,
105
+ ): Promise<OpenedStoreContext> {
106
+ const state = await resolveRepoState({
107
+ targetRepo: opts.targetRepo,
108
+ exec: opts.exec,
109
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
110
+ ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
111
+ })
112
+ return openStoreForRepoState(state, opts)
113
+ }
114
+
115
+ /** Finite command ownership boundary. Opening failures occur before the
116
+ * try/finally, so only a successfully returned handle is ever closed.
117
+ *
118
+ * Remote store handles do not session-scope (bearer authorization is the
119
+ * server's concern), so a command core that needs own-build authority must
120
+ * consume `ambient` from the context directly rather than rely on the handle
121
+ * enforcing a scope. */
122
+ export async function withSessionlessStore<T>(
123
+ opts: SessionlessStoreOpts,
124
+ use: (context: OpenedStoreContext) => Promise<T> | T,
125
+ ): Promise<T> {
126
+ const context = await openSessionlessStore(opts)
127
+ try {
128
+ return await use(context)
129
+ } finally {
130
+ await context.store.close()
131
+ }
132
+ }
133
+
134
+ /** Dedicated opener for the finite query commands whose operator-sessionless
135
+ * syntax is also useful inside a phase. Invalid ambient identity fails before
136
+ * repository canonicalization or Store opening; absent identity preserves the
137
+ * ordinary unscoped operator path. */
138
+ export async function openAmbientReadStore(
139
+ opts: SessionlessStoreOpts,
140
+ ): Promise<OpenedStoreContext> {
141
+ const ambient = resolveAmbientReadSession(opts.env)
142
+ const state = await resolveRepoState({
143
+ targetRepo: opts.targetRepo,
144
+ exec: opts.exec,
145
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
146
+ ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
147
+ })
148
+ const context = openStoreForRepoState(state, opts)
149
+ if (ambient === undefined) return context
150
+ return {
151
+ ...context,
152
+ ambient,
153
+ store: scopeSelectedStoreToPhaseSession(context.store, state.storeRef, ambient),
154
+ }
155
+ }
156
+
157
+ export async function withAmbientReadStore<T>(
158
+ opts: SessionlessStoreOpts,
159
+ use: (context: OpenedStoreContext) => Promise<T> | T,
160
+ ): Promise<T> {
161
+ const context = await openAmbientReadStore(opts)
162
+ try {
163
+ return await use(context)
164
+ } finally {
165
+ await context.store.close()
166
+ }
167
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `AB_STORE` reference resolution (SPEC §7.2, §8.1): a local filesystem path
3
+ * opens the SQLite+blob-dir store; an http(s) URL is the remote HTTP adapter.
4
+ *
5
+ * The production remote adapter is composed in `src/cli/store-opening.ts` via
6
+ * `remoteFactory`. This low-level parser stays decoupled from that adapter, so
7
+ * it remains independently testable and an unwired entry point fails clearly.
8
+ */
9
+ import { openLocalStore } from '../store/local/store'
10
+ import type { BuildStore } from '../store/types'
11
+ import { isRemoteStoreRef } from './repo-state'
12
+
13
+ export interface ResolveStoreOpts {
14
+ /** Constructs the remote HTTP store (§7.2.2); production wiring is external. */
15
+ remoteFactory?: (url: string, token?: string) => BuildStore
16
+ /** Scoped token (D8, `AB_TOKEN`) — passed through to the remote factory. */
17
+ token?: string
18
+ }
19
+
20
+ export function resolveStore(ref: string, opts: ResolveStoreOpts = {}): BuildStore {
21
+ if (isRemoteStoreRef(ref)) {
22
+ if (opts.remoteFactory === undefined) {
23
+ throw new Error(
24
+ `remote store support not wired: cannot open "${ref}" from this entry ` +
25
+ 'point — supply the remote HTTP adapter (SPEC §7.2.2) as ' +
26
+ "resolveStore's remoteFactory; production composition lives in " +
27
+ 'src/cli/store-opening.ts',
28
+ )
29
+ }
30
+ return opts.remoteFactory(ref, opts.token)
31
+ }
32
+ return openLocalStore(ref)
33
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Terminal modes owned by the interactive dispatch dashboard. A declaration is
3
+ * the single source for both its ordinary enter/leave writes and the
4
+ * process-boundary emergency restore.
5
+ */
6
+ export interface TerminalMode {
7
+ readonly name: string
8
+ readonly enter: string
9
+ readonly restore: string
10
+ /** Lower values restore first when the process boundary unwinds all modes. */
11
+ readonly restoreOrder: number
12
+ }
13
+
14
+ export const ALTERNATE_SCREEN_MODE = {
15
+ name: 'alternate-screen',
16
+ enter: '\x1b[?1049h',
17
+ restore: '\x1b[?1049l',
18
+ restoreOrder: 30,
19
+ } as const satisfies TerminalMode
20
+
21
+ export const HIDDEN_CURSOR_MODE = {
22
+ name: 'hidden-cursor',
23
+ enter: '\x1b[?25l',
24
+ restore: '\x1b[?25h',
25
+ restoreOrder: 40,
26
+ } as const satisfies TerminalMode
27
+
28
+ export const BRACKETED_PASTE_MODE = {
29
+ name: 'bracketed-paste',
30
+ enter: '\x1b[?2004h',
31
+ restore: '\x1b[?2004l',
32
+ restoreOrder: 20,
33
+ } as const satisfies TerminalMode
34
+
35
+ export const KITTY_KEYBOARD_FLAGS = 0b1_1101
36
+
37
+ export const KITTY_KEYBOARD_MODE = {
38
+ name: 'kitty-keyboard-flags',
39
+ enter: `\x1b[>${KITTY_KEYBOARD_FLAGS}u`,
40
+ restore: '\x1b[<1u',
41
+ restoreOrder: 10,
42
+ } as const satisfies TerminalMode
43
+
44
+ export interface TerminalModeController {
45
+ /** Arm before writing the enter sequence. Re-entering an active mode is inert. */
46
+ enter(mode: TerminalMode): void
47
+ /** Write the ordinary restore sequence, then disarm. */
48
+ leave(mode: TerminalMode): void
49
+ /** Synchronously and best-effort restore every active mode. */
50
+ restoreAll(): void
51
+ isActive(mode: TerminalMode): boolean
52
+ readonly activeModes: readonly TerminalMode[]
53
+ }
54
+
55
+ /**
56
+ * Keep the currently active terminal modes at the output seam.
57
+ *
58
+ * Enter arms before its write and leave disarms only after a successful write.
59
+ * A failed normal write therefore remains visible to the synchronous emergency
60
+ * path. Emergency restoration attempts every mode even if one write fails.
61
+ */
62
+ export function createTerminalModeController(
63
+ write: (chunk: string) => void,
64
+ emergencyWrite: (chunk: string) => void = write,
65
+ ): TerminalModeController {
66
+ const active = new Map<TerminalMode, number>()
67
+ let activation = 0
68
+
69
+ return {
70
+ enter(mode): void {
71
+ if (active.has(mode)) return
72
+ active.set(mode, activation++)
73
+ write(mode.enter)
74
+ },
75
+ leave(mode): void {
76
+ if (!active.has(mode)) return
77
+ write(mode.restore)
78
+ active.delete(mode)
79
+ },
80
+ restoreAll(): void {
81
+ const modes = [...active.entries()].sort(
82
+ ([left, leftActivation], [right, rightActivation]) =>
83
+ left.restoreOrder - right.restoreOrder || leftActivation - rightActivation,
84
+ )
85
+ for (const [mode] of modes) {
86
+ try {
87
+ emergencyWrite(mode.restore)
88
+ active.delete(mode)
89
+ } catch {
90
+ // Best effort means one broken write cannot prevent the remaining
91
+ // independent modes from being restored. The failed mode stays armed
92
+ // so a later exit boundary may retry it.
93
+ }
94
+ }
95
+ },
96
+ isActive: (mode): boolean => active.has(mode),
97
+ get activeModes(): readonly TerminalMode[] {
98
+ return [...active.keys()]
99
+ },
100
+ }
101
+ }
102
+
103
+ export const TERMINATING_SIGNALS = [
104
+ 'SIGHUP',
105
+ 'SIGQUIT',
106
+ 'SIGTERM',
107
+ ] as const satisfies readonly NodeJS.Signals[]
108
+
109
+ export interface TerminalRestoreProcess {
110
+ readonly pid: number
111
+ on(event: string, listener: (...args: unknown[]) => void): unknown
112
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown
113
+ kill(pid: number, signal: NodeJS.Signals): unknown
114
+ }
115
+
116
+ export interface TerminalRestoreHook {
117
+ /** Restore active modes, remove this hook's listeners, then replay a captured signal. */
118
+ close(): void
119
+ }
120
+
121
+ /**
122
+ * Install the synchronous process-boundary fallback used only by `ab dispatch`.
123
+ * SIGINT is deliberately absent: raw Ctrl-C and the binary's AbortController
124
+ * retain the dashboard's normal final-frame teardown. Other terminating signals
125
+ * restore immediately but are replayed only after the async dispatch drain.
126
+ */
127
+ export function installTerminalRestoreHook(
128
+ modes: TerminalModeController,
129
+ boundary: TerminalRestoreProcess = process as unknown as TerminalRestoreProcess,
130
+ requestStop: (signal: (typeof TERMINATING_SIGNALS)[number]) => void = () => {},
131
+ ): TerminalRestoreHook {
132
+ let closed = false
133
+ let pendingSignal: (typeof TERMINATING_SIGNALS)[number] | undefined
134
+ const registrations: Array<[string, (...args: unknown[]) => void]> = []
135
+
136
+ const restore = (): void => {
137
+ modes.restoreAll()
138
+ }
139
+ const remove = (): void => {
140
+ if (closed) return
141
+ closed = true
142
+ for (const [event, listener] of registrations) boundary.removeListener(event, listener)
143
+ }
144
+ const add = (event: string, listener: (...args: unknown[]) => void): void => {
145
+ registrations.push([event, listener])
146
+ boundary.on(event, listener)
147
+ }
148
+
149
+ // `uncaughtExceptionMonitor` observes an uncaught fault without changing
150
+ // Node's default crash behavior. `exit` is the final synchronous fallback.
151
+ add('uncaughtExceptionMonitor', restore)
152
+ add('exit', restore)
153
+ for (const signal of TERMINATING_SIGNALS) {
154
+ add(signal, () => {
155
+ restore()
156
+ if (pendingSignal !== undefined) return
157
+ pendingSignal = signal
158
+ requestStop(signal)
159
+ })
160
+ }
161
+
162
+ return {
163
+ close(): void {
164
+ if (closed) return
165
+ // Closing after runCli returns also catches an unexpectedly active ledger
166
+ // without depending on another promise turn or stream drain.
167
+ restore()
168
+ remove()
169
+ // Replay only after the supervised child has drained and the handlers are
170
+ // gone, preserving the operating system's terminating-signal semantics.
171
+ if (pendingSignal !== undefined) boundary.kill(boundary.pid, pendingSignal)
172
+ },
173
+ }
174
+ }