@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -21,13 +21,25 @@ export type InstallScope = 'local' | 'global'
21
21
  * forge dependency, or a version range satisfied from the npm registry. */
22
22
  export type InstallChannel = 'github' | 'npm'
23
23
 
24
+ /** The owning manifest sections a self-update target may be declared in. */
25
+ export type DependencySection = 'dependencies' | 'devDependencies'
26
+
27
+ /** Searched in this order; when a manifest declares the package in both, the
28
+ * first wins (the runtime declaration, which a plain `bun add` would rewrite). */
29
+ const DEPENDENCY_SECTIONS: readonly DependencySection[] = ['dependencies', 'devDependencies']
30
+ const SEARCHED_SECTIONS = DEPENDENCY_SECTIONS.join(' or ')
31
+
24
32
  interface ManagedInstallationBase extends DistributionIdentity {
25
33
  sourceCheckout: false
26
34
  ownerRoot: string
27
35
  ownerManifest: string
28
36
  ownerLock: string
29
- /** The owning manifest's direct dependency value, byte for byte. */
37
+ /** The owning manifest's direct dependency value in `section`, byte for byte. */
30
38
  dependency: string
39
+ /** The manifest section the dependency was chosen from. */
40
+ section: DependencySection
41
+ /** The manifest also declares the package in the other section. */
42
+ dual: boolean
31
43
  scope: InstallScope
32
44
  }
33
45
 
@@ -180,9 +192,27 @@ async function parseJsonc(path: string): Promise<Record<string, unknown>> {
180
192
  }
181
193
  }
182
194
 
