@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- 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
|
-
|
|
552
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|