@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
@@ -1,7 +1,8 @@
1
+ // Client half + shared wire vocabulary for the remote BuildStore (SPEC §7.2).
2
+ // The HTTP server half ships in `@defrex/autobuild-hosted-store-service`;
3
+ // this barrel is what a local CLI and the hosted package both build against.
1
4
  export { AuthError, RemoteBuildStore } from './client'
2
5
  export type { RemoteBuildStoreOptions, RemoteStoreIdentity } from './client'
3
- export { createStoreServer, startStoreServer } from './server'
4
- export type { StartStoreServerOptions, StoreServer, StoreServerOptions } from './server'
5
6
  export { mintToken, tokenResource, verifyToken } from './token'
6
7
  export type { OperatorTokenScope, TokenScope } from './token'
7
8
  export {
@@ -10,3 +11,12 @@ export {
10
11
  REMOTE_STORE_PROTOCOL_VERSION,
11
12
  REMOTE_STORE_PROTOCOL_VERSION_HEADER,
12
13
  } from './version'
14
+ // The whole wire protocol: schemas, error vocabulary, and the placeholder-ref
15
+ // deposit convention. The protocol servers (hosted package) compose these;
16
+ // clients parse them for feedback.
17
+ export * from './protocol'
18
+ // Event vocabulary the wire envelopes and the server's D6 mapping rely on.
19
+ export { EventValidationError } from '../../events/catalog'
20
+ export type { Via } from '../../events/envelope'
21
+ export type { SessionEvent, SessionEventWrite } from '../../events/sessions'
22
+ export type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
@@ -40,6 +40,18 @@ export const errorBodySchema = z.object({
40
40
  })
41
41
  export type ErrorBody = z.infer<typeof errorBodySchema>
42
42
 
43
+ /**
44
+ * Deliberately loose: the ontology is enforced by `validateEventWrite` in
45
+ * the *backing store* (§8), so its precise EventValidationError message —
46
+ * the agent feedback D6 exists for — survives the wire instead of being
47
+ * shadowed by a duplicate wire-schema error.
48
+ */
49
+ export const eventWriteWireSchema = z.object({
50
+ actor: z.unknown(),
51
+ type: z.string().min(1),
52
+ payload: z.unknown(),
53
+ })
54
+
43
55
  // ── Builds ───────────────────────────────────────────────────────────────────
44
56
 
45
57
  export const newBuildBodySchema = z.object({
@@ -51,6 +63,9 @@ export const newBuildBodySchema = z.object({
51
63
  repoOrigin: z.string().optional(),
52
64
  ticket: ticketRefSchema.optional(),
53
65
  branch: z.string().min(1).optional(),
66
+ // Additive optional initial `build.created` event, appended atomically with
67
+ // the record. Loose like every event wire schema: the backing store validates.
68
+ created: eventWriteWireSchema.optional(),
54
69
  })
55
70
 
56
71
  export const buildRecordWireSchema = z.object({
@@ -68,18 +83,6 @@ export const buildRecordListSchema = z.array(buildRecordWireSchema)
68
83
 
69
84
  // ── Events ───────────────────────────────────────────────────────────────────
70
85
 
71
- /**
72
- * Deliberately loose: the ontology is enforced by `validateEventWrite` in
73
- * the *backing store* (§8), so its precise EventValidationError message —
74
- * the agent feedback D6 exists for — survives the wire instead of being
75
- * shadowed by a duplicate wire-schema error.
76
- */
77
- export const eventWriteWireSchema = z.object({
78
- actor: z.unknown(),
79
- type: z.string().min(1),
80
- payload: z.unknown(),
81
- })
82
-
83
86
  export const eventEnvelopeWireSchema = z.object({
84
87
  build: z.string(),
85
88
  seq: z.number().int().positive(),
@@ -138,6 +141,68 @@ export const repoDepositsResponseSchema = z.object({
138
141
  artifacts: repositoryArtifactMetaListSchema,
139
142
  })
140
143
 
144
+ // ── Ticket assets (SPEC §7.1) ────────────────────────────────────────────────
145
+
146
+ export const ticketAssetLayoutSchema = z.enum(['file', 'tree'])
147
+ /** An entry on the put request: `{type:'file',path,contentBase64}` or `{type:'dir',path}`. */
148
+ export const ticketAssetEntryWireInputSchema = z.discriminatedUnion('type', [
149
+ z.object({ type: z.literal('file'), path: z.string(), contentBase64: z.string() }),
150
+ z.object({ type: z.literal('dir'), path: z.string() }),
151
+ ])
152
+ export const putTicketAssetBodySchema = z.object({
153
+ kind: z.string(),
154
+ name: z.string(),
155
+ layout: ticketAssetLayoutSchema,
156
+ entries: z.array(ticketAssetEntryWireInputSchema),
157
+ })
158
+ /** A manifest entry: `dir` entries carry no size or blobRef. */
159
+ export const ticketAssetEntryWireSchema = z.discriminatedUnion('type', [
160
+ z.object({
161
+ type: z.literal('file'),
162
+ path: z.string(),
163
+ size: z.number().int().nonnegative(),
164
+ blobRef: z.string(),
165
+ }),
166
+ z.object({ type: z.literal('dir'), path: z.string() }),
167
+ ])
168
+ export const ticketAssetMetaWireSchema = z.object({
169
+ repo: z.string(),
170
+ ticketId: z.string(),
171
+ kind: z.string(),
172
+ name: z.string(),
173
+ revision: z.number().int().nonnegative(),
174
+ layout: ticketAssetLayoutSchema,
175
+ size: z.number().int().nonnegative(),
176
+ entries: z.array(ticketAssetEntryWireSchema),
177
+ createdAt: z.string(),
178
+ })
179
+ export const ticketAssetSummaryWireSchema = z.object({
180
+ repo: z.string(),
181
+ ticketId: z.string(),
182
+ kind: z.string(),
183
+ name: z.string(),
184
+ revision: z.number().int().nonnegative(),
185
+ layout: ticketAssetLayoutSchema,
186
+ size: z.number().int().nonnegative(),
187
+ fileCount: z.number().int().nonnegative(),
188
+ dirCount: z.number().int().nonnegative(),
189
+ createdAt: z.string(),
190
+ })
191
+ export const ticketAssetSummaryListSchema = z.array(ticketAssetSummaryWireSchema)
192
+ /** `GET …/assets` answers the manifest only (no bytes); null when absent or removed. */
193
+ export const ticketAssetManifestResponseSchema = ticketAssetMetaWireSchema.nullable()
194
+ /** `GET …/assets/file` answers one file's bytes; null when absent. */
195
+ export const ticketAssetFileResponseSchema = z.object({ contentBase64: z.string() }).nullable()
196
+ export const removeTicketAssetBodySchema = z.object({ kind: z.string(), name: z.string() })
197
+ export const removeTicketAssetResponseSchema = ticketAssetMetaWireSchema.nullable()
198
+ export const ticketAssetLimitsWireSchema = z.object({
199
+ maxBytes: z.number().int().nonnegative(),
200
+ maxEntries: z.number().int().positive(),
201
+ /** Largest request or response body the deployment can carry. Absent: no ceiling. */
202
+ maxRequestBytes: z.number().int().positive().optional(),
203
+ })
204
+ export type TicketAssetLimitsWire = z.infer<typeof ticketAssetLimitsWireSchema>
205
+
141
206
  // ── Operator sessions (SPEC §7.1.1) ─────────────────────────────────────────
142
207
 
143
208
  export const newSessionBodySchema = z.object({
@@ -166,6 +231,12 @@ export const sessionEventEnvelopeWireSchema = z.object({
166
231
  })
167
232
  export const sessionEventListSchema = z.array(sessionEventEnvelopeWireSchema)
168
233
 
234
+ export const conditionalSessionEventBodySchema = z.object({
235
+ expectedSeq: z.number().int().nonnegative(),
236
+ event: eventWriteWireSchema,
237
+ })
238
+ export const conditionalSessionEventResponseSchema = sessionEventEnvelopeWireSchema.nullable()
239
+
169
240
  export const sessionArtifactMetaWireSchema = z.object({
170
241
  session: z.string(),
171
242
  kind: z.string(),
@@ -187,6 +258,23 @@ export const sessionDepositsResponseSchema = z.object({
187
258
  artifacts: sessionArtifactMetaListSchema,
188
259
  })
189
260
 
261
+ // ── Build digests (repo-scoped batch read, AUT-487) ────────────────────────
262
+ // The wire form of `BuildDigest` (store/types.ts). `terminal` is omitted when
263
+ // the build's log carries no terminal fact, per the protocol's omission
264
+ // convention for optional members.
265
+ export const buildDigestWireSchema = z.object({
266
+ slug: z.string().min(1),
267
+ terminal: z.enum(['done', 'aborted']).optional(),
268
+ observations: z.array(
269
+ z.object({
270
+ seq: z.number().int().positive(),
271
+ ts: z.string().min(1),
272
+ }),
273
+ ),
274
+ merged: z.string().min(1).optional(),
275
+ })
276
+ export const buildDigestListSchema = z.array(buildDigestWireSchema)
277
+
190
278
  // ── Artifacts ────────────────────────────────────────────────────────────────
191
279
 
192
280
  export const artifactMetaWireSchema = z.object({
@@ -3,8 +3,12 @@ import manifest from '../../../../../package.json'
3
3
  /** Release-synchronized identity sent by every remote-store client. */
4
4
  export const AUTOBUILD_VERSION = manifest.version
5
5
 
6
- /** Wire compatibility version for the remote BuildStore protocol. */
7
- export const REMOTE_STORE_PROTOCOL_VERSION = '2'
6
+ /** Wire compatibility version for the remote BuildStore protocol.
7
+ * Version 3 (AUT-521): the build-digest wire shape gains per-occurrence
8
+ * observation timestamps and the `merged` fact — a breaking shape change,
9
+ * so stale peers are rejected at handshake instead of failing mid-request
10
+ * at schema parse. */
11
+ export const REMOTE_STORE_PROTOCOL_VERSION = '3'
8
12
 
9
13
  export const AUTOBUILD_VERSION_HEADER = 'x-autobuild-version'
10
14
  export const REMOTE_STORE_PROTOCOL_VERSION_HEADER = 'x-autobuild-protocol-version'
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The bounded repository-journal read (AUT-489): a pure subset derivation
3
+ * that stateless full-state readers can fetch instead of the whole journal
4
+ * without changing what their reducers report.
5
+ *
6
+ * The hosted dispatcher appends ~4 journal events per invocation (run-started,
7
+ * tick-started, tick-completed/tick-yielded, run-stopped) whether or not it
8
+ * did anything, so the journal grows forever with wall-clock time while the
9
+ * durable signal stays small. Every repository-state consumer reduces the
10
+ * journal through one of four pure reducers whose event-type dependencies
11
+ * partition the catalog:
12
+ *
13
+ * - `reduceDispatchSettings` — only the three dispatcher setting types
14
+ * (latest-wins by seq).
15
+ * - `reduceDispatchStatus(events, latestRun)` — only run-correlated
16
+ * dispatcher facts, and it skips every event whose `payload.run` is not the
17
+ * latest run. A run's facts are appended under its own run id, which is
18
+ * minted at `dispatcher.run-started`, so all facts the reducer keeps live at
19
+ * or after the latest run-started.
20
+ * - `reduceHarvest` (and everything built on it) — only `harvest.*` events
21
+ * plus the three setting types, which it explicitly ignores.
22
+ * - `sandboxStates` — only `orchestrator.sandbox.*` events.
23
+ *
24
+ * So the bounded subset is: **every event of a durable (slow-growing) type —
25
+ * all `harvest.*`, all `orchestrator.sandbox.*`, and the three dispatcher
26
+ * setting types — plus, when the journal contains at least one
27
+ * `dispatcher.run-started`, the tail from that latest run-started to the end
28
+ * of the journal.** For a hosted repository with N no-op invocations this
29
+ * subset's size is a constant (one invocation's ~4 facts plus the durable
30
+ * signal), independent of N.
31
+ *
32
+ * INVARIANT FOR AUTHORS OF NEW REPOSITORY EVENT TYPES: a new type must either
33
+ * (a) join `REPOSITORY_STATE_EVENT_TYPES` — it is durable, slow-growing state
34
+ * some reducer depends on at full replay depth — or (b) join
35
+ * `REPOSITORY_RUN_SCOPED_EVENT_TYPES` — it is run-correlated and its only
36
+ * consumers reduce it for the latest run, so the tail from the latest
37
+ * `dispatcher.run-started` covers it. `tests/store/repo-state-events.test.ts`
38
+ * asserts the two sets exactly partition `REPOSITORY_EVENT_TYPES`; adding a
39
+ * type to neither fails the build until the author makes the call. A type in
40
+ * neither set would be silently dropped from bounded reads.
41
+ *
42
+ * Reducing this subset with the state readers yields exactly what a full
43
+ * replay yields: `reduceDispatchSettings`, `reduceHarvest`, `sandboxStates`
44
+ * see identical event lists, and `reduceDispatchStatus` sees every event it
45
+ * keeps (the rest are skipped by the `payload.run !== latestRun` guard). The
46
+ * journal itself stays append-only; no event is deleted, rewritten, or
47
+ * re-ordered, and a replay from the start is untouched.
48
+ */
49
+ import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
50
+ import {
51
+ dispatcherSettingEventPayloadSchemas,
52
+ dispatcherStatusEventPayloadSchemas,
53
+ harvestEventPayloadSchemas,
54
+ orchestratorSandboxEventPayloadSchemas,
55
+ } from '../events/repository'
56
+
57
+ /** The anchor fact a bounded read's tail hangs from. */
58
+ const RUN_STARTED = 'dispatcher.run-started'
59
+
60
+ /** Durable, slow-growing repository event types: every fact a state reducer
61
+ * may consult at full replay depth. Derived from the catalog groups so a new
62
+ * harvest or sandbox event type joins automatically; the three dispatcher
63
+ * setting types are enumerated because they share one schema group with the
64
+ * run-correlated `dispatcher.config-reloaded`. */
65
+ export const REPOSITORY_STATE_EVENT_TYPES: readonly RepositoryEventType[] = [
66
+ ...Object.keys(harvestEventPayloadSchemas),
67
+ ...Object.keys(orchestratorSandboxEventPayloadSchemas),
68
+ ...Object.keys(dispatcherSettingEventPayloadSchemas).filter(
69
+ (type) => type !== 'dispatcher.config-reloaded',
70
+ ),
71
+ ] as RepositoryEventType[]
72
+
73
+ /** Run-correlated repository event types: facts whose only state consumers
74
+ * reduce them for the latest dispatcher run, so the tail from the latest
75
+ * `dispatcher.run-started` contains everything those consumers keep. */
76
+ export const REPOSITORY_RUN_SCOPED_EVENT_TYPES: readonly RepositoryEventType[] = [
77
+ ...Object.keys(dispatcherStatusEventPayloadSchemas),
78
+ 'dispatcher.config-reloaded',
79
+ ] as RepositoryEventType[]
80
+
81
+ const STATE_TYPES = new Set<string>(REPOSITORY_STATE_EVENT_TYPES)
82
+
83
+ /**
84
+ * The race-closed read shape every SQL adapter delegates `getRepoStateEvents`
85
+ * to (AUT-551). Both adapters issue the anchor probe and the selection as two
86
+ * separate statements, so a concurrent append of the journal's first
87
+ * `dispatcher.run-started` between them is invisible to the probe, absent
88
+ * from the durable-only selection, yet present in the journal by the time the
89
+ * method returns — a stateless reader can transiently throw
90
+ * `effective-config-unavailable` where a moment-later full replay would
91
+ * succeed.
92
+ *
93
+ * The fix is a **one-shot anchor re-check on the durable-only outcome**:
94
+ * after a selection that took the durable-only branch (the probe saw no
95
+ * anchor), re-probe the anchor once; if an anchor now exists, re-run the
96
+ * anchored selection against it. The invariant this achieves: a durable-only
97
+ * result is returned only when the journal was anchor-free as of the final
98
+ * probe — so a run-started appended between the original probe and the
99
+ * selection can never be missed while present. When the first probe *did*
100
+ * find an anchor, no re-check is needed: the anchored select's
101
+ * `seq >= anchor` arm is monotone-inclusive, so any later run-started (higher
102
+ * seq) is picked up by that same select. A run-started appended after the
103
+ * final re-probe is an ordinary post-read concurrent append — outside this
104
+ * read's snapshot, exactly as if it had landed after the method returned —
105
+ * and is not this race; that bound is why the re-check is one-shot and never
106
+ * a loop.
107
+ *
108
+ * Non-concurrent behavior is byte-identical: the same two statements run in
109
+ * the same order; only the durable-only outcome may trigger one extra
110
+ * probe+select pair.
111
+ */
112
+ export async function readRepoStateEventsWithAnchorRecheck(
113
+ probeAnchor: () => Promise<number | undefined>,
114
+ select: (anchor: number | undefined) => Promise<RepositoryEvent[]>,
115
+ ): Promise<RepositoryEvent[]> {
116
+ const anchor = await probeAnchor()
117
+ const events = await select(anchor)
118
+ if (anchor !== undefined) return events
119
+ const rechecked = await probeAnchor()
120
+ return rechecked === undefined ? events : select(rechecked)
121
+ }
122
+
123
+ /** The normative subset derivation every adapter implements and the contract
124
+ * tests use as the oracle: durable types across the whole journal, plus —
125
+ * only when the journal has one — every event from the latest
126
+ * `dispatcher.run-started` onward, ordered by seq. A journal with no
127
+ * run-started has **no tail at all** (durable types only); the anchor is
128
+ * never defaulted to 0, which would make a `seq >= anchor` clause select the
129
+ * whole journal and defeat the bound. */
130
+ export function projectRepositoryStateEvents(
131
+ events: readonly RepositoryEvent[],
132
+ ): RepositoryEvent[] {
133
+ let anchor: number | undefined
134
+ for (const event of events) {
135
+ if (event.type === RUN_STARTED) anchor = event.seq
136
+ }
137
+ return events.filter(
138
+ (event) => STATE_TYPES.has(event.type) || (anchor !== undefined && event.seq >= anchor),
139
+ )
140
+ }
@@ -38,6 +38,13 @@
38
38
  */
39
39
  export const OPERATOR_NOTES_REPO_KIND = 'operator-notes'
40
40
 
41
+ /** The repository artifact carrying the schema-validated composed Config one
42
+ * dispatch run used. The embedded orchestrator's hosted wiring resolves its
43
+ * per-repository configuration from the latest revision of this kind
44
+ * (AUT-342). Defined here — the store must not import from the CLI — and
45
+ * re-exported by `cli/dispatch.ts`, which deposits it. */
46
+ export const DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT = 'dispatcher-effective-config'
47
+
41
48
  /**
42
49
  * Repository-scoped artifact kinds subject to retention. Values mirror the
43
50
  * kind constants in `cli/dispatch.ts` (DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT)
@@ -13,6 +13,7 @@ import type {
13
13
  Artifact,
14
14
  ArtifactInput,
15
15
  ArtifactMeta,
16
+ BuildDigest,
16
17
  BuildRecord,
17
18
  BuildStore,
18
19
  NewBuildInput,
@@ -26,6 +27,13 @@ import type {
26
27
  SubscribeOptions,
27
28
  Unsubscribe,
28
29
  } from './types'
30
+ import type {
31
+ TicketAsset,
32
+ TicketAssetInput,
33
+ TicketAssetLimits,
34
+ TicketAssetMeta,
35
+ TicketAssetSummary,
36
+ } from './ticket-assets'
29
37
  import type {
30
38
  StreamChunk,
31
39
  StreamOutcome,
@@ -160,6 +168,12 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
160
168
  getRepoEvents(repo: string, _sinceSeq?: number): Promise<RepositoryEvent[]> {
161
169
  return Promise.reject(new SessionScopeError(scope, 'getRepoEvents', repo))
162
170
  },
171
+ getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
172
+ return Promise.reject(new SessionScopeError(scope, 'getRepoStateEvents', repo))
173
+ },
174
+ getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
175
+ return Promise.reject(new SessionScopeError(scope, 'getRepoBuildDigests', repo))
176
+ },
163
177
  putRepoArtifact(repo: string, _artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
164
178
  return Promise.reject(new SessionScopeError(scope, 'putRepoArtifact', repo))
165
179
  },
@@ -173,6 +187,48 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
173
187
  listRepoArtifacts(repo: string, _kind?: string): Promise<RepositoryArtifactMeta[]> {
174
188
  return Promise.reject(new SessionScopeError(scope, 'listRepoArtifacts', repo))
175
189
  },
190
+ ticketAssetLimits(repo: string): Promise<TicketAssetLimits> {
191
+ return Promise.reject(new SessionScopeError(scope, 'ticketAssetLimits', repo))
192
+ },
193
+ putTicketAsset(
194
+ repo: string,
195
+ _ticketId: string,
196
+ _asset: TicketAssetInput,
197
+ ): Promise<TicketAssetMeta> {
198
+ return Promise.reject(new SessionScopeError(scope, 'putTicketAsset', repo))
199
+ },
200
+ getTicketAsset(
201
+ repo: string,
202
+ _ticketId: string,
203
+ _kind: string,
204
+ _name: string,
205
+ _rev?: number,
206
+ ): Promise<TicketAsset | null> {
207
+ return Promise.reject(new SessionScopeError(scope, 'getTicketAsset', repo))
208
+ },
209
+ getPinnedTicketAsset(
210
+ slug: string,
211
+ _kind: string,
212
+ _name: string,
213
+ _rev?: number,
214
+ ): Promise<TicketAsset | null> {
215
+ return Promise.reject(new SessionScopeError(scope, 'getPinnedTicketAsset', slug))
216
+ },
217
+ listTicketAssets(
218
+ repo: string,
219
+ _ticketId: string,
220
+ _opts?: { revisions?: boolean },
221
+ ): Promise<TicketAssetSummary[]> {
222
+ return Promise.reject(new SessionScopeError(scope, 'listTicketAssets', repo))
223
+ },
224
+ removeTicketAsset(
225
+ repo: string,
226
+ _ticketId: string,
227
+ _kind: string,
228
+ _name: string,
229
+ ): Promise<TicketAssetMeta | null> {
230
+ return Promise.reject(new SessionScopeError(scope, 'removeTicketAsset', repo))
231
+ },
176
232
  claimRepoLease(repo: string, _holder: string, _ttlMs: number): Promise<boolean> {
177
233
  return Promise.reject(new SessionScopeError(scope, 'claimRepoLease', repo))
178
234
  },
@@ -199,6 +255,14 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
199
255
  own('appendSessionEvent', id)
200
256
  return store.appendSessionEvent(id, event)
201
257
  },
258
+ async appendSessionEventIfCurrent<T extends SessionEventType>(
259
+ id: string,
260
+ expectedSeq: number,
261
+ event: SessionEventWrite<T>,
262
+ ): Promise<SessionEventEnvelope<T> | null> {
263
+ own('appendSessionEventIfCurrent', id)
264
+ return store.appendSessionEventIfCurrent(id, expectedSeq, event)
265
+ },
202
266
  async getSessionEvents(
203
267
  id: string,
204
268
  sinceSeq?: number,
@@ -13,7 +13,7 @@ export type TurnOutcomeState = 'open' | 'suspended' | 'completed' | 'failed'
13
13
 
14
14
  export type SessionTurnTrigger =
15
15
  | { kind: 'message'; messageSeq: number }
16
- | { kind: 'wake'; build: string; seq: number; type: string }
16
+ | { kind: 'wake'; build?: string; journal?: true; seq: number; type: string }
17
17
 
18
18
  export interface SessionTurn {
19
19
  turn: string
@@ -21,8 +21,11 @@ export interface SessionTurn {
21
21
  startedSeq: number
22
22
  trigger: SessionTurnTrigger
23
23
  state: TurnOutcomeState
24
- usage?: { inputTokens: number; outputTokens: number; turns?: number }
24
+ usage?: { inputTokens: number; outputTokens: number; steps: number; turns?: number }
25
25
  error?: string
26
+ /** The typed failure class of a failed turn (provider-unavailable,
27
+ * exhausted, credentials, configuration, or internal). */
28
+ kind?: 'provider-unavailable' | 'exhausted' | 'credentials' | 'configuration' | 'internal'
26
29
  }
27
30
 
28
31
  export interface SessionState {
@@ -43,6 +46,10 @@ export interface SessionState {
43
46
  wakeGlobs: string[]
44
47
  /** Per build, the highest build event seq a wake trigger has consumed. */
45
48
  wakeCursors: Record<string, number>
49
+ /** The highest repository-journal seq a wake trigger has consumed (0 when
50
+ * no journal wake has been consumed). The journal has an independent seq
51
+ * space with no build slug, so it gets its own cursor. */
52
+ journalWakeCursor: number
46
53
  /** Every turn in start order, with its stream id and outcome. */
47
54
  turns: SessionTurn[]
48
55
  }
@@ -63,6 +70,7 @@ export function reduceSession(events: SessionEvent[]): SessionState {
63
70
  let pendingApproval: SessionState['pendingApproval']
64
71
  let wakeGlobs: string[] = []
65
72
  const wakeCursors: Record<string, number> = {}
73
+ let journalWakeCursor = 0
66
74
 
67
75
  const open = (): TurnEntry | undefined => turns.findLast((turn) => !isTerminal(turn.state))
68
76
 
@@ -85,8 +93,15 @@ export function reduceSession(events: SessionEvent[]): SessionState {
85
93
  }
86
94
  turns.push(entry)
87
95
  if (event.payload.trigger.kind === 'wake') {
88
- const build = event.payload.trigger.build
89
- wakeCursors[build] = Math.max(wakeCursors[build] ?? 0, event.payload.trigger.seq)
96
+ if (event.payload.trigger.journal === true) {
97
+ journalWakeCursor = Math.max(journalWakeCursor, event.payload.trigger.seq)
98
+ } else {
99
+ // The catalog's `.check()` guarantees `build` when `journal` is
100
+ // absent — a stored trigger can never be a journal wake without
101
+ // the flag or a build wake without the slug.
102
+ const build = event.payload.trigger.build!
103
+ wakeCursors[build] = Math.max(wakeCursors[build] ?? 0, event.payload.trigger.seq)
104
+ }
90
105
  }
91
106
  break
92
107
  }
@@ -118,11 +133,13 @@ export function reduceSession(events: SessionEvent[]): SessionState {
118
133
  pendingApproval.toolCallId === event.payload.toolCallId
119
134
  ) {
120
135
  pendingApproval = undefined
121
- // The answer resumes the turn; `turn.resumed` is the runner's
122
- // acknowledgement of the same fact, so between answer and resume
123
- // the status is already `running` when a turn is open.
124
- const entry = turns.findLast((candidate) => candidate.turn === event.payload.turn)
125
- if (entry && entry.state === 'suspended') entry.state = 'open'
136
+ // The answer only clears the pending approval — `turn.resumed` is
137
+ // the sole resume fact, recorded by the runner when it actually
138
+ // picks the turn up. Flipping the turn state here (the earlier
139
+ // behavior) made a session look `running` while no runner existed:
140
+ // an answered approval whose resuming invocation died would be
141
+ // unrecoverable. The status sequence after an answer is therefore
142
+ // awaiting-approval → suspended(approval) → running.
126
143
  }
127
144
  break
128
145
  }
@@ -139,6 +156,7 @@ export function reduceSession(events: SessionEvent[]): SessionState {
139
156
  if (entry && !isTerminal(entry.state)) {
140
157
  entry.state = 'failed'
141
158
  entry.error = event.payload.error
159
+ entry.kind = event.payload.kind
142
160
  }
143
161
  break
144
162
  }
@@ -180,6 +198,7 @@ export function reduceSession(events: SessionEvent[]): SessionState {
180
198
  ...(pendingApproval !== undefined ? { pendingApproval } : {}),
181
199
  wakeGlobs,
182
200
  wakeCursors,
201
+ journalWakeCursor,
183
202
  turns: turns.map(({ suspensionCause: _suspensionCause, ...turn }) => turn),
184
203
  }
185
204
  }
@@ -23,7 +23,6 @@
23
23
  * never reorder), retries re-queue at the front under the same discipline,
24
24
  * and `close` waits out any in-flight drain before closing the stream.
25
25
  */
26
- import type { SessionStreamSink } from '../../ports/types'
27
26
  import { STREAM_PART_MAX_BYTES, truncationPart } from '../../ports/runner/stream-parts'
28
27
  import type { JsonRecord } from '../../ports/runner/json-record'
29
28
  import {
@@ -52,6 +51,22 @@ export interface SessionStreamSinkOptions {
52
51
  schedule?: (flush: () => void, ms: number) => () => void
53
52
  }
54
53
 
54
+ /** The sink's concrete surface. Structurally a superset of the
55
+ * `SessionStreamSink` port (ports/types.ts): `open` keeps its port-declared
56
+ * shape for the build-runner's `openSessionStreamLabel` caller, while the
57
+ * orchestrator turn runner additionally uses `resume` (bind to an
58
+ * already-open stream — a resumed invocation must append to the turn's
59
+ * existing stream, never create a second one) and `flush` (await the drain,
60
+ * so a step-boundary checkpoint is durable before the next step starts). */
61
+ export type SessionStreamWriter = {
62
+ open(label: string): Promise<string>
63
+ append(parts: StreamPart[]): void
64
+ close(outcome: StreamOutcome): Promise<void>
65
+ } & {
66
+ resume(streamId: string): Promise<string>
67
+ flush(): Promise<void>
68
+ }
69
+
55
70
  /** Consecutive failed flushes before the writer stops streaming a session. */
56
71
  const MAX_FLUSH_FAILURES = 3
57
72
 
@@ -131,8 +146,10 @@ function boundPart(part: StreamPart, maxBytes: number): StreamPart[] {
131
146
  /**
132
147
  * Create the bracket-scoped sink. `open` creates the store stream and
133
148
  * returns its id; appends buffer until then and flush on the cadence.
149
+ * `resume` binds to an already-open stream instead of creating one;
150
+ * `flush` awaits the drain without closing.
134
151
  */
135
- export function createSessionStreamSink(options: SessionStreamSinkOptions): SessionStreamSink {
152
+ export function createSessionStreamSink(options: SessionStreamSinkOptions): SessionStreamWriter {
136
153
  const { store, scope } = options
137
154
  const flushMs = options.flushMs ?? 250
138
155
  const maxPartBytes = STREAM_PART_MAX_BYTES
@@ -288,6 +305,29 @@ export function createSessionStreamSink(options: SessionStreamSinkOptions): Sess
288
305
  }
289
306
  },
290
307
 
308
+ /** Bind to an already-open stream — the resume path. Never calls
309
+ * `createStream`, so a fresh invocation appends to the turn's existing
310
+ * stream instead of creating a second one nothing reads. Appends made
311
+ * before the bind stay buffered and flush right after. */
312
+ async resume(boundStreamId) {
313
+ if (streamId !== undefined) {
314
+ throw new Error(`session stream sink already bound to "${streamId}"`)
315
+ }
316
+ streamId = boundStreamId
317
+ ensureTimer()
318
+ await requestFlush()
319
+ return streamId
320
+ },
321
+
322
+ /** Await the drain of everything appended so far (joining the in-flight
323
+ * drain under the single-flight rule), so a caller can make a checkpoint
324
+ * durable without closing. Best-effort like `append`: a defunct writer
325
+ * resolves without throwing, and the diagnostic hook already fired. */
326
+ async flush() {
327
+ if (streamId === undefined) return
328
+ await requestFlush()
329
+ },
330
+
291
331
  append(parts) {
292
332
  // Never throws: after defunct/close or before a successful open,
293
333
  // parts are dropped or buffered without surfacing an error.
@@ -10,7 +10,7 @@
10
10
  * synchronous (SQLite) and asynchronous (PostgreSQL) backends is worth more
11
11
  * than wake-on-write precision. The loop serves two cadences: held *stream*
12
12
  * reads are presentation content and poll at the ~25 ms `STREAM_WAIT_POLL_MS`;
13
- * held *event* reads (build, repository, session — AUT-334/381/383) take the
13
+ * held *event* reads (build, repository, session — AUT-334/381/383/394) take the
14
14
  * one-second `EVENT_WAIT_POLL_MS` budget when the adapter passes it per call
15
15
  * (see `EVENT_WAIT_POLL_MS` for which adapters do). Closed streams never wait.
16
16
  *
@@ -28,15 +28,14 @@ export const STREAM_WAIT_POLL_MS = 25
28
28
 
29
29
  /**
30
30
  * The one-second held-event poll budget (the hosted per-query budget,
31
- * AUT-334/381/383): held event reads — build, repository, and session —
31
+ * AUT-334/381/383/394): held event reads — build, repository, and session —
32
32
  * re-query at most once per second, so an append is observed at the next
33
33
  * poll, typically within about one second. Held *stream* reads are excluded:
34
34
  * they are presentation content and stay at the ~25 ms `STREAM_WAIT_POLL_MS`
35
35
  * cadence. This deliberately diverges from `STREAM_WAIT_POLL_MS`; adapters
36
36
  * pass it as `pollMs` on their event-family calls to `readEventsWithWait` —
37
- * the PostgreSQL and memory stores do on all three families (AUT-334/381/383).
38
- * The SQLite store has not adopted the budget (out of scope for AUT-383), so
39
- * its held event reads fall back to the stream default until it does.
37
+ * the PostgreSQL, memory, and SQLite stores do on all three families
38
+ * (AUT-334/381/383/394).
40
39
  *
41
40
  * The PostgreSQL adapter treats the bound as nominal, not hard: a worst-case
42
41
  * ≤1 s guarantee would need Postgres LISTEN/NOTIFY wake-on-append — one