@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -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/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -11,7 +11,7 @@
11
11
  * content-addressed.
12
12
  */
13
13
  import { Database } from 'bun:sqlite'
14
- import { and, asc, desc, eq, gt, inArray, sql } from 'drizzle-orm'
14
+ import { and, asc, desc, eq, gt, gte, inArray, or, sql } from 'drizzle-orm'
15
15
  import { drizzle, type BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'
16
16
  import { mkdirSync } from 'node:fs'
17
17
  import { access, copyFile, mkdtemp, rm } from 'node:fs/promises'
@@ -40,7 +40,14 @@ import {
40
40
  type SessionEventWrite,
41
41
  } from '../../events/sessions'
42
42
  import { createBuildScopedStore } from '../build-scope'
43
+ import type { ReducerSnapshot, SnapshotScope } from '../snapshots'
44
+ import { validateCreatedEvent } from '../new-build'
43
45
  import { createSessionScopedStore } from '../session-handle'
46
+ import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
47
+ import {
48
+ REPOSITORY_STATE_EVENT_TYPES,
49
+ readRepoStateEventsWithAnchorRecheck,
50
+ } from '../repo-state-events'
44
51
  import {
45
52
  DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
46
53
  isRetentionManagedKind,
@@ -49,7 +56,7 @@ import {
49
56
  import { pollingSubscribe } from '../subscribe'
50
57
  import { StreamLocks } from '../streams/lock'
51
58
  import { assembleUIMessageDocument } from '../streams/assemble'
52
- import { readEventsWithWait, readStreamWithWait } from '../streams/wait'
59
+ import { EVENT_WAIT_POLL_MS, readEventsWithWait, readStreamWithWait } from '../streams/wait'
53
60
  import {
54
61
  serializedBatchSize,
55
62
  STREAM_BATCH_MAX_BYTES,
@@ -76,6 +83,7 @@ import {
76
83
  type ArtifactMeta,
77
84
  type BlobStore,
78
85
  type BuildRecord,
86
+ type BuildDigest,
79
87
  type BuildScopedStore,
80
88
  type BuildStore,
81
89
  type Clock,
@@ -91,6 +99,19 @@ import {
91
99
  type SubscribeOptions,
92
100
  type Unsubscribe,
93
101
  } from '../types'
102
+ import {
103
+ DEFAULT_TICKET_ASSET_LIMITS,
104
+ loadTicketAsset,
105
+ storeTicketAssetBlobs,
106
+ resolvePinnedAsset,
107
+ summarizeTicketAsset,
108
+ validateTicketAssetInput,
109
+ type TicketAsset,
110
+ type TicketAssetInput,
111
+ type TicketAssetLimits,
112
+ type TicketAssetMeta,
113
+ type TicketAssetSummary,
114
+ } from '../ticket-assets'
94
115
  import { DirBlobStore } from './blobs'
95
116
  import {
96
117
  artifacts,
@@ -102,6 +123,7 @@ import {
102
123
  sessionArtifacts,
103
124
  sessionEvents,
104
125
  sessions,
126
+ ticketAssets,
105
127
  streamChunks,
106
128
  streams,
107
129
  } from './schema'
@@ -111,7 +133,21 @@ import {
111
133
  * the drizzle schema is the source of truth; this is its inlined form so
112
134
  * opening a store never needs a migration step.
113
135
  */
114
- const BOOTSTRAP_DDL = [
136
+ // Exported for the schema-parity test, which pins this DDL against the
137
+ // drizzle schema; schema.ts stays the source of truth, this is its inlined form.
138
+ export const BOOTSTRAP_DDL = [
139
+ // Reducer snapshots: additive and idempotent, so databases that predate the
140
+ // table gain it on open.
141
+ `CREATE TABLE IF NOT EXISTS reducer_snapshots (
142
+ scope_kind TEXT NOT NULL,
143
+ scope_key TEXT NOT NULL,
144
+ reducer TEXT NOT NULL,
145
+ version INTEGER NOT NULL,
146
+ cursor INTEGER NOT NULL,
147
+ state TEXT NOT NULL,
148
+ updated_at TEXT NOT NULL,
149
+ PRIMARY KEY (scope_kind, scope_key, reducer)
150
+ )`,
115
151
  `CREATE TABLE IF NOT EXISTS builds (
116
152
  slug TEXT PRIMARY KEY,
117
153
  repo TEXT NOT NULL,
@@ -134,6 +170,10 @@ const BOOTSTRAP_DDL = [
134
170
  payload TEXT NOT NULL,
135
171
  PRIMARY KEY (build, seq)
136
172
  )`,
173
+ // The build-digest scan (AUT-487): type-leading so the digest query's cost
174
+ // grows with the observation/terminal events themselves, not with total
175
+ // history. Idempotent at open, so pre-existing databases gain it too.
176
+ `CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq)`,
137
177
  `CREATE TABLE IF NOT EXISTS artifacts (
138
178
  build TEXT NOT NULL,
139
179
  kind TEXT NOT NULL,
@@ -170,6 +210,19 @@ const BOOTSTRAP_DDL = [
170
210
  created_at TEXT NOT NULL,
171
211
  PRIMARY KEY (repo, kind, revision)
172
212
  )`,
213
+ `CREATE TABLE IF NOT EXISTS ticket_assets (
214
+ repo TEXT NOT NULL,
215
+ ticket_id TEXT NOT NULL,
216
+ kind TEXT NOT NULL,
217
+ name TEXT NOT NULL,
218
+ revision INTEGER NOT NULL,
219
+ layout TEXT NOT NULL,
220
+ manifest TEXT NOT NULL,
221
+ size INTEGER NOT NULL,
222
+ removed INTEGER NOT NULL,
223
+ created_at TEXT NOT NULL,
224
+ PRIMARY KEY (repo, ticket_id, kind, name, revision)
225
+ )`,
173
226
  `CREATE TABLE IF NOT EXISTS sessions (
174
227
  id TEXT PRIMARY KEY,
175
228
  repo TEXT NOT NULL,
@@ -248,6 +301,15 @@ export interface SqliteBuildStoreOptions {
248
301
  retention?: { maxRevisions?: number }
249
302
  }
250
303
 
304
+ function ticketAssetKey(repo: string, ticketId: string, kind: string, name: string) {
305
+ return and(
306
+ eq(ticketAssets.repo, repo),
307
+ eq(ticketAssets.ticketId, ticketId),
308
+ eq(ticketAssets.kind, kind),
309
+ eq(ticketAssets.name, name),
310
+ )
311
+ }
312
+
251
313
  export class SqliteBuildStore implements BuildStore {
252
314
  private readonly sqlite: Database
253
315
  private readonly db: BunSQLiteDatabase
@@ -399,6 +461,7 @@ export class SqliteBuildStore implements BuildStore {
399
461
  }
400
462
 
401
463
  async createBuild(input: NewBuildInput): Promise<BuildRecord> {
464
+ const created = validateCreatedEvent(input)
402
465
  const ts = this.now()
403
466
  return this.writeTx(() => {
404
467
  if (this.buildRow(input.slug)) {
@@ -416,6 +479,7 @@ export class SqliteBuildStore implements BuildStore {
416
479
  updatedAt: ts,
417
480
  })
418
481
  .run()
482
+ if (created !== undefined) this.appendInTx(input.slug, created)
419
483
  return this.toRecord(this.requireBuild(input.slug))
420
484
  })
421
485
  }
@@ -430,6 +494,59 @@ export class SqliteBuildStore implements BuildStore {
430
494
  return rows.map((row) => this.toRecord(row))
431
495
  }
432
496
 
497
+ async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
498
+ // From the builds side (AUT-487): the type filter lives in the ON clause
499
+ // so a build whose log holds none of the digest-relevant event
500
+ // types still yields its row — a join that starts from `events` would
501
+ // silently drop such builds and break the operation's completeness
502
+ // contract. Only the digest-relevant types are fetched, then one shared
503
+ // derivation
504
+ // per build, so the answer cannot drift from `reduceBuild`.
505
+ const rows = this.sqlite
506
+ .query(
507
+ `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
508
+ CASE WHEN e.type = 'pr-attachment.reclaimed'
509
+ THEN json_extract(e.payload, '$.hostedSeq') END AS hostedSeq,
510
+ CASE WHEN e.type IN ('execution.started', 'execution.ended')
511
+ THEN json_extract(e.payload, '$.instance') END AS instance
512
+ FROM builds b
513
+ LEFT JOIN events e
514
+ ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map(() => '?').join(', ')})
515
+ WHERE b.repo = ?
516
+ ORDER BY b.slug, e.seq`,
517
+ )
518
+ .all(...DIGEST_EVENT_TYPES, repo) as {
519
+ slug: string
520
+ seq: number | null
521
+ ts: string | null
522
+ type: string | null
523
+ hostedSeq: number | null
524
+ instance: string | null
525
+ }[]
526
+ const eventsByBuild = new Map<string, DigestEventRow[]>()
527
+ for (const row of rows) {
528
+ if (row.seq === null || row.ts === null || row.type === null) continue
529
+ const events = eventsByBuild.get(row.slug) ?? []
530
+ events.push({
531
+ type: row.type as AbEvent['type'],
532
+ seq: row.seq,
533
+ ts: row.ts,
534
+ ...(row.hostedSeq !== null ? { hostedSeq: row.hostedSeq } : {}),
535
+ ...(row.instance !== null ? { instance: row.instance } : {}),
536
+ })
537
+ eventsByBuild.set(row.slug, events)
538
+ }
539
+ const digests = new Map<string, BuildDigest>()
540
+ for (const row of rows) {
541
+ if (digests.has(row.slug)) continue
542
+ digests.set(row.slug, {
543
+ slug: row.slug,
544
+ ...reduceBuildDigest(eventsByBuild.get(row.slug) ?? []),
545
+ })
546
+ }
547
+ return digests
548
+ }
549
+
433
550
  /** Current build-stream tail inside an open transaction. */
434
551
  private currentSeqInTx(slug: string): number {
435
552
  this.requireBuild(slug)
@@ -664,6 +781,10 @@ export class SqliteBuildStore implements BuildStore {
664
781
  },
665
782
  waitSeconds: opts?.waitSeconds,
666
783
  signal: opts?.signal,
784
+ // Hosted budget: held build-event reads poll at most once per second
785
+ // (AUT-394) — not the 25 ms stream default, which is presentation
786
+ // content's cadence.
787
+ pollMs: EVENT_WAIT_POLL_MS,
667
788
  })
668
789
  }
669
790
 
@@ -935,9 +1056,163 @@ export class SqliteBuildStore implements BuildStore {
935
1056
  },
936
1057
  waitSeconds: opts?.waitSeconds,
937
1058
  signal: opts?.signal,
1059
+ // Hosted budget: held repository-event reads poll at most once per
1060
+ // second (AUT-394) — not the 25 ms stream default, which is
1061
+ // presentation content's cadence.
1062
+ pollMs: EVENT_WAIT_POLL_MS,
938
1063
  })
939
1064
  }
940
1065
 
1066
+ async getRepoHighWater(repo: string): Promise<number> {
1067
+ this.requireRepo(repo)
1068
+ const row = this.db
1069
+ .select({ seq: sql<number | null>`max(${repoEvents.seq})` })
1070
+ .from(repoEvents)
1071
+ .where(eq(repoEvents.repo, repo))
1072
+ .get()
1073
+ return row?.seq ?? 0
1074
+ }
1075
+
1076
+ /** The scope's log tail, or `undefined` for a scope that does not exist. */
1077
+ private snapshotTail(scope: SnapshotScope): number | undefined {
1078
+ if (scope.kind === 'build') {
1079
+ const row = this.sqlite
1080
+ .query(
1081
+ 'SELECT (SELECT COALESCE(MAX(seq), 0) FROM events WHERE build = ?1) AS tail FROM builds WHERE slug = ?1',
1082
+ )
1083
+ .get(scope.slug) as { tail: number } | null
1084
+ return row?.tail
1085
+ }
1086
+ const row = this.sqlite
1087
+ .query(
1088
+ 'SELECT (SELECT COALESCE(MAX(seq), 0) FROM repo_events WHERE repo = ?1) AS tail FROM repo_streams WHERE repo = ?1',
1089
+ )
1090
+ .get(scope.repo) as { tail: number } | null
1091
+ return row?.tail
1092
+ }
1093
+
1094
+ async getReducerSnapshot(
1095
+ scope: SnapshotScope,
1096
+ reducer: string,
1097
+ version: number,
1098
+ ): Promise<ReducerSnapshot | null> {
1099
+ const tail = this.snapshotTail(scope)
1100
+ if (tail === undefined) return null
1101
+ const row = this.sqlite
1102
+ .query(
1103
+ 'SELECT version, cursor, state FROM reducer_snapshots WHERE scope_kind = ?1 AND scope_key = ?2 AND reducer = ?3',
1104
+ )
1105
+ .get(scope.kind, scope.kind === 'build' ? scope.slug : scope.repo, reducer) as {
1106
+ version: number
1107
+ cursor: number
1108
+ state: string
1109
+ } | null
1110
+ if (row === null || row.version !== version || row.cursor > tail) return null
1111
+ try {
1112
+ return { version: row.version, cursor: row.cursor, state: JSON.parse(row.state) }
1113
+ } catch {
1114
+ return null
1115
+ }
1116
+ }
1117
+
1118
+ async putReducerSnapshot(
1119
+ scope: SnapshotScope,
1120
+ reducer: string,
1121
+ snapshot: ReducerSnapshot,
1122
+ ): Promise<boolean> {
1123
+ const tail = this.snapshotTail(scope)
1124
+ if (tail === undefined || snapshot.cursor > tail) return false
1125
+ // One statement: the stored cursor never decreases, whatever interleaves.
1126
+ const result = this.sqlite
1127
+ .query(
1128
+ `INSERT INTO reducer_snapshots (scope_kind, scope_key, reducer, version, cursor, state, updated_at)
1129
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1130
+ ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
1131
+ version = excluded.version, cursor = excluded.cursor,
1132
+ state = excluded.state, updated_at = excluded.updated_at
1133
+ WHERE excluded.cursor > reducer_snapshots.cursor
1134
+ OR (excluded.cursor = reducer_snapshots.cursor AND excluded.version > reducer_snapshots.version)`,
1135
+ )
1136
+ .run(
1137
+ scope.kind,
1138
+ scope.kind === 'build' ? scope.slug : scope.repo,
1139
+ reducer,
1140
+ snapshot.version,
1141
+ snapshot.cursor,
1142
+ JSON.stringify(snapshot.state ?? null),
1143
+ this.clock().toISOString(),
1144
+ )
1145
+ return result.changes > 0
1146
+ }
1147
+
1148
+ async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
1149
+ this.requireRepo(repo)
1150
+ // Two queries, mirroring the oracle's derivation: the latest
1151
+ // run-started anchor, then one select of durable types plus the tail
1152
+ // from that anchor. No new index: local journals are small and the
1153
+ // existing (repo, seq) PK index keeps the anchor scan acceptable.
1154
+ //
1155
+ // The two drizzle reads are synchronous, so there is no in-process await
1156
+ // point between them — but a concurrent writer from another process can
1157
+ // still land between the two statements in real time (AUT-551): a
1158
+ // first-ever `dispatcher.run-started` appended between them would be
1159
+ // invisible to the probe, absent from the durable-only selection, yet
1160
+ // present in the journal by the time this method returns. The shared
1161
+ // helper below closes that window with a one-shot anchor re-check on the
1162
+ // durable-only outcome: a durable-only result is returned only when the
1163
+ // journal was anchor-free as of the final probe. When the probe *does*
1164
+ // find an anchor, the anchored select's `gte(seq, anchor)` arm is
1165
+ // monotone-inclusive, so any later run-started is picked up by that same
1166
+ // select; an append after the final re-probe is an ordinary post-read
1167
+ // concurrent append, not this race.
1168
+ return readRepoStateEventsWithAnchorRecheck(
1169
+ async () => {
1170
+ // This probe is a LIMIT 1 select, not an aggregate: it returns zero or
1171
+ // one rows, so row absence is the deliberate empty-journal signal — no
1172
+ // MAX() NULL semantics involved (contrast the Postgres adapter's
1173
+ // MAX(seq) probe, which always returns one row and must test the NULL
1174
+ // explicitly). The `anchor === undefined` branch in the select below is
1175
+ // the explicit durable-only path; a null anchor must never reach the
1176
+ // gte(repoEvents.seq, anchor) arm (in either dialect the comparison is
1177
+ // never true, so the durable-only outcome would then survive only by
1178
+ // accident), and the no-anchor case must not degenerate into
1179
+ // gte(repoEvents.seq, 0), which would select the whole journal.
1180
+ const anchorRows = this.db
1181
+ .select({ seq: repoEvents.seq })
1182
+ .from(repoEvents)
1183
+ .where(and(eq(repoEvents.repo, repo), eq(repoEvents.type, 'dispatcher.run-started')))
1184
+ .orderBy(desc(repoEvents.seq))
1185
+ .limit(1)
1186
+ .all()
1187
+ return anchorRows[0]?.seq
1188
+ },
1189
+ async (anchor) => {
1190
+ const durable = inArray(repoEvents.type, [...REPOSITORY_STATE_EVENT_TYPES])
1191
+ const rows = this.db
1192
+ .select()
1193
+ .from(repoEvents)
1194
+ .where(
1195
+ anchor === undefined
1196
+ ? and(eq(repoEvents.repo, repo), durable)
1197
+ : and(eq(repoEvents.repo, repo), or(gte(repoEvents.seq, anchor), durable)),
1198
+ )
1199
+ .orderBy(asc(repoEvents.seq))
1200
+ .all()
1201
+ return rows.map(
1202
+ (row) =>
1203
+ ({
1204
+ repo: row.repo,
1205
+ seq: row.seq,
1206
+ ts: row.ts,
1207
+ actor: row.actor,
1208
+ type: row.type,
1209
+ payload: row.payload,
1210
+ }) as RepositoryEvent,
1211
+ )
1212
+ },
1213
+ )
1214
+ }
1215
+
941
1216
  async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
942
1217
  const prepared = await this.prepareArtifact(artifact)
943
1218
  return this.writeTx(() => this.depositRepoInTx(repo, prepared))
@@ -994,6 +1269,144 @@ export class SqliteBuildStore implements BuildStore {
994
1269
  .map((row) => this.toRepoMeta(row))
995
1270
  }
996
1271
 
1272
+ async ticketAssetLimits(_repo: string): Promise<TicketAssetLimits> {
1273
+ return { ...DEFAULT_TICKET_ASSET_LIMITS }
1274
+ }
1275
+
1276
+ private toTicketAssetMeta(row: typeof ticketAssets.$inferSelect): TicketAssetMeta {
1277
+ return {
1278
+ repo: row.repo,
1279
+ ticketId: row.ticketId,
1280
+ kind: row.kind,
1281
+ name: row.name,
1282
+ revision: row.revision,
1283
+ layout: row.layout,
1284
+ size: row.size,
1285
+ entries: row.manifest,
1286
+ createdAt: row.createdAt,
1287
+ }
1288
+ }
1289
+
1290
+ async putTicketAsset(
1291
+ repo: string,
1292
+ ticketId: string,
1293
+ asset: TicketAssetInput,
1294
+ ): Promise<TicketAssetMeta> {
1295
+ const size = validateTicketAssetInput(asset)
1296
+ const entries = await storeTicketAssetBlobs(this.blobs, asset.entries)
1297
+ return this.writeTx(() => {
1298
+ const latest = this.db
1299
+ .select({ revision: ticketAssets.revision })
1300
+ .from(ticketAssets)
1301
+ .where(ticketAssetKey(repo, ticketId, asset.kind, asset.name))
1302
+ .orderBy(desc(ticketAssets.revision))
1303
+ .limit(1)
1304
+ .get()
1305
+ const row: typeof ticketAssets.$inferInsert = {
1306
+ repo,
1307
+ ticketId,
1308
+ kind: asset.kind,
1309
+ name: asset.name,
1310
+ revision: (latest?.revision ?? -1) + 1,
1311
+ layout: asset.layout,
1312
+ manifest: entries,
1313
+ size,
1314
+ removed: 0,
1315
+ createdAt: this.clock().toISOString(),
1316
+ }
1317
+ this.db.insert(ticketAssets).values(row).run()
1318
+ return this.toTicketAssetMeta(row as typeof ticketAssets.$inferSelect)
1319
+ })
1320
+ }
1321
+
1322
+ async getTicketAsset(
1323
+ repo: string,
1324
+ ticketId: string,
1325
+ kind: string,
1326
+ name: string,
1327
+ rev?: number,
1328
+ ): Promise<TicketAsset | null> {
1329
+ const scoped = ticketAssetKey(repo, ticketId, kind, name)
1330
+ const row =
1331
+ rev === undefined
1332
+ ? this.db
1333
+ .select()
1334
+ .from(ticketAssets)
1335
+ .where(scoped)
1336
+ .orderBy(desc(ticketAssets.revision))
1337
+ .limit(1)
1338
+ .get()
1339
+ : this.db
1340
+ .select()
1341
+ .from(ticketAssets)
1342
+ .where(and(scoped, eq(ticketAssets.revision, rev)))
1343
+ .get()
1344
+ if (!row || row.removed) return null
1345
+ return loadTicketAsset(this.blobs, this.toTicketAssetMeta(row))
1346
+ }
1347
+
1348
+ async getPinnedTicketAsset(
1349
+ slug: string,
1350
+ kind: string,
1351
+ name: string,
1352
+ rev?: number,
1353
+ ): Promise<TicketAsset | null> {
1354
+ return resolvePinnedAsset(this, slug, kind, name, rev)
1355
+ }
1356
+
1357
+ async listTicketAssets(
1358
+ repo: string,
1359
+ ticketId: string,
1360
+ opts?: { revisions?: boolean },
1361
+ ): Promise<TicketAssetSummary[]> {
1362
+ const rows = this.db
1363
+ .select()
1364
+ .from(ticketAssets)
1365
+ .where(and(eq(ticketAssets.repo, repo), eq(ticketAssets.ticketId, ticketId)))
1366
+ .orderBy(asc(ticketAssets.kind), asc(ticketAssets.name), asc(ticketAssets.revision))
1367
+ .all()
1368
+ if (opts?.revisions) {
1369
+ return rows
1370
+ .filter((row) => !row.removed)
1371
+ .map((row) => summarizeTicketAsset(this.toTicketAssetMeta(row)))
1372
+ }
1373
+ const latest = new Map<string, typeof ticketAssets.$inferSelect>()
1374
+ for (const row of rows) latest.set(JSON.stringify([row.kind, row.name]), row)
1375
+ return [...latest.values()]
1376
+ .filter((row) => !row.removed)
1377
+ .map((row) => summarizeTicketAsset(this.toTicketAssetMeta(row)))
1378
+ }
1379
+
1380
+ async removeTicketAsset(
1381
+ repo: string,
1382
+ ticketId: string,
1383
+ kind: string,
1384
+ name: string,
1385
+ ): Promise<TicketAssetMeta | null> {
1386
+ return this.writeTx(() => {
1387
+ const latest = this.db
1388
+ .select()
1389
+ .from(ticketAssets)
1390
+ .where(ticketAssetKey(repo, ticketId, kind, name))
1391
+ .orderBy(desc(ticketAssets.revision))
1392
+ .limit(1)
1393
+ .get()
1394
+ if (!latest || latest.removed) return null
1395
+ this.db
1396
+ .insert(ticketAssets)
1397
+ .values({
1398
+ ...latest,
1399
+ revision: latest.revision + 1,
1400
+ manifest: [],
1401
+ size: 0,
1402
+ removed: 1,
1403
+ createdAt: this.clock().toISOString(),
1404
+ })
1405
+ .run()
1406
+ return this.toTicketAssetMeta(latest)
1407
+ })
1408
+ }
1409
+
997
1410
  async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
998
1411
  return this.writeTx(() => {
999
1412
  const row = this.requireRepo(repo)
@@ -1189,6 +1602,26 @@ export class SqliteBuildStore implements BuildStore {
1189
1602
  return this.writeTx(() => this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>)
1190
1603
  }
1191
1604
 
1605
+ async appendSessionEventIfCurrent<T extends SessionEventType>(
1606
+ id: string,
1607
+ expectedSeq: number,
1608
+ event: SessionEventWrite<T>,
1609
+ ): Promise<SessionEventEnvelope<T> | null> {
1610
+ validateExpectedSeq(expectedSeq)
1611
+ const validated = validateSessionEventWrite(event)
1612
+ return this.writeTx(() => {
1613
+ // BEGIN IMMEDIATE serializes this comparison and append across every
1614
+ // SQLite connection/process using the same store file.
1615
+ const row = this.db
1616
+ .select({ count: sql<number>`count(*)` })
1617
+ .from(sessionEvents)
1618
+ .where(eq(sessionEvents.session, id))
1619
+ .get()
1620
+ if ((row?.count ?? 0) !== expectedSeq) return null
1621
+ return this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>
1622
+ })
1623
+ }
1624
+
1192
1625
  async getSessionEvents(
1193
1626
  id: string,
1194
1627
  sinceSeq = 0,
@@ -1214,7 +1647,15 @@ export class SqliteBuildStore implements BuildStore {
1214
1647
  }) as SessionEvent,
1215
1648
  )
1216
1649
  }
1217
- return readEventsWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
1650
+ return readEventsWithWait({
1651
+ read,
1652
+ waitSeconds: opts?.waitSeconds,
1653
+ signal: opts?.signal,
1654
+ // Hosted budget: held session-event reads poll at most once per second
1655
+ // (AUT-394), matching the build and repository event reads — not the
1656
+ // 25 ms stream default, which is presentation content's cadence.
1657
+ pollMs: EVENT_WAIT_POLL_MS,
1658
+ })
1218
1659
  }
1219
1660
 
1220
1661
  /** Runs inside an open transaction — see `depositInTx` for `prune`. */