@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,432 @@
1
+ /**
2
+ * FakeWorkspaceProvider (SPEC §3.2): WorkspaceProvider for seam tests. Its
3
+ * default filesystem mode copies a source working tree so the returned path
4
+ * has the same usable-path semantics as GitWorktreeProvider. High-volume
5
+ * process-decision tests may explicitly select `logical` mode when their
6
+ * synthetic repo/path values are intentionally not filesystem fixtures.
7
+ *
8
+ * Shape parity with GitWorktreeProvider: `ref` and `path` are the same
9
+ * string (`<root>/<branch>`), provision is idempotent per branch (resume is
10
+ * a re-run, not a special path — constitution #2), and release of an
11
+ * unknown or already-released workspace is a no-op, never an error.
12
+ */
13
+ import { createHash } from 'node:crypto'
14
+ import {
15
+ cp,
16
+ mkdir,
17
+ readFile as fsReadFile,
18
+ rm,
19
+ stat,
20
+ writeFile as fsWriteFile,
21
+ } from 'node:fs/promises'
22
+ import { dirname, join, resolve, sep } from 'node:path'
23
+ import type { WorkspaceBase } from '../../ontology'
24
+ import {
25
+ SandboxOperationError,
26
+ type OperatorSandboxExecution,
27
+ type SandboxCommandResult,
28
+ type SandboxEnvironmentIdentity,
29
+ type SandboxWaitResult,
30
+ } from './operator-sandbox'
31
+ import type { WorkspaceHandle, WorkspaceProvider, WorkspaceProvisionResult } from '../types'
32
+
33
+ export interface ProvisionRecord {
34
+ repo: string
35
+ baseBranch: string
36
+ branch: string
37
+ }
38
+
39
+ export type FakeWorkspaceMode = 'filesystem' | 'logical'
40
+
41
+ async function pathExists(path: string): Promise<boolean> {
42
+ try {
43
+ await stat(path)
44
+ return true
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
47
+ throw error
48
+ }
49
+ }
50
+
51
+ export class FakeWorkspaceProvider implements WorkspaceProvider {
52
+ readonly name = 'fake'
53
+ readonly orchestratorSandbox: OperatorSandboxExecution
54
+
55
+ /** Journals — public so tests assert directly on call order and args. */
56
+ readonly provisions: ProvisionRecord[] = []
57
+ readonly releases: WorkspaceHandle[] = []
58
+
59
+ private readonly root: string
60
+ private readonly initialBase: WorkspaceBase
61
+ private readonly mode: FakeWorkspaceMode
62
+ private readonly sandboxRoot: string
63
+ private readonly sandboxSetupCommand: string | undefined
64
+ private readonly sandboxEnvSource: Record<string, string | undefined>
65
+ private readonly sandboxEnvironmentVariables: readonly string[]
66
+ /** ref → handle for workspaces provisioned and not yet released. */
67
+ private readonly active = new Map<string, WorkspaceHandle>()
68
+ /** Durable fake branch heads survive release, like real Git branches. */
69
+ private readonly branchHeads = new Map<string, string>()
70
+ private readonly failures = new Map<'provision' | 'release', Error>()
71
+
72
+ constructor(
73
+ opts: {
74
+ root?: string
75
+ base?: WorkspaceBase
76
+ /** Default `filesystem` enforces a real usable working-copy path. */
77
+ mode?: FakeWorkspaceMode
78
+ /** Operator-sandbox knobs: sandbox root (defaults to a sibling of the
79
+ * workspace root), the setup command, and forwarded variable names
80
+ * resolved from `envSource`. */
81
+ sandboxRoot?: string
82
+ sandboxSetupCommand?: string
83
+ sandboxEnvironmentVariables?: readonly string[]
84
+ envSource?: Record<string, string | undefined>
85
+ } = {},
86
+ ) {
87
+ this.root = resolve(opts.root ?? '/fake/workspaces')
88
+ this.initialBase = opts.base ?? { source: 'remote', sha: 'fake-base-sha' }
89
+ this.mode = opts.mode ?? 'filesystem'
90
+ this.sandboxRoot = resolve(opts.sandboxRoot ?? join(this.root, '..', 'orchestrator-sandboxes'))
91
+ this.sandboxSetupCommand = opts.sandboxSetupCommand
92
+ this.sandboxEnvironmentVariables = opts.sandboxEnvironmentVariables ?? []
93
+ this.sandboxEnvSource = opts.envSource ?? {}
94
+ this.orchestratorSandbox = {
95
+ describe: (input) => Promise.resolve(this.sandboxIdentity(input.repo, input.operator)),
96
+ ensure: (input) => this.ensureSandbox(input),
97
+ exec: (handle, request) => this.sandboxExec(handle, request),
98
+ start: (handle, request) => this.sandboxStart(handle, request),
99
+ wait: (handle, input) => this.sandboxWait(handle, input),
100
+ readFile: (handle, path) => this.sandboxReadFile(handle, path),
101
+ writeFile: (handle, path, content) => this.sandboxWriteFile(handle, path, content),
102
+ stop: () => Promise.resolve({ outcome: 'stopped' as const }),
103
+ release: (input) => this.sandboxRelease(input),
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Injectable failure: while set, the named operation throws `error` on
109
+ * every call (pass `null` to clear). Lets tests drive the provision/release
110
+ * failure paths without a real provider.
111
+ */
112
+ setFailure(op: 'provision' | 'release', error: Error | null): void {
113
+ if (error === null) this.failures.delete(op)
114
+ else this.failures.set(op, error)
115
+ }
116
+
117
+ /** Whether the workspace at `ref` is currently provisioned. */
118
+ isActive(ref: string): boolean {
119
+ return this.active.has(ref)
120
+ }
121
+
122
+ /** Test seam for commits made between provision calls. */
123
+ setBranchHead(branch: string, sha: string): void {
124
+ this.branchHeads.set(branch, sha)
125
+ }
126
+
127
+ async provision(opts: {
128
+ repo: string
129
+ baseBranch: string
130
+ branch: string
131
+ }): Promise<WorkspaceProvisionResult> {
132
+ const failure = this.failures.get('provision')
133
+ if (failure) throw failure
134
+ const ref = resolve(join(this.root, opts.branch))
135
+ const existing = this.active.get(ref)
136
+ if (existing) {
137
+ if (this.mode === 'logical' || (await pathExists(existing.path))) {
138
+ this.provisions.push({ ...opts })
139
+ return {
140
+ ...existing,
141
+ base: {
142
+ source: 'existing',
143
+ sha: this.branchHeads.get(opts.branch) ?? this.initialBase.sha,
144
+ },
145
+ }
146
+ }
147
+ // The active map is only process-local bookkeeping. If its filesystem
148
+ // working copy disappeared out of band, forget that stale registration
149
+ // and rematerialize below without changing the durable fake branch head.
150
+ this.active.delete(ref)
151
+ }
152
+
153
+ if (this.mode === 'filesystem') {
154
+ await mkdir(dirname(ref), { recursive: true })
155
+ // The fake owns its root. Remove an out-of-band leftover before making
156
+ // the new active working copy, just as worktree prune permits recovery.
157
+ await rm(ref, { recursive: true, force: true })
158
+ await cp(opts.repo, ref, { recursive: true })
159
+ }
160
+
161
+ const handle: WorkspaceHandle = {
162
+ provider: this.name,
163
+ ref,
164
+ path: ref,
165
+ branch: opts.branch,
166
+ }
167
+ this.active.set(ref, handle)
168
+ this.provisions.push({ ...opts })
169
+
170
+ const existingSha = this.branchHeads.get(opts.branch)
171
+ if (existingSha !== undefined) {
172
+ return { ...handle, base: { source: 'existing', sha: existingSha } }
173
+ }
174
+ this.branchHeads.set(opts.branch, this.initialBase.sha)
175
+ return { ...handle, base: { ...this.initialBase } }
176
+ }
177
+
178
+ /** Idempotent: releasing an unknown or already-released handle is a no-op
179
+ * (matching GitWorktreeProvider's already-gone-worktree behavior). */
180
+ async release(handle: WorkspaceHandle): Promise<void> {
181
+ const failure = this.failures.get('release')
182
+ if (failure) throw failure
183
+ this.releases.push({
184
+ provider: handle.provider,
185
+ ref: handle.ref,
186
+ path: handle.path,
187
+ branch: handle.branch,
188
+ })
189
+ const active = this.active.get(handle.ref)
190
+ if (!active) return
191
+ if (this.mode === 'filesystem') {
192
+ await rm(active.path, { recursive: true, force: true })
193
+ }
194
+ this.active.delete(handle.ref)
195
+ }
196
+
197
+ // ── Operator sandbox capability (AUT-340) ───────────────────
198
+ // Filesystem-backed so service and registry contract tests exercise real
199
+ // exec/read/write/reset semantics against a disposable tree.
200
+
201
+ /** Journal — environments idly stopped via the capability's `stop`. */
202
+ readonly sandboxStops: Array<{ operator: string; environmentId: string }> = []
203
+ /** Journal — full teardowns via the capability's `release`. */
204
+ readonly sandboxReleases: Array<{ repo: string; operator: string; environmentId: string }> = []
205
+ /** Serialization probe: in-flight exec recording, for overlap tests. */
206
+ private readonly sandboxChildren = new Map<
207
+ string,
208
+ { proc: ReturnType<typeof Bun.spawn>; stdout: string; stderr: string; exitCode: number | null }
209
+ >()
210
+
211
+ private sandboxIdentity(repo: string, operator: string): SandboxEnvironmentIdentity {
212
+ const digest = createHash('sha256').update(`${repo}\0${operator}`).digest('hex').slice(0, 10)
213
+ const path = join(this.sandboxRoot, digest)
214
+ return { provider: this.name, environmentId: path, workspacePath: path }
215
+ }
216
+
217
+ /** The credential-free guest environment: empty record + host PATH + the
218
+ * forwarded names. Mirrors the builtin adapters' rule. */
219
+ private sandboxEnv(): Record<string, string> {
220
+ const env: Record<string, string> = {
221
+ PATH: this.sandboxEnvSource.PATH ?? '/usr/local/bin:/usr/bin:/bin',
222
+ }
223
+ for (const name of this.sandboxEnvironmentVariables) {
224
+ const value = this.sandboxEnvSource[name]
225
+ if (value === undefined || value === '') {
226
+ throw new SandboxOperationError(
227
+ 'environment',
228
+ `operator sandbox environment variable ${name} is not set in the host environment`,
229
+ )
230
+ }
231
+ env[name] = value
232
+ }
233
+ return env
234
+ }
235
+
236
+ private async ensureSandbox(input: {
237
+ repo: string
238
+ operator: string
239
+ baseBranch: string
240
+ }): Promise<SandboxEnvironmentIdentity> {
241
+ const identity = this.sandboxIdentity(input.repo, input.operator)
242
+ if (await pathExists(join(identity.workspacePath, '.autobuild-sandbox-provisioned'))) {
243
+ return identity
244
+ }
245
+ if (this.mode !== 'filesystem') {
246
+ throw new SandboxOperationError(
247
+ 'provision',
248
+ 'the logical fake mode has no filesystem to host an operator sandbox',
249
+ )
250
+ }
251
+ await mkdir(dirname(identity.workspacePath), { recursive: true })
252
+ await rm(identity.workspacePath, { recursive: true, force: true })
253
+ await cp(input.repo, identity.workspacePath, { recursive: true })
254
+ try {
255
+ if (this.sandboxSetupCommand !== undefined && this.sandboxSetupCommand.trim() !== '') {
256
+ const proc = Bun.spawn(['sh', '-c', this.sandboxSetupCommand], {
257
+ cwd: identity.workspacePath,
258
+ stdin: 'ignore',
259
+ stdout: 'pipe',
260
+ stderr: 'pipe',
261
+ env: this.sandboxEnv(),
262
+ })
263
+ const [stdout, stderr, exitCode] = await Promise.all([
264
+ new Response(proc.stdout).text(),
265
+ new Response(proc.stderr).text(),
266
+ proc.exited,
267
+ ])
268
+ if (exitCode !== 0) {
269
+ throw new SandboxOperationError(
270
+ 'provision',
271
+ `operator sandbox setup failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`,
272
+ )
273
+ }
274
+ }
275
+ await fsWriteFile(join(identity.workspacePath, '.autobuild-sandbox-provisioned'), '')
276
+ } catch (error) {
277
+ await rm(identity.workspacePath, { recursive: true, force: true })
278
+ throw error
279
+ }
280
+ return identity
281
+ }
282
+
283
+ private sandboxCwd(handle: SandboxEnvironmentIdentity, cwd: string | undefined): string {
284
+ return cwd === undefined ? handle.workspacePath : join(handle.workspacePath, cwd)
285
+ }
286
+
287
+ private async sandboxExec(
288
+ handle: SandboxEnvironmentIdentity,
289
+ request: { command: string; cwd?: string; timeoutSeconds?: number },
290
+ ): Promise<SandboxCommandResult> {
291
+ const timeoutSeconds = Math.min(Math.max(request.timeoutSeconds ?? 120, 1), 300)
292
+ const proc = Bun.spawn(['sh', '-c', request.command], {
293
+ cwd: this.sandboxCwd(handle, request.cwd),
294
+ stdin: 'ignore',
295
+ stdout: 'pipe',
296
+ stderr: 'pipe',
297
+ env: this.sandboxEnv(),
298
+ })
299
+ const timer = AbortSignal.timeout(timeoutSeconds * 1000)
300
+ const outcome = await Promise.race([
301
+ proc.exited.then((code) => ({ kind: 'exited' as const, code })),
302
+ new Promise<never>((_, reject) => {
303
+ timer.addEventListener(
304
+ 'abort',
305
+ () =>
306
+ reject(
307
+ new SandboxOperationError(
308
+ 'exec-timeout',
309
+ `sandbox command exceeded its ${timeoutSeconds}s bound and was killed`,
310
+ ),
311
+ ),
312
+ { once: true },
313
+ )
314
+ }),
315
+ ]).catch((error: unknown) => {
316
+ try {
317
+ proc.kill('SIGTERM')
318
+ } catch {
319
+ /* already exited */
320
+ }
321
+ throw error
322
+ })
323
+ const [stdout, stderr] = await Promise.all([
324
+ new Response(proc.stdout).text(),
325
+ new Response(proc.stderr).text(),
326
+ ])
327
+ return { exitCode: outcome.code ?? -1, stdout, stderr }
328
+ }
329
+
330
+ private async sandboxStart(
331
+ handle: SandboxEnvironmentIdentity,
332
+ request: { command: string; cwd?: string },
333
+ ): Promise<{ commandId: string }> {
334
+ const proc = Bun.spawn(['sh', '-c', request.command], {
335
+ cwd: this.sandboxCwd(handle, request.cwd),
336
+ stdin: 'ignore',
337
+ stdout: 'pipe',
338
+ stderr: 'pipe',
339
+ env: this.sandboxEnv(),
340
+ })
341
+ const commandId = `sbcmd-${Math.random().toString(36).slice(2, 12)}`
342
+ const tracked = { proc, stdout: '', stderr: '', exitCode: null as number | null }
343
+ this.sandboxChildren.set(commandId, tracked)
344
+ void (async () => {
345
+ const decoder = new TextDecoder()
346
+ const reader = proc.stdout.getReader()
347
+ for (;;) {
348
+ const { done, value } = await reader.read()
349
+ if (done) break
350
+ tracked.stdout += decoder.decode(value, { stream: true })
351
+ }
352
+ })()
353
+ void (async () => {
354
+ const decoder = new TextDecoder()
355
+ const reader = proc.stderr.getReader()
356
+ for (;;) {
357
+ const { done, value } = await reader.read()
358
+ if (done) break
359
+ tracked.stderr += decoder.decode(value, { stream: true })
360
+ }
361
+ })()
362
+ void proc.exited.then((code) => {
363
+ tracked.exitCode = code ?? -1
364
+ })
365
+ return { commandId }
366
+ }
367
+
368
+ private async sandboxWait(
369
+ _handle: SandboxEnvironmentIdentity,
370
+ input: { commandId: string; waitSeconds: number },
371
+ ): Promise<SandboxWaitResult> {
372
+ const child = this.sandboxChildren.get(input.commandId)
373
+ if (child === undefined) {
374
+ throw new SandboxOperationError(
375
+ 'not-found',
376
+ `unknown sandbox command ${JSON.stringify(input.commandId)}`,
377
+ )
378
+ }
379
+ const deadline = Date.now() + Math.min(Math.max(input.waitSeconds, 0), 300) * 1000
380
+ while (child.exitCode === null && Date.now() < deadline) {
381
+ await new Promise((resolve) => setTimeout(resolve, 25))
382
+ }
383
+ if (child.exitCode === null) {
384
+ return { state: 'running', stdout: child.stdout, stderr: child.stderr }
385
+ }
386
+ return {
387
+ state: 'exited',
388
+ exitCode: child.exitCode,
389
+ stdout: child.stdout,
390
+ stderr: child.stderr,
391
+ }
392
+ }
393
+
394
+ private async sandboxReadFile(
395
+ handle: SandboxEnvironmentIdentity,
396
+ path: string,
397
+ ): Promise<Uint8Array> {
398
+ return new Uint8Array(await fsReadFile(this.sandboxRootedPath(handle, path)))
399
+ }
400
+
401
+ private async sandboxWriteFile(
402
+ handle: SandboxEnvironmentIdentity,
403
+ path: string,
404
+ content: Uint8Array,
405
+ ): Promise<void> {
406
+ const rooted = this.sandboxRootedPath(handle, path)
407
+ await mkdir(dirname(rooted), { recursive: true })
408
+ await fsWriteFile(rooted, content)
409
+ }
410
+
411
+ /** Root tool paths at the checkout; rejects escapes lexically. */
412
+ private sandboxRootedPath(handle: SandboxEnvironmentIdentity, path: string): string {
413
+ const rooted = resolve(handle.workspacePath, path)
414
+ if (!rooted.startsWith(resolve(handle.workspacePath) + sep)) {
415
+ throw new SandboxOperationError(
416
+ 'exec',
417
+ `sandbox path ${JSON.stringify(path)} escapes the checkout`,
418
+ )
419
+ }
420
+ return rooted
421
+ }
422
+
423
+ private async sandboxRelease(input: {
424
+ repo: string
425
+ operator: string
426
+ environmentId: string
427
+ }): Promise<{ snapshots: { outcome: 'confirmed' } }> {
428
+ this.sandboxReleases.push({ ...input })
429
+ await rm(input.environmentId, { recursive: true, force: true })
430
+ return { snapshots: { outcome: 'confirmed' } }
431
+ }
432
+ }