@defrex/autobuild 0.12.0 → 0.14.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -92,6 +92,9 @@ export type {
92
92
  OperatorTicketQueue,
93
93
  } from './tickets'
94
94
  export {
95
+ prepareOperatorTicketAssets,
96
+ listOperatorTicketStates,
97
+ createOperatorTicketState,
95
98
  getOperatorTicket,
96
99
  listOperatorTickets,
97
100
  mutateOperatorTicket,
@@ -132,3 +135,7 @@ export {
132
135
  findEffectiveConfigRef,
133
136
  decodeEffectiveConfig,
134
137
  } from './query'
138
+
139
+ export { ticketStateCreateRequestSchema } from './protocol'
140
+
141
+ export { readBuildFacts } from '../store/reduced-reads'
@@ -55,15 +55,27 @@ export const ticketCreateRequestSchema = z.strictObject({
55
55
  state: z.string().min(1).optional(),
56
56
  blockedBy: z.array(z.string().min(1)).optional(),
57
57
  })
58
+ const expectedRevisionSchema = z
59
+ .string()
60
+ .refine((v) => v.trim().length > 0, 'expectedRevision must be nonblank')
61
+ .optional()
58
62
  export const ticketUpdateRequestSchema = z
59
63
  .strictObject({
64
+ expectedRevision: expectedRevisionSchema,
60
65
  title: z.string().optional(),
61
66
  body: z.string().optional(),
62
67
  labels: z.array(z.string()).optional(),
63
68
  })
64
- .refine((value) => Object.keys(value).length > 0, 'update must name at least one field')
65
- export const ticketMoveRequestSchema = z.strictObject({ state: z.string().min(1) })
69
+ .refine(
70
+ (value) => value.title !== undefined || value.body !== undefined || value.labels !== undefined,
71
+ 'update must name at least one field',
72
+ )
73
+ export const ticketMoveRequestSchema = z.strictObject({
74
+ expectedRevision: expectedRevisionSchema,
75
+ state: z.string().min(1),
76
+ })
66
77
  export const ticketBlockerRequestSchema = z.strictObject({
78
+ expectedRevision: expectedRevisionSchema,
67
79
  blockerIds: z.array(z.string().min(1)).min(1),
68
80
  })
69
81
  export type OperatorTicketCreateRequest = z.infer<typeof ticketCreateRequestSchema>
@@ -96,3 +108,8 @@ export const sessionApprovalRequestSchema = z.strictObject({
96
108
  decision: z.enum(['approve', 'deny']),
97
109
  })
98
110
  export type OperatorSessionApprovalRequest = z.infer<typeof sessionApprovalRequestSchema>
111
+
112
+ export const ticketStateCreateRequestSchema = z.strictObject({
113
+ name: z.string(),
114
+ about: z.string().optional(),
115
+ })
@@ -1,3 +1,9 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import {
3
+ readBuildFacts,
4
+ readRepositoryFacts,
5
+ type RepositoryReadFacts,
6
+ } from '../store/reduced-reads'
1
7
  import { readDispatcherLiveness } from '../cli/dispatcher-liveness'
2
8
  import { composeBuildConfig } from '../config/live'
3
9
  import type { PipelineSourceMeta } from '../config/pipeline-source'
@@ -9,26 +15,25 @@ import {
9
15
  } from '../processes/build-execution-state'
10
16
  import type { Artifact } from '../store/types'
11
17
  import {
12
- detail,
18
+ detailFromFacts,
13
19
  statusFilter,
14
20
  summarizeFrom,
15
21
  type BuildDetail,
16
22
  type BuildSummary,
17
23
  } from '../cli/status'
18
- import { projectRepositoryStatus, type RepositoryStatus } from '../cli/repository-status'
24
+ import { repositoryStatusFromFacts, type RepositoryStatus } from '../cli/repository-status'
19
25
  import { projectHarvestStatus, type HarvestStatusView } from '../cli/harvest'
20
26
  import {
21
27
  buildDashboardFromProjected,
22
28
  effectiveStatus,
23
- projectBuild,
24
29
  projectBuildFromFacts,
25
30
  type DashboardBuild,
26
31
  type DashboardModel,
27
32
  } from '../cli/dashboard/model'
28
- import { reduceBuild, type BuildState } from '../kernel/reducer'
33
+ import type { BuildState } from '../kernel/reducer'
29
34
  import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
30
35
  import { reduceDispatchStatus } from '../kernel/dispatch-status'
31
- import { readRepoEventsIfRecorded, unclaimedObservationCount } from '../processes/harvest'
36
+ import { unclaimedObservationCount } from '../processes/harvest'
32
37
  import type { RepoViewStore } from '../processes/repo-view'
33
38
  import { withOperatorView } from './read-view'
34
39
  import type { RepositoryEvent } from '../events/repository'
@@ -74,7 +79,7 @@ async function listFromView(
74
79
  // finished builds accumulate. The `--all` scope includes terminal statuses,
75
80
  // so it takes no digest read and skips nothing; its terminal builds are
76
81
  // restored from the reducer snapshots the dispatcher leaves at settlement.
77
- const digests = opts.scope === 'all' ? undefined : await view.getRepoBuildDigests(opts.repo)
82
+ const digests = opts.scope === 'all' ? undefined : await readBuildDigests(view, opts.repo)
78
83
  const output: BuildSummary[] = []
79
84
  for (const record of records) {
80
85
  if (record.repo !== opts.repo) continue
@@ -114,14 +119,19 @@ export async function effectiveConfig(
114
119
  repo: string,
115
120
  /** The bounded journal, when the caller already holds it (a repository
116
121
  * view); otherwise it is read from the store. */
117
- journal?: RepositoryEvent[],
122
+ journal?: RepositoryReadFacts,
118
123
  ): Promise<{
119
124
  config: Config
120
- repositoryEvents: RepositoryEvent[]
125
+ repositoryEvents: RepositoryReadFacts
121
126
  status: ReturnType<typeof reduceDispatchStatus>
122
127
  }> {
123
- const repositoryEvents = journal ?? (await readRepoEventsIfRecorded(store, repo))
124
- const status = selectEffectiveConfig(repositoryEvents, repo)
128
+ const repositoryEvents = journal ?? (await readRepositoryFacts(store, repo)).value
129
+ const status = repositoryEvents.status
130
+ if (status?.effectiveConfig === undefined)
131
+ throw new OperatorQueryError(
132
+ 'effective-config-unavailable',
133
+ `effective config unavailable for repository "${repo}"${repositoryEvents.config.run === undefined ? ': no dispatcher run is recorded' : ''}`,
134
+ )
125
135
  const ref = status.effectiveConfig!
126
136
  const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
127
137
  if (artifact === null) {
@@ -258,19 +268,19 @@ export async function getOperatorBuild(opts: {
258
268
  now: Date
259
269
  }): Promise<OperatorBuildView> {
260
270
  const record = await requireBuild(opts.store, opts.repo, opts.slug)
261
- const events = await opts.store.getEvents(opts.slug)
262
- const state = reduceBuild(events)
271
+ const facts = (await readBuildFacts(opts.store, opts.slug)).value
272
+ const state = facts.state
263
273
  const { config } = await effectiveConfig(opts.store, opts.repo)
264
274
  let dashboardRow: DashboardBuild | null = null
265
275
  if (!projectsNoDashboardRow(state)) {
266
276
  const pinned = await readPinnedConfig(opts.store, opts.slug, config)
267
- const row = projectBuild(record, state, config, events, undefined, pinned.config)
277
+ const row = projectBuildFromFacts(record, state, facts, config, undefined, pinned.config)
268
278
  // Implied by the gate; kept as an explicit check (not an assertion) so the
269
279
  // code never lies if `effectiveStatus` and `projectBuild` ever drift.
270
280
  if (row !== null) dashboardRow = decorateWithPinnedMeta(row, pinned)
271
281
  }
272
282
  return {
273
- detail: detail(record, events, opts.now),
283
+ detail: detailFromFacts(record, facts, opts.now),
274
284
  dashboardRow,
275
285
  }
276
286
  }
@@ -281,8 +291,12 @@ export async function getRepositoryStatus(
281
291
  now = new Date(),
282
292
  ): Promise<RepositoryStatus> {
283
293
  return withOperatorView(store, repo, 'journal', async (view) => {
284
- const { dispatcher } = await readDispatcherLiveness(store, repo, now, view.recordedJournal())
285
- return projectRepositoryStatus(repo, view.recordedJournal(), dispatcher)
294
+ const { dispatcher } = await readDispatcherLiveness(store, repo, now)
295
+ return repositoryStatusFromFacts(
296
+ repo,
297
+ (await readRepositoryFacts(view, repo)).value,
298
+ dispatcher,
299
+ )
286
300
  })
287
301
  }
288
302
 
@@ -291,7 +305,7 @@ export async function getHarvestStatus(
291
305
  repo: string,
292
306
  ): Promise<HarvestStatusView> {
293
307
  return withOperatorView(store, repo, 'journal', async (view) =>
294
- projectHarvestStatus(repo, view.recordedJournal()),
308
+ projectHarvestStatus(repo, view.repositoryFacts()),
295
309
  )
296
310
  }
297
311
 
@@ -321,7 +335,7 @@ async function dashboardFromView(
321
335
  const { config, repositoryEvents, status } = await effectiveConfig(
322
336
  view,
323
337
  opts.repo,
324
- view.recordedJournal(),
338
+ view.repositoryFacts(),
325
339
  )
326
340
  const records = await view.listBuilds()
327
341
  // One digest read covers every build of the repository (AUT-487): the
@@ -330,7 +344,7 @@ async function dashboardFromView(
330
344
  // builds accumulate. Row-rendering builds derive from the repository view's
331
345
  // reducer snapshots plus a delta read (AUT-650); every snapshot is a cache
332
346
  // the event log regenerates, so the response equals a full replay's.
333
- const digests = await view.getRepoBuildDigests(opts.repo)
347
+ const digests = await readBuildDigests(view, opts.repo)
334
348
  const projected: DashboardBuild[] = []
335
349
  let activeCount = 0
336
350
  for (const record of records) {
@@ -372,7 +386,10 @@ async function dashboardFromView(
372
386
  // `effectiveConfig` already returned — no per-build history reads (AUT-487).
373
387
  // The pure core writes nothing — a snapshot performs no store writes and
374
388
  // never creates or locks the repository record.
375
- const observationCount = unclaimedObservationCount({ digests, harvestEvents: repositoryEvents })
389
+ const observationCount = unclaimedObservationCount({
390
+ digests,
391
+ harvestEvents: repositoryEvents.harvest,
392
+ })
376
393
  const warningLines = [
377
394
  ...status.roleWarnings,
378
395
  ...(status.warningNotice !== undefined ? [status.warningNotice] : []),
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * The operator read paths' repository view: every poll-driven route (dashboard,
3
3
  * repository and harvest status, build listings) derives its state from a
4
- * `RepoViewStore` cold-started from stored reducer snapshots and advanced with
5
- * delta reads, so a poll's event-row cost follows the events appended since the
6
- * last one, never the history, the open tabs, or the watchers.
4
+ * `RepoViewStore` backed by persisted reduced values. Each pass refreshes
5
+ * repository facts and metadata without hydrating raw history.
7
6
  *
8
7
  * Passes are single-flight per `(store, repo)` within one serving process: at
9
8
  * most one pass runs at a time, and requests arriving while it runs join the
10
9
  * one trailing pass (each needs state at least as new as its arrival, which the
11
10
  * running pass cannot promise). That trailing pass restores from the snapshot
12
- * the previous pass persisted, so N concurrent pollers cost one delta read per
13
- * pass, not N. The coordination is scheduling inside this process; every datum
11
+ * the previous pass persisted for builds, so N concurrent pollers cost one
12
+ * build delta read per pass, not N. The coordination is scheduling inside this process; every datum
14
13
  * still comes from durable state, and a second serving process simply reads the
15
14
  * same delta once itself (duplicate reads are bounded by the process count).
16
15
  */
@@ -58,14 +57,6 @@ async function pass(store: BuildStore, repo: string, waiters: Waiter[]): Promise
58
57
  return
59
58
  }
60
59
  await Promise.all(waiters.map((waiter) => waiter.run(view).then(waiter.resolve, waiter.reject)))
61
- // Leave the advanced state behind for the next pass (this process's or
62
- // another's). `held` also writes a settled build read for a listing. The
63
- // cache never fails a read.
64
- try {
65
- await view.persistSnapshots({ force: true, held: true })
66
- } catch {
67
- // A cache: the next pass retries.
68
- }
69
60
  }
70
61
 
71
62
  async function drain(store: BuildStore, repo: string, flight: Flight): Promise<void> {
@@ -1,3 +1,4 @@
1
+ import { TicketRevisionConflict, TicketRevisionUnsupported } from '../ports/tickets/revision'
1
2
  /**
2
3
  * The agent tool registry — one typed table that is the single source of
3
4
  * truth for the agent-facing operator surface (AUT-338).
@@ -58,6 +59,9 @@ import { answerAction, controlPrechecks, requireRouteBuild, RouteRefusalError }
58
59
  import { SandboxOperationError } from '../ports/workspace/operator-sandbox'
59
60
  import type { OperatorSandboxService } from './sandbox'
60
61
  import {
62
+ prepareOperatorTicketAssets,
63
+ listOperatorTicketStates,
64
+ createOperatorTicketState,
61
65
  getOperatorTicket,
62
66
  listOperatorTickets,
63
67
  mutateOperatorTicket,
@@ -598,7 +602,13 @@ export const TOOLS: readonly ToolEntry[] = [
598
602
  user: attributed(ctx),
599
603
  ...(ctx.via !== undefined ? { via: ctx.via } : {}),
600
604
  action,
601
- ...(readTicketBody !== undefined ? { readTicketBody } : {}),
605
+ ...(readTicketBody !== undefined
606
+ ? {
607
+ readTicketBody,
608
+ prepareTicketAssets: (ref, store, repo) =>
609
+ prepareOperatorTicketAssets({ ref, store, repo, backend: ctx.tickets }),
610
+ }
611
+ : {}),
602
612
  })
603
613
  },
604
614
  ),
@@ -691,6 +701,30 @@ export const TOOLS: readonly ToolEntry[] = [
691
701
  })
692
702
  },
693
703
  ),
704
+ defineTool(
705
+ 'tickets.states',
706
+ 'List all ticket states with ticket counts and optional purposes.',
707
+ z.strictObject({ repo: z.string().min(1) }),
708
+ 'Array of {name,tickets,about?}.',
709
+ async (raw, ctx) => {
710
+ const input = raw as { repo: string }
711
+ return listOperatorTicketStates({
712
+ store: ctx.store,
713
+ repo: input.repo,
714
+ backend: ticketsOf(ctx),
715
+ })
716
+ },
717
+ ),
718
+ defineTool(
719
+ 'tickets.state.create',
720
+ 'Create a custom ticket state with an optional one-line purpose. Hosted duplicates are refused.',
721
+ z.strictObject({ repo: z.string().min(1), name: z.string(), about: z.string().optional() }),
722
+ '{state: {name,tickets,about?},created}.',
723
+ async (raw, ctx) => {
724
+ const input = raw as { repo: string; name: string; about?: string }
725
+ return createOperatorTicketState({ store: ctx.store, backend: ticketsOf(ctx), ...input })
726
+ },
727
+ ),
694
728
  defineTool(
695
729
  'tickets.list',
696
730
  'List the repository’s ticket grooming queue from the configured ticket source.',
@@ -709,9 +743,9 @@ export const TOOLS: readonly ToolEntry[] = [
709
743
  ),
710
744
  defineTool(
711
745
  'tickets.get',
712
- 'Show one ticket with its blockers and any related build.',
746
+ 'Show one ticket with its hosted comments, blockers and any related build.',
713
747
  ticketDetailInput,
714
- '{ticket, blockers, build}: the ticket, its dependency states, and the most relevant build for it (null when none).',
748
+ '{ticket, blockers, build}: the ticket (including hosted ticket.comments when available), its dependency states, and the most relevant build for it (null when none).',
715
749
  async (raw, ctx) => {
716
750
  const input = raw as z.infer<typeof ticketDetailInput>
717
751
  return getOperatorTicket({
@@ -752,12 +786,12 @@ export const TOOLS: readonly ToolEntry[] = [
752
786
  '{ticket, blockers, build}: the updated ticket.',
753
787
  async (raw, ctx) => {
754
788
  const input = raw as z.infer<typeof ticketsUpdateInput>
755
- const { repo, id, ...patch } = input
789
+ const { repo, id, expectedRevision, ...patch } = input
756
790
  return mutateOperatorTicket({
757
791
  store: ctx.store,
758
792
  repo,
759
793
  backend: ticketsOf(ctx),
760
- operation: { kind: 'update', id, patch },
794
+ operation: { kind: 'update', id, patch, expectedRevision },
761
795
  })
762
796
  },
763
797
  ),
@@ -772,7 +806,12 @@ export const TOOLS: readonly ToolEntry[] = [
772
806
  store: ctx.store,
773
807
  repo: input.repo,
774
808
  backend: ticketsOf(ctx),
775
- operation: { kind: 'block', id: input.id, blockerIds: input.blockerIds },
809
+ operation: {
810
+ kind: 'block',
811
+ id: input.id,
812
+ blockerIds: input.blockerIds,
813
+ expectedRevision: input.expectedRevision,
814
+ },
776
815
  })
777
816
  },
778
817
  ),
@@ -787,7 +826,12 @@ export const TOOLS: readonly ToolEntry[] = [
787
826
  store: ctx.store,
788
827
  repo: input.repo,
789
828
  backend: ticketsOf(ctx),
790
- operation: { kind: 'unblock', id: input.id, blockerIds: input.blockerIds },
829
+ operation: {
830
+ kind: 'unblock',
831
+ id: input.id,
832
+ blockerIds: input.blockerIds,
833
+ expectedRevision: input.expectedRevision,
834
+ },
791
835
  })
792
836
  },
793
837
  ),
@@ -802,7 +846,12 @@ export const TOOLS: readonly ToolEntry[] = [
802
846
  store: ctx.store,
803
847
  repo: input.repo,
804
848
  backend: ticketsOf(ctx),
805
- operation: { kind: 'move', id: input.id, state: input.state },
849
+ operation: {
850
+ kind: 'move',
851
+ id: input.id,
852
+ state: input.state,
853
+ expectedRevision: input.expectedRevision,
854
+ },
806
855
  })
807
856
  },
808
857
  ),
@@ -964,6 +1013,10 @@ function mapDomainError(error: unknown): RegistryError {
964
1013
  error: error.refusal.error,
965
1014
  })
966
1015
  }
1016
+ if (error instanceof TicketRevisionConflict)
1017
+ return new RegistryError('domain', error.message, { kind: 'conflict', error: error.message })
1018
+ if (error instanceof TicketRevisionUnsupported)
1019
+ return new RegistryError('domain', error.message, { kind: 'refusal', error: error.message })
967
1020
  if (error instanceof TicketOperationError) {
968
1021
  return new RegistryError('domain', error.message, {
969
1022
  kind: error.code === 'not-found' ? 'not-found' : 'refusal',
@@ -1,16 +1,4 @@
1
- /**
2
- * The route-level glue the operator HTTP server applies around the shared
3
- * services, re-derived for the agent tool registry (operator/registry.ts).
4
- *
5
- * This module is registry-owned on purpose: the operator API routes were
6
- * declared out of scope for the registry ticket, so the parity between a
7
- * tool and its route is *proven* by the contract suite
8
- * (packages/hosted-store-service/src/registry.contract.test.ts) rather than
9
- * obtained by extraction. Every refusal text here is load-bearing — it must
10
- * stay byte-identical to the hosted package's `operator-server.ts` so the
11
- * suite's full-body comparisons hold.
12
- */
13
- import { reduceBuild } from '../kernel/reducer'
1
+ import { readBuildFacts } from '../store/reduced-reads'
14
2
  import { effectiveStatus } from '../cli/dashboard/model'
15
3
  import { BuildControlError, type BuildControlAction } from '../cli/build-control'
16
4
  import type { BuildStore } from '../store/types'
@@ -80,7 +68,7 @@ export async function controlPrechecks(
80
68
  ): Promise<void> {
81
69
  await requireRouteBuild(store, repo, slug)
82
70
  if (action === 'pause' || action === 'cancel-pause') {
83
- const state = reduceBuild(await store.getEvents(slug))
71
+ const state = (await readBuildFacts(store, slug)).value.state
84
72
  const display = effectiveStatus(state)
85
73
  if (action === 'pause' && display === 'pausing') {
86
74
  throw new BuildControlError(
@@ -13,7 +13,6 @@
13
13
  import { createHash } from 'node:crypto'
14
14
  import { humanActor } from '../events/envelope'
15
15
  import type { Via } from '../events/envelope'
16
- import type { RepositoryEvent } from '../events/repository'
17
16
  import type { Forge } from '../ports/types'
18
17
  import {
19
18
  SANDBOX_FORBIDDEN_ENV,
@@ -23,8 +22,7 @@ import {
23
22
  type SandboxWaitResult,
24
23
  } from '../ports/workspace/operator-sandbox'
25
24
  import type { WorkspaceProvider } from '../ports/types'
26
- import { readRepoEventsIfRecorded } from '../processes/harvest'
27
- import { sandboxStates } from '../processes/sandbox-state'
25
+ import { readRepositoryFacts } from '../store/reduced-reads'
28
26
  import { systemClock, type BuildStore, type Clock } from '../store/types'
29
27
 
30
28
  /** Hard bound on one sandbox command wait, in seconds — shared with the
@@ -253,8 +251,11 @@ export async function createOperatorSandboxService(
253
251
  }
254
252
 
255
253
  /** The operator's journal state, or undefined when no environment exists. */
256
- const operatorState = (identity: string, events: RepositoryEvent[]) =>
257
- sandboxStates(events)
254
+ const operatorState = (
255
+ identity: string,
256
+ events: import('../store/reduced-reads').RepositoryReadFacts,
257
+ ) =>
258
+ events.sandboxes
258
259
  .filter((state) => state.operator === identity && state.state !== 'released')
259
260
  .at(-1)
260
261
 
@@ -327,7 +328,7 @@ export async function createOperatorSandboxService(
327
328
  ): Promise<T> => {
328
329
  const key = `${repo}\0${identity}`
329
330
  return serialize(key, async () => {
330
- const events = await readRepoEventsIfRecorded(store, repo)
331
+ const events = (await readRepositoryFacts(store, repo)).value
331
332
  const state = operatorState(identity, events)
332
333
  const resolved = await ensureWithFacts(identity, state).catch((error: unknown) => {
333
334
  if (error instanceof SandboxOperationError) throw error
@@ -337,7 +338,7 @@ export async function createOperatorSandboxService(
337
338
  { cause: error },
338
339
  )
339
340
  })
340
- const freshEvents = await readRepoEventsIfRecorded(store, repo)
341
+ const freshEvents = (await readRepositoryFacts(store, repo)).value
341
342
  const freshState = operatorState(identity, freshEvents)
342
343
  try {
343
344
  const result = await operation(resolved, freshState)
@@ -630,7 +631,7 @@ export async function createOperatorSandboxService(
630
631
  requireRepo(input.repo)
631
632
  const key = `${repo}\0${identity}`
632
633
  await serialize(key, async () => {
633
- const events = await readRepoEventsIfRecorded(store, repo)
634
+ const events = (await readRepositoryFacts(store, repo)).value
634
635
  const state = operatorState(identity, events)
635
636
  const resolved = await capability.describe({ repo, operator: identity })
636
637
  await append(identity, 'orchestrator.sandbox.reset', {
@@ -662,7 +663,7 @@ export async function createOperatorSandboxService(
662
663
  requireRepo(input.repo)
663
664
  const key = `${repo}\0${identity}`
664
665
  await serialize(key, async () => {
665
- const events = await readRepoEventsIfRecorded(store, repo)
666
+ const events = (await readRepositoryFacts(store, repo)).value
666
667
  const state = operatorState(identity, events)
667
668
  // No environment ever provisioned: a no-op, with no fact and no
668
669
  // provider traffic beyond the pure describe.
@@ -1,3 +1,4 @@
1
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
1
2
  /**
2
3
  * A dispatcher ticket source as the registry's ticket backend (AUT-342):
3
4
  * the tick's turn runner takes an `OperatorToolRegistry`, and
@@ -14,34 +15,29 @@ import type { Config } from '../config/schema'
14
15
  import type { OperatorTicketBackend, OperatorTicketContext } from './tickets'
15
16
  import type { TicketSource } from '../ports/types'
16
17
 
17
- /** The dispatcher's resolved triage state (the same default
18
- * `processes/dispatcher.ts` applies); inlined rather than imported so this
19
- * operator-seam module does not pull the dispatcher's module graph. */
20
- function defaultTriageState(config: Config): string {
21
- return (
22
- config.tickets.triageState ??
23
- (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
24
- ? 'Backlog'
25
- : 'Triage')
26
- )
27
- }
28
-
29
18
  export function ticketBackendFromSource(options: {
30
19
  source: TicketSource
31
20
  config: Config
32
21
  }): OperatorTicketBackend {
33
22
  return {
34
23
  sourceFor: (_context: OperatorTicketContext) => options.source,
35
- statesFor: (_context: OperatorTicketContext, _source: TicketSource): string[] => {
24
+ statesFor: async (_context: OperatorTicketContext, source: TicketSource): Promise<string[]> => {
25
+ if (source.listStates) return (await source.listStates()).map((s) => s.name)
36
26
  const tickets = options.config.tickets
37
- const states = [
38
- tickets.triageState ?? defaultTriageState(options.config),
39
- tickets.readyState,
40
- tickets.createState,
41
- tickets.claimedState,
42
- tickets.doneState,
43
- tickets.proposalState ?? defaultTriageState(options.config),
44
- ].filter((state): state is string => typeof state === 'string' && state.trim().length > 0)
27
+ const lifecycle = ticketLifecycle(tickets)
28
+ const states =
29
+ tickets.source === 'hosted'
30
+ ? Object.values(lifecycle)
31
+ : [
32
+ lifecycle.triage,
33
+ tickets.readyState,
34
+ tickets.createState,
35
+ tickets.claimedState,
36
+ tickets.doneState,
37
+ lifecycle.proposal,
38
+ ].filter(
39
+ (state): state is string => typeof state === 'string' && state.trim().length > 0,
40
+ )
45
41
  return [...new Set(states)]
46
42
  },
47
43
  }