183
- function lockDependency(lock: Record<string, unknown>, packageName: string): unknown {
195
+ function lockDependency(
196
+ lock: Record<string, unknown>,
197
+ packageName: string,
198
+ section: DependencySection,
199
+ ): unknown {
184
200
  const workspace = object(object(lock.workspaces)?.[''])
185
- return object(workspace?.dependencies)?.[packageName]
201
+ return object(workspace?.[section])?.[packageName]
202
+ }
203
+
204
+ /** Pick the section that declares the package: a pure function of the
205
+ * manifest, in fixed `DEPENDENCY_SECTIONS` order. */
206
+ function declaredSection(
207
+ manifest: Record<string, unknown>,
208
+ packageName: string,
209
+ ): { section: DependencySection; value: string; dual: boolean } | undefined {
210
+ const declared = DEPENDENCY_SECTIONS.flatMap((section) => {
211
+ const value = object(manifest[section])?.[packageName]
212
+ return typeof value === 'string' ? [{ section, value }] : []
213
+ })
214
+ const first = declared[0]
215
+ return first === undefined ? undefined : { ...first, dual: declared.length > 1 }
186
216
  }
187
217
 
188
218
  function lockPackageRecord(
@@ -235,21 +265,29 @@ export async function inspectInstallation(options: {
235
265
  try {
236
266
  const manifest = await parseJson(ownerManifest)
237
267
  const lock = await parseJsonc(ownerLock)
238
- const dependency = object(manifest.dependencies)?.[identity.packageName]
268
+ const declared = declaredSection(manifest, identity.packageName)
269
+ if (declared === undefined) {
270
+ return {
271
+ kind: 'unknown',
272
+ identity,
273
+ reason: `${ownerManifest} does not declare ${identity.packageName} under ${SEARCHED_SECTIONS} as a direct github: dependency or an npm registry version range satisfied by ${identity.version}`,
274
+ }
275
+ }
276
+ const { section, value: dependency, dual } = declared
239
277
  const repository = githubDependency(dependency)
240
278
  const registry = repository === undefined && registryDependency(dependency, identity.version)
241
- if ((repository === undefined && !registry) || typeof dependency !== 'string') {
279
+ if (repository === undefined && !registry) {
242
280
  return {
243
281
  kind: 'unknown',
244
282
  identity,
245
- reason: `${ownerManifest} does not declare ${identity.packageName} as a direct github: dependency or an npm registry version range satisfied by ${identity.version}`,
283
+ reason: `${ownerManifest} declares ${identity.packageName} under ${section} as "${dependency}", which is neither a direct github: dependency nor an npm registry version range satisfied by ${identity.version}`,
246
284
  }
247
285
  }
248
- if (lockDependency(lock, identity.packageName) !== dependency) {
286
+ if (lockDependency(lock, identity.packageName, section) !== dependency) {
249
287
  return {
250
288
  kind: 'unknown',
251
289
  identity,
252
- reason: `${ownerLock} does not agree with the direct dependency in ${ownerManifest}`,
290
+ reason: `${ownerLock} does not agree with the direct ${section} entry in ${ownerManifest}`,
253
291
  }
254
292
  }
255
293
  const scope: InstallScope = (await samePath(join(options.globalBin, 'ab'), identity.binaryPath))
@@ -280,6 +318,8 @@ export async function inspectInstallation(options: {
280
318
  ownerManifest,
281
319
  ownerLock,
282
320
  dependency,
321
+ section,
322
+ dual,
283
323
  scope,
284
324
  },
285
325
  }
@@ -320,6 +360,8 @@ export async function inspectInstallation(options: {
320
360
  owner: repository.owner,
321
361
  repository: repository.repository,
322
362
  dependency,
363
+ section,
364
+ dual,
323
365
  scope,
324
366
  },
325
367
  }
@@ -19,7 +19,7 @@ import type { Forge } from '../ports/types'
19
19
  import type { Exec } from '../ports/workspace/git-worktree'
20
20
  import type { IdSource } from '../ids'
21
21
  import { reduceBuild } from '../kernel/reducer'
22
- import { artifactDownload, artifactGet, artifactPut } from './artifact'
22
+ import { artifactDownload, artifactDownloadStream, artifactGet, artifactPut } from './artifact'
23
23
  import { multiFlag, parseArgs, stringFlag, type ParsedArgs } from './args'
24
24
  import { abBuildControl, type BuildControlAction, type BuildControlResult } from './build-control'
25
25
  import {
@@ -45,7 +45,7 @@ import { observe } from './observe'
45
45
  import { abPlugin, type PluginContractSubprocess } from './plugin'
46
46
  import { preparePrAttachments } from './pr-attachments'
47
47
  import { renderPrSummary } from './pr-summary'
48
- import { abBuilds, abBuildStatus } from './status'
48
+ import { abBuildAssetGet, abBuilds, abBuildStatus } from './status'
49
49
  import { abRepositoryStatus } from './repository-status'
50
50
  import { abWatch, WATCH_USAGE } from './watch'
51
51
  import { abWait, WAIT_USAGE } from './wait'
@@ -478,6 +478,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
478
478
  const report = await (deps.initValidation ?? validateInitReadiness)({
479
479
  targetRepo,
480
480
  env,
481
+ processEnv: deps.processEnv ?? process.env,
481
482
  stdout,
482
483
  ...(deps.exec !== undefined ? { exec: deps.exec } : {}),
483
484
  ...(deps.initRuntimes !== undefined ? { runtimes: deps.initRuntimes } : {}),
@@ -790,9 +791,44 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
790
791
  }
791
792
 
792
793
  case 'build': {
793
- const usage = 'usage: ab build status <slug> [--events <n>] [--json] [--store <ref>] (§8.2)'
794
+ const usage =
795
+ 'usage: ab build status <slug> [--events <n>] [--json] [--store <ref>] (§8.2)\n' +
796
+ ' ab build asset get <slug> <kind> <name> <dest> [--rev <n>] [--json] [--store <ref>] (§8.8)'
794
797
  const [sub, ...more] = rest
795
- // Only `status` today; the subcommand shape keeps room for `ab build <verb>`.
798
+ if (sub === 'asset') {
799
+ const [action, ...assetArgs] = more
800
+ if (action !== 'get') throw new Error(usage)
801
+ const assetParsed = parseArgs(
802
+ assetArgs,
803
+ { rev: 'value', json: 'boolean', store: 'value' },
804
+ usage,
805
+ )
806
+ const [slug, kind, name, dest, ...extra] = assetParsed.positionals
807
+ if (!slug || !kind || !name || !dest || extra.length > 0) throw new Error(usage)
808
+ const rawRev = stringFlag(assetParsed, 'rev')
809
+ if (rawRev !== undefined && !/^\d+$/.test(rawRev)) {
810
+ throw new Error(`--rev must be a nonnegative integer, got "${rawRev}" — ${usage}`)
811
+ }
812
+ const assetStore = stringFlag(assetParsed, 'store')
813
+ if (deps.exec === undefined) {
814
+ throw new Error("'ab build' needs an exec seam — this is a wiring bug in the ab binary")
815
+ }
816
+ await abBuildAssetGet({
817
+ targetRepo: deps.workspacePath,
818
+ env: deps.processEnv ?? {},
819
+ exec: deps.exec,
820
+ stdout,
821
+ slug,
822
+ kind,
823
+ name,
824
+ dest,
825
+ json: assetParsed.flags.has('json'),
826
+ ...(rawRev !== undefined ? { rev: Number(rawRev) } : {}),
827
+ ...(assetStore !== undefined ? { storeRef: assetStore } : {}),
828
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
829
+ })
830
+ return 0
831
+ }
796
832
  if (sub !== 'status') {
797
833
  throw new Error(usage)
798
834
  }
@@ -1211,8 +1247,37 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1211
1247
  const usage =
1212
1248
  'usage: ab artifact download <build> <kind>[@rev] --output <file> [--store <ref>] (§8.2)'
1213
1249
  const parsed = parseArgs(more, { output: 'value', store: 'value' }, usage)
1214
- const [build, spec] = parsed.positionals
1250
+ const [first, second] = parsed.positionals
1215
1251
  const outputPath = stringFlag(parsed, 'output')
1252
+ const storeRef = stringFlag(parsed, 'store')
1253
+ if (deps.exec === undefined) {
1254
+ throw new Error(
1255
+ "'ab artifact download' needs an exec seam — this is a wiring bug in the ab binary",
1256
+ )
1257
+ }
1258
+ // A stream-addressed first positional can never be a build slug: the
1259
+ // dispatcher's slug charset forbids ':'. Repo-scoped streams take the
1260
+ // one-positional operator form; everything else stays on the unchanged
1261
+ // build-scoped path.
1262
+ if (first?.startsWith('stream:')) {
1263
+ const streamUsage =
1264
+ 'usage: ab artifact download stream:<id>[@rev] --output <file> [--store <ref>] (§8.2)'
1265
+ if (second !== undefined || outputPath === undefined) throw new Error(streamUsage)
1266
+ const downloaded = await artifactDownloadStream({
1267
+ targetRepo: deps.workspacePath,
1268
+ env: deps.processEnv ?? {},
1269
+ exec: deps.exec,
1270
+ spec: first,
1271
+ outputPath,
1272
+ ...(storeRef !== undefined ? { storeRef } : {}),
1273
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
1274
+ })
1275
+ stdout(
1276
+ `downloaded ${downloaded.artifact.meta.kind}@${downloaded.artifact.meta.revision} to ${downloaded.outputPath}`,
1277
+ )
1278
+ return 0
1279
+ }
1280
+ const [build, spec] = parsed.positionals
1216
1281
  if (
1217
1282
  build === undefined ||
1218
1283
  spec === undefined ||
@@ -1221,12 +1286,6 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1221
1286
  ) {
1222
1287
  throw new Error(usage)
1223
1288
  }
1224
- const storeRef = stringFlag(parsed, 'store')
1225
- if (deps.exec === undefined) {
1226
- throw new Error(
1227
- "'ab artifact download' needs an exec seam — this is a wiring bug in the ab binary",
1228
- )
1229
- }
1230
1289
  const downloaded = await artifactDownload({
1231
1290
  targetRepo: deps.workspacePath,
1232
1291
  env: deps.processEnv ?? {},
@@ -26,6 +26,8 @@ import type { Config } from '../config/schema'
26
26
  import type { Via } from '../events/envelope'
27
27
  import { defaultTriageState } from '../processes/dispatcher'
28
28
  import { createWorkspaceProvider } from '../ports/workspace/create'
29
+ import { createForge } from '../ports/forge/create'
30
+ import type { Forge } from '../ports/types'
29
31
  import { createTicketSource } from '../ports/tickets/create'
30
32
  import { loadPlugins } from '../plugins/load'
31
33
  import { AUTOBUILD_VERSION } from '../store/remote/version'
@@ -221,8 +223,9 @@ export async function abMcp(opts: AbMcpOpts): Promise<number> {
221
223
  throw error
222
224
  }
223
225
  if (mcpConfig.orchestrator.enabled) {
226
+ const plugins = await loadPlugins(mcpConfig.plugins, context.checkout)
224
227
  const provider = await createWorkspaceProvider(mcpConfig.workspace, {
225
- registry: await loadPlugins(mcpConfig.plugins, context.checkout),
228
+ registry: plugins,
226
229
  worktreeRoot: context.worktreeRoot,
227
230
  repoRoot: context.checkout,
228
231
  env: opts.env,
@@ -231,13 +234,32 @@ export async function abMcp(opts: AbMcpOpts): Promise<number> {
231
234
  sandboxSetupCommand: mcpConfig.commands.setup,
232
235
  sandboxRoot: join(context.localStateRoot, 'orchestrator-sandboxes'),
233
236
  sandboxEnvironmentVariables: mcpConfig.orchestrator.sandbox.environmentVariables,
237
+ orchestratorSandboxEnabled: true,
234
238
  })
239
+ // The forge is needed only for sandbox.publish; a construction
240
+ // failure must not drop every sandbox tool, so it is contained here
241
+ // and publication is silently omitted (canPublish stays false).
242
+ let forge: Forge | undefined
243
+ try {
244
+ forge = await createForge({
245
+ name: mcpConfig.forge,
246
+ registry: plugins,
247
+ env: opts.env,
248
+ repoRoot: context.checkout,
249
+ repository: context.repo,
250
+ })
251
+ } catch (forgeError) {
252
+ opts.stderr(
253
+ `ab mcp: forge unavailable, sandbox.publish omitted: ${forgeError instanceof Error ? forgeError.message : String(forgeError)}`,
254
+ )
255
+ }
235
256
  sandbox = await createOperatorSandboxService({
236
257
  store: context.store,
237
258
  repo: context.repo,
238
259
  provider,
239
260
  sandbox: mcpConfig.orchestrator.sandbox,
240
261
  baseBranch: mcpConfig.baseBranch,
262
+ ...(forge !== undefined ? { forge } : {}),
241
263
  ...(opts.clock !== undefined ? { clock: opts.clock } : {}),
242
264
  })
243
265
  }
@@ -77,6 +77,9 @@ function renderModuleReport(report: PluginDiagnosis['reports'][number]): string
77
77
  report.api === undefined
78
78
  ? ''
79
79
  : ` api=${report.api.declaredRange} host=${report.api.hostVersion} ${report.api.status}`
80
+ if (report.status === 'skipped') {
81
+ return `SKIP ${report.module} kind=${report.resolutionKind} stage=${report.stage}${location}${source}${plugin}${api}: ${report.notice}`
82
+ }
80
83
  return report.status === 'loaded'
81
84
  ? `OK ${report.module} kind=${report.resolutionKind}${location}${source}${plugin}${api}`
82
85
  : `FAIL ${report.module} kind=${report.resolutionKind} stage=${report.stage}${location}${source}${plugin}${api}: ${report.error}`
@@ -149,8 +152,8 @@ export async function abPlugin(argv: readonly string[], opts: PluginCliOpts): Pr
149
152
  if (diagnosis.reports.length === 0) opts.stdout(' (none)')
150
153
  for (const report of diagnosis.reports) {
151
154
  const line = ` ${renderModuleReport(report)}`
152
- if (report.status === 'loaded') opts.stdout(line)
153
- else opts.stderr(line)
155
+ if (report.status === 'failed') opts.stderr(line)
156
+ else opts.stdout(line)
154
157
  }
155
158
  opts.stdout('adapters:')
156
159
  for (const port of PLUGIN_PORTS) {
@@ -170,8 +173,8 @@ export async function abPlugin(argv: readonly string[], opts: PluginCliOpts): Pr
170
173
  }
171
174
  for (const report of diagnosis.reports) {
172
175
  const line = renderModuleReport(report)
173
- if (report.status === 'loaded') opts.stdout(line)
174
- else opts.stderr(line)
176
+ if (report.status === 'failed') opts.stderr(line)
177
+ else opts.stdout(line)
175
178
  }
176
179
  return diagnosis.healthy ? 0 : 1
177
180
  }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The shared remote long-poll task machinery behind `ab watch` and `ab wait`
3
+ * (AUT-334, corrected for `ab wait` in AUT-368, extracted in AUT-403). One
4
+ * long-poll task per tracked stream runs concurrently against an `http(s)`
5
+ * store: a held `getEvents` request per stream is always in flight, gap-fill
6
+ * spaces request starts at least one interval apart, and every stop — a
7
+ * domain condition, an external abort, or an elapsed deadline — cancels the
8
+ * in-flight holds and wakes the gap sleeps instead of waiting out a full
9
+ * hold. A hold's own bound is capped at the remaining time budget.
10
+ *
11
+ * The two commands keep their different semantics through injected
12
+ * callbacks (`shouldStop`, per-task `endCheck`, the discovery step) and two
13
+ * explicit configuration points (`drain` mode, `deadlineMs`); everything
14
+ * mechanical lives here, so a fix to streak or stop handling cannot land in
15
+ * one command and silently miss the other.
16
+ *
17
+ * Timing is fully injected (`now`, `sleep`): the module never reads the wall
18
+ * clock and never schedules a real timer, so tests drive every wait
19
+ * deterministically.
20
+ */
21
+ import { REMOTE_EVENT_WAIT_SECONDS } from '../store/remote/client'
22
+
23
+ /** The read options one held long-poll request carries. */
24
+ export interface RemotePollReadOpts {
25
+ waitSeconds?: number
26
+ signal?: AbortSignal
27
+ }
28
+
29
+ /**
30
+ * The default abortable sleep: resolves after `ms`, or as soon as `signal`
31
+ * aborts. Shared verbatim by `ab watch` and `ab wait` as the `delay` default.
32
+ */
33
+ export function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
34
+ return new Promise((resolve) => {
35
+ if (signal?.aborted === true) {
36
+ resolve()
37
+ return
38
+ }
39
+ const finish = (): void => {
40
+ clearTimeout(timer)
41
+ signal?.removeEventListener('abort', onAbort)
42
+ resolve()
43
+ }
44
+ const timer = setTimeout(finish, ms)
45
+ const onAbort = (): void => finish()
46
+ signal?.addEventListener('abort', onAbort, { once: true })
47
+ })
48
+ }
49
+
50
+ /**
51
+ * A failed read is reported once per failure streak, advances nothing, and
52
+ * is retried at the next interval (local) or next request (remote); a
53
+ * success on the same source re-arms the report. Each read source — per
54
+ * stream task, plus discovery — carries its own streak, so a persistent
55
+ * failure on one stream interleaved with successes elsewhere is still
56
+ * reported only once.
57
+ */
58
+ export function makeFailureStreak(
59
+ command: string,
60
+ stderr: (line: string) => void,
61
+ ): { onFailure: (error: unknown) => void; onSuccess: () => void } {
62
+ let reported = false
63
+ return {
64
+ onFailure: (error: unknown): void => {
65
+ if (reported) return
66
+ reported = true
67
+ const message = error instanceof Error ? error.message : String(error)
68
+ stderr(`ab ${command}: a store read failed (${message}); retrying at the next interval`)
69
+ },
70
+ onSuccess: (): void => {
71
+ reported = false
72
+ },
73
+ }
74
+ }
75
+
76
+ export interface RemotePollRunnerOpts {
77
+ /** `'watch' | 'wait'` — the streak message's command prefix. */
78
+ command: string
79
+ stderr: (line: string) => void
80
+ /** Injected clock; never the wall clock directly. */
81
+ now: () => Date
82
+ /** Injected delay; every wait the runner makes goes through this. */
83
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>
84
+ /** The gap-fill floor between request starts. */
85
+ intervalMs: number
86
+ /** Epoch ms at which reads stop; `Number.POSITIVE_INFINITY` for an
87
+ * unbounded `--timeout 0`. */
88
+ deadlineMs: number
89
+ /** The external signal only (SIGINT) — never widened to the domain stop
90
+ * terms, so a failure observed as the deadline elapses is still reported. */
91
+ aborted: () => boolean
92
+ /** The loop-guard predicate: `aborted() || deadline elapsed || domain
93
+ * stop`. Evaluated at the top of every task cycle. */
94
+ shouldStop: () => boolean
95
+ /** Invoked on every runner stop — a task's endCheck, the discovery task's
96
+ * endCheck, or a caller-initiated `requestStop`. `ab watch` trips its
97
+ * shared `stop` flag here: `pollBuild`/`pollRepository` gate their batch
98
+ * loops on that flag (`if (stop) break`), so a sibling task whose held
99
+ * read has already resolved must stop delivering its batch the moment any
100
+ * task observes the terminal condition — exactly what the pre-extraction
101
+ * command-scoped `requestStop` did (f_42263f38). May fire more than once
102
+ * (every `requestStop` call fires it); the callback must be idempotent. */
103
+ onStop?: () => void
104
+ /** How `drain()` treats tasks launched while it is awaiting.
105
+ * `'snapshot'`: await only the tasks present when `drain()` is called
106
+ * (`ab watch`'s one-shot semantics). `'quiesce'`: keep awaiting until no
107
+ * task remains (`ab wait`'s semantics — the exit decision must observe
108
+ * every task settled). */
109
+ drain: 'snapshot' | 'quiesce'
110
+ }
111
+
112
+ export interface RemotePollRunner {
113
+ /** Whether a stop has been requested — by `requestStop` or a task's own
114
+ * `endCheck`. */
115
+ readonly stopped: boolean
116
+ /** Abort every in-flight held read, wake the gap sleeps, mark stopped. */
117
+ requestStop(): void
118
+ /** Launch one per-stream long-poll task; idempotent per key. `endCheck`
119
+ * runs after each successful poll; true ⇒ requestStop + return. */
120
+ launch(
121
+ key: string,
122
+ poll: (readOpts: RemotePollReadOpts) => Promise<unknown>,
123
+ endCheck?: () => boolean,
124
+ ): void
125
+ /** The interval-cadence discovery task, self-tracked for drain. `step`
126
+ * (the discovery read) runs inside the try/catch; `launchPending` (the
127
+ * launch-everything loop) runs UNCONDITIONALLY after the catch, so streams
128
+ * registered before a mid-discovery throw are still launched; `endCheck`
129
+ * (wait only) runs after `launchPending`, throw or not. */
130
+ runDiscovery(opts: {
131
+ step: () => Promise<void>
132
+ launchPending: () => void
133
+ endCheck?: () => boolean
134
+ }): Promise<void>
135
+ /** Await launched tasks per the configured strategy. */
136
+ drain(): Promise<void>
137
+ }
138
+
139
+ /**
140
+ * Build the per-command runner over the shared machinery. Every piece is
141
+ * moved verbatim from the command-local copies this module replaces; the
142
+ * commands' different stop and drain semantics enter only through `opts`.
143
+ */
144
+ export function createRemotePollRunner(opts: RemotePollRunnerOpts): RemotePollRunner {
145
+ // Held reads in flight right now, each with its own controller. When the
146
+ // command stops for any reason, every in-flight read is cancelled —
147
+ // otherwise a stop during a quiet window would wait out a full wait
148
+ // window (up to the remote hold) before the command could exit.
149
+ const inFlightReads = new Set<AbortController>()
150
+ // Wakes stop-aware gap sleeps the moment the command stops.
151
+ const stopController = new AbortController()
152
+ let stopped = false
153
+ const requestStop = (): void => {
154
+ stopped = true
155
+ opts.onStop?.()
156
+ stopController.abort()
157
+ for (const controller of inFlightReads) controller.abort()
158
+ }
159
+
160
+ /** The wait bound for one held read: the remote default, capped at the
161
+ * command's remaining time budget. With an unbounded deadline the same
162
+ * expression yields exactly the remote default, so one form covers both. */
163
+ const readWaitSeconds = (atMs: number): number => {
164
+ const remaining = Math.floor((opts.deadlineMs - atMs) / 1000)
165
+ return Math.max(0, Math.min(REMOTE_EVENT_WAIT_SECONDS, remaining))
166
+ }
167
+
168
+ // One self-cleaning task set for both commands. Awaiting a snapshot that
169
+ // omits already-settled tasks is equivalent to awaiting the plain array
170
+ // the one-shot drain used before the extraction.
171
+ const tasks = new Set<Promise<void>>()
172
+ /** Track a task and keep the set accurate as tasks settle, so the quiesce
173
+ * drain also waits for tasks launched while it is awaiting. */
174
+ const track = (task: Promise<void>): void => {
175
+ tasks.add(task)
176
+ void task.then(
177
+ () => tasks.delete(task),
178
+ () => tasks.delete(task),
179
+ )
180
+ }
181
+
182
+ const runStreamTask = async (
183
+ poll: (readOpts: RemotePollReadOpts) => Promise<unknown>,
184
+ endCheck?: () => boolean,
185
+ ): Promise<void> => {
186
+ const streak = makeFailureStreak(opts.command, opts.stderr)
187
+ while (!stopped && !opts.shouldStop()) {
188
+ const started = opts.now().getTime()
189
+ const controller = new AbortController()
190
+ inFlightReads.add(controller)
191
+ try {
192
+ await poll({ waitSeconds: readWaitSeconds(started), signal: controller.signal })
193
+ streak.onSuccess()
194
+ if (endCheck?.()) {
195
+ requestStop()
196
+ return
197
+ }
198
+ } catch (error) {
199
+ // A cancelled held read is the command stopping, not a store
200
+ // failure — never report it. The check stays on the external signal
201
+ // (not the full guard), so a failure observed as the deadline
202
+ // elapses is still reported.
203
+ if (!stopped && !opts.aborted() && !controller.signal.aborted) streak.onFailure(error)
204
+ } finally {
205
+ inFlightReads.delete(controller)
206
+ }
207
+ if (stopped || opts.aborted()) return
208
+ const elapsed = opts.now().getTime() - started
209
+ if (elapsed < opts.intervalMs) {
210
+ await opts.sleep(opts.intervalMs - elapsed, stopController.signal)
211
+ }
212
+ }
213
+ }
214
+
215
+ const launched = new Set<string>()
216
+ const launch = (
217
+ key: string,
218
+ poll: (readOpts: RemotePollReadOpts) => Promise<unknown>,
219
+ endCheck?: () => boolean,
220
+ ): void => {
221
+ if (launched.has(key)) return
222
+ launched.add(key)
223
+ track(runStreamTask(poll, endCheck))
224
+ }
225
+
226
+ const runDiscovery = (discovery: {
227
+ step: () => Promise<void>
228
+ launchPending: () => void
229
+ endCheck?: () => boolean
230
+ }): Promise<void> => {
231
+ const task = (async (): Promise<void> => {
232
+ const streak = makeFailureStreak(opts.command, opts.stderr)
233
+ while (!stopped && !opts.shouldStop()) {
234
+ try {
235
+ await discovery.step()
236
+ streak.onSuccess()
237
+ } catch (error) {
238
+ streak.onFailure(error)
239
+ }
240
+ // Unconditional — after the catch: streams registered before a
241
+ // mid-discovery throw still get tasks whose polls then run
242
+ // independently.
243
+ discovery.launchPending()
244
+ if (discovery.endCheck?.()) {
245
+ requestStop()
246
+ return
247
+ }
248
+ if (stopped || opts.aborted()) return
249
+ await opts.sleep(opts.intervalMs, stopController.signal)
250
+ }
251
+ })()
252
+ track(task)
253
+ return task
254
+ }
255
+
256
+ const drain = async (): Promise<void> => {
257
+ if (opts.drain === 'quiesce') {
258
+ // Await every task — including ones launched while awaiting: the
259
+ // discovery task adds stream tasks mid-wait, and the caller's exit
260
+ // decision must observe every task settled.
261
+ while (tasks.size > 0) await Promise.all(tasks)
262
+ return
263
+ }
264
+ // One-shot snapshot: tasks launched during the await are NOT awaited.
265
+ await Promise.all([...tasks])
266
+ }
267
+
268
+ return {
269
+ get stopped(): boolean {
270
+ return stopped
271
+ },
272
+ requestStop,
273
+ launch,
274
+ runDiscovery,
275
+ drain,
276
+ }
277
+ }
@@ -190,6 +190,28 @@ export async function buildInRepository(
190
190
  return normalizeGitRemoteUrl(record.repoOrigin) === identity
191
191
  }
192
192
 
193
+ /**
194
+ * Whether a repo-scoped stream's recorded scope belongs to `identity` — the
195
+ * same origin-aware first arm `buildInRepository` applies to builds, with the
196
+ * recorded side normalized like `record.repoOrigin` there so the guard never
197
+ * depends on the writer's normalizer vintage (an scp-like ssh-spelled scope
198
+ * equals the https identity of the same origin).
199
+ *
200
+ * Full parity with `buildInRepository` is impossible by construction:
201
+ * `StreamScope` carries no `repoOrigin`, so a legacy physical-path
202
+ * `scope.repo` (which `normalizeGitRemoteUrl` passes through unchanged) has no
203
+ * recorded origin to compare and can never match an origin identity — it stays
204
+ * rejected. That is the accepted AUT-314-class limitation, not something this
205
+ * guard can forgive.
206
+ *
207
+ * No `exec` parameter: the caller's identity is already resolved (the
208
+ * context's `repo` is `origin ?? resolved checkout` from `resolveRepoState`),
209
+ * so re-running git would be redundant.
210
+ */
211
+ export function streamInRepository(scopeRepo: string, identity: string): boolean {
212
+ return normalizeGitRemoteUrl(scopeRepo) === identity
213
+ }
214
+
193
215
  /** Resolve repository identity, then select all state paths from it.
194
216
  *
195
217
  * Identity is the resolved main checkout's normalized origin remote, falling