@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
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The shared build-digest derivation (AUT-487): every BuildStore adapter
3
+ * answers `getRepoBuildDigests` by fetching only the digest-relevant event
4
+ * types from its existing event table and running this one pure function per
5
+ * build — identical results by construction, and the digest's `terminal` can
6
+ * never drift from `reduceBuild`'s terminal rule because this is the same
7
+ * in-order-overwrite of the latest terminal fact (§15.5). AUT-521 widens the
8
+ * projection with the observation timestamps and merge facts the harvest
9
+ * pressure gate needs, keeping that gate's cost flat in accumulated build
10
+ * history.
11
+ */
12
+ import type { AbEvent } from '../events/catalog'
13
+ import type { BuildDigest } from './types'
14
+
15
+ /** The event types a digest is derived from. Adapters filter their event
16
+ * rows to exactly these, so a per-build type-filtered index keeps the scan's
17
+ * cost growing with the actual signal, not with total history. */
18
+ export const DIGEST_EVENT_TYPES = [
19
+ 'build.completed',
20
+ 'build.aborted',
21
+ 'observation.recorded',
22
+ 'pr.merged',
23
+ ] as const
24
+
25
+ /** The digest-relevant projection of one build's events, in log order.
26
+ * `terminal` follows `reduceBuild` exactly: `build.completed` sets `done`,
27
+ * `build.aborted` sets `aborted`, latest wins (in-order overwrite) — and
28
+ * `merged` follows the same overwrite pattern for `pr.merged` timestamps.
29
+ * The events carry no slug, so the result is slug-less; every adapter attaches
30
+ * `slug` itself when grouping its per-build event rows. */
31
+ export function reduceBuildDigest(
32
+ events: Pick<AbEvent, 'type' | 'seq' | 'ts'>[],
33
+ ): Omit<BuildDigest, 'slug'> {
34
+ let terminal: BuildDigest['terminal']
35
+ let merged: string | undefined
36
+ const observations: { seq: number; ts: string }[] = []
37
+ for (const event of events) {
38
+ if (event.type === 'build.completed') terminal = 'done'
39
+ else if (event.type === 'build.aborted') terminal = 'aborted'
40
+ else if (event.type === 'pr.merged') merged = event.ts
41
+ else if (event.type === 'observation.recorded')
42
+ observations.push({ seq: event.seq, ts: event.ts })
43
+ }
44
+ return {
45
+ observations,
46
+ ...(merged !== undefined ? { merged } : {}),
47
+ ...(terminal !== undefined ? { terminal } : {}),
48
+ }
49
+ }
@@ -9,9 +9,10 @@
9
9
  * be kept in lockstep with this schema (this file is the drizzle-kit source
10
10
  * of truth).
11
11
  */
12
- import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
12
+ import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
13
13
  import type { Actor } from '../../events/envelope'
14
14
  import type { TicketRef } from '../../ontology'
15
+ import type { TicketAssetEntry } from '../ticket-assets'
15
16
  import type { StreamPart } from '../streams/types'
16
17
 
17
18
  export const builds = sqliteTable('builds', {
@@ -39,7 +40,12 @@ export const events = sqliteTable(
39
40
  type: text('type').notNull(),
40
41
  payload: text('payload', { mode: 'json' }).notNull(),
41
42
  },
42
- (t) => [primaryKey({ columns: [t.build, t.seq] })],
43
+ // Mirrors the BOOTSTRAP_DDL digest-scan index (AUT-487), type-leading so the
44
+ // digest query's cost grows with digest-relevant events, not total history.
45
+ (t) => [
46
+ primaryKey({ columns: [t.build, t.seq] }),
47
+ index('events_type_build_seq').on(t.type, t.build, t.seq),
48
+ ],
43
49
  )
44
50
 
45
51
  export const repoStreams = sqliteTable('repo_streams', {
@@ -78,6 +84,27 @@ export const repoArtifacts = sqliteTable(
78
84
  (t) => [primaryKey({ columns: [t.repo, t.kind, t.revision] })],
79
85
  )
80
86
 
87
+ /** Ticket assets (SPEC §7.1): one row per revision, manifest as JSON, file
88
+ * bytes in the BlobStore. A removal is a tombstone row (`removed`, empty
89
+ * manifest) taking the next revision. No FK to `repo_streams`: assets can
90
+ * precede any repo-journal row. */
91
+ export const ticketAssets = sqliteTable(
92
+ 'ticket_assets',
93
+ {
94
+ repo: text('repo').notNull(),
95
+ ticketId: text('ticket_id').notNull(),
96
+ kind: text('kind').notNull(),
97
+ name: text('name').notNull(),
98
+ revision: integer('revision').notNull(),
99
+ layout: text('layout').notNull().$type<'file' | 'tree'>(),
100
+ manifest: text('manifest', { mode: 'json' }).notNull().$type<TicketAssetEntry[]>(),
101
+ size: integer('size').notNull(),
102
+ removed: integer('removed').notNull(),
103
+ createdAt: text('created_at').notNull(),
104
+ },
105
+ (t) => [primaryKey({ columns: [t.repo, t.ticketId, t.kind, t.name, t.revision] })],
106
+ )
107
+
81
108
  /** Operator sessions (SPEC §7.1.1): hosted-only durable orchestrator
82
109
  * conversation state. Adapters implement the contract uniformly; nothing
83
110
  * local creates one. */
@@ -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,13 @@ import {
40
40
  type SessionEventWrite,
41
41
  } from '../../events/sessions'
42
42
  import { createBuildScopedStore } from '../build-scope'
43
+ import { validateCreatedEvent } from '../new-build'
43
44
  import { createSessionScopedStore } from '../session-handle'
45
+ import { DIGEST_EVENT_TYPES, reduceBuildDigest } from '../digest'
46
+ import {
47
+ REPOSITORY_STATE_EVENT_TYPES,
48
+ readRepoStateEventsWithAnchorRecheck,
49
+ } from '../repo-state-events'
44
50
  import {
45
51
  DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
46
52
  isRetentionManagedKind,
@@ -49,7 +55,7 @@ import {
49
55
  import { pollingSubscribe } from '../subscribe'
50
56
  import { StreamLocks } from '../streams/lock'
51
57
  import { assembleUIMessageDocument } from '../streams/assemble'
52
- import { readEventsWithWait, readStreamWithWait } from '../streams/wait'
58
+ import { EVENT_WAIT_POLL_MS, readEventsWithWait, readStreamWithWait } from '../streams/wait'
53
59
  import {
54
60
  serializedBatchSize,
55
61
  STREAM_BATCH_MAX_BYTES,
@@ -76,6 +82,7 @@ import {
76
82
  type ArtifactMeta,
77
83
  type BlobStore,
78
84
  type BuildRecord,
85
+ type BuildDigest,
79
86
  type BuildScopedStore,
80
87
  type BuildStore,
81
88
  type Clock,
@@ -91,6 +98,19 @@ import {
91
98
  type SubscribeOptions,
92
99
  type Unsubscribe,
93
100
  } from '../types'
101
+ import {
102
+ DEFAULT_TICKET_ASSET_LIMITS,
103
+ loadTicketAsset,
104
+ storeTicketAssetBlobs,
105
+ resolvePinnedAsset,
106
+ summarizeTicketAsset,
107
+ validateTicketAssetInput,
108
+ type TicketAsset,
109
+ type TicketAssetInput,
110
+ type TicketAssetLimits,
111
+ type TicketAssetMeta,
112
+ type TicketAssetSummary,
113
+ } from '../ticket-assets'
94
114
  import { DirBlobStore } from './blobs'
95
115
  import {
96
116
  artifacts,
@@ -102,6 +122,7 @@ import {
102
122
  sessionArtifacts,
103
123
  sessionEvents,
104
124
  sessions,
125
+ ticketAssets,
105
126
  streamChunks,
106
127
  streams,
107
128
  } from './schema'
@@ -111,7 +132,9 @@ import {
111
132
  * the drizzle schema is the source of truth; this is its inlined form so
112
133
  * opening a store never needs a migration step.
113
134
  */
114
- const BOOTSTRAP_DDL = [
135
+ // Exported for the schema-parity test, which pins this DDL against the
136
+ // drizzle schema; schema.ts stays the source of truth, this is its inlined form.
137
+ export const BOOTSTRAP_DDL = [
115
138
  `CREATE TABLE IF NOT EXISTS builds (
116
139
  slug TEXT PRIMARY KEY,
117
140
  repo TEXT NOT NULL,
@@ -134,6 +157,10 @@ const BOOTSTRAP_DDL = [
134
157
  payload TEXT NOT NULL,
135
158
  PRIMARY KEY (build, seq)
136
159
  )`,
160
+ // The build-digest scan (AUT-487): type-leading so the digest query's cost
161
+ // grows with the observation/terminal events themselves, not with total
162
+ // history. Idempotent at open, so pre-existing databases gain it too.
163
+ `CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq)`,
137
164
  `CREATE TABLE IF NOT EXISTS artifacts (
138
165
  build TEXT NOT NULL,
139
166
  kind TEXT NOT NULL,
@@ -170,6 +197,19 @@ const BOOTSTRAP_DDL = [
170
197
  created_at TEXT NOT NULL,
171
198
  PRIMARY KEY (repo, kind, revision)
172
199
  )`,
200
+ `CREATE TABLE IF NOT EXISTS ticket_assets (
201
+ repo TEXT NOT NULL,
202
+ ticket_id TEXT NOT NULL,
203
+ kind TEXT NOT NULL,
204
+ name TEXT NOT NULL,
205
+ revision INTEGER NOT NULL,
206
+ layout TEXT NOT NULL,
207
+ manifest TEXT NOT NULL,
208
+ size INTEGER NOT NULL,
209
+ removed INTEGER NOT NULL,
210
+ created_at TEXT NOT NULL,
211
+ PRIMARY KEY (repo, ticket_id, kind, name, revision)
212
+ )`,
173
213
  `CREATE TABLE IF NOT EXISTS sessions (
174
214
  id TEXT PRIMARY KEY,
175
215
  repo TEXT NOT NULL,
@@ -248,6 +288,15 @@ export interface SqliteBuildStoreOptions {
248
288
  retention?: { maxRevisions?: number }
249
289
  }
250
290
 
291
+ function ticketAssetKey(repo: string, ticketId: string, kind: string, name: string) {
292
+ return and(
293
+ eq(ticketAssets.repo, repo),
294
+ eq(ticketAssets.ticketId, ticketId),
295
+ eq(ticketAssets.kind, kind),
296
+ eq(ticketAssets.name, name),
297
+ )
298
+ }
299
+
251
300
  export class SqliteBuildStore implements BuildStore {
252
301
  private readonly sqlite: Database
253
302
  private readonly db: BunSQLiteDatabase
@@ -399,6 +448,7 @@ export class SqliteBuildStore implements BuildStore {
399
448
  }
400
449
 
401
450
  async createBuild(input: NewBuildInput): Promise<BuildRecord> {
451
+ const created = validateCreatedEvent(input)
402
452
  const ts = this.now()
403
453
  return this.writeTx(() => {
404
454
  if (this.buildRow(input.slug)) {
@@ -416,6 +466,7 @@ export class SqliteBuildStore implements BuildStore {
416
466
  updatedAt: ts,
417
467
  })
418
468
  .run()
469
+ if (created !== undefined) this.appendInTx(input.slug, created)
419
470
  return this.toRecord(this.requireBuild(input.slug))
420
471
  })
421
472
  }
@@ -430,6 +481,47 @@ export class SqliteBuildStore implements BuildStore {
430
481
  return rows.map((row) => this.toRecord(row))
431
482
  }
432
483
 
484
+ async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
485
+ // From the builds side (AUT-487): the type filter lives in the ON clause
486
+ // so a build whose log holds none of the digest-relevant event
487
+ // types still yields its row — a join that starts from `events` would
488
+ // silently drop such builds and break the operation's completeness
489
+ // contract. Only the digest-relevant types are fetched, then one shared
490
+ // derivation
491
+ // per build, so the answer cannot drift from `reduceBuild`.
492
+ const rows = this.sqlite
493
+ .query(
494
+ `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type
495
+ FROM builds b
496
+ LEFT JOIN events e
497
+ ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map(() => '?').join(', ')})
498
+ WHERE b.repo = ?
499
+ ORDER BY b.slug, e.seq`,
500
+ )
501
+ .all(...DIGEST_EVENT_TYPES, repo) as {
502
+ slug: string
503
+ seq: number | null
504
+ ts: string | null
505
+ type: string | null
506
+ }[]
507
+ const eventsByBuild = new Map<string, Pick<AbEvent, 'type' | 'seq' | 'ts'>[]>()
508
+ for (const row of rows) {
509
+ if (row.seq === null || row.ts === null || row.type === null) continue
510
+ const events = eventsByBuild.get(row.slug) ?? []
511
+ events.push({ type: row.type as AbEvent['type'], seq: row.seq, ts: row.ts })
512
+ eventsByBuild.set(row.slug, events)
513
+ }
514
+ const digests = new Map<string, BuildDigest>()
515
+ for (const row of rows) {
516
+ if (digests.has(row.slug)) continue
517
+ digests.set(row.slug, {
518
+ slug: row.slug,
519
+ ...reduceBuildDigest(eventsByBuild.get(row.slug) ?? []),
520
+ })
521
+ }
522
+ return digests
523
+ }
524
+
433
525
  /** Current build-stream tail inside an open transaction. */
434
526
  private currentSeqInTx(slug: string): number {
435
527
  this.requireBuild(slug)
@@ -664,6 +756,10 @@ export class SqliteBuildStore implements BuildStore {
664
756
  },
665
757
  waitSeconds: opts?.waitSeconds,
666
758
  signal: opts?.signal,
759
+ // Hosted budget: held build-event reads poll at most once per second
760
+ // (AUT-394) — not the 25 ms stream default, which is presentation
761
+ // content's cadence.
762
+ pollMs: EVENT_WAIT_POLL_MS,
667
763
  })
668
764
  }
669
765
 
@@ -935,9 +1031,81 @@ export class SqliteBuildStore implements BuildStore {
935
1031
  },
936
1032
  waitSeconds: opts?.waitSeconds,
937
1033
  signal: opts?.signal,
1034
+ // Hosted budget: held repository-event reads poll at most once per
1035
+ // second (AUT-394) — not the 25 ms stream default, which is
1036
+ // presentation content's cadence.
1037
+ pollMs: EVENT_WAIT_POLL_MS,
938
1038
  })
939
1039
  }
940
1040
 
1041
+ async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
1042
+ this.requireRepo(repo)
1043
+ // Two queries, mirroring the oracle's derivation: the latest
1044
+ // run-started anchor, then one select of durable types plus the tail
1045
+ // from that anchor. No new index: local journals are small and the
1046
+ // existing (repo, seq) PK index keeps the anchor scan acceptable.
1047
+ //
1048
+ // The two drizzle reads are synchronous, so there is no in-process await
1049
+ // point between them — but a concurrent writer from another process can
1050
+ // still land between the two statements in real time (AUT-551): a
1051
+ // first-ever `dispatcher.run-started` appended between them would be
1052
+ // invisible to the probe, absent from the durable-only selection, yet
1053
+ // present in the journal by the time this method returns. The shared
1054
+ // helper below closes that window with a one-shot anchor re-check on the
1055
+ // durable-only outcome: a durable-only result is returned only when the
1056
+ // journal was anchor-free as of the final probe. When the probe *does*
1057
+ // find an anchor, the anchored select's `gte(seq, anchor)` arm is
1058
+ // monotone-inclusive, so any later run-started is picked up by that same
1059
+ // select; an append after the final re-probe is an ordinary post-read
1060
+ // concurrent append, not this race.
1061
+ return readRepoStateEventsWithAnchorRecheck(
1062
+ async () => {
1063
+ // This probe is a LIMIT 1 select, not an aggregate: it returns zero or
1064
+ // one rows, so row absence is the deliberate empty-journal signal — no
1065
+ // MAX() NULL semantics involved (contrast the Postgres adapter's
1066
+ // MAX(seq) probe, which always returns one row and must test the NULL
1067
+ // explicitly). The `anchor === undefined` branch in the select below is
1068
+ // the explicit durable-only path; a null anchor must never reach the
1069
+ // gte(repoEvents.seq, anchor) arm (in either dialect the comparison is
1070
+ // never true, so the durable-only outcome would then survive only by
1071
+ // accident), and the no-anchor case must not degenerate into
1072
+ // gte(repoEvents.seq, 0), which would select the whole journal.
1073
+ const anchorRows = this.db
1074
+ .select({ seq: repoEvents.seq })
1075
+ .from(repoEvents)
1076
+ .where(and(eq(repoEvents.repo, repo), eq(repoEvents.type, 'dispatcher.run-started')))
1077
+ .orderBy(desc(repoEvents.seq))
1078
+ .limit(1)
1079
+ .all()
1080
+ return anchorRows[0]?.seq
1081
+ },
1082
+ async (anchor) => {
1083
+ const durable = inArray(repoEvents.type, [...REPOSITORY_STATE_EVENT_TYPES])
1084
+ const rows = this.db
1085
+ .select()
1086
+ .from(repoEvents)
1087
+ .where(
1088
+ anchor === undefined
1089
+ ? and(eq(repoEvents.repo, repo), durable)
1090
+ : and(eq(repoEvents.repo, repo), or(gte(repoEvents.seq, anchor), durable)),
1091
+ )
1092
+ .orderBy(asc(repoEvents.seq))
1093
+ .all()
1094
+ return rows.map(
1095
+ (row) =>
1096
+ ({
1097
+ repo: row.repo,
1098
+ seq: row.seq,
1099
+ ts: row.ts,
1100
+ actor: row.actor,
1101
+ type: row.type,
1102
+ payload: row.payload,
1103
+ }) as RepositoryEvent,
1104
+ )
1105
+ },
1106
+ )
1107
+ }
1108
+
941
1109
  async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
942
1110
  const prepared = await this.prepareArtifact(artifact)
943
1111
  return this.writeTx(() => this.depositRepoInTx(repo, prepared))
@@ -994,6 +1162,144 @@ export class SqliteBuildStore implements BuildStore {
994
1162
  .map((row) => this.toRepoMeta(row))
995
1163
  }
996
1164
 
1165
+ async ticketAssetLimits(_repo: string): Promise<TicketAssetLimits> {
1166
+ return { ...DEFAULT_TICKET_ASSET_LIMITS }
1167
+ }
1168
+
1169
+ private toTicketAssetMeta(row: typeof ticketAssets.$inferSelect): TicketAssetMeta {
1170
+ return {
1171
+ repo: row.repo,
1172
+ ticketId: row.ticketId,
1173
+ kind: row.kind,
1174
+ name: row.name,
1175
+ revision: row.revision,
1176
+ layout: row.layout,
1177
+ size: row.size,
1178
+ entries: row.manifest,
1179
+ createdAt: row.createdAt,
1180
+ }
1181
+ }
1182
+
1183
+ async putTicketAsset(
1184
+ repo: string,
1185
+ ticketId: string,
1186
+ asset: TicketAssetInput,
1187
+ ): Promise<TicketAssetMeta> {
1188
+ const size = validateTicketAssetInput(asset)
1189
+ const entries = await storeTicketAssetBlobs(this.blobs, asset.entries)
1190
+ return this.writeTx(() => {
1191
+ const latest = this.db
1192
+ .select({ revision: ticketAssets.revision })
1193
+ .from(ticketAssets)
1194
+ .where(ticketAssetKey(repo, ticketId, asset.kind, asset.name))
1195
+ .orderBy(desc(ticketAssets.revision))
1196
+ .limit(1)
1197
+ .get()
1198
+ const row: typeof ticketAssets.$inferInsert = {
1199
+ repo,
1200
+ ticketId,
1201
+ kind: asset.kind,
1202
+ name: asset.name,
1203
+ revision: (latest?.revision ?? -1) + 1,
1204
+ layout: asset.layout,
1205
+ manifest: entries,
1206
+ size,
1207
+ removed: 0,
1208
+ createdAt: this.clock().toISOString(),
1209
+ }
1210
+ this.db.insert(ticketAssets).values(row).run()
1211
+ return this.toTicketAssetMeta(row as typeof ticketAssets.$inferSelect)
1212
+ })
1213
+ }
1214
+
1215
+ async getTicketAsset(
1216
+ repo: string,
1217
+ ticketId: string,
1218
+ kind: string,
1219
+ name: string,
1220
+ rev?: number,
1221
+ ): Promise<TicketAsset | null> {
1222
+ const scoped = ticketAssetKey(repo, ticketId, kind, name)
1223
+ const row =
1224
+ rev === undefined
1225
+ ? this.db
1226
+ .select()
1227
+ .from(ticketAssets)
1228
+ .where(scoped)
1229
+ .orderBy(desc(ticketAssets.revision))
1230
+ .limit(1)
1231
+ .get()
1232
+ : this.db
1233
+ .select()
1234
+ .from(ticketAssets)
1235
+ .where(and(scoped, eq(ticketAssets.revision, rev)))
1236
+ .get()
1237
+ if (!row || row.removed) return null
1238
+ return loadTicketAsset(this.blobs, this.toTicketAssetMeta(row))
1239
+ }
1240
+
1241
+ async getPinnedTicketAsset(
1242
+ slug: string,
1243
+ kind: string,
1244
+ name: string,
1245
+ rev?: number,
1246
+ ): Promise<TicketAsset | null> {
1247
+ return resolvePinnedAsset(this, slug, kind, name, rev)
1248
+ }
1249
+
1250
+ async listTicketAssets(
1251
+ repo: string,
1252
+ ticketId: string,
1253
+ opts?: { revisions?: boolean },
1254
+ ): Promise<TicketAssetSummary[]> {
1255
+ const rows = this.db
1256
+ .select()
1257
+ .from(ticketAssets)
1258
+ .where(and(eq(ticketAssets.repo, repo), eq(ticketAssets.ticketId, ticketId)))
1259
+ .orderBy(asc(ticketAssets.kind), asc(ticketAssets.name), asc(ticketAssets.revision))
1260
+ .all()
1261
+ if (opts?.revisions) {
1262
+ return rows
1263
+ .filter((row) => !row.removed)
1264
+ .map((row) => summarizeTicketAsset(this.toTicketAssetMeta(row)))
1265
+ }
1266
+ const latest = new Map<string, typeof ticketAssets.$inferSelect>()
1267
+ for (const row of rows) latest.set(JSON.stringify([row.kind, row.name]), row)
1268
+ return [...latest.values()]
1269
+ .filter((row) => !row.removed)
1270
+ .map((row) => summarizeTicketAsset(this.toTicketAssetMeta(row)))
1271
+ }
1272
+
1273
+ async removeTicketAsset(
1274
+ repo: string,
1275
+ ticketId: string,
1276
+ kind: string,
1277
+ name: string,
1278
+ ): Promise<TicketAssetMeta | null> {
1279
+ return this.writeTx(() => {
1280
+ const latest = this.db
1281
+ .select()
1282
+ .from(ticketAssets)
1283
+ .where(ticketAssetKey(repo, ticketId, kind, name))
1284
+ .orderBy(desc(ticketAssets.revision))
1285
+ .limit(1)
1286
+ .get()
1287
+ if (!latest || latest.removed) return null
1288
+ this.db
1289
+ .insert(ticketAssets)
1290
+ .values({
1291
+ ...latest,
1292
+ revision: latest.revision + 1,
1293
+ manifest: [],
1294
+ size: 0,
1295
+ removed: 1,
1296
+ createdAt: this.clock().toISOString(),
1297
+ })
1298
+ .run()
1299
+ return this.toTicketAssetMeta(latest)
1300
+ })
1301
+ }
1302
+
997
1303
  async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
998
1304
  return this.writeTx(() => {
999
1305
  const row = this.requireRepo(repo)
@@ -1189,6 +1495,26 @@ export class SqliteBuildStore implements BuildStore {
1189
1495
  return this.writeTx(() => this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>)
1190
1496
  }
1191
1497
 
1498
+ async appendSessionEventIfCurrent<T extends SessionEventType>(
1499
+ id: string,
1500
+ expectedSeq: number,
1501
+ event: SessionEventWrite<T>,
1502
+ ): Promise<SessionEventEnvelope<T> | null> {
1503
+ validateExpectedSeq(expectedSeq)
1504
+ const validated = validateSessionEventWrite(event)
1505
+ return this.writeTx(() => {
1506
+ // BEGIN IMMEDIATE serializes this comparison and append across every
1507
+ // SQLite connection/process using the same store file.
1508
+ const row = this.db
1509
+ .select({ count: sql<number>`count(*)` })
1510
+ .from(sessionEvents)
1511
+ .where(eq(sessionEvents.session, id))
1512
+ .get()
1513
+ if ((row?.count ?? 0) !== expectedSeq) return null
1514
+ return this.appendSessionInTx(id, validated) as SessionEventEnvelope<T>
1515
+ })
1516
+ }
1517
+
1192
1518
  async getSessionEvents(
1193
1519
  id: string,
1194
1520
  sinceSeq = 0,
@@ -1214,7 +1540,15 @@ export class SqliteBuildStore implements BuildStore {
1214
1540
  }) as SessionEvent,
1215
1541
  )
1216
1542
  }
1217
- return readEventsWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
1543
+ return readEventsWithWait({
1544
+ read,
1545
+ waitSeconds: opts?.waitSeconds,
1546
+ signal: opts?.signal,
1547
+ // Hosted budget: held session-event reads poll at most once per second
1548
+ // (AUT-394), matching the build and repository event reads — not the
1549
+ // 25 ms stream default, which is presentation content's cadence.
1550
+ pollMs: EVENT_WAIT_POLL_MS,
1551
+ })
1218
1552
  }
1219
1553
 
1220
1554
  /** Runs inside an open transaction — see `depositInTx` for `prune`. */