@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -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/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -35,7 +35,7 @@ import {
35
35
  loadScanPacket,
36
36
  validateProposalCoverage,
37
37
  } from '../processes/harvest'
38
- import type { BuildStore } from '../store/types'
38
+ import type { BuildStore, StreamRecord } from '../store/types'
39
39
  import type { HarvestCliEnv } from './env'
40
40
  import { withSessionlessStore, type StoreOpener } from './store-opening'
41
41
 
@@ -121,7 +121,8 @@ async function readArtifactJson(
121
121
 
122
122
  export async function buildHarvestContext(deps: HarvestCliDeps): Promise<HarvestContextManifest> {
123
123
  const { store, env, workspacePath } = deps
124
- const events = await store.getRepoEvents(env.repo)
124
+ // Bounded read (AUT-489): runForEnv reduces harvest facts only.
125
+ const events = await store.getRepoStateEvents(env.repo)
125
126
  const run = runForEnv(events, env)
126
127
  const packet = await loadScanPacket(store, env.repo, run.scan)
127
128
  const abDir = join(workspacePath, '.ab')
@@ -217,7 +218,9 @@ export async function submitHarvestProposals(
217
218
  `'ab harvest submit' is only valid in synthesize@<round>; current phase is ${env.phase}@${env.round}`,
218
219
  )
219
220
  }
220
- const events = await store.getRepoEvents(env.repo)
221
+ // Bounded read (AUT-489): runForEnv and assertNoSessionTerminal consume
222
+ // harvest facts only.
223
+ const events = await store.getRepoStateEvents(env.repo)
221
224
  const run = runForEnv(events, env)
222
225
  assertNoSessionTerminal(events, env)
223
226
 
@@ -307,7 +310,9 @@ export async function submitHarvestVerdict(
307
310
  `harvest verdict "${opts.verdict}" is invalid — expected approve | revise | escalate`,
308
311
  )
309
312
  }
310
- const events = await store.getRepoEvents(env.repo)
313
+ // Bounded read (AUT-489): runForEnv and assertNoSessionTerminal consume
314
+ // harvest facts only.
315
+ const events = await store.getRepoStateEvents(env.repo)
311
316
  const run = runForEnv(events, env)
312
317
  assertNoSessionTerminal(events, env)
313
318
  if (proposalArtifactForRound(run, env.round) === undefined) {
@@ -398,6 +403,22 @@ export interface HarvestFiledStatusView {
398
403
  blockers?: { declared: string[]; derived: string[] }
399
404
  }
400
405
 
406
+ /** One harvest session's discoverability row: its stream id and open/closed
407
+ * status, at parity with the build-session rows. `stream` is absent on
408
+ * sessions that never streamed. */
409
+ export interface HarvestSessionStatusView {
410
+ session: string
411
+ role: 'harvest' | 'harvest-review'
412
+ step: 'synthesize' | 'review'
413
+ round: number
414
+ /** Live-view stream id (SPEC §9); absent on sessions that never streamed. */
415
+ stream?: string
416
+ /** Open until the session's `harvest.session.ended` lands; the store's
417
+ * records refine this to the stream's authoritative status when available. */
418
+ streamStatus: 'open' | 'closed'
419
+ status: 'open' | 'ended'
420
+ }
421
+
401
422
  export interface HarvestRunStatusView {
402
423
  run: string
403
424
  status: HarvestRunState['status']
@@ -408,6 +429,7 @@ export interface HarvestRunStatusView {
408
429
  steps: HarvestRunState['steps']
409
430
  rounds: number
410
431
  filed: HarvestFiledStatusView[]
432
+ sessions: HarvestSessionStatusView[]
411
433
  escalation?: HarvestRunState['escalation']
412
434
  failure?: HarvestRunState['failure']
413
435
  recovery: HarvestRecoveryStatus
@@ -481,7 +503,73 @@ function projectRecovery(run: HarvestRunState | undefined): HarvestRecoveryStatu
481
503
  }
482
504
  }
483
505
 
484
- function projectHarvestRunStatus(run: HarvestRunState): HarvestRunStatusView {
506
+ /** Pair the append-only `harvest.session.started` / `harvest.session.ended`
507
+ * facts per run without introducing transition state. `streams` is the
508
+ * optional authoritative enrichment from `listStreams` on the repo scope:
509
+ * records whose label matches `session:<id>` supply each session's stream id
510
+ * and open/closed status.
511
+ *
512
+ * Verified (AUT-574): no repo-identity comparison happens in this code —
513
+ * membership is decided store-side by `listStreams({ kind: 'repo', repo })`,
514
+ * scoped by the same resolved identity the caller passes, and the `scope.kind`
515
+ * check below is only a defensive kind filter over records the store already
516
+ * scoped. Store scoping semantics are out of scope here. */
517
+ function projectHarvestSessions(
518
+ events: RepositoryEvent[],
519
+ streams?: readonly StreamRecord[],
520
+ ): Map<string, HarvestSessionStatusView[]> {
521
+ const recordsByLabel = new Map<string, StreamRecord>()
522
+ if (streams !== undefined) {
523
+ for (const record of streams) {
524
+ if (record.scope.kind === 'repo') recordsByLabel.set(record.label, record)
525
+ }
526
+ }
527
+
528
+ const byRun = new Map<string, HarvestSessionStatusView[]>()
529
+ const open = new Map<string, HarvestSessionStatusView>()
530
+ for (const event of events) {
531
+ if (event.type === 'harvest.session.started') {
532
+ const record = recordsByLabel.get(`session:${event.payload.session}`)
533
+ const view: HarvestSessionStatusView = {
534
+ session: event.payload.session,
535
+ role: event.payload.role,
536
+ step: event.payload.step,
537
+ round: event.payload.round,
538
+ status: 'open',
539
+ // The store's records are authoritative when available; without them
540
+ // the pairing degrades gracefully (open until `harvest.session.ended`).
541
+ ...(record !== undefined
542
+ ? { stream: record.id, streamStatus: record.status }
543
+ : {
544
+ ...(event.payload.stream !== undefined ? { stream: event.payload.stream } : {}),
545
+ streamStatus: event.payload.stream !== undefined ? 'open' : 'closed',
546
+ }),
547
+ }
548
+ const list = byRun.get(event.payload.run) ?? []
549
+ list.push(view)
550
+ byRun.set(event.payload.run, list)
551
+ open.set(view.session, view)
552
+ continue
553
+ }
554
+ if (event.type !== 'harvest.session.ended') continue
555
+ const started = open.get(event.payload.session)
556
+ if (started === undefined) continue
557
+ started.status = 'ended'
558
+ // A session bracket has ended: without an authoritative record its
559
+ // stream reads closed.
560
+ const record = recordsByLabel.get(`session:${started.session}`)
561
+ if (record !== undefined) {
562
+ started.stream = record.id
563
+ started.streamStatus = record.status
564
+ } else {
565
+ started.streamStatus = 'closed'
566
+ }
567
+ open.delete(started.session)
568
+ }
569
+ return byRun
570
+ }
571
+
572
+ function projectHarvestRunStatus(run: HarvestRunState): Omit<HarvestRunStatusView, 'sessions'> {
485
573
  return {
486
574
  run: run.run,
487
575
  status: run.status,
@@ -506,6 +594,7 @@ export function projectHarvestStatus(
506
594
  repo: string,
507
595
  events: RepositoryEvent[],
508
596
  newestEvents?: number,
597
+ streams?: readonly StreamRecord[],
509
598
  ): HarvestStatusView {
510
599
  const state = reduceHarvest(events)
511
600
  const history =
@@ -515,7 +604,13 @@ export function projectHarvestStatus(
515
604
  const included = new Set(
516
605
  [...unresolved, ...open, ...(state.latest ? [state.latest] : [])].map((run) => run.run),
517
606
  )
518
- const runs = state.runs.filter((run) => included.has(run.run)).map(projectHarvestRunStatus)
607
+ const sessionsByRun = projectHarvestSessions(events, streams)
608
+ const runs = state.runs
609
+ .filter((run) => included.has(run.run))
610
+ .map((run) => ({
611
+ ...projectHarvestRunStatus(run),
612
+ sessions: sessionsByRun.get(run.run) ?? [],
613
+ }))
519
614
  const unresolvedIds = new Set(unresolved.map((run) => run.run))
520
615
  const primary =
521
616
  runs.find((run) => unresolvedIds.has(run.run)) ??
@@ -568,6 +663,13 @@ function renderHarvestRunStatus(run: HarvestRunStatusView, paused: boolean): str
568
663
  `${step.outcome ?? (step.completedSeq === undefined ? 'running' : 'done')}`,
569
664
  )
570
665
  }
666
+ for (const session of run.sessions) {
667
+ const stream =
668
+ session.stream !== undefined ? ` stream ${session.stream} (${session.streamStatus})` : ''
669
+ lines.push(
670
+ ` session ${session.session} (${session.role}, ${session.step}@${session.round})${stream}`,
671
+ )
672
+ }
571
673
  if (run.recovery.stopped !== undefined) {
572
674
  lines.push(
573
675
  `stopped at: ${run.recovery.stopped.step}${
@@ -689,8 +791,20 @@ export async function abHarvestStatus(opts: HarvestStatusOpts): Promise<void> {
689
791
  },
690
792
  async ({ store, repo }) => {
691
793
  const record = await store.getRepo(repo)
692
- const events = record === null ? [] : await store.getRepoEvents(repo)
693
- const view = projectHarvestStatus(repo, events, opts.events)
794
+ // Bounded read (AUT-489): projectHarvestStatus reduces harvest facts only.
795
+ const events = record === null ? [] : await store.getRepoStateEvents(repo)
796
+ // Best-effort stream enrichment for the session rows, mirroring the
797
+ // degrade-gracefully pattern of 'ab build status': a store that cannot
798
+ // list streams keeps the payload-derived projection instead of failing.
799
+ let streams: StreamRecord[] | undefined
800
+ if (record !== null) {
801
+ try {
802
+ streams = await store.listStreams({ kind: 'repo', repo })
803
+ } catch {
804
+ streams = undefined
805
+ }
806
+ }
807
+ const view = projectHarvestStatus(repo, events, opts.events, streams)
694
808
  if (opts.json === true) opts.stdout(JSON.stringify(view, null, 2))
695
809
  else for (const line of renderHarvestStatus(view)) opts.stdout(line)
696
810
  },
@@ -163,8 +163,11 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
163
163
  ' ab ticket block <id> <blocker-id[,blocker-id...]> [--json]',
164
164
  ' ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json]',
165
165
  ' ab ticket list [--state <state>] [--labels a,b] [--json]',
166
- ' ab ticket show <id> [--json]',
166
+ ' ab ticket show <id> [--store <ref>] [--json]',
167
167
  ' ab ticket move <id> <state> [--json]',
168
+ ' ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json]',
169
+ ' ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json]',
170
+ ' ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json]',
168
171
  '',
169
172
  'Operate on the configured [tickets] source (§8.8); all ticket commands run',
170
173
  'outside sessions. create files a ticket and may establish initial blockers.',
@@ -177,7 +180,19 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
177
180
  '',
178
181
  'With no list filters, list uses the same ready criteria as dispatch. show one ticket, including its body/spec.',
179
182
  'Human output is the default. --json emits exactly one bare value: Ticket[]',
180
- 'for list and the complete resulting Ticket for every other subcommand.',
183
+ 'for list and the complete resulting Ticket for every other subcommand; show',
184
+ 'adds an assets array (kind, name, revision, size, layout, fileCount).',
185
+ '',
186
+ 'Ticket assets are files or folders (a design export, a reference screenshot, a',
187
+ 'sample payload) attached to a ticket and kept in the BuildStore, never in the',
188
+ 'ticket source. attach stores <path> under <kind> and a name (default: the',
189
+ 'basename); attaching the same kind and name again makes a new revision and',
190
+ 'earlier ones stay retrievable. asset get writes the exact bytes of the latest',
191
+ '(or --rev) revision to <dest>: a file asset to that file, a folder asset to',
192
+ 'that directory. asset rm drops it from the current assets; revisions already',
193
+ 'pinned by a build stay retrievable. attach and rm leave a note on the ticket in',
194
+ 'its source naming the asset and how to download it. Assets over the store',
195
+ 'limit (25 MiB by default, lower on a hosted deployment) are refused.',
181
196
  '',
182
197
  'Ticket ids are source-local (for example AUT-8 or file-1). For block and',
183
198
  'unblock, the first id is always the ticket being changed. State names and',
@@ -227,6 +242,7 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
227
242
  detail: page([
228
243
  'Usage:',
229
244
  ' ab build status <slug> [--events <n>] [--json] [--store <ref>]',
245
+ ' ab build asset get <slug> <kind> <name> <dest> [--rev <n>] [--json] [--store <ref>]',
230
246
  '',
231
247
  'Show detailed state for one build: unresolved escalations, open sessions,',
232
248
  'verify progress, PR state, durable-event progress, heartbeat, and lease health.',
@@ -234,6 +250,10 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
234
250
  'order. --json emits the complete projection; --store overrides the',
235
251
  'BuildStore path or URL.',
236
252
  '',
253
+ 'asset get downloads a ticket asset exactly as the build froze it at its claim',
254
+ '(or at the last spec revision from the ticket), also after the build has',
255
+ 'finished. --rev <n> names an earlier pinned revision.',
256
+ '',
237
257
  'This command is read-only and requires no session identity for operator use.',
238
258
  'Inside a phase, a complete ambient tuple permits only the ambient build;',
239
259
  'foreign builds and malformed or partial identity are rejected.',
@@ -294,9 +314,12 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
294
314
  'Exit codes: the watch ends with exit 0 when --timeout elapses, when --count records',
295
315
  'have been emitted, or, only when slugs were named, when every named build has reached a',
296
316
  'terminal status. Usage errors, scope violations, unknown slugs, cursor rejection, and a',
297
- 'store that cannot be opened at start exit 1 with a message on stderr. A store read that',
298
- 'fails after the watch has started is reported once on stderr and retried at the next',
299
- 'interval without ending the watch or duplicating or skipping any event.',
317
+ 'store that cannot be opened at start exit 1 with a message on stderr. A store read',
318
+ 'that fails after the watch has started is reported on stderr',
319
+ 'once per failing read source — locally the poll cycle is one source, while against',
320
+ 'an http(s) store each tracked stream and the discovery pass carries its own failure',
321
+ 'streak — and retried at the next interval without ending the watch or duplicating or',
322
+ 'skipping any event.',
300
323
  '',
301
324
  'This command is read-only: it appends no event, takes no lease, and creates no build or',
302
325
  'repository record, and requires no session identity for operator use. Inside a phase, a',
@@ -355,9 +378,18 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
355
378
  'any condition was satisfied, with the same cursor behavior as exit 3.',
356
379
  '',
357
380
  'The default poll interval and the --interval override match ab watch: 5s for an http(s)',
358
- 'store, 1s for a local store. A store read that fails while waiting is reported once on',
359
- 'stderr, retried at the next interval, and never causes a condition to be missed or',
360
- 'reported twice.',
381
+ 'store, 1s for a local store. Cadence follows the store kind, as in ab watch: a local',
382
+ 'store ticks sequentially at the interval, while against an http(s) store each targeted',
383
+ 'build keeps one held read in flight — one long-held request per wait window (AUT-334),',
384
+ 'so an appended event is seen within about a second — with --interval the floor between',
385
+ 'request starts. A held read’s window is the remote default (25s, whole seconds, clamped',
386
+ 'by the store’s ceiling), capped at the remaining --timeout budget, and every stop — a',
387
+ 'satisfied condition, an all-terminal named set, the timeout, an interrupt — cancels',
388
+ 'in-flight holds promptly. A store read that fails while waiting is reported on stderr',
389
+ 'once per failing read source — locally the poll cycle is one source, while against an',
390
+ 'http(s) store each tracked build and the discovery pass carries its own failure',
391
+ 'streak — retried at the next interval (the next request on the remote path), and never',
392
+ 'causes a condition to be missed or reported twice.',
361
393
  '',
362
394
  'This command is read-only: it appends no event, takes no lease, and creates no build or',
363
395
  'repository record, and requires no session identity for operator use. Inside a phase, a',
@@ -565,6 +597,7 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
565
597
  ' ab artifact put <kind> <file> [--attach]',
566
598
  ' ab artifact get <kind>[@rev]',
567
599
  ' ab artifact download <build> <kind>[@rev] --output <file> [--store <ref>]',
600
+ ' ab artifact download stream:<id>[@rev] --output <file> [--store <ref>]',
568
601
  '',
569
602
  'put deposits a versioned artifact and prints its assigned revision. --attach',
570
603
  'also designates that exact revision for the PR. get fetches an artifact within',
@@ -575,6 +608,12 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
575
608
  'read-only and requires no session identity for operator use; --store overrides',
576
609
  'the BuildStore path or URL. Inside a phase, a complete ambient tuple permits',
577
610
  'only the ambient build; foreign builds and malformed or partial identity fail.',
611
+ '',
612
+ 'The stream:<id> form retrieves a finalized repository-scoped stream artifact',
613
+ "(for example a harvest session's closed stream). The stream id is globally",
614
+ 'unique, so no build argument is needed; find ids in the session rows of',
615
+ "'ab harvest status'. This form is strictly an operator read: any ambient",
616
+ 'build or Harvest identity fails closed (§8.2).',
578
617
  ]),
579
618
  },
580
619
  {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The builtin `vercel-sandbox` provider's remote readiness validation
3
+ * (AUT-516): the body of `validateInitReadiness`'s remote branch, moved here
4
+ * so the capability declaration can point at it without
5
+ * `cli/init-validation.ts` naming the provider. Store precondition checks
6
+ * stay with the caller, driven by the `storeRequirements` declaration.
7
+ */
8
+ import type { VercelSandboxConfig } from '../config/schema'
9
+ import type {
10
+ InitValidationReport,
11
+ WorkspaceReadinessContext,
12
+ } from '../ports/workspace/provider-capabilities'
13
+ import { validateVercelSandbox, type VercelSandboxFacade } from '../ports/workspace/vercel-sandbox'
14
+ import { gitText, parseGuestOutput } from './init-readiness-shared'
15
+
16
+ export async function validateRemoteReadiness(
17
+ ctx: WorkspaceReadinessContext,
18
+ ): Promise<InitValidationReport> {
19
+ const vercel = ctx.providerConfig as VercelSandboxConfig
20
+ const remoteLine = await gitText(
21
+ ctx.exec,
22
+ ctx.repo,
23
+ ['ls-remote', '--heads', 'origin', `refs/heads/${ctx.baseBranch}`],
24
+ ctx.signal,
25
+ )
26
+ const remoteRevision = remoteLine.split(/\s+/)[0]
27
+ if (!/^[0-9a-f]{40,64}$/i.test(remoteRevision ?? '')) {
28
+ throw new Error(
29
+ `remote base ${ctx.baseBranch} does not exist; commit and push setup changes before validating`,
30
+ )
31
+ }
32
+ await gitText(
33
+ ctx.exec,
34
+ ctx.repo,
35
+ [
36
+ 'fetch',
37
+ '--no-tags',
38
+ '--no-write-fetch-head',
39
+ '--refmap=',
40
+ 'origin',
41
+ `refs/heads/${ctx.baseBranch}`,
42
+ ],
43
+ ctx.signal,
44
+ )
45
+ const shownConfig = await ctx.exec(['git', 'show', `${remoteRevision}:autobuild.toml`], {
46
+ cwd: ctx.repo,
47
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
48
+ })
49
+ if (shownConfig.exitCode !== 0) {
50
+ throw new Error(
51
+ `remote ${ctx.baseBranch} does not contain autobuild.toml; commit and push setup changes before validating`,
52
+ )
53
+ }
54
+ if (shownConfig.stdout !== ctx.configBytes) {
55
+ throw new Error(
56
+ `remote ${ctx.baseBranch} autobuild.toml differs from this checkout; commit and push setup changes before validating`,
57
+ )
58
+ }
59
+ let remote: Awaited<ReturnType<typeof validateVercelSandbox>>
60
+ try {
61
+ remote = await validateVercelSandbox({
62
+ config: vercel,
63
+ env: ctx.env,
64
+ storeRef: ctx.storeRef,
65
+ storeToken: ctx.storeToken,
66
+ repo: ctx.repo,
67
+ baseBranch: ctx.baseBranch,
68
+ ...(ctx.facade !== undefined ? { facade: ctx.facade as VercelSandboxFacade } : {}),
69
+ exec: ctx.exec,
70
+ ...(ctx.packageArchive !== undefined ? { packageArchive: ctx.packageArchive } : {}),
71
+ runtimeReferences: [...ctx.runtimeReferences],
72
+ ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
73
+ onSandbox: (name) => ctx.stdout(`Disposable Vercel Sandbox: ${name} (active)`),
74
+ })
75
+ } catch (error) {
76
+ throw new Error(ctx.redact(error))
77
+ }
78
+ let guest: Awaited<ReturnType<typeof parseGuestOutput>>
79
+ try {
80
+ guest = parseGuestOutput(remote.output)
81
+ } catch (error) {
82
+ throw new Error(
83
+ `disposable sandbox ${remote.sandbox} was deleted, but its readiness output was invalid: ${ctx.redact(error)}`,
84
+ )
85
+ }
86
+ return {
87
+ provider: 'vercel-sandbox',
88
+ context: 'Vercel Sandbox',
89
+ workspace: remote.sandbox,
90
+ revision: remote.revision,
91
+ snapshotsDeleted: remote.snapshotsDeleted,
92
+ checks: [
93
+ {
94
+ name: 'repository acquisition',
95
+ status: 'pass',
96
+ detail: `${remote.origin} ${remote.revision}`,
97
+ },
98
+ {
99
+ name: 'system provisioning',
100
+ status: 'pass',
101
+ detail:
102
+ remote.provisioning.length === 0
103
+ ? 'no workspace.config.provisioning steps declared'
104
+ : `completed: ${remote.provisioning.join(', ')}`,
105
+ },
106
+ ...guest.checks,
107
+ ],
108
+ exitCode: guest.checks.some((check) => check.status === 'fail') ? 1 : 0,
109
+ }
110
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Readiness-probe helpers shared by the local-worktree and remote validation
3
+ * paths (AUT-516). Moved out of `cli/init-validation.ts` so the remote
4
+ * capability implementation can use them without init-validation importing
5
+ * the provider implementation; `init-validation.ts` re-exports for
6
+ * compatibility.
7
+ */
8
+ import type { Exec } from '../ports/workspace/git-worktree'
9
+ import type { GuestProbeReport } from '../ports/workspace/provider-capabilities'
10
+
11
+ export const INIT_PROBE_MARKER = 'AB_INIT_READINESS_V1='
12
+
13
+ function message(error: unknown): string {
14
+ if (error instanceof AggregateError) {
15
+ return [error.message, ...error.errors.map(message)].filter(Boolean).join('; ')
16
+ }
17
+ if (error instanceof Error)
18
+ return `${error.message}${error.cause === undefined ? '' : `; ${message(error.cause)}`}`
19
+ return String(error)
20
+ }
21
+
22
+ /** Replace every supplied nonempty value, longest first, in all diagnostics. */
23
+ export function createReadinessRedactor(
24
+ env: Readonly<Record<string, string | undefined>>,
25
+ explicitSecretNames: readonly string[] = [],
26
+ ): (value: unknown) => string {
27
+ const namedSecrets = new Set(explicitSecretNames)
28
+ const secrets = [
29
+ ...new Set(
30
+ Object.entries(env)
31
+ .filter(
32
+ ([name, value]) =>
33
+ value !== undefined &&
34
+ value !== '' &&
35
+ (namedSecrets.has(name) || /(?:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)),
36
+ )
37
+ .map(([, value]) => value as string),
38
+ ),
39
+ ].sort((left, right) => right.length - left.length)
40
+ return (value) => {
41
+ let text = message(value)
42
+ for (const secret of secrets) text = text.split(secret).join('[REDACTED]')
43
+ return text
44
+ }
45
+ }
46
+
47
+ export function parseGuestOutput(output: string): GuestProbeReport {
48
+ const line = output.split(/\r?\n/).find((candidate) => candidate.startsWith(INIT_PROBE_MARKER))
49
+ if (line === undefined)
50
+ throw new Error('remote readiness probe returned malformed output (result marker absent)')
51
+ const value = JSON.parse(line.slice(INIT_PROBE_MARKER.length)) as GuestProbeReport
52
+ if (!Array.isArray(value.checks))
53
+ throw new Error('remote readiness probe returned malformed checks')
54
+ return value
55
+ }
56
+
57
+ export async function gitText(
58
+ exec: Exec,
59
+ repo: string,
60
+ args: string[],
61
+ signal?: AbortSignal,
62
+ ): Promise<string> {
63
+ const result = await exec(['git', ...args], {
64
+ cwd: repo,
65
+ ...(signal === undefined ? {} : { signal }),
66
+ })
67
+ if (result.exitCode !== 0)
68
+ throw new Error(
69
+ `git ${args.join(' ')} exited ${result.exitCode}: ${result.stderr.trim() || result.stdout.trim()}`,
70
+ )
71
+ return result.stdout.trim()
72
+ }