@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,319 @@
1
+ /**
2
+ * The dispatcher tick's orchestrator step (AUT-342): resume suspended turns,
3
+ * reap crashed ones, and wake idle sessions from build attention events.
4
+ *
5
+ * Two independent gates keep this inert where it does not belong, and BOTH
6
+ * are required:
7
+ *
8
+ * 1. The step is constructed only in origin mode — the gate lives in
9
+ * `processes/dispatcher.ts` on `DispatcherDeps.repoOrigin` (set
10
+ * exclusively by origin mode), so a local `ab dispatch` never constructs
11
+ * the step at all: an `enabled = true` in a local repository's
12
+ * autobuild.toml lists no sessions, builds no registry, and calls no
13
+ * model.
14
+ * 2. In origin mode the step returns immediately unless the repository's
15
+ * effective config enables the orchestrator — the disabled-repository
16
+ * no-op.
17
+ *
18
+ * The wake cursor advances only through the recorded trigger (the reducer
19
+ * takes `max`), so a crash between scan and start cannot skip or duplicate a
20
+ * wake; other builds' — and the journal's — matching events re-trigger on
21
+ * later ticks. One turn per session per tick, never concurrent.
22
+ */
23
+ import { ORCHESTRATOR_MIN_TURN_SECONDS, type Config } from '../config/schema'
24
+ import { compileEventGlobs } from '../events/globs'
25
+ import type { RepositoryEvent } from '../events/repository'
26
+ import type { TicketSource } from '../ports/types'
27
+ import { buildRegistry, type OperatorToolRegistry } from '../operator/registry'
28
+ import type { OperatorSandboxService } from '../operator/sandbox'
29
+ import { ticketBackendFromSource } from '../operator/ticket-source-backend'
30
+ import { reduceSession, type SessionTurnTrigger } from '../store/session-reducer'
31
+ import { reduceBuild } from '../kernel/reducer'
32
+ import type { AbEvent } from '../events/catalog'
33
+ import type { BuildStore, Clock } from '../store/types'
34
+ import type { IdSource } from '../ids'
35
+ import type { LanguageModel } from 'ai'
36
+ import {
37
+ createOrchestratorTurnRunner,
38
+ type OrchestratorTurnRunner,
39
+ type WakeTurnInput,
40
+ } from '../orchestrator/turn-runner'
41
+
42
+ /** Minutes an open, unsuspended turn's stream may stay silent before the
43
+ * reaper fails it: a crashed invocation leaves the session `running` with an
44
+ * open stream and no runner. Suspended turns are NEVER reaped — a turn
45
+ * suspended for approval is precisely an open stream that goes quiet for
46
+ * however long the operator takes. */
47
+ export const ORCHESTRATOR_STALE_TURN_MINUTES = 15
48
+
49
+ export interface OrchestratorTickOptions {
50
+ store: BuildStore
51
+ repo: string
52
+ config: Config
53
+ clock: Clock
54
+ ids: IdSource
55
+ /** The dispatcher's ticket source, adapted into the registry's ticket
56
+ * backend so wake and resumed turns keep the full ticket surface. */
57
+ tickets: TicketSource
58
+ /** Injected language model; defaults to the configured gateway model. */
59
+ model?: LanguageModel
60
+ /** The operator-sandbox backend (AUT-584): when supplied, the tick's turns
61
+ * serve the `sandbox.*` tools under the same configuration/approval gating
62
+ * every binding applies. Absent (the compatibility case) → the registry
63
+ * filters them from advertisement AND dispatch. The origin-mode dispatcher
64
+ * builds it from its own wired provider; every other caller omits it. */
65
+ sandbox?: OperatorSandboxService
66
+ /** Remaining tick budget in seconds at step entry; every turn's deadline
67
+ * is recomputed from this against the clock, so N sessions draw down the
68
+ * SAME budget instead of each getting a fresh full slice (f_9c1161ae).
69
+ * Less than ORCHESTRATOR_MIN_TURN_SECONDS skips the whole step
70
+ * (suspension state is durable — skipping is safe). */
71
+ remainingBudgetSeconds?: number
72
+ log?: (message: string) => void
73
+ }
74
+
75
+ export interface OrchestratorTickReport {
76
+ resumed: number
77
+ reaped: number
78
+ woken: number
79
+ }
80
+
81
+ /** The registry the tick's turns run against: the in-process binding over
82
+ * the dispatcher's store, with the ticket source adapted into the backend
83
+ * `buildRegistry`'s ticket tools require (`buildMcpTicketBackend` reads a
84
+ * checkout's autobuild.toml and cannot run in origin mode). */
85
+ export function orchestratorTickRegistry(options: OrchestratorTickOptions): OperatorToolRegistry {
86
+ return buildRegistry({
87
+ store: options.store,
88
+ tickets: ticketBackendFromSource({ source: options.tickets, config: options.config }),
89
+ ...(options.sandbox !== undefined ? { sandbox: options.sandbox } : {}),
90
+ clock: options.clock,
91
+ allowedRepo: options.repo,
92
+ })
93
+ }
94
+
95
+ export function orchestratorTickRunner(options: OrchestratorTickOptions): OrchestratorTurnRunner {
96
+ return createOrchestratorTurnRunner({
97
+ store: options.store,
98
+ registry: orchestratorTickRegistry(options),
99
+ repo: options.repo,
100
+ config: options.config,
101
+ clock: options.clock,
102
+ ids: options.ids,
103
+ ...(options.model !== undefined ? { model: options.model } : {}),
104
+ ...(options.remainingBudgetSeconds !== undefined
105
+ ? { remainingBudgetSeconds: options.remainingBudgetSeconds }
106
+ : {}),
107
+ })
108
+ }
109
+
110
+ /** One pass of the orchestrator step. The caller has already evaluated both
111
+ * gates (origin mode and `enabled`); this function assumes it may run. */
112
+ export async function runOrchestratorTickStep(
113
+ options: OrchestratorTickOptions,
114
+ ): Promise<OrchestratorTickReport> {
115
+ const { store, repo, config, clock } = options
116
+ const report: OrchestratorTickReport = { resumed: 0, reaped: 0, woken: 0 }
117
+ const entry =
118
+ options.remainingBudgetSeconds === undefined
119
+ ? config.orchestrator.invocationBudgetSeconds
120
+ : options.remainingBudgetSeconds
121
+ if (entry < ORCHESTRATOR_MIN_TURN_SECONDS) return report
122
+
123
+ // The step's deadline, fixed at entry: every turn's slice is recomputed
124
+ // from the clock against THIS deadline, so the step can only consume the
125
+ // remaining slack no matter how many sessions it serves. (A fixed
126
+ // per-session value handed to each turn would let N sessions run
127
+ // N × remaining seconds past the tick deadline — f_9c1161ae.)
128
+ const deadlineMs = clock().getTime() + entry * 1000
129
+ const remainingNow = (): number =>
130
+ Math.max(0, Math.floor((deadlineMs - clock().getTime()) / 1000))
131
+
132
+ const runner = orchestratorTickRunner(options)
133
+ const sessions = await store.listSessions(repo)
134
+
135
+ // ── Resume pass ─────────────────────────────────────────────────────────
136
+ for (const record of sessions) {
137
+ const events = await store.getSessionEvents(record.id)
138
+ const state = reduceSession(events)
139
+ if (state.status !== 'suspended' || state.openTurn === undefined) continue
140
+ // Budget suspensions resume always; approval suspensions only once the
141
+ // answer has landed (`pendingApproval` gone — the answered-approval
142
+ // fallback when the resuming operator-route invocation died). An
143
+ // unanswered approval stays put until the operator answers.
144
+ if (state.pendingApproval !== undefined) continue
145
+ // An answered approval resumes with the recorded decision, so the
146
+ // runner recovers the wire approvalId and appends the response part.
147
+ let approval: { decision: 'approve' | 'deny'; toolCallId: string } | undefined
148
+ if (state.suspendedCause === 'approval') {
149
+ for (const event of [...events].reverse()) {
150
+ if (event.type === 'approval.answered' && event.payload.turn === state.openTurn.turn) {
151
+ approval = { decision: event.payload.decision, toolCallId: event.payload.toolCallId }
152
+ break
153
+ }
154
+ }
155
+ if (approval === undefined) continue
156
+ }
157
+ // Draw down the shared deadline: a session is skipped — suspension state
158
+ // is durable, so skipping is always safe — when the step's remaining
159
+ // slack no longer leaves the floor for a meaningful turn slice.
160
+ const remaining = remainingNow() - ORCHESTRATOR_MIN_TURN_SECONDS
161
+ if (remaining < ORCHESTRATOR_MIN_TURN_SECONDS) break
162
+ const resumed = await runner.resumeTurn(record.id, {
163
+ ...(approval !== undefined ? { approval } : {}),
164
+ remainingBudgetSeconds: remaining,
165
+ })
166
+ if (resumed.resumed) {
167
+ report.resumed += 1
168
+ // One turn per session per tick, never concurrent: the loop outcome
169
+ // runs to its suspension/completion before the next session.
170
+ await resumed.outcome
171
+ }
172
+ }
173
+
174
+ // ── Crash reaper ────────────────────────────────────────────────────────
175
+ const staleMs = ORCHESTRATOR_STALE_TURN_MINUTES * 60_000
176
+ for (const record of sessions) {
177
+ const events = await store.getSessionEvents(record.id)
178
+ const state = reduceSession(events)
179
+ // Reap only a session whose reduced status is exactly `running` — an
180
+ // open turn with no pending approval and no suspension. A late approve
181
+ // answer must still find its pending approval and its turn.
182
+ if (state.status !== 'running' || state.openTurn === undefined) continue
183
+ const lastChunkTs = await lastStreamActivity(store, state.openTurn.stream)
184
+ if (lastChunkTs === null) continue
185
+ if (clock().getTime() - Date.parse(lastChunkTs) < staleMs) continue
186
+ const lastSeq = events[events.length - 1]?.seq ?? 0
187
+ const reaped = await store
188
+ .appendSessionEventIfCurrent(record.id, lastSeq, {
189
+ actor: { kind: 'agent', role: 'orchestrator', session: state.openTurn.turn },
190
+ type: 'turn.failed',
191
+ payload: {
192
+ turn: state.openTurn.turn,
193
+ kind: 'internal',
194
+ error: `turn invocation ended without an outcome (no stream activity for ${ORCHESTRATOR_STALE_TURN_MINUTES} minutes)`,
195
+ },
196
+ })
197
+ .catch(() => null)
198
+ if (reaped === null) continue // a resume landed first
199
+ await store.closeStream(state.openTurn.stream, 'aborted').catch(() => undefined)
200
+ report.reaped += 1
201
+ }
202
+
203
+ // ── Wake pass ───────────────────────────────────────────────────────────
204
+ const builds = (await store.listBuilds()).filter((candidate) => candidate.repo === repo)
205
+ for (const record of sessions) {
206
+ const events = await store.getSessionEvents(record.id)
207
+ const state = reduceSession(events)
208
+ if (state.status !== 'idle') continue
209
+ if (state.wakeGlobs.length === 0) continue
210
+ let filters: RegExp[]
211
+ try {
212
+ filters = compileEventGlobs(state.wakeGlobs, {
213
+ repository: true,
214
+ usage: '[orchestrator].wake',
215
+ })
216
+ } catch {
217
+ continue // unmatchable settings wake nothing
218
+ }
219
+
220
+ // Scan every build's events after this session's per-build wake cursor,
221
+ // then the repository journal after its own cursor; select the single
222
+ // newest matching event across all scanned sources.
223
+ let newest:
224
+ | { build: string; event: AbEvent }
225
+ | { journal: true; event: RepositoryEvent }
226
+ | undefined
227
+ for (const build of builds) {
228
+ const cursor = state.wakeCursors[build.slug] ?? 0
229
+ const buildEvents = await store.getEvents(build.slug)
230
+ for (const event of buildEvents) {
231
+ if (event.seq <= cursor) continue
232
+ if (!filters.some((regex) => regex.test(event.type))) continue
233
+ if (
234
+ newest === undefined ||
235
+ event.ts > newest.event.ts ||
236
+ (event.ts === newest.event.ts && event.seq > newest.event.seq)
237
+ ) {
238
+ newest = { build: build.slug, event }
239
+ }
240
+ }
241
+ }
242
+ // The journal's seq space is independent of every build's; a `seq`
243
+ // tie-break across sources compares numbers from different spaces —
244
+ // deterministic, and only matters when two sources' newest matches share
245
+ // a millisecond timestamp.
246
+ const journalCursor = state.journalWakeCursor
247
+ const journalEvents = await store.getRepoEvents(repo, journalCursor)
248
+ for (const event of journalEvents) {
249
+ if (event.seq <= journalCursor) continue
250
+ if (!filters.some((regex) => regex.test(event.type))) continue
251
+ if (
252
+ newest === undefined ||
253
+ event.ts > newest.event.ts ||
254
+ (event.ts === newest.event.ts && event.seq > newest.event.seq)
255
+ ) {
256
+ newest = { journal: true, event }
257
+ }
258
+ }
259
+ if (newest === undefined) continue
260
+
261
+ // Same drawdown for wake turns: one turn per session per tick, bounded
262
+ // by the step's shared deadline; past the floor the wake defers to a
263
+ // later tick.
264
+ const remaining = remainingNow() - ORCHESTRATOR_MIN_TURN_SECONDS
265
+ if (remaining < ORCHESTRATOR_MIN_TURN_SECONDS) break
266
+
267
+ // A journal wake delivers the frozen event record only — the turn's
268
+ // operator registry already exposes bounded repository reads, so no
269
+ // reduced state is coupled in. A build wake delivers the build's reduced
270
+ // state as before.
271
+ const event = {
272
+ seq: newest.event.seq,
273
+ ts: newest.event.ts,
274
+ type: newest.event.type,
275
+ payload: newest.event.payload,
276
+ }
277
+ const trigger: SessionTurnTrigger =
278
+ 'journal' in newest
279
+ ? { kind: 'wake', journal: true, seq: newest.event.seq, type: newest.event.type }
280
+ : { kind: 'wake', build: newest.build, seq: newest.event.seq, type: newest.event.type }
281
+ const wake: WakeTurnInput =
282
+ 'journal' in newest
283
+ ? { event }
284
+ : {
285
+ event,
286
+ buildState: reduceBuild(await store.getEvents(newest.build)) as unknown as Record<
287
+ string,
288
+ unknown
289
+ >,
290
+ }
291
+ const start = await runner.startTurn(record.id, trigger, wake, {
292
+ remainingBudgetSeconds: remaining,
293
+ })
294
+ if (start.started) {
295
+ report.woken += 1
296
+ // The cursor advances only through the recorded trigger (the reducer
297
+ // takes `max`), so a crash between scan and start cannot skip or
298
+ // duplicate a wake — and other builds' matching events re-trigger on
299
+ // later ticks.
300
+ await start.outcome
301
+ }
302
+ }
303
+
304
+ options.log?.(
305
+ `orchestrator step: resumed=${report.resumed} reaped=${report.reaped} woken=${report.woken}`,
306
+ )
307
+ return report
308
+ }
309
+
310
+ /** The newest chunk timestamp on a stream, or its creation time when the
311
+ * stream has no chunks yet — the liveness signal the reaper reads. */
312
+ async function lastStreamActivity(store: BuildStore, streamId: string): Promise<string | null> {
313
+ const read = await store.readStream(streamId).catch(() => null)
314
+ if (read === null) return null
315
+ const last = read.chunks[read.chunks.length - 1]
316
+ if (last !== undefined) return last.ts
317
+ const record = await store.getStream(streamId)
318
+ return record?.createdAt ?? null
319
+ }
@@ -22,6 +22,12 @@ export interface SandboxEnvironmentSnapshot {
22
22
  * `[orchestrator].sandbox.idleMinutes`. */
23
23
  lastEvidenceTs: string
24
24
  sessionId?: string
25
+ /** The base branch head the fresh provision selected; carried forward
26
+ * through later lifecycle facts (the base head cannot change without a
27
+ * fresh provision, which overwrites it) — the publication precondition's
28
+ * reference point, read by the publish operation. Absent on snapshots
29
+ * provisioned before this field existed. */
30
+ baseSha?: string
25
31
  }
26
32
 
27
33
  type MutableSnapshot = {
@@ -31,6 +37,7 @@ type MutableSnapshot = {
31
37
  state: SandboxEnvironmentState
32
38
  lastEvidenceTs: string
33
39
  sessionId?: string
40
+ baseSha?: string
34
41
  }
35
42
 
36
43
  /** Per-environment sandbox state, keyed deterministically by
@@ -49,6 +56,7 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
49
56
  state: 'live',
50
57
  lastEvidenceTs: ts,
51
58
  ...(event.payload.sessionId !== undefined ? { sessionId: event.payload.sessionId } : {}),
59
+ ...(event.payload.baseSha !== undefined ? { baseSha: event.payload.baseSha } : {}),
52
60
  })
53
61
  } else if (event.type === 'orchestrator.sandbox.resumed') {
54
62
  const previous = byEnvironment.get(event.payload.environmentId)
@@ -63,6 +71,7 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
63
71
  : previous?.sessionId !== undefined
64
72
  ? { sessionId: previous.sessionId }
65
73
  : {}),
74
+ ...(previous?.baseSha !== undefined ? { baseSha: previous.baseSha } : {}),
66
75
  })
67
76
  } else if (event.type === 'orchestrator.sandbox.activity') {
68
77
  const current = byEnvironment.get(event.payload.environmentId)
@@ -94,6 +103,7 @@ export function sandboxStates(events: readonly RepositoryEvent[]): SandboxEnviro
94
103
  state: 'released',
95
104
  lastEvidenceTs: ts,
96
105
  ...(existing?.sessionId !== undefined ? { sessionId: existing.sessionId } : {}),
106
+ ...(existing?.baseSha !== undefined ? { baseSha: existing.baseSha } : {}),
97
107
  })
98
108
  }
99
109
  }
@@ -16,8 +16,39 @@ export type {
16
16
  SessionEventType,
17
17
  SessionEventWrite,
18
18
  } from '../events/sessions'
19
+ export {
20
+ DEFAULT_TICKET_ASSET_LIMITS,
21
+ effectiveTicketAssetLimits,
22
+ loadTicketAsset,
23
+ resolvePinnedAsset,
24
+ storeTicketAssetBlobs,
25
+ summarizeTicketAsset,
26
+ TICKET_ASSET_MAX_BYTES,
27
+ TICKET_ASSET_MAX_ENTRIES,
28
+ TicketAssetValidationError,
29
+ validateTicketAssetInput,
30
+ } from './ticket-assets'
31
+ export type {
32
+ TicketAsset,
33
+ TicketAssetContentEntry,
34
+ TicketAssetEntry,
35
+ TicketAssetEntryInput,
36
+ TicketAssetInput,
37
+ TicketAssetLayout,
38
+ TicketAssetLimits,
39
+ TicketAssetMeta,
40
+ TicketAssetSummary,
41
+ } from './ticket-assets'
42
+ export { validateCreatedEvent } from './new-build'
19
43
  export { createBuildScopedStore } from './build-scope'
20
44
  export { createSessionScopedStore } from './session-handle'
45
+ export { DIGEST_EVENT_TYPES, reduceBuildDigest } from './digest'
46
+ export {
47
+ projectRepositoryStateEvents,
48
+ REPOSITORY_RUN_SCOPED_EVENT_TYPES,
49
+ REPOSITORY_STATE_EVENT_TYPES,
50
+ readRepoStateEventsWithAnchorRecheck,
51
+ } from './repo-state-events'
21
52
  export {
22
53
  DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
23
54
  DISPATCHER_RETENTION_BUILD_KINDS,
@@ -61,6 +92,7 @@ export type {
61
92
  ArtifactInput,
62
93
  ArtifactMeta,
63
94
  BlobStore,
95
+ BuildDigest,
64
96
  BuildRecord,
65
97
  BuildScopedStore,
66
98
  BuildStore,
@@ -13,6 +13,7 @@ import type {
13
13
  Artifact,
14
14
  ArtifactInput,
15
15
  ArtifactMeta,
16
+ BuildDigest,
16
17
  BuildRecord,
17
18
  BuildScopedStore,
18
19
  BuildStore,
@@ -25,6 +26,13 @@ import type {
25
26
  SubscribeOptions,
26
27
  Unsubscribe,
27
28
  } from './types'
29
+ import type {
30
+ TicketAsset,
31
+ TicketAssetInput,
32
+ TicketAssetLimits,
33
+ TicketAssetMeta,
34
+ TicketAssetSummary,
35
+ } from './ticket-assets'
28
36
  import type {
29
37
  StreamChunk,
30
38
  StreamOutcome,
@@ -179,6 +187,12 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
179
187
  getRepoEvents(repo: string, _sinceSeq?: number): Promise<RepositoryEvent[]> {
180
188
  return Promise.reject(new BuildScopeError(scope, 'getRepoEvents', repo))
181
189
  },
190
+ getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
191
+ return Promise.reject(new BuildScopeError(scope, 'getRepoStateEvents', repo))
192
+ },
193
+ getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
194
+ return Promise.reject(new BuildScopeError(scope, 'getRepoBuildDigests', repo))
195
+ },
182
196
  putRepoArtifact(repo: string, _artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
183
197
  return Promise.reject(new BuildScopeError(scope, 'putRepoArtifact', repo))
184
198
  },
@@ -192,6 +206,49 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
192
206
  listRepoArtifacts(repo: string, _kind?: string): Promise<RepositoryArtifactMeta[]> {
193
207
  return Promise.reject(new BuildScopeError(scope, 'listRepoArtifacts', repo))
194
208
  },
209
+ ticketAssetLimits(repo: string): Promise<TicketAssetLimits> {
210
+ return Promise.reject(new BuildScopeError(scope, 'ticketAssetLimits', repo))
211
+ },
212
+ putTicketAsset(
213
+ repo: string,
214
+ _ticketId: string,
215
+ _asset: TicketAssetInput,
216
+ ): Promise<TicketAssetMeta> {
217
+ return Promise.reject(new BuildScopeError(scope, 'putTicketAsset', repo))
218
+ },
219
+ getTicketAsset(
220
+ repo: string,
221
+ _ticketId: string,
222
+ _kind: string,
223
+ _name: string,
224
+ _rev?: number,
225
+ ): Promise<TicketAsset | null> {
226
+ return Promise.reject(new BuildScopeError(scope, 'getTicketAsset', repo))
227
+ },
228
+ async getPinnedTicketAsset(
229
+ slug: string,
230
+ kind: string,
231
+ name: string,
232
+ rev?: number,
233
+ ): Promise<TicketAsset | null> {
234
+ own('getPinnedTicketAsset', slug)
235
+ return store.getPinnedTicketAsset(slug, kind, name, rev)
236
+ },
237
+ listTicketAssets(
238
+ repo: string,
239
+ _ticketId: string,
240
+ _opts?: { revisions?: boolean },
241
+ ): Promise<TicketAssetSummary[]> {
242
+ return Promise.reject(new BuildScopeError(scope, 'listTicketAssets', repo))
243
+ },
244
+ removeTicketAsset(
245
+ repo: string,
246
+ _ticketId: string,
247
+ _kind: string,
248
+ _name: string,
249
+ ): Promise<TicketAssetMeta | null> {
250
+ return Promise.reject(new BuildScopeError(scope, 'removeTicketAsset', repo))
251
+ },
195
252
  claimRepoLease(repo: string, _holder: string, _ttlMs: number): Promise<boolean> {
196
253
  return Promise.reject(new BuildScopeError(scope, 'claimRepoLease', repo))
197
254
  },
@@ -216,6 +273,13 @@ export function createBuildScopedStore(store: BuildStore, scope: string): BuildS
216
273
  ): Promise<SessionEventEnvelope<T>> {
217
274
  return Promise.reject(new BuildScopeError(scope, 'appendSessionEvent', id))
218
275
  },
276
+ appendSessionEventIfCurrent<T extends SessionEventType>(
277
+ id: string,
278
+ _expectedSeq: number,
279
+ _event: SessionEventWrite<T>,
280
+ ): Promise<SessionEventEnvelope<T> | null> {
281
+ return Promise.reject(new BuildScopeError(scope, 'appendSessionEventIfCurrent', id))
282
+ },
219
283
  getSessionEvents(id: string, _sinceSeq?: number): Promise<SessionEvent[]> {
220
284
  return Promise.reject(new BuildScopeError(scope, 'getSessionEvents', id))
221
285
  },