@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
@@ -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
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
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,