@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
|
@@ -26,12 +26,28 @@ import {
|
|
|
26
26
|
} from '../events/sessions'
|
|
27
27
|
import { humanActor } from '../events/envelope'
|
|
28
28
|
import { createBuildScopedStore } from './build-scope'
|
|
29
|
+
import { validateCreatedEvent } from './new-build'
|
|
29
30
|
import { createSessionScopedStore } from './session-handle'
|
|
31
|
+
import { reduceBuildDigest } from './digest'
|
|
32
|
+
import { projectRepositoryStateEvents } from './repo-state-events'
|
|
30
33
|
import {
|
|
31
34
|
DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
|
|
32
35
|
isRetentionManagedKind,
|
|
33
36
|
revisionsToPrune,
|
|
34
37
|
} from './retention'
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_TICKET_ASSET_LIMITS,
|
|
40
|
+
loadTicketAsset,
|
|
41
|
+
resolvePinnedAsset,
|
|
42
|
+
storeTicketAssetBlobs,
|
|
43
|
+
summarizeTicketAsset,
|
|
44
|
+
validateTicketAssetInput,
|
|
45
|
+
type TicketAsset,
|
|
46
|
+
type TicketAssetInput,
|
|
47
|
+
type TicketAssetLimits,
|
|
48
|
+
type TicketAssetMeta,
|
|
49
|
+
type TicketAssetSummary,
|
|
50
|
+
} from './ticket-assets'
|
|
35
51
|
import { pollingSubscribe } from './subscribe'
|
|
36
52
|
import { StreamLocks } from './streams/lock'
|
|
37
53
|
import { assembleUIMessageDocument } from './streams/assemble'
|
|
@@ -62,6 +78,7 @@ import {
|
|
|
62
78
|
type ArtifactMeta,
|
|
63
79
|
type BlobStore,
|
|
64
80
|
type BuildRecord,
|
|
81
|
+
type BuildDigest,
|
|
65
82
|
type BuildScopedStore,
|
|
66
83
|
type BuildStore,
|
|
67
84
|
type Clock,
|
|
@@ -146,6 +163,9 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
146
163
|
* stream's close against its appends inside this process (AUT-348). */
|
|
147
164
|
private readonly streamLocks = new StreamLocks()
|
|
148
165
|
private readonly sessions = new Map<string, SessionState>()
|
|
166
|
+
/** `[repo, ticketId, kind, name]` → rows in revision order. A removal is a
|
|
167
|
+
* tombstone row (`removed`, empty manifest) taking the next revision. */
|
|
168
|
+
private readonly ticketAssets = new Map<string, { meta: TicketAssetMeta; removed: boolean }[]>()
|
|
149
169
|
private readonly clock: Clock
|
|
150
170
|
private readonly maxRevisions: number
|
|
151
171
|
readonly blobs: BlobStore
|
|
@@ -244,6 +264,7 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
244
264
|
}
|
|
245
265
|
|
|
246
266
|
async createBuild(input: NewBuildInput): Promise<BuildRecord> {
|
|
267
|
+
const created = validateCreatedEvent(input)
|
|
247
268
|
if (this.builds.has(input.slug)) {
|
|
248
269
|
throw new Error(`build "${input.slug}" already exists`)
|
|
249
270
|
}
|
|
@@ -261,6 +282,18 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
261
282
|
events: [],
|
|
262
283
|
artifacts: new Map(),
|
|
263
284
|
}
|
|
285
|
+
if (created !== undefined) {
|
|
286
|
+
state.events.push(
|
|
287
|
+
structuredClone({
|
|
288
|
+
build: input.slug,
|
|
289
|
+
seq: 1,
|
|
290
|
+
ts,
|
|
291
|
+
actor: created.actor,
|
|
292
|
+
type: created.type,
|
|
293
|
+
payload: created.payload,
|
|
294
|
+
}) as AbEvent,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
264
297
|
this.builds.set(input.slug, state)
|
|
265
298
|
return this.snapshot(state)
|
|
266
299
|
}
|
|
@@ -274,6 +307,22 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
274
307
|
return [...this.builds.values()].map((state) => this.snapshot(state))
|
|
275
308
|
}
|
|
276
309
|
|
|
310
|
+
async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
311
|
+
// Insertion order is creation order (the listSessions tiebreak note); the
|
|
312
|
+
// digest map is returned in slug order so every adapter answers the same
|
|
313
|
+
// shape for the same store contents.
|
|
314
|
+
const slugs = [...this.builds.entries()]
|
|
315
|
+
.filter(([, state]) => state.record.repo === repo)
|
|
316
|
+
.map(([slug]) => slug)
|
|
317
|
+
.sort()
|
|
318
|
+
const digests = new Map<string, BuildDigest>()
|
|
319
|
+
for (const slug of slugs) {
|
|
320
|
+
const state = this.builds.get(slug)!
|
|
321
|
+
digests.set(slug, { slug, ...reduceBuildDigest(state.events) })
|
|
322
|
+
}
|
|
323
|
+
return digests
|
|
324
|
+
}
|
|
325
|
+
|
|
277
326
|
async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
|
|
278
327
|
const state = this.state(slug)
|
|
279
328
|
const validated = validateEventWrite(event)
|
|
@@ -629,6 +678,14 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
629
678
|
})
|
|
630
679
|
}
|
|
631
680
|
|
|
681
|
+
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
682
|
+
// The oracle runs on the in-memory journal itself, which keeps the pure
|
|
683
|
+
// derivation honest against real adapter data.
|
|
684
|
+
return structuredClone(
|
|
685
|
+
projectRepositoryStateEvents(this.repoState(repo).events as RepositoryEvent[]),
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
632
689
|
async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
633
690
|
const state = this.repoState(repo)
|
|
634
691
|
if (!artifact.kind) throw new Error('artifact kind is required')
|
|
@@ -676,6 +733,102 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
676
733
|
)
|
|
677
734
|
}
|
|
678
735
|
|
|
736
|
+
async ticketAssetLimits(_repo: string): Promise<TicketAssetLimits> {
|
|
737
|
+
return { ...DEFAULT_TICKET_ASSET_LIMITS }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async putTicketAsset(
|
|
741
|
+
repo: string,
|
|
742
|
+
ticketId: string,
|
|
743
|
+
asset: TicketAssetInput,
|
|
744
|
+
): Promise<TicketAssetMeta> {
|
|
745
|
+
const size = validateTicketAssetInput(asset)
|
|
746
|
+
const entries = await storeTicketAssetBlobs(this.blobs, asset.entries)
|
|
747
|
+
const key = JSON.stringify([repo, ticketId, asset.kind, asset.name])
|
|
748
|
+
const rows = this.ticketAssets.get(key) ?? []
|
|
749
|
+
const meta: TicketAssetMeta = {
|
|
750
|
+
repo,
|
|
751
|
+
ticketId,
|
|
752
|
+
kind: asset.kind,
|
|
753
|
+
name: asset.name,
|
|
754
|
+
revision: MemoryBuildStore.nextRevision(rows.map((row) => row.meta)),
|
|
755
|
+
layout: asset.layout,
|
|
756
|
+
size,
|
|
757
|
+
entries,
|
|
758
|
+
createdAt: this.now(),
|
|
759
|
+
}
|
|
760
|
+
rows.push({ meta, removed: false })
|
|
761
|
+
this.ticketAssets.set(key, rows)
|
|
762
|
+
return structuredClone(meta)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async getTicketAsset(
|
|
766
|
+
repo: string,
|
|
767
|
+
ticketId: string,
|
|
768
|
+
kind: string,
|
|
769
|
+
name: string,
|
|
770
|
+
rev?: number,
|
|
771
|
+
): Promise<TicketAsset | null> {
|
|
772
|
+
const rows = this.ticketAssets.get(JSON.stringify([repo, ticketId, kind, name]))
|
|
773
|
+
const row =
|
|
774
|
+
rev === undefined ? rows?.at(-1) : rows?.find((candidate) => candidate.meta.revision === rev)
|
|
775
|
+
if (!row || row.removed) return null
|
|
776
|
+
return loadTicketAsset(this.blobs, row.meta)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async getPinnedTicketAsset(
|
|
780
|
+
slug: string,
|
|
781
|
+
kind: string,
|
|
782
|
+
name: string,
|
|
783
|
+
rev?: number,
|
|
784
|
+
): Promise<TicketAsset | null> {
|
|
785
|
+
return resolvePinnedAsset(this, slug, kind, name, rev)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async listTicketAssets(
|
|
789
|
+
repo: string,
|
|
790
|
+
ticketId: string,
|
|
791
|
+
opts?: { revisions?: boolean },
|
|
792
|
+
): Promise<TicketAssetSummary[]> {
|
|
793
|
+
const out: TicketAssetSummary[] = []
|
|
794
|
+
for (const rows of this.ticketAssets.values()) {
|
|
795
|
+
const first = rows[0]
|
|
796
|
+
if (!first || first.meta.repo !== repo || first.meta.ticketId !== ticketId) continue
|
|
797
|
+
if (opts?.revisions) {
|
|
798
|
+
for (const row of rows) if (!row.removed) out.push(summarizeTicketAsset(row.meta))
|
|
799
|
+
} else {
|
|
800
|
+
const latest = rows.at(-1)
|
|
801
|
+
if (latest && !latest.removed) out.push(summarizeTicketAsset(latest.meta))
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return out.sort(
|
|
805
|
+
(a, b) =>
|
|
806
|
+
a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name) || a.revision - b.revision,
|
|
807
|
+
)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async removeTicketAsset(
|
|
811
|
+
repo: string,
|
|
812
|
+
ticketId: string,
|
|
813
|
+
kind: string,
|
|
814
|
+
name: string,
|
|
815
|
+
): Promise<TicketAssetMeta | null> {
|
|
816
|
+
const rows = this.ticketAssets.get(JSON.stringify([repo, ticketId, kind, name]))
|
|
817
|
+
const latest = rows?.at(-1)
|
|
818
|
+
if (!rows || !latest || latest.removed) return null
|
|
819
|
+
rows.push({
|
|
820
|
+
meta: {
|
|
821
|
+
...latest.meta,
|
|
822
|
+
revision: latest.meta.revision + 1,
|
|
823
|
+
size: 0,
|
|
824
|
+
entries: [],
|
|
825
|
+
createdAt: this.now(),
|
|
826
|
+
},
|
|
827
|
+
removed: true,
|
|
828
|
+
})
|
|
829
|
+
return structuredClone(latest.meta)
|
|
830
|
+
}
|
|
831
|
+
|
|
679
832
|
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
680
833
|
const state = this.repoState(repo)
|
|
681
834
|
const now = this.clock().getTime()
|
|
@@ -791,6 +944,32 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
791
944
|
return envelope
|
|
792
945
|
}
|
|
793
946
|
|
|
947
|
+
async appendSessionEventIfCurrent<T extends SessionEventType>(
|
|
948
|
+
id: string,
|
|
949
|
+
expectedSeq: number,
|
|
950
|
+
event: SessionEventWrite<T>,
|
|
951
|
+
): Promise<SessionEventEnvelope<T> | null> {
|
|
952
|
+
const state = this.sessionState(id)
|
|
953
|
+
validateExpectedSeq(expectedSeq)
|
|
954
|
+
const validated = validateSessionEventWrite(event)
|
|
955
|
+
|
|
956
|
+
// No await occurs between comparison and mutation. JavaScript's run-to-
|
|
957
|
+
// completion semantics make this one atomic critical section even when
|
|
958
|
+
// concurrent remote requests interleave elsewhere in the adapter.
|
|
959
|
+
if (state.events.length !== expectedSeq) return null
|
|
960
|
+
const envelope = {
|
|
961
|
+
session: id,
|
|
962
|
+
seq: expectedSeq + 1,
|
|
963
|
+
ts: this.now(),
|
|
964
|
+
actor: validated.actor,
|
|
965
|
+
type: validated.type,
|
|
966
|
+
payload: validated.payload,
|
|
967
|
+
} as SessionEventEnvelope<T>
|
|
968
|
+
state.events.push(structuredClone(envelope) as SessionEvent)
|
|
969
|
+
state.record.updatedAt = envelope.ts
|
|
970
|
+
return envelope
|
|
971
|
+
}
|
|
972
|
+
|
|
794
973
|
async getSessionEvents(
|
|
795
974
|
id: string,
|
|
796
975
|
sinceSeq = 0,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EventValidationError, validateEventWrite, type EventWrite } from '../events/catalog'
|
|
2
|
+
import type { NewBuildInput } from './types'
|
|
3
|
+
|
|
4
|
+
/** Validate `input.created` before an adapter opens its create transaction, so
|
|
5
|
+
* an invalid initial event creates nothing. Returns the validated write. */
|
|
6
|
+
export function validateCreatedEvent(
|
|
7
|
+
input: NewBuildInput,
|
|
8
|
+
): EventWrite<'build.created'> | undefined {
|
|
9
|
+
if (input.created === undefined) return undefined
|
|
10
|
+
const validated = validateEventWrite(input.created)
|
|
11
|
+
if (validated.type !== 'build.created') {
|
|
12
|
+
throw new EventValidationError(
|
|
13
|
+
`createBuild's initial event must be "build.created", got "${validated.type}"`,
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
return validated as EventWrite<'build.created'>
|
|
17
|
+
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
Artifact,
|
|
24
24
|
ArtifactInput,
|
|
25
25
|
ArtifactMeta,
|
|
26
|
+
BuildDigest,
|
|
26
27
|
BuildRecord,
|
|
27
28
|
BuildStore,
|
|
28
29
|
NewBuildInput,
|
|
@@ -35,6 +36,13 @@ import type {
|
|
|
35
36
|
SubscribeOptions,
|
|
36
37
|
Unsubscribe,
|
|
37
38
|
} from './types'
|
|
39
|
+
import type {
|
|
40
|
+
TicketAsset,
|
|
41
|
+
TicketAssetInput,
|
|
42
|
+
TicketAssetLimits,
|
|
43
|
+
TicketAssetMeta,
|
|
44
|
+
TicketAssetSummary,
|
|
45
|
+
} from './ticket-assets'
|
|
38
46
|
import type {
|
|
39
47
|
StreamChunk,
|
|
40
48
|
StreamOutcome,
|
|
@@ -316,6 +324,14 @@ export function scopeLocalStoreToPhaseSession(
|
|
|
316
324
|
own('getRepoEvents', 'repo', repo)
|
|
317
325
|
return store.getRepoEvents(repo, sinceSeq, opts)
|
|
318
326
|
},
|
|
327
|
+
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
328
|
+
own('getRepoStateEvents', 'repo', repo)
|
|
329
|
+
return store.getRepoStateEvents(repo)
|
|
330
|
+
},
|
|
331
|
+
async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
332
|
+
own('getRepoBuildDigests', 'repo', repo)
|
|
333
|
+
return store.getRepoBuildDigests(repo)
|
|
334
|
+
},
|
|
319
335
|
async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
320
336
|
own('putRepoArtifact', 'repo', repo)
|
|
321
337
|
return store.putRepoArtifact(repo, artifact)
|
|
@@ -332,6 +348,54 @@ export function scopeLocalStoreToPhaseSession(
|
|
|
332
348
|
own('listRepoArtifacts', 'repo', repo)
|
|
333
349
|
return store.listRepoArtifacts(repo, kind)
|
|
334
350
|
},
|
|
351
|
+
async ticketAssetLimits(repo: string): Promise<TicketAssetLimits> {
|
|
352
|
+
own('ticketAssetLimits', 'repo', repo)
|
|
353
|
+
return store.ticketAssetLimits(repo)
|
|
354
|
+
},
|
|
355
|
+
async putTicketAsset(
|
|
356
|
+
repo: string,
|
|
357
|
+
ticketId: string,
|
|
358
|
+
asset: TicketAssetInput,
|
|
359
|
+
): Promise<TicketAssetMeta> {
|
|
360
|
+
own('putTicketAsset', 'repo', repo)
|
|
361
|
+
return store.putTicketAsset(repo, ticketId, asset)
|
|
362
|
+
},
|
|
363
|
+
async getTicketAsset(
|
|
364
|
+
repo: string,
|
|
365
|
+
ticketId: string,
|
|
366
|
+
kind: string,
|
|
367
|
+
name: string,
|
|
368
|
+
rev?: number,
|
|
369
|
+
): Promise<TicketAsset | null> {
|
|
370
|
+
own('getTicketAsset', 'repo', repo)
|
|
371
|
+
return store.getTicketAsset(repo, ticketId, kind, name, rev)
|
|
372
|
+
},
|
|
373
|
+
async getPinnedTicketAsset(
|
|
374
|
+
slug: string,
|
|
375
|
+
kind: string,
|
|
376
|
+
name: string,
|
|
377
|
+
rev?: number,
|
|
378
|
+
): Promise<TicketAsset | null> {
|
|
379
|
+
own('getPinnedTicketAsset', 'build', slug)
|
|
380
|
+
return store.getPinnedTicketAsset(slug, kind, name, rev)
|
|
381
|
+
},
|
|
382
|
+
async listTicketAssets(
|
|
383
|
+
repo: string,
|
|
384
|
+
ticketId: string,
|
|
385
|
+
opts?: { revisions?: boolean },
|
|
386
|
+
): Promise<TicketAssetSummary[]> {
|
|
387
|
+
own('listTicketAssets', 'repo', repo)
|
|
388
|
+
return store.listTicketAssets(repo, ticketId, opts)
|
|
389
|
+
},
|
|
390
|
+
async removeTicketAsset(
|
|
391
|
+
repo: string,
|
|
392
|
+
ticketId: string,
|
|
393
|
+
kind: string,
|
|
394
|
+
name: string,
|
|
395
|
+
): Promise<TicketAssetMeta | null> {
|
|
396
|
+
own('removeTicketAsset', 'repo', repo)
|
|
397
|
+
return store.removeTicketAsset(repo, ticketId, kind, name)
|
|
398
|
+
},
|
|
335
399
|
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
336
400
|
own('claimRepoLease', 'repo', repo)
|
|
337
401
|
return store.claimRepoLease(repo, holder, ttlMs)
|
|
@@ -390,6 +454,13 @@ export function scopeLocalStoreToPhaseSession(
|
|
|
390
454
|
): Promise<SessionEventEnvelope<T>> {
|
|
391
455
|
return admin('appendSessionEvent')
|
|
392
456
|
},
|
|
457
|
+
appendSessionEventIfCurrent<T extends SessionEventType>(
|
|
458
|
+
_id: string,
|
|
459
|
+
_expectedSeq: number,
|
|
460
|
+
_event: SessionEventWrite<T>,
|
|
461
|
+
): Promise<SessionEventEnvelope<T> | null> {
|
|
462
|
+
return admin('appendSessionEventIfCurrent')
|
|
463
|
+
},
|
|
393
464
|
getSessionEvents(_id: string, _sinceSeq?: number): Promise<SessionEvent[]> {
|
|
394
465
|
return admin('getSessionEvents')
|
|
395
466
|
},
|
|
@@ -32,9 +32,21 @@ import type {
|
|
|
32
32
|
SessionEventType,
|
|
33
33
|
SessionEventWrite,
|
|
34
34
|
} from '../../events/sessions'
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_TICKET_ASSET_LIMITS,
|
|
37
|
+
TicketAssetValidationError,
|
|
38
|
+
validateTicketAssetInput,
|
|
39
|
+
type TicketAsset,
|
|
40
|
+
type TicketAssetContentEntry,
|
|
41
|
+
type TicketAssetInput,
|
|
42
|
+
type TicketAssetLimits,
|
|
43
|
+
type TicketAssetMeta,
|
|
44
|
+
type TicketAssetSummary,
|
|
45
|
+
} from '../ticket-assets'
|
|
35
46
|
import { createBuildScopedStore } from '../build-scope'
|
|
36
47
|
import { createSessionScopedStore } from '../session-handle'
|
|
37
48
|
import { pollingSubscribe } from '../subscribe'
|
|
49
|
+
import type { BuildDigest } from '../types'
|
|
38
50
|
import type {
|
|
39
51
|
StreamChunk,
|
|
40
52
|
StreamOutcome,
|
|
@@ -68,9 +80,11 @@ import {
|
|
|
68
80
|
artifactGetResponseSchema,
|
|
69
81
|
artifactMetaListSchema,
|
|
70
82
|
artifactMetaWireSchema,
|
|
83
|
+
buildDigestListSchema,
|
|
71
84
|
buildRecordListSchema,
|
|
72
85
|
buildRecordWireSchema,
|
|
73
86
|
conditionalEventResponseSchema,
|
|
87
|
+
conditionalSessionEventResponseSchema,
|
|
74
88
|
decodeBase64,
|
|
75
89
|
depositsResponseSchema,
|
|
76
90
|
encodeBase64,
|
|
@@ -96,6 +110,12 @@ import {
|
|
|
96
110
|
sessionRecordWireSchema,
|
|
97
111
|
streamChunkWireSchema,
|
|
98
112
|
streamReadWireSchema,
|
|
113
|
+
ticketAssetFileResponseSchema,
|
|
114
|
+
ticketAssetLimitsWireSchema,
|
|
115
|
+
ticketAssetManifestResponseSchema,
|
|
116
|
+
ticketAssetMetaWireSchema,
|
|
117
|
+
ticketAssetSummaryListSchema,
|
|
118
|
+
removeTicketAssetResponseSchema,
|
|
99
119
|
streamRecordListSchema,
|
|
100
120
|
streamRecordWireSchema,
|
|
101
121
|
} from './protocol'
|
|
@@ -187,7 +207,11 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
187
207
|
return this.fetchFn(`${this.base}${path}`, {
|
|
188
208
|
method,
|
|
189
209
|
headers,
|
|
190
|
-
|
|
210
|
+
// A string body is already serialized JSON (the ticket-asset put sizes
|
|
211
|
+
// its exact bytes before sending).
|
|
212
|
+
...(body !== undefined
|
|
213
|
+
? { body: typeof body === 'string' ? body : JSON.stringify(body) }
|
|
214
|
+
: {}),
|
|
191
215
|
...(signal !== undefined ? { signal } : {}),
|
|
192
216
|
})
|
|
193
217
|
}
|
|
@@ -205,6 +229,12 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
205
229
|
}
|
|
206
230
|
// D6: validation feedback crosses the wire as the same error type with
|
|
207
231
|
// the server's message intact.
|
|
232
|
+
if (
|
|
233
|
+
(response.status === 422 || response.status === 413) &&
|
|
234
|
+
message.startsWith('ticket asset')
|
|
235
|
+
) {
|
|
236
|
+
return new TicketAssetValidationError(message)
|
|
237
|
+
}
|
|
208
238
|
if (response.status === 422) return new EventValidationError(message)
|
|
209
239
|
// Typed stream rejections rehydrate by the server's message shape: the
|
|
210
240
|
// ceiling names its bytes, the closed-append names its stream (a 409 is
|
|
@@ -246,6 +276,19 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
246
276
|
return this.requestJson('GET', '/builds', buildRecordListSchema)
|
|
247
277
|
}
|
|
248
278
|
|
|
279
|
+
async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
280
|
+
// A repo-scoped batch read (AUT-487): one request regardless of build
|
|
281
|
+
// count. The route answers from build records, so an unknown repo or a
|
|
282
|
+
// repo without a journal record answers 200 with an empty array rather
|
|
283
|
+
// than 404 — the server composes it before the repository-existence gate.
|
|
284
|
+
const digests = await this.requestJson(
|
|
285
|
+
'GET',
|
|
286
|
+
`${this.repoPath(repo)}/build-digests`,
|
|
287
|
+
buildDigestListSchema,
|
|
288
|
+
)
|
|
289
|
+
return new Map(digests.map((digest) => [digest.slug, digest]))
|
|
290
|
+
}
|
|
291
|
+
|
|
249
292
|
async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
|
|
250
293
|
const envelope = await this.requestJson(
|
|
251
294
|
'POST',
|
|
@@ -492,6 +535,19 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
492
535
|
)
|
|
493
536
|
}
|
|
494
537
|
|
|
538
|
+
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
539
|
+
// The bounded repository-journal read (AUT-489): one additive route next
|
|
540
|
+
// to `GET events`, reusing the same wire shape. No protocol-version bump
|
|
541
|
+
// and no change to existing routes — a client on the previous release
|
|
542
|
+
// never calls this route, so behavioral identity of the old surface is
|
|
543
|
+
// the whole cross-version contract.
|
|
544
|
+
return this.requestJson(
|
|
545
|
+
'GET',
|
|
546
|
+
`${this.repoPath(repo)}/state-events`,
|
|
547
|
+
repositoryEventListSchema,
|
|
548
|
+
) as Promise<RepositoryEvent[]>
|
|
549
|
+
}
|
|
550
|
+
|
|
495
551
|
async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
496
552
|
return this.requestJson(
|
|
497
553
|
'POST',
|
|
@@ -531,6 +587,192 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
531
587
|
)
|
|
532
588
|
}
|
|
533
589
|
|
|
590
|
+
/** The limits this store handle enforces locally, fetched once per repo
|
|
591
|
+
* and cached. A server that predates the route (404) gets the defaults. */
|
|
592
|
+
private readonly assetLimits = new Map<
|
|
593
|
+
string,
|
|
594
|
+
Promise<TicketAssetLimits & { maxRequestBytes?: number }>
|
|
595
|
+
>()
|
|
596
|
+
|
|
597
|
+
private assetLimitsFor(repo: string): Promise<TicketAssetLimits & { maxRequestBytes?: number }> {
|
|
598
|
+
let cached = this.assetLimits.get(repo)
|
|
599
|
+
if (cached === undefined) {
|
|
600
|
+
cached = (async () => {
|
|
601
|
+
const response = await this.raw('GET', `${this.repoPath(repo)}/ticket-asset-limits`)
|
|
602
|
+
if (response.status === 404) return { ...DEFAULT_TICKET_ASSET_LIMITS }
|
|
603
|
+
if (!response.ok) throw await this.toError(response)
|
|
604
|
+
return ticketAssetLimitsWireSchema.parse(await response.json())
|
|
605
|
+
})()
|
|
606
|
+
cached.catch(() => this.assetLimits.delete(repo))
|
|
607
|
+
this.assetLimits.set(repo, cached)
|
|
608
|
+
}
|
|
609
|
+
return cached
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async ticketAssetLimits(repo: string): Promise<TicketAssetLimits> {
|
|
613
|
+
const { maxBytes, maxEntries } = await this.assetLimitsFor(repo)
|
|
614
|
+
return { maxBytes, maxEntries }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private assetPath(repo: string, ticketId: string): string {
|
|
618
|
+
return `${this.repoPath(repo)}/tickets/${encodeURIComponent(ticketId)}/assets`
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async putTicketAsset(
|
|
622
|
+
repo: string,
|
|
623
|
+
ticketId: string,
|
|
624
|
+
asset: TicketAssetInput,
|
|
625
|
+
): Promise<TicketAssetMeta> {
|
|
626
|
+
const limits = await this.assetLimitsFor(repo)
|
|
627
|
+
try {
|
|
628
|
+
validateTicketAssetInput(asset, limits)
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (
|
|
631
|
+
error instanceof TicketAssetValidationError &&
|
|
632
|
+
limits.maxRequestBytes !== undefined &&
|
|
633
|
+
/^ticket asset exceeds the \d+-byte/.test(error.message)
|
|
634
|
+
) {
|
|
635
|
+
throw new TicketAssetValidationError(
|
|
636
|
+
`ticket asset exceeds this store's ${limits.maxBytes}-byte limit (deployment request ceiling ${limits.maxRequestBytes} bytes)`,
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
throw error
|
|
640
|
+
}
|
|
641
|
+
const text = JSON.stringify({
|
|
642
|
+
kind: asset.kind,
|
|
643
|
+
name: asset.name,
|
|
644
|
+
layout: asset.layout,
|
|
645
|
+
entries: asset.entries.map((entry) =>
|
|
646
|
+
entry.type === 'dir'
|
|
647
|
+
? { type: 'dir', path: entry.path }
|
|
648
|
+
: { type: 'file', path: entry.path, contentBase64: encodeBase64(entry.content) },
|
|
649
|
+
),
|
|
650
|
+
})
|
|
651
|
+
if (limits.maxRequestBytes !== undefined) {
|
|
652
|
+
const bytes = new TextEncoder().encode(text).length
|
|
653
|
+
if (bytes > limits.maxRequestBytes) {
|
|
654
|
+
throw new TicketAssetValidationError(
|
|
655
|
+
`ticket asset exceeds this store's ${limits.maxBytes}-byte limit (deployment request ceiling ${limits.maxRequestBytes} bytes): the encoded request is ${bytes} bytes`,
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return this.requestJson('POST', this.assetPath(repo, ticketId), ticketAssetMetaWireSchema, text)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async getTicketAsset(
|
|
663
|
+
repo: string,
|
|
664
|
+
ticketId: string,
|
|
665
|
+
kind: string,
|
|
666
|
+
name: string,
|
|
667
|
+
rev?: number,
|
|
668
|
+
): Promise<TicketAsset | null> {
|
|
669
|
+
const params = new URLSearchParams({ kind, name })
|
|
670
|
+
if (rev !== undefined) params.set('rev', String(rev))
|
|
671
|
+
const meta = await this.requestJson(
|
|
672
|
+
'GET',
|
|
673
|
+
`${this.assetPath(repo, ticketId)}?${params}`,
|
|
674
|
+
ticketAssetManifestResponseSchema,
|
|
675
|
+
)
|
|
676
|
+
if (meta === null) return null
|
|
677
|
+
// One request per file, pinned to the manifest's revision, so no response
|
|
678
|
+
// carries more than a single file.
|
|
679
|
+
const entries: TicketAssetContentEntry[] = []
|
|
680
|
+
for (const entry of meta.entries) {
|
|
681
|
+
if (entry.type === 'dir') {
|
|
682
|
+
entries.push({ type: 'dir', path: entry.path })
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
const fileParams = new URLSearchParams({
|
|
686
|
+
kind,
|
|
687
|
+
name,
|
|
688
|
+
rev: String(meta.revision),
|
|
689
|
+
path: entry.path,
|
|
690
|
+
})
|
|
691
|
+
const file = await this.requestJson(
|
|
692
|
+
'GET',
|
|
693
|
+
`${this.assetPath(repo, ticketId)}/file?${fileParams}`,
|
|
694
|
+
ticketAssetFileResponseSchema,
|
|
695
|
+
)
|
|
696
|
+
if (file === null) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
`ticket asset ${kind}/${name} revision ${meta.revision} vanished mid-download`,
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
entries.push({ type: 'file', path: entry.path, content: decodeBase64(file.contentBase64) })
|
|
702
|
+
}
|
|
703
|
+
return { meta, entries }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async getPinnedTicketAsset(
|
|
707
|
+
slug: string,
|
|
708
|
+
kind: string,
|
|
709
|
+
name: string,
|
|
710
|
+
rev?: number,
|
|
711
|
+
): Promise<TicketAsset | null> {
|
|
712
|
+
const params = new URLSearchParams({ kind, name })
|
|
713
|
+
if (rev !== undefined) params.set('rev', String(rev))
|
|
714
|
+
const meta = await this.requestJson(
|
|
715
|
+
'GET',
|
|
716
|
+
`${this.buildPath(slug)}/ticket-assets?${params}`,
|
|
717
|
+
ticketAssetManifestResponseSchema,
|
|
718
|
+
)
|
|
719
|
+
if (meta === null) return null
|
|
720
|
+
// One request per file, pinned to the manifest's revision, so no response
|
|
721
|
+
// carries more than a single file.
|
|
722
|
+
const entries: TicketAssetContentEntry[] = []
|
|
723
|
+
for (const entry of meta.entries) {
|
|
724
|
+
if (entry.type === 'dir') {
|
|
725
|
+
entries.push({ type: 'dir', path: entry.path })
|
|
726
|
+
continue
|
|
727
|
+
}
|
|
728
|
+
const fileParams = new URLSearchParams({
|
|
729
|
+
kind,
|
|
730
|
+
name,
|
|
731
|
+
rev: String(meta.revision),
|
|
732
|
+
path: entry.path,
|
|
733
|
+
})
|
|
734
|
+
const file = await this.requestJson(
|
|
735
|
+
'GET',
|
|
736
|
+
`${this.buildPath(slug)}/ticket-assets/file?${fileParams}`,
|
|
737
|
+
ticketAssetFileResponseSchema,
|
|
738
|
+
)
|
|
739
|
+
if (file === null) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`ticket asset ${kind}/${name} revision ${meta.revision} vanished mid-download`,
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
entries.push({ type: 'file', path: entry.path, content: decodeBase64(file.contentBase64) })
|
|
745
|
+
}
|
|
746
|
+
return { meta, entries }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async listTicketAssets(
|
|
750
|
+
repo: string,
|
|
751
|
+
ticketId: string,
|
|
752
|
+
opts?: { revisions?: boolean },
|
|
753
|
+
): Promise<TicketAssetSummary[]> {
|
|
754
|
+
const query = opts?.revisions ? '?revisions=1' : ''
|
|
755
|
+
return this.requestJson(
|
|
756
|
+
'GET',
|
|
757
|
+
`${this.assetPath(repo, ticketId)}/list${query}`,
|
|
758
|
+
ticketAssetSummaryListSchema,
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async removeTicketAsset(
|
|
763
|
+
repo: string,
|
|
764
|
+
ticketId: string,
|
|
765
|
+
kind: string,
|
|
766
|
+
name: string,
|
|
767
|
+
): Promise<TicketAssetMeta | null> {
|
|
768
|
+
return this.requestJson(
|
|
769
|
+
'POST',
|
|
770
|
+
`${this.assetPath(repo, ticketId)}/remove`,
|
|
771
|
+
removeTicketAssetResponseSchema,
|
|
772
|
+
{ kind, name },
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
|
|
534
776
|
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
535
777
|
const result = await this.requestJson(
|
|
536
778
|
'POST',
|
|
@@ -595,6 +837,23 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
595
837
|
return envelope as unknown as SessionEventEnvelope<T>
|
|
596
838
|
}
|
|
597
839
|
|
|
840
|
+
async appendSessionEventIfCurrent<T extends SessionEventType>(
|
|
841
|
+
id: string,
|
|
842
|
+
expectedSeq: number,
|
|
843
|
+
event: SessionEventWrite<T>,
|
|
844
|
+
): Promise<SessionEventEnvelope<T> | null> {
|
|
845
|
+
const envelope = await this.requestJson(
|
|
846
|
+
'POST',
|
|
847
|
+
`${this.sessionPath(id)}/events/conditional`,
|
|
848
|
+
conditionalSessionEventResponseSchema,
|
|
849
|
+
{
|
|
850
|
+
expectedSeq,
|
|
851
|
+
event: { actor: event.actor, type: event.type, payload: event.payload },
|
|
852
|
+
},
|
|
853
|
+
)
|
|
854
|
+
return envelope as SessionEventEnvelope<T> | null
|
|
855
|
+
}
|
|
856
|
+
|
|
598
857
|
async getSessionEvents(
|
|
599
858
|
id: string,
|
|
600
859
|
sinceSeq = 0,
|