@defrex/autobuild 0.9.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 (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
package/README.md CHANGED
@@ -67,7 +67,7 @@ usable on your machine.
67
67
  <!-- release-install:start -->
68
68
 
69
69
  ```sh
70
- bun add -g @defrex/autobuild@0.9.0
70
+ bun add -g @defrex/autobuild@0.10.0
71
71
  ```
72
72
 
73
73
  <!-- release-install:end -->
package/SPEC.md CHANGED
@@ -241,6 +241,16 @@ same BuildStore. Consequences, by design:
241
241
 
242
242
  - **State is a reduction of events.** Any state snapshot is a cache, never
243
243
  the source of truth. Resumability falls out.
244
+ - **Reducer snapshots are a cache the log regenerates, never an authority.**
245
+ The store may keep, per scope (one build log or the repository journal) and
246
+ reducer, that reducer's serialized state as of a cursor, keyed by scope,
247
+ reducer, reducer version, and cursor, so a process with no hot memory loads
248
+ it, reads only the newer events, and advances. A missing snapshot, one
249
+ written by a different reducer version, or one ahead of the log is ignored
250
+ and the state is rebuilt by full replay with identical results; stored
251
+ cursors never decrease; deleting every snapshot changes no observable
252
+ output. A store without snapshot storage behaves as if every snapshot were
253
+ missing.
244
254
  - **The UI layer is a subscriber** plus a command channel back. TUI, web —
245
255
  same adapter pattern. The shipped interactive terminal owns a supervised
246
256
  kernel child, and the BuildStore is their only changing-state boundary.
@@ -131,6 +131,115 @@ session's status, open turn, pending approval, and wake cursors from its own
131
131
  closed catalog. No decision anywhere
132
132
  consults a snapshot in place of the append-only log.
133
133
 
134
+ **Incremental reducers.** Every pure projection of an event array is a fold plus
135
+ a final derivation, defined once with `defineReducer` in
136
+ `packages/core/src/kernel/incremental.ts`: `initial()` builds an empty
137
+ accumulator, `advance(acc, events)` folds in only the events newer than the
138
+ accumulator, and `finish(acc)` derives the public state. The whole-array
139
+ functions (`reduceBuild`, `reduceHarvest`, `reduceSession`, and the rest) are
140
+ wrappers over the same fold, so results cannot diverge, and a property test
141
+ checks every split point of fixture and generated logs. The accumulator is plain
142
+ JSON, so it survives a serialize-and-parse round trip. `advance` never mutates
143
+ its input: it clones, then folds, so a state derived earlier is never disturbed
144
+ by a later advance. Each reducer publishes a `version`; bump it whenever the
145
+ accumulator shape or the fold semantics change, because a cached accumulator is
146
+ only valid for the version that wrote it. `kernel/reducer-registry.ts` lists
147
+ every reducer with its version, and its snapshot test fails when an accumulator
148
+ shape changes without a bump. Most callers still read whole logs (the terminal
149
+ dashboard poll advances a carried build accumulator by per-build deltas; the
150
+ dispatcher view below restores from stored accumulators).
151
+
152
+ **Reducer snapshots.** The `BuildStore` port can keep, per scope (one build log
153
+ or the repository journal) and registered reducer, that reducer's accumulator as
154
+ of a cursor: `getReducerSnapshot(scope, reducer, version)` and
155
+ `putReducerSnapshot(scope, reducer, {version, cursor, state})`
156
+ (`packages/core/src/store/snapshots.ts`). The log stays the only authority. The
157
+ store enforces the invariants: the stored cursor never decreases (a write whose
158
+ cursor does not advance is a no-op, so concurrent writers cannot undo each
159
+ other), a row of another reducer version reads as absent and is replaced once a
160
+ writer advances past it, and a cursor beyond the log's tail is refused and never
161
+ returned. Every consumer treats a missing, mismatched, ahead-of-log, malformed
162
+ or unreadable snapshot as a miss and replays the log, with identical results;
163
+ deleting all snapshots changes no observable output (the integration harness
164
+ audits this for every scenario at cleanup). Memory, SQLite, Postgres and the
165
+ remote client implement it, and the hosted store service serves it on
166
+ `GET|PUT /builds|repos/{id}/snapshots/{reducer}`
167
+ (`docs/remote-store-protocol.md`). SQLite creates the `reducer_snapshots` table
168
+ at open. Postgres keeps it outside the versioned schema: `reducer_snapshots` is
169
+ created by the idempotent `SNAPSHOT_SCHEMA_DDL` in `migratePostgres`, with no
170
+ marker, version or checksum change, so a deployment at the current schema keeps
171
+ serving across the upgrade, a new client against a database that lacks the table
172
+ reads every snapshot as missing and replays (SQLSTATE `42P01` is never an
173
+ error), and an older client never asks. Run `bun run postgres:migrate` once to
174
+ gain the table; until then the dispatcher and every other reader replay in full.
175
+
176
+ **Dispatcher repository view.** A dispatcher process builds its view of the
177
+ repository once and refreshes it by deltas
178
+ (`packages/core/src/processes/repo-view.ts`). `RepoViewStore` decorates the
179
+ `BuildStore` port: the first `refresh()` reads the journal's high-water mark
180
+ (`getRepoHighWater`), the bounded journal subset, the build listing and digests,
181
+ and the full log of every build that still has work (the same predicate the tick
182
+ loops use); every later `refresh()` reads only events newer than its cursors —
183
+ one journal delta, one delta per held build, plus the listing and digest reads
184
+ that discover new builds. Every tick stage reads and writes through the view, so
185
+ an event a stage appends is folded in at once and later stages in the same tick
186
+ see it with no re-read; a foreign append interleaved with ours leaves a seq gap
187
+ that marks the log dirty, a lost compare-and-set does the same, and any delta
188
+ that is not contiguous from its cursor discards the log and re-reads it in full,
189
+ so no event is skipped. Every state transition of one source (a build, the
190
+ journal) runs on that source's single promise chain — snapshot and delta reads,
191
+ window opening and widening, the fold after an own append, a lost
192
+ compare-and-set's invalidation — and readers queue on it too, so overlapping
193
+ readers join the repair in flight instead of observing half-applied state.
194
+ Decisions that deliberately re-read at the last moment
195
+ (`refreshBuild`) take an explicit delta. Cursor-bearing readers such as the
196
+ orchestrator wake pass get per-source windows that hold only events above the
197
+ lowest session cursor. A `--once` invocation builds the view cold and reads each
198
+ log once; a long-running dispatcher pays that replay at its first tick and not
199
+ again.
200
+
201
+ The view restores from reducer snapshots on cold start. The journal comes from
202
+ the `journalView` reducer (the journal pruned on arrival: durable types plus the
203
+ tail from the latest `dispatcher.run-started`); each work build comes from one
204
+ `work` snapshot row — the `build`, `logIndex`, `openExecution`,
205
+ `openBuildWorkspace`, `publicationState`, `lastExecutionOutcome`, `buildDigest`,
206
+ `dashboardFacts` and `currentDeferralObservation` accumulators at a single
207
+ cursor. One row is one atomic write on every adapter, so a reader in another
208
+ process sees the old bundle or the new, never a mix; the bundle version is
209
+ derived from the component reducer versions, so a stale bundle misses and the
210
+ build is replayed once. The view
211
+ reads only the events newer than that cursor and holds, for a snapshot-backed
212
+ build, just that delta plus the reduced state of everything before it, so a tick
213
+ with no new events reads zero event rows (apart from the discovery listing and
214
+ digests) and one with M new events reads exactly M. Tick stages that run for every
215
+ build each tick take `buildFacts(slug)` (state, log index, open execution, open
216
+ workspace, publication view) instead of the event array, and read raw history
217
+ only on the branches that act on it. `persistSnapshots()` writes the advanced
218
+ state back at the end of each tick and on shutdown — best effort, throttled in a
219
+ resident dispatcher, always run by a cold single-tick process — and never writes
220
+ a cursor that was not read from the log.
221
+
222
+ **Operator reads.** The web dashboard, the repository and Harvest status
223
+ routes, the operator build listings, and the `ab watch`/`ab wait` discovery
224
+ passes derive from that same view instead of replaying the log
225
+ (`packages/core/src/operator/read-view.ts`). A dashboard row renders from the
226
+ `BuildState` plus the `dashboardFacts` reducer
227
+ (`packages/core/src/cli/dashboard/facts.ts`: queued-row dispatch text, session
228
+ brackets, phase intervals, pause time, and the per-round loop-output seqs) and
229
+ the auto-merge deferral ledger; `projectBuild(…, events)` is the same projection
230
+ over the whole-array reduction, so the snapshot and replay paths cannot differ.
231
+ Within one serving process, passes are single-flight per repository: requests
232
+ arriving while a pass runs join the one trailing pass, which restores from the
233
+ snapshot the previous pass persisted. A poll at idle therefore reads no event
234
+ rows beyond snapshot lookups and build discovery, and a poll after M new events
235
+ reads those M rows once, however many tabs or watchers the process serves.
236
+ Serving processes do not coordinate: with P of them, each may read the same M
237
+ rows once, so duplicate reads are bounded by P and never by history, tabs, or
238
+ watchers. A settled build listed under `--all` is restored from the snapshot the
239
+ dispatcher left at settlement, or read once and then persisted. The first
240
+ operator poll after upgrading replays each work build once, because the stored
241
+ bundle layout changed.
242
+
134
243
  **Session Store authority.** `packages/core/src/cli/binary.ts` validates a complete build or
135
244
  Harvest ambient tuple before opening its phase Store through
136
245
  `packages/core/src/cli/store-opening.ts`. `packages/core/src/cli/env.ts` also classifies optional ambient
@@ -202,7 +202,7 @@ These fields hot-reload:
202
202
  | `[workspace]` provider and config (including `provisioning`) | Next launch of a build whose OWN branch carries it. A base-branch reload never re-provisions an in-flight build. |
203
203
  | `[roles]` | Next agent invocation for that role. Runtime, model, args, alternates, and the session budget are captured together and delivered live. |
204
204
  | `[policy]` | Next policy, convergence, or agent-session boundary, delivered live. A running session keeps its captured budget. |
205
- | `tickets.readyLabels`, `tickets.readyState` | Next ready-ticket scan. |
205
+ | `tickets.readyLabels`, `tickets.readyState`, `tickets.readyAssignee` | Next ready-ticket scan. |
206
206
  | `tickets.triageState`, `tickets.proposalState` | Next dispatcher handback or harvest filing boundary. |
207
207
 
208
208
  Adapter selectors and factory inputs are constructed once and require a
@@ -1167,6 +1167,7 @@ state. Within a present table, `source` and `readyState` are required.
1167
1167
  | `source` | — | required nonblank builtin or plugin registration name | Select the ticket adapter after configured plugins load. |
1168
1168
  | `readyLabels` | source-aware | optional array of nonempty strings; `[]` allowed | Require every listed label in addition to the state gate. |
1169
1169
  | `readyState` | — | required nonblank string | The one workflow state eligible for dispatch. |
1170
+ | `readyAssignee` | — | optional; `"me"` or an email address; Linear only (rejected for file, hosted, and plugin sources) | Require the ticket to be assigned to one Linear user, in addition to the state and label gates. |
1170
1171
  | `teamKey` | — | required for Linear and hosted; forbidden for file; optional for plugins | Backend team key such as `"ENG"`, or an existing plugin config field. |
1171
1172
  | `claimedState` | `"In Progress"` for direct Linear; hosted backend default otherwise | optional nonempty string; forbidden for file; allowed for plugins | Workflow state entered when a ticket is claimed. |
1172
1173
  | `createState` | provider default | optional nonempty string | Default state for newly created tickets when `ab ticket create` omits `--state`. |
@@ -1174,13 +1175,33 @@ state. Within a present table, `source` and `readyState` are required.
1174
1175
  | `proposalState` | the resolved `triageState` | optional nonempty string | State harvest files its synthesized proposals into. Setting it to `readyState` waives the human grooming gate, not dependency eligibility. |
1175
1176
  | `dir` | file: selected local state root's `tickets/`; plugin: omitted | optional nonempty path; forbidden for Linear; allowed for plugins | Root containing file-source state directories, or an existing plugin config field. Relative file paths resolve from the repository. |
1176
1177
 
1178
+ `readyAssignee = "me"` resolves to the Linear user who owns `LINEAR_API_KEY`,
1179
+ so one committed value makes each person's local dispatcher take only the work
1180
+ assigned to them; an email names a specific user, for a shared or bot key. The
1181
+ gate is conjunctive with `readyState` and `readyLabels`, and a ticket with no
1182
+ assignee is never ready while it is set. It applies to every ready surface
1183
+ (dispatch, the queued count, and `ab ticket list` without filters); blockers
1184
+ still prevent claim. It is empty or blank, or neither `"me"` nor an email,
1185
+ fails validation. `ab init --validate` prints the user the gate resolves to and
1186
+ fails when an email matches no workspace user. The field reloads without a
1187
+ restart.
1188
+
1177
1189
  When `readyLabels` is absent, direct Linear uses `["autobuild"]`; hosted, file,
1178
1190
  and plugin sources use `[]`, meaning no host-imposed label gate. An explicit value always
1179
1191
  wins. A nonempty list is
1180
1192
  conjunctive: every configured label must be present. `readyState` remains
1181
1193
  mandatory regardless of labels. Linear compares state and label names exactly
1182
- and case-sensitively. The file source accepts state names case-insensitively and
1183
- canonicalizes them to `triage/`, `ready/`, `doing/`, or `done/`.
1194
+ and case-sensitively. The file source matches state names case-insensitively against the directories
1195
+ under the tracker root: the four lifecycle directories (`triage/`, `ready/`,
1196
+ `doing/`, `done/`, always present) plus any directory the repository adds, such
1197
+ as `icebox/`. A name matching no directory is an error listing those that
1198
+ exist, so creating a state stays an explicit act: `ab ticket state create <name>
1199
+ [--about <text>]` makes one, and `ab ticket states` lists every state with its
1200
+ roles, ticket count, and purpose. A purpose is stored as a one-line `.about` file
1201
+ inside the state directory, so it travels with a git-tracked `dir` and is never
1202
+ read as a ticket. `createState`, `readyState`,
1203
+ `triageState`, and `proposalState` may each name any existing directory;
1204
+ `ab ticket list` with no filter still lists only `readyState`.
1184
1205
 
1185
1206
  Source-specific validation is strict:
1186
1207
 
@@ -297,6 +297,8 @@ it to exist. An unknown build returns `404 not-found`.
297
297
  | `appendIfCurrent` | `POST /builds/{slug}/events/conditional` | `{"expectedSeq": nonnegative integer, "event": event write}` | appended: `201` + build event envelope; stream advanced: `200 null` |
298
298
  | `getEvents` | `GET /builds/{slug}/events?since={n}&wait={s}` | optional `since` query value, parsed below; absence defaults to `0`. Optional `wait` in whole seconds, parsed per the event-wait rules below | `200` + envelopes whose `seq` is strictly greater than parsed `since`, in increasing sequence order |
299
299
  | `appendWithArtifacts` | `POST /builds/{slug}/deposits` | atomic deposit request | `201` + `{event, artifacts}` |
300
+ | `getReducerSnapshot` | `GET /builds/{slug}/snapshots/{reducer}?version={n}` | required `version` query value, a positive integer; `reducer` is a registry name (`^[A-Za-z][A-Za-z0-9._-]{0,63}$`) | `200` + `{"version", "cursor", "state"}` or `200 null` when absent, written by another reducer version, or ahead of the build log's tail; an unknown build is `404`, which the shipped client also maps to `null` |
301
+ | `putReducerSnapshot` | `PUT /builds/{slug}/snapshots/{reducer}` | `{"version": int >= 1, "cursor": int >= 0, "state": JSON}`, at most 16 MiB (`413` above) | `200 {"written": boolean}`; the stored cursor never decreases, so a write at an equal or lower cursor (or an equal cursor at an equal or lower version) and one past the log's tail answer `written: false` |
300
302
  | `putArtifact` | `POST /builds/{slug}/artifacts` | artifact input | `201` + build artifact metadata |
301
303
  | `getArtifact` | `GET /builds/{slug}/artifacts?kind={kind}&rev={n}` | `kind` is required and nonempty; optional `rev` is parsed below | `200` + artifact read; an absent `rev` parameter selects the latest revision; a missing kind/revision is `200 null` |
302
304
  | `listArtifacts` | `GET /builds/{slug}/artifact-list?kind={kind}` | optional `kind`; absence means all kinds | `200` + metadata ordered by kind and then increasing revision |
@@ -336,6 +338,32 @@ part of this protocol. Retention is enforced server-side inside the store at
336
338
  deposit time (bounded pruning of dispatcher run/config artifact revisions),
337
339
  not through any endpoint.
338
340
 
341
+ ### Reducer snapshots
342
+
343
+ A reducer snapshot is one registered reducer's serialized state as of a log
344
+ cursor, kept per scope (one build log or one repository journal) and reducer
345
+ name. It is a cache the log regenerates, never an authority: a process with no
346
+ hot memory loads it, reads only the events newer than its cursor, advances the
347
+ reducer, and writes the advanced state back, and deleting every snapshot changes
348
+ no observable output. The routes above and in section 4 are additive and carry
349
+ no protocol-version bump. The store, not the client, enforces the invariants:
350
+
351
+ - the stored cursor never decreases, whatever the versions of the competing
352
+ writers; a write succeeds only when no row exists, when its cursor is above the
353
+ stored one, or when its cursor equals the stored one and its version is higher
354
+ (an upgraded reducer replacing the old shape at an unchanged tail);
355
+ - a read answers `null` for a row whose version differs from the requested one,
356
+ and for one whose cursor is beyond the scope's log tail; a write beyond the
357
+ tail is refused;
358
+ - an unknown build or repository answers `null` (read) or `written: false`
359
+ (write) and never creates the scope.
360
+
361
+ A client treats `404` and `405` from either route as "no snapshot", so a client
362
+ newer than its store replays the log with no error, and a client older than its
363
+ store never calls the routes. Authentication and transport failures still
364
+ surface normally. A build token may touch only its own build's snapshots and a
365
+ repository token only its own repository's, exactly as for events.
366
+
339
367
  ### The event-read bounded wait
340
368
 
341
369
  Both event reads (`getEvents` and, symmetrically, the `getRepoEvents` read of
@@ -382,6 +410,8 @@ without a journal record, or a repository without builds.
382
410
  | `appendRepo` | `POST /repos/{repo}/events` | event write | `201` + repository event envelope |
383
411
  | `getRepoEvents` | `GET /repos/{repo}/events?since={n}&wait={s}` | optional `since` query value, parsed as in section 3; absence defaults to `0`. Optional `wait` in whole seconds, per the event-wait rules of section 3 | `200` + envelopes with `seq >` parsed `since`, in increasing sequence order |
384
412
  | `getRepoStateEvents` | `GET /repos/{repo}/state-events` | none | `200` + the bounded repository-journal subset (AUT-489): every durable event type (`harvest.*`, `orchestrator.sandbox.*`, the three dispatcher setting types) plus, when the journal has one, the tail from the latest `dispatcher.run-started`, in increasing sequence order; served inside the repository-existence gate like `GET events`, so an unknown repository is `404` |
413
+ | `getReducerSnapshot` (repo scope) | `GET /repos/{repo}/snapshots/{reducer}?version={n}` | required `version` query value, a positive integer; `reducer` is a registry name (`^[A-Za-z][A-Za-z0-9._-]{0,63}$`) | `200` + `{"version", "cursor", "state"}` or `200 null` when absent, written by another reducer version, ahead of the journal's tail, or the repository has no journal record; an unknown repository is `404`, which the shipped client also maps to `null` |
414
+ | `putReducerSnapshot` (repo scope) | `PUT /repos/{repo}/snapshots/{reducer}` | `{"version": int >= 1, "cursor": int >= 0, "state": JSON}`, at most 16 MiB (`413` above) | `200 {"written": boolean}`; the stored cursor never decreases, so a write at an equal or lower cursor (or an equal cursor at an equal or lower version) and one past the journal's tail answer `written: false` |
385
415
  | `appendRepoWithArtifacts` | `POST /repos/{repo}/deposits` | atomic deposit request | `201` + `{event, artifacts}` using repository shapes |
386
416
  | `putRepoArtifact` | `POST /repos/{repo}/artifacts` | artifact input | `201` + repository artifact metadata |
387
417
  | `getRepoArtifact` | `GET /repos/{repo}/artifacts?kind={kind}&rev={n}` | required nonempty `kind`; optional `rev` is parsed as in section 3 | `200` + artifact read; latest only when the `rev` parameter is absent; missing kind/revision is `200 null` |
@@ -834,6 +864,11 @@ resource access; an explicit resource whose id is `"*"` is not admin.
834
864
  | repo | no | no | no | exact id only | yes | exact id only | exact id only | exact repo only | no |
835
865
  | session | no | no | no | no | no | no | no | no | exact id only |
836
866
 
867
+ The snapshot routes (`/builds/{slug}/snapshots/{reducer}` and
868
+ `/repos/{repo}/snapshots/{reducer}`) follow the one-matching-build and
869
+ one-matching-repo columns: a build token reads and writes only its own build's
870
+ snapshots, a repo token only its own repository's.
871
+
837
872
  A valid token used for the wrong resource receives `403 auth`. Resource scope
838
873
  gates all operations, including reads, artifact operations, and leases. A
839
874
  session resource token's authority is exactly its one session — it cannot
package/docs/website.md CHANGED
@@ -5,6 +5,10 @@ The marketing site is one static page in `packages/website`, reproduced from the
5
5
  - `bun run website:dev` serves it on port 3300 (`PORT` overrides), rebuilding on every request.
6
6
  - `bun run website:build` writes `packages/website/dist/` (`index.html`, `site.css`, `site.js`).
7
7
 
8
+ The hero is the real `ab dispatch` dashboard: `packages/website/src/hero-frame.txt` holds the exact colored lines the terminal dashboard painted for the scripted `website-hero` capture, and the page renders them as preformatted HTML with each terminal color mapped to its design token. `bun run capture:website-hero` regenerates the file from the capture, and `bun run check` fails when it is stale.
9
+
10
+ Shared links preview through Open Graph and Twitter card tags in the page head, pointing at `og.png`, a 1200×630 card drawn from the same frame in the site's own styles. `bun run capture:website-og` screenshots it with a local Chromium into `packages/website/src/og.png`; rerun it after changing the headline, the hero frame, or the styles, and look at the result, since font rasterization differs between machines and nothing checks the bytes. The canonical origin every absolute URL is built from is `SITE_URL` in `packages/website/src/constants.ts`.
11
+
8
12
  `dist/` is plain static files: any static host can serve it with no server runtime, credentials, or environment variables. On Vercel, set the root directory to `packages/website`, the build command to `bun run build`, and the output directory to `dist`.
9
13
 
10
14
  The page loads one webfont from Google Fonts and makes no other third-party requests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Agent-driven software delivery from groomed ticket to merged PR",
5
5
  "repository": {
6
6
  "type": "git",
@@ -117,9 +117,11 @@
117
117
  "website:dev": "bun packages/website/src/dev.ts",
118
118
  "website:build": "bun packages/website/src/build.ts",
119
119
  "capture:readme-headline": "bun tools/readme-headline.ts",
120
+ "capture:website-hero": "bun tools/website-hero.ts",
121
+ "capture:website-og": "bun tools/website-og.ts",
120
122
  "release": "bun tools/release.ts",
121
123
  "postgres:migrate": "bun packages/postgres-store/src/bin.ts migrate",
122
- "check": "bun tools/biome-check.ts && bun tools/product-name-check.ts && bun tools/docs-asset-check.ts && bun tools/skill-docs-asset-check.ts && bun tools/readme-headline.ts --check && bun tools/workspace-manifest-check.ts && bun tools/package-boundary-check.ts && bun tools/bin-mode-check.ts && bun tools/publish-contents-check.ts && bun tools/publish-imports-check.ts",
124
+ "check": "bun tools/biome-check.ts && bun tools/product-name-check.ts && bun tools/docs-asset-check.ts && bun tools/skill-docs-asset-check.ts && bun tools/readme-headline.ts --check && bun tools/website-hero.ts --check && bun tools/workspace-manifest-check.ts && bun tools/package-boundary-check.ts && bun tools/bin-mode-check.ts && bun tools/publish-contents-check.ts && bun tools/publish-imports-check.ts",
123
125
  "format:staged": "git-format-staged --formatter './node_modules/.bin/biome format --stdin-file-path {}' '*.js' '*.jsx' '*.ts' '*.tsx' '*.json' '*.jsonc' '*.css' '*.graphql' '*.gql'",
124
126
  "prepare": "husky",
125
127
  "test": "bun test packages/*/src tools bin packages/hosted-store-service/app",
@@ -137,8 +139,8 @@
137
139
  },
138
140
  "devDependencies": {
139
141
  "@biomejs/biome": "2.5.5",
140
- "@defrex/autobuild-postgres-store": "0.8.0",
141
- "@defrex/autobuild-vercel-sandbox": "0.8.0",
142
+ "@defrex/autobuild-postgres-store": "0.9.0",
143
+ "@defrex/autobuild-vercel-sandbox": "0.9.0",
142
144
  "@resvg/resvg-js": "2.6.2",
143
145
  "@types/bun": "^1.3.14",
144
146
  "@types/node": "^24.0.0",
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The facts a dashboard row reads from a build's event array, as an
3
+ * incremental reducer: the queued-row dispatch text, the session brackets, the
4
+ * per-phase wall-clock intervals, the pause timestamp, and the seqs of the four
5
+ * loop outputs. `projectBuildFromFacts` renders a row from these plus the
6
+ * reduced `BuildState`, so a row derives from a stored accumulator and a delta
7
+ * read with no event array, and `projectBuild(…, events, …)` is the same
8
+ * projection over `dashboardFactsReducer.reduce(events)` — one implementation,
9
+ * so the snapshot and replay paths agree by construction.
10
+ *
11
+ * The accumulator is plain JSON (see `kernel/incremental.ts`).
12
+ */
13
+ import type { AbEvent } from '../../events/catalog'
14
+ import { defineReducer } from '../../kernel/incremental'
15
+ import { verifyPhase } from '../../ontology'
16
+
17
+ export const DASHBOARD_FACTS_REDUCER_VERSION = 1
18
+
19
+ /** One phase occurrence's wall-clock span, keyed by phase. `startSeq` is the
20
+ * seq of the `*.started` event, so callers can scope by the same seq
21
+ * boundaries (`restartSince`/`cycleSince`) the step STATES already use. */
22
+ export interface PhaseInterval {
23
+ start: number
24
+ end: number
25
+ startSeq: number
26
+ }
27
+
28
+ export interface PhaseTiming {
29
+ closed: PhaseInterval[]
30
+ open?: { start: number; startSeq: number }
31
+ }
32
+
33
+ export interface DispatchFailureFact {
34
+ seq: number
35
+ stage: string
36
+ attempt: number
37
+ error: string
38
+ }
39
+
40
+ /** Max seq per round of one loop output event. */
41
+ export type RoundSeqs = Record<string, number>
42
+
43
+ type SessionEvent = Extract<AbEvent, { type: 'session.started' | 'session.ended' }>
44
+
45
+ export interface DashboardFacts {
46
+ /** Seq of the latest event of each queued-row kind (`undefined` until seen). */
47
+ created?: number
48
+ workspaceProvisioned?: number
49
+ workspaceReleased?: number
50
+ specLanded?: number
51
+ commentPosted?: number
52
+ latestFailure?: DispatchFailureFact
53
+ /** The session brackets, in log order; `projectSessions` pairs them. */
54
+ sessionEvents: SessionEvent[]
55
+ intervals: Record<string, PhaseTiming>
56
+ /** Timestamp of the latest `build.paused`. */
57
+ pausedAt?: string
58
+ produced: {
59
+ planCompleted: RoundSeqs
60
+ planReviewVerdict: RoundSeqs
61
+ implementCompleted: RoundSeqs
62
+ codeReviewVerdict: RoundSeqs
63
+ }
64
+ }
65
+
66
+ /** Whether a loop output of `round` landed after `since` (the restart boundary). */
67
+ export function producedAfter(seqs: RoundSeqs, round: number, since: number): boolean {
68
+ const seq = seqs[String(round)]
69
+ return seq !== undefined && seq > since
70
+ }
71
+
72
+ function raise(seqs: RoundSeqs, round: number, seq: number): void {
73
+ const key = String(round)
74
+ const current = seqs[key]
75
+ seqs[key] = current === undefined ? seq : Math.max(current, seq)
76
+ }
77
+
78
+ function foldInterval(intervals: Record<string, PhaseTiming>, ev: AbEvent): void {
79
+ const ms = Date.parse(ev.ts)
80
+ const get = (key: string): PhaseTiming => {
81
+ let t = intervals[key]
82
+ if (t === undefined) {
83
+ t = { closed: [] }
84
+ intervals[key] = t
85
+ }
86
+ return t
87
+ }
88
+ const open = (key: string): void => {
89
+ get(key).open = { start: ms, startSeq: ev.seq }
90
+ }
91
+ const close = (key: string): void => {
92
+ const t = get(key)
93
+ if (t.open === undefined) return
94
+ t.closed.push({ start: t.open.start, end: ms, startSeq: t.open.startSeq })
95
+ delete t.open
96
+ }
97
+ // Each `*.started` opens an interval for its phase key and its terminal event
98
+ // (`.completed`/`.verdict`) closes it. A second `*.started` while one is still
99
+ // open REPLACES the open start — a §15.6-C cross-sandbox re-run starts the
100
+ // phase afresh, so the crashed attempt contributes nothing. Finalize
101
+ // post-steps (`finalize.step-completed`) have no `.started` and no interval.
102
+ switch (ev.type) {
103
+ case 'plan.started':
104
+ open('plan')
105
+ break
106
+ case 'plan.completed':
107
+ close('plan')
108
+ break
109
+ case 'plan-review.started':
110
+ open('plan-review')
111
+ break
112
+ case 'plan-review.verdict':
113
+ close('plan-review')
114
+ break
115
+ case 'implement.started':
116
+ open('implement')
117
+ break
118
+ case 'implement.completed':
119
+ close('implement')
120
+ break
121
+ case 'code-review.started':
122
+ open('code-review')
123
+ break
124
+ case 'code-review.verdict':
125
+ close('code-review')
126
+ break
127
+ case 'verify.started':
128
+ open(verifyPhase(ev.payload.step))
129
+ break
130
+ case 'verify.completed':
131
+ close(verifyPhase(ev.payload.step))
132
+ break
133
+ case 'finalize.started':
134
+ open('finalize')
135
+ break
136
+ case 'finalize.completed':
137
+ close('finalize')
138
+ break
139
+ case 'reconcile.started':
140
+ open('reconcile')
141
+ break
142
+ case 'reconcile.completed':
143
+ close('reconcile')
144
+ break
145
+ default:
146
+ break
147
+ }
148
+ }
149
+
150
+ export const dashboardFactsReducer = defineReducer<DashboardFacts, AbEvent, DashboardFacts>({
151
+ version: DASHBOARD_FACTS_REDUCER_VERSION,
152
+ initial: () => ({
153
+ sessionEvents: [],
154
+ intervals: {},
155
+ produced: {
156
+ planCompleted: {},
157
+ planReviewVerdict: {},
158
+ implementCompleted: {},
159
+ codeReviewVerdict: {},
160
+ },
161
+ }),
162
+ fold(acc, events) {
163
+ for (const event of events) {
164
+ foldInterval(acc.intervals, event)
165
+ switch (event.type) {
166
+ case 'build.created':
167
+ acc.created = event.seq
168
+ break
169
+ case 'workspace.provisioned':
170
+ acc.workspaceProvisioned = event.seq
171
+ break
172
+ case 'workspace.released':
173
+ acc.workspaceReleased = event.seq
174
+ break
175
+ case 'spec.imported':
176
+ case 'spec.authored':
177
+ acc.specLanded = event.seq
178
+ break
179
+ case 'dispatch.comment-posted':
180
+ acc.commentPosted = event.seq
181
+ break
182
+ case 'dispatch.failed':
183
+ acc.latestFailure = {
184
+ seq: event.seq,
185
+ stage: event.payload.stage,
186
+ attempt: event.payload.attempt,
187
+ error: event.payload.error,
188
+ }
189
+ break
190
+ case 'session.started':
191
+ case 'session.ended':
192
+ acc.sessionEvents.push(event)
193
+ break
194
+ case 'build.paused':
195
+ acc.pausedAt = event.ts
196
+ break
197
+ case 'plan.completed':
198
+ raise(acc.produced.planCompleted, event.payload.round, event.seq)
199
+ break
200
+ case 'plan-review.verdict':
201
+ raise(acc.produced.planReviewVerdict, event.payload.round, event.seq)
202
+ break
203
+ case 'implement.completed':
204
+ raise(acc.produced.implementCompleted, event.payload.round, event.seq)
205
+ break
206
+ case 'code-review.verdict':
207
+ raise(acc.produced.codeReviewVerdict, event.payload.round, event.seq)
208
+ break
209
+ default:
210
+ break
211
+ }
212
+ }
213
+ },
214
+ finish: (acc) => structuredClone(acc),
215
+ })