@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
@@ -39,10 +39,13 @@ import { loadConfig } from '../config/load'
39
39
  import type { Config } from '../config/schema'
40
40
  import {
41
41
  BUILD_EFFECTIVE_CONFIG_ARTIFACT,
42
+ parseBuildConfigMetadata,
42
43
  parseEffectiveBuildConfig,
43
44
  } from '../processes/build-execution-state'
45
+ import type { PipelineSourceMeta } from '../config/pipeline-source'
44
46
  import { openExecution } from '../processes/execution-settlement'
45
47
  import { currentAutoMergeDeferral } from '../kernel/auto-merge'
48
+ import { autoMergeDisplay, type AutoMergeDisplay } from './dashboard/model'
46
49
  import { decideNext } from '../kernel/engine'
47
50
  import {
48
51
  reduceBuild,
@@ -64,6 +67,7 @@ import type { BuildRecord, BuildStore, StreamRecord, StreamScope } from '../stor
64
67
  import { buildProgress, isDiverged, type BuildProgress } from './build-progress'
65
68
  import { normalizeGitRemoteUrl } from '../kernel/origin'
66
69
  import { buildInRepository, resolveMainRepo } from './repo-state'
70
+ import { writeAssetTo } from './ticket-assets'
67
71
  import { withAmbientReadStore, type StoreOpener } from './store-opening'
68
72
 
69
73
  /** Backward-compatible name for callers/tests; repository resolution is shared. */
@@ -135,6 +139,10 @@ export interface BuildDetail extends BuildSummary {
135
139
  /** Current work-owner decision. Open PRs use the build-owned config; ended
136
140
  * PRs project the repository completion path directly from durable state. */
137
141
  decision?: BuildDecisionProjection
142
+ /** The build's effective auto-merge display state (off, requested, enabled,
143
+ * cancelling) — the same projection the dashboards render, so `--json` and
144
+ * the text agree and `waiting: on PR` can say why it is waiting. */
145
+ autoMerge: AutoMergeDisplay
138
146
  openEscalations: OpenEscalation[]
139
147
  /** Per-loop review overrides for the current spec; omitted when none are set. */
140
148
  reviewRoundCeilings?: { plan?: number; code?: number }
@@ -155,6 +163,11 @@ export interface BuildDetail extends BuildSummary {
155
163
  lastEvent?: { type: string; seq: number; ts: string; actor: Actor }
156
164
  /** Present only with `--events <n>`: the newest n, chronological. */
157
165
  events?: AbEvent[]
166
+ /** Which `autobuild.toml` the build's pinned pipeline was read from, and the
167
+ * effective-config artifact revision it was deposited under (SPEC §16.1).
168
+ * Absent when no effective-config artifact has been deposited. */
169
+ pipelineSource?: PipelineSourceMeta
170
+ effectiveConfigRev?: number
158
171
  outcome?: BuildOutcome
159
172
  }
160
173
 
@@ -287,6 +300,7 @@ export function detail(
287
300
  }))
288
301
  return {
289
302
  ...summary,
303
+ autoMerge: autoMergeDisplay(state),
290
304
  ...(decision !== undefined ? { decision } : {}),
291
305
  openEscalations: state.openEscalations,
292
306
  ...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
@@ -493,6 +507,16 @@ export function renderDetail(d: BuildDetail, now: Date): string[] {
493
507
  lines.push(` review round ceiling: ${ceilings.join(', ')}`)
494
508
  }
495
509
  lines.push(` updated: ${d.updatedAt} (${relativeTime(d.updatedAt, now)})`)
510
+ if (d.pipelineSource !== undefined || d.effectiveConfigRev !== undefined) {
511
+ const source =
512
+ d.pipelineSource !== undefined
513
+ ? `autobuild.toml@${
514
+ d.pipelineSource.commit !== undefined ? d.pipelineSource.commit.slice(0, 7) : 'unknown'
515
+ } (${d.pipelineSource.ref})`
516
+ : 'autobuild.toml@unknown (pre-pin)'
517
+ const rev = d.effectiveConfigRev !== undefined ? ` config rev ${d.effectiveConfigRev}` : ''
518
+ lines.push(` pipeline: ${source}${rev}`)
519
+ }
496
520
 
497
521
  // Its own line, never folded into status: a running build with an expired
498
522
  // lease is the case this exists to make visible.
@@ -548,9 +572,11 @@ export function renderDetail(d: BuildDetail, now: Date): string[] {
548
572
  )
549
573
  }
550
574
  if (d.decision?.kind === 'awaiting-pr') {
551
- lines.push(
552
- ` waiting: on PR${d.decision.reason !== undefined ? ` — ${d.decision.reason}` : ''}`,
553
- )
575
+ // The deferral reason (a durable observation about WHY consent could not
576
+ // be applied) takes precedence; otherwise the auto-merge state itself is
577
+ // what the parked operator needs — off names the missing consent.
578
+ const consent = d.decision.reason ?? `auto merge ${d.autoMerge}`
579
+ lines.push(` waiting: on PR — ${consent}`)
554
580
  } else if (d.decision?.kind === 'repository-completion') {
555
581
  lines.push(
556
582
  ` waiting: repository-level completion after the PR was ${d.decision.prState}; no runner re-attachment is pending`,
@@ -703,9 +729,43 @@ export async function abBuilds(opts: AbBuildsOpts): Promise<void> {
703
729
  const wanted = new Set(statusFilter(opts.all, opts.queued))
704
730
  // Cross-repo aggregation is out of scope (§12: one dispatcher per repo,
705
731
  // one repo's builds per answer).
732
+ // Records are read and held before the digest read: builds are never
733
+ // deleted, so every identity-keyed record iterated below is guaranteed an
734
+ // entry in the digest map fetched after it — a build created between the
735
+ // two reads appears only in the digest map, which is harmless — and the
736
+ // completeness check can only ever catch a genuine adapter bug, never a
737
+ // concurrent dispatch (AUT-488 finding f_3cb67aba).
706
738
  const records = (await store.listBuilds()).filter(mine)
739
+ // One digest read gates the per-build history reads (AUT-488): the active
740
+ // and queued scopes never show a terminal build, so an identity-keyed
741
+ // build whose digest already carries a terminal fact skips its `getEvents`
742
+ // round trip and the listing's store cost stays flat as finished builds
743
+ // accumulate. The `--all` scope includes terminal statuses, so it takes no
744
+ // digest read and skips nothing — byte-for-byte the old loop.
745
+ const digests = opts.all === true ? undefined : await store.getRepoBuildDigests(identity)
707
746
  const summaries: BuildSummary[] = []
708
747
  for (const record of records) {
748
+ if (digests !== undefined && record.repo === identity) {
749
+ // Completeness is contractual (one entry per repo build); a missing
750
+ // entry is an adapter bug and must fail loudly rather than silently
751
+ // drop the build from the listing.
752
+ const digest = digests.get(record.slug)
753
+ if (digest === undefined) {
754
+ throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
755
+ }
756
+ // The digest's `terminal` follows `reduceBuild`'s terminal rule
757
+ // exactly (in-order overwrite of `build.completed`/`build.aborted`,
758
+ // never cleared — pinned against `reduceBuild` in
759
+ // store/digest.test.ts), so `terminal !== undefined` is exactly
760
+ // reduced status `done`/`aborted`, which the active and queued status
761
+ // sets never include. Skipping the history read therefore cannot
762
+ // change the emitted summaries.
763
+ if (digest.terminal !== undefined) continue
764
+ }
765
+ // A record matched only through the legacy `repoOrigin` path has no
766
+ // entry under `identity` — `getRepoBuildDigests` groups by `record.repo`
767
+ // exactly — so it keeps its per-build history read (correct, just not
768
+ // accelerated).
709
769
  const summary = summarize(record, await store.getEvents(record.slug), now)
710
770
  if (wanted.has(summary.status)) summaries.push(summary)
711
771
  }
@@ -830,6 +890,40 @@ async function projectBuildDecision(
830
890
  }
831
891
  }
832
892
 
893
+ type AmbientReadContext = Parameters<Parameters<typeof withAmbientReadStore>[1]>[0]
894
+
895
+ /** The build record, after the repository gate every single-build read shares
896
+ * (see `abBuildStatus`). */
897
+ async function requireReadableBuild(
898
+ opts: AbBuildStatusOpts,
899
+ context: AmbientReadContext,
900
+ ): Promise<BuildRecord> {
901
+ const record = await context.store.getBuild(opts.slug)
902
+ if (record === null) {
903
+ throw new Error(
904
+ `no build "${opts.slug}" in this store — run 'ab builds --all' to list ` +
905
+ "this repo's builds, or pass --store <ref> if it lives in another store",
906
+ )
907
+ }
908
+ // Own-session authority: slug equality bounds the read to the session's
909
+ // own build, and an explicit --store must name the session's own store for
910
+ // the bypass to apply. Every other combination falls through to the
911
+ // ordinary repository gate, unchanged — which now keys on the checkout's
912
+ // normalized origin identity (`context.checkout` is the physical path;
913
+ // `buildInRepository` derives the identity from it).
914
+ const ownSession =
915
+ context.ambient !== undefined &&
916
+ 'build' in context.ambient &&
917
+ context.ambient.build === opts.slug &&
918
+ (opts.storeRef === undefined || opts.storeRef === context.ambient.store)
919
+ if (!(ownSession || (await buildInRepository(record, context.checkout, opts.exec)))) {
920
+ throw new Error(
921
+ `build "${opts.slug}" belongs to repository "${record.repo}", not "${context.repo}"`,
922
+ )
923
+ }
924
+ return record
925
+ }
926
+
833
927
  /** `ab build status <slug>` — one build in detail. Read-only.
834
928
  *
835
929
  * Repository authority mirrors the other ambient reads: an operator invocation
@@ -849,29 +943,7 @@ async function projectBuildDecision(
849
943
  export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
850
944
  const now = (opts.now ?? (() => new Date()))()
851
945
  await withAmbientReadStore(opts, async (context) => {
852
- const record = await context.store.getBuild(opts.slug)
853
- if (record === null) {
854
- throw new Error(
855
- `no build "${opts.slug}" in this store — run 'ab builds --all' to list ` +
856
- "this repo's builds, or pass --store <ref> if it lives in another store",
857
- )
858
- }
859
- // Own-session authority: slug equality bounds the read to the session's
860
- // own build, and an explicit --store must name the session's own store for
861
- // the bypass to apply. Every other combination falls through to the
862
- // ordinary repository gate, unchanged — which now keys on the checkout's
863
- // normalized origin identity (`context.checkout` is the physical path;
864
- // `buildInRepository` derives the identity from it).
865
- const ownSession =
866
- context.ambient !== undefined &&
867
- 'build' in context.ambient &&
868
- context.ambient.build === opts.slug &&
869
- (opts.storeRef === undefined || opts.storeRef === context.ambient.store)
870
- if (!(ownSession || (await buildInRepository(record, context.checkout, opts.exec)))) {
871
- throw new Error(
872
- `build "${opts.slug}" belongs to repository "${record.repo}", not "${context.repo}"`,
873
- )
874
- }
946
+ const record = await requireReadableBuild(opts, context)
875
947
  const events = await context.store.getEvents(opts.slug)
876
948
  const decision = await projectBuildDecision(opts.slug, events, context.store)
877
949
  // Stream enrichment is presentation (SPEC §9): an unavailable read
@@ -885,7 +957,22 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
885
957
  } catch {
886
958
  streams = undefined
887
959
  }
888
- const d = detail(record, events, now, opts.events, decision, streams)
960
+ const projected = detail(record, events, now, opts.events, decision, streams)
961
+ // Operator visibility (SPEC §16.1): which autobuild.toml the build's
962
+ // pinned pipeline came from, alongside the effective-config revision.
963
+ const configArtifact = await context.store.getArtifact(
964
+ opts.slug,
965
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
966
+ )
967
+ const configMeta =
968
+ configArtifact !== null ? parseBuildConfigMetadata(configArtifact) : undefined
969
+ const d: BuildDetail = {
970
+ ...projected,
971
+ ...(configMeta?.revision !== undefined ? { effectiveConfigRev: configMeta.revision } : {}),
972
+ ...(configMeta?.pipelineSource !== undefined
973
+ ? { pipelineSource: configMeta.pipelineSource }
974
+ : {}),
975
+ }
889
976
  if (opts.json === true) {
890
977
  opts.stdout(JSON.stringify(d, null, 2))
891
978
  return
@@ -893,3 +980,38 @@ export async function abBuildStatus(opts: AbBuildStatusOpts): Promise<void> {
893
980
  for (const line of renderDetail(d, now)) opts.stdout(line)
894
981
  })
895
982
  }
983
+
984
+ export interface AbBuildAssetGetOpts extends AbBuildStatusOpts {
985
+ kind: string
986
+ name: string
987
+ dest: string
988
+ rev?: number
989
+ }
990
+
991
+ /** `ab build asset get <slug> <kind> <name> <dest>` — the exact bytes a build
992
+ * froze at its claim (SPEC §6.3), available after the build has finished. */
993
+ export async function abBuildAssetGet(opts: AbBuildAssetGetOpts): Promise<void> {
994
+ await withAmbientReadStore(opts, async (context) => {
995
+ await requireReadableBuild(opts, context)
996
+ const asset = await context.store.getPinnedTicketAsset(
997
+ opts.slug,
998
+ opts.kind,
999
+ opts.name,
1000
+ opts.rev,
1001
+ )
1002
+ if (asset === null) {
1003
+ const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
1004
+ throw new Error(
1005
+ `build "${opts.slug}" pinned no ticket asset ${opts.kind}/${opts.name}${revision}`,
1006
+ )
1007
+ }
1008
+ const written = await writeAssetTo(asset, opts.dest)
1009
+ if (opts.json === true) {
1010
+ opts.stdout(JSON.stringify({ ...asset.meta, path: written }, null, 2))
1011
+ return
1012
+ }
1013
+ opts.stdout(
1014
+ `build asset downloaded: ${opts.slug} ${opts.kind}/${opts.name} revision ${asset.meta.revision} → ${written}`,
1015
+ )
1016
+ })
1017
+ }
@@ -20,6 +20,7 @@ import { z } from 'zod'
20
20
  import { loadConfig } from '../config/load'
21
21
  import type { AbEvent, EventEnvelope } from '../events/catalog'
22
22
  import { agentActor, KERNEL } from '../events/envelope'
23
+ import { isRemoteWorkspace } from '../events/workspace-remote'
23
24
  import type { IdSource } from '../ids'
24
25
  import {
25
26
  autoMergeApplicationType,
@@ -258,7 +259,7 @@ function requireImplementationProvisioning(events: AbEvent[]): void {
258
259
  function remotePublication(events: readonly AbEvent[]): boolean {
259
260
  let remote = false
260
261
  for (const event of events) {
261
- if (event.type === 'workspace.provisioned') remote = event.payload.provider === 'vercel-sandbox'
262
+ if (event.type === 'workspace.provisioned') remote = isRemoteWorkspace(event.payload)
262
263
  else if (event.type === 'workspace.released') remote = false
263
264
  }
264
265
  return remote
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Filesystem and note plumbing for `ab ticket attach` / `asset get` /
3
+ * `asset rm` (SPEC §8.8). The store owns the bytes and the validation
4
+ * (store/ticket-assets.ts); this module owns the two edges that touch the
5
+ * local filesystem and the one outward projection: the note left on the
6
+ * ticket in its source (SPEC §13).
7
+ */
8
+ import { lstat, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
9
+ import { basename, dirname, join, resolve, sep } from 'node:path'
10
+ import {
11
+ ticketAssetEntriesError,
12
+ ticketAssetSizeError,
13
+ validateTicketAssetPath,
14
+ validateTicketAssetStructure,
15
+ type TicketAsset,
16
+ type TicketAssetEntryInput,
17
+ type TicketAssetInput,
18
+ type TicketAssetLayout,
19
+ type TicketAssetLimits,
20
+ type TicketAssetMeta,
21
+ } from '../store/ticket-assets'
22
+
23
+ export type TicketAssetNoteEvent = 'attached' | 'replaced' | 'removed'
24
+
25
+ export function formatAssetSize(bytes: number): string {
26
+ if (bytes < 1024) return `${bytes} B`
27
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
28
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
29
+ }
30
+
31
+ function plural(count: number, word: string): string {
32
+ return `${count} ${word}${count === 1 ? '' : 's'}`
33
+ }
34
+
35
+ /**
36
+ * The note left on the ticket in its source. Plain Markdown with backticks
37
+ * (no bold, no source-specific syntax) so every source and plugin renders it.
38
+ * It names the asset and how to fetch it, and states that the contents are in
39
+ * the Autobuild store, not in this ticket.
40
+ */
41
+ export function ticketAssetNote(
42
+ event: TicketAssetNoteEvent,
43
+ meta: TicketAssetMeta,
44
+ cli = 'ab',
45
+ ): string {
46
+ const fileCount = meta.entries.filter((entry) => entry.type === 'file').length
47
+ const label = `\`${meta.kind}/${meta.name}\``
48
+ const get = `${cli} ticket asset get ${meta.ticketId} ${meta.kind} ${meta.name} <dest>`
49
+ const contents = `${plural(fileCount, 'file')}, ${formatAssetSize(meta.size)}`
50
+ switch (event) {
51
+ case 'attached':
52
+ return [
53
+ `Ticket asset attached: ${label} (revision ${meta.revision}, ${contents}).`,
54
+ '',
55
+ 'The contents live in the Autobuild store, not in this ticket.',
56
+ `Download the latest revision: \`${get}\``,
57
+ `Download this exact revision: \`${get} --rev ${meta.revision}\``,
58
+ ].join('\n')
59
+ case 'replaced':
60
+ return [
61
+ `Ticket asset replaced: ${label} is now revision ${meta.revision} (${contents}).`,
62
+ '',
63
+ 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
64
+ `Download the latest revision: \`${get}\``,
65
+ `Download this exact revision: \`${get} --rev ${meta.revision}\``,
66
+ ].join('\n')
67
+ case 'removed':
68
+ return [
69
+ `Ticket asset removed: ${label} is no longer one of this ticket's current assets (last revision ${meta.revision}).`,
70
+ '',
71
+ 'The contents live in the Autobuild store, not in this ticket. That revision stays retrievable by number.',
72
+ `Download it: \`${get} --rev ${meta.revision}\``,
73
+ ].join('\n')
74
+ }
75
+ }
76
+
77
+ export interface AssetWalk {
78
+ layout: TicketAssetLayout
79
+ /** Default asset name: the path's basename. */
80
+ basename: string
81
+ files: { path: string; abs: string; size: number }[]
82
+ dirs: string[]
83
+ }
84
+
85
+ /**
86
+ * Walk a file or folder for attachment. Symlinks and non-regular files are
87
+ * refused. The folder root is never an entry — it is represented only by the
88
+ * `tree` layout — so every entry path is relative, with no empty or `.`
89
+ * segment, and an empty folder walks to zero entries.
90
+ */
91
+ export async function walkAssetPath(path: string): Promise<AssetWalk> {
92
+ const root = resolve(path)
93
+ const info = await lstat(root).catch((error: NodeJS.ErrnoException) => {
94
+ if (error.code === 'ENOENT') throw new Error(`${path}: no such file or directory`)
95
+ throw error
96
+ })
97
+ if (info.isSymbolicLink()) {
98
+ throw new Error(`${path}: symbolic links cannot be attached — attach the real file or folder`)
99
+ }
100
+ if (info.isFile()) {
101
+ return {
102
+ layout: 'file',
103
+ basename: basename(root),
104
+ files: [{ path: basename(root), abs: root, size: info.size }],
105
+ dirs: [],
106
+ }
107
+ }
108
+ if (!info.isDirectory()) {
109
+ throw new Error(`${path}: only regular files and folders can be attached`)
110
+ }
111
+ const files: AssetWalk['files'] = []
112
+ const dirs: string[] = []
113
+ const visit = async (abs: string, rel: string): Promise<void> => {
114
+ const names = (await readdir(abs)).sort()
115
+ for (const name of names) {
116
+ const childAbs = join(abs, name)
117
+ const childRel = rel === '' ? name : `${rel}/${name}`
118
+ const child = await lstat(childAbs)
119
+ if (child.isSymbolicLink()) {
120
+ throw new Error(
121
+ `${childAbs}: symbolic links cannot be attached — remove it or attach its target`,
122
+ )
123
+ }
124
+ if (child.isDirectory()) {
125
+ dirs.push(childRel)
126
+ await visit(childAbs, childRel)
127
+ } else if (child.isFile()) {
128
+ files.push({ path: childRel, abs: childAbs, size: child.size })
129
+ } else {
130
+ throw new Error(`${childAbs}: only regular files and folders can be attached`)
131
+ }
132
+ }
133
+ }
134
+ await visit(root, '')
135
+ return { layout: 'tree', basename: basename(root), files, dirs }
136
+ }
137
+
138
+ /**
139
+ * Build the put input from a walk. Everything the stat sizes can refuse —
140
+ * names, paths, structure, entry count, total bytes — is refused here, before
141
+ * any file's bytes are read.
142
+ */
143
+ export async function buildAssetInput(
144
+ walk: AssetWalk,
145
+ asset: { kind: string; name: string },
146
+ limits: TicketAssetLimits,
147
+ ): Promise<TicketAssetInput> {
148
+ const entryCount = walk.files.length + walk.dirs.length
149
+ if (entryCount > limits.maxEntries) throw ticketAssetEntriesError(entryCount, limits.maxEntries)
150
+ const total = walk.files.reduce((sum, file) => sum + file.size, 0)
151
+ if (total > limits.maxBytes) throw ticketAssetSizeError(total, limits.maxBytes)
152
+ validateTicketAssetStructure(
153
+ {
154
+ kind: asset.kind,
155
+ name: asset.name,
156
+ layout: walk.layout,
157
+ entries: [
158
+ ...walk.dirs.map((path) => ({ type: 'dir' as const, path })),
159
+ ...walk.files.map((file) => ({ type: 'file' as const, path: file.path, size: file.size })),
160
+ ],
161
+ },
162
+ limits,
163
+ )
164
+ const entries: TicketAssetEntryInput[] = walk.dirs.map((path) => ({ type: 'dir', path }))
165
+ for (const file of walk.files) {
166
+ entries.push({
167
+ type: 'file',
168
+ path: file.path,
169
+ content: new Uint8Array(await readFile(file.abs)),
170
+ })
171
+ }
172
+ return { kind: asset.kind, name: asset.name, layout: walk.layout, entries }
173
+ }
174
+
175
+ /** Resolve a stored entry path under `root`, refusing anything that would land
176
+ * outside it. Stored paths are never trusted, even though upload validates. */
177
+ function guardedTarget(root: string, path: string): string {
178
+ validateTicketAssetPath(path)
179
+ const target = resolve(root, path)
180
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
181
+ throw new Error(`ticket asset path "${path}" escapes the destination`)
182
+ }
183
+ return target
184
+ }
185
+
186
+ /** Refuse to write through a symlink: every existing component of `target`
187
+ * below `root`, and `target` itself, must not be one. Containment is checked
188
+ * against the real filesystem, since mkdir and writeFile follow links. */
189
+ async function assertNoSymlinks(root: string, target: string): Promise<void> {
190
+ const rel = target === root ? '' : target.slice(root.length + 1)
191
+ let current = root
192
+ const parts = rel === '' ? [] : rel.split(sep)
193
+ for (const part of ['', ...parts]) {
194
+ if (part !== '') current = join(current, part)
195
+ if (current === root) continue
196
+ const info = await lstat(current).catch(() => null)
197
+ if (info === null) return
198
+ if (info.isSymbolicLink()) {
199
+ throw new Error(`${current}: refusing to write through a symbolic link in the destination`)
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Write a downloaded asset to `dest`. A `file` asset becomes the file `dest`
206
+ * (or `dest/<filename>` when `dest` is an existing directory); a `tree` asset
207
+ * becomes the directory `dest` itself — created even when empty — with every
208
+ * `dir` entry made, then every file written.
209
+ */
210
+ export async function writeAssetTo(asset: TicketAsset, dest: string): Promise<string> {
211
+ const root = resolve(dest)
212
+ if (asset.meta.layout === 'file') {
213
+ const file = asset.entries.find((entry) => entry.type === 'file')
214
+ if (file === undefined || file.type !== 'file') {
215
+ throw new Error(`ticket asset ${asset.meta.kind}/${asset.meta.name} holds no file`)
216
+ }
217
+ const isDir = await stat(root)
218
+ .then((info) => info.isDirectory())
219
+ .catch(() => false)
220
+ const target = isDir ? guardedTarget(root, file.path) : root
221
+ if (isDir) await assertNoSymlinks(root, target)
222
+ else {
223
+ const existing = await lstat(target).catch(() => null)
224
+ if (existing?.isSymbolicLink()) {
225
+ throw new Error(`${target}: refusing to write through a symbolic link`)
226
+ }
227
+ }
228
+ await mkdir(dirname(target), { recursive: true })
229
+ await writeFile(target, file.content)
230
+ return target
231
+ }
232
+ await mkdir(root, { recursive: true })
233
+ for (const entry of asset.entries) {
234
+ if (entry.type !== 'dir') continue
235
+ const target = guardedTarget(root, entry.path)
236
+ await assertNoSymlinks(root, target)
237
+ await mkdir(target, { recursive: true })
238
+ }
239
+ for (const entry of asset.entries) {
240
+ if (entry.type !== 'file') continue
241
+ const target = guardedTarget(root, entry.path)
242
+ await assertNoSymlinks(root, target)
243
+ await mkdir(dirname(target), { recursive: true })
244
+ await writeFile(target, entry.content)
245
+ }
246
+ return root
247
+ }