@defrex/autobuild 0.11.0 → 0.13.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -0,0 +1,89 @@
1
+ import { defineReducer } from './incremental'
2
+ import type { AbEvent } from '../events/catalog'
3
+
4
+ /** Latest completed occurrence only: never replay an obsolete publication backlog. */
5
+ export const summaryBoundaryReducer = defineReducer<
6
+ {
7
+ occurrence?: number
8
+ boundary?: number
9
+ sessions: Record<string, true>
10
+ },
11
+ AbEvent,
12
+ { occurrence: number; boundary: number } | undefined
13
+ >({
14
+ version: 1,
15
+ initial: () => ({ sessions: {} }),
16
+ fold(acc, events) {
17
+ for (const event of events) {
18
+ if (
19
+ [
20
+ 'plan.started',
21
+ 'plan-review.started',
22
+ 'implement.started',
23
+ 'code-review.started',
24
+ 'verify.started',
25
+ 'reconcile.started',
26
+ 'finalize.started',
27
+ 'finalize.step-started',
28
+ ].includes(event.type)
29
+ ) {
30
+ acc.occurrence = event.seq
31
+ delete acc.boundary
32
+ }
33
+ if (event.type === 'session.started') acc.sessions[event.payload.session] = true
34
+ if (event.type === 'session.ended') delete acc.sessions[event.payload.session]
35
+ if (
36
+ acc.occurrence !== undefined &&
37
+ (event.type === 'session.ended' ||
38
+ [
39
+ 'plan.completed',
40
+ 'plan-review.verdict',
41
+ 'implement.completed',
42
+ 'code-review.verdict',
43
+ 'verify.completed',
44
+ 'reconcile.completed',
45
+ 'finalize.completed',
46
+ 'finalize.step-completed',
47
+ 'phase.failed',
48
+ 'escalation.raised',
49
+ 'publication.requested',
50
+ ].includes(event.type))
51
+ )
52
+ acc.boundary = event.seq
53
+ }
54
+ },
55
+ finish: (acc) =>
56
+ acc.occurrence !== undefined &&
57
+ acc.boundary !== undefined &&
58
+ Object.keys(acc.sessions).length === 0
59
+ ? { occurrence: acc.occurrence, boundary: acc.boundary }
60
+ : undefined,
61
+ })
62
+
63
+ export function summaryBoundary(events: readonly AbEvent[]) {
64
+ return summaryBoundaryReducer.reduce(events)
65
+ }
66
+
67
+ type SummaryRequest = Extract<AbEvent, { type: 'pr-summary.requested' }>
68
+ export const summaryRequestsReducer = defineReducer<
69
+ {
70
+ requests: SummaryRequest[]
71
+ settled: number[]
72
+ },
73
+ AbEvent,
74
+ SummaryRequest[]
75
+ >({
76
+ version: 1,
77
+ initial: () => ({ requests: [], settled: [] }),
78
+ fold(acc, events) {
79
+ for (const event of events) {
80
+ if (event.type === 'pr-summary.requested') acc.requests.push(event)
81
+ if (event.type === 'pr-summary.settled') acc.settled.push(event.payload.request)
82
+ }
83
+ },
84
+ finish: (acc) => acc.requests.filter((event) => !acc.settled.includes(event.seq)),
85
+ })
86
+
87
+ export function pendingSummaryRequests(events: readonly AbEvent[]) {
88
+ return summaryRequestsReducer.reduce(events)
89
+ }
@@ -1,3 +1,6 @@
1
+ import { executionNoProgressReducer } from '../processes/execution-no-progress'
2
+ import { summaryBoundaryReducer, summaryRequestsReducer } from './pr-summary'
3
+ import { dispatchFailureBudgetReducer } from '../processes/dispatch-failure-budget'
1
4
  import { ticketSourceHealthReducer } from './ticket-source-health'
2
5
  /**
3
6
  * Every incremental reducer, with its published version and the whole-array
@@ -58,6 +61,9 @@ export interface RegisteredReducer {
58
61
  /** Parameterized reducers are registered with representative arguments; the
59
62
  * accumulator shape does not depend on them. */
60
63
  export const REDUCERS: Record<string, RegisteredReducer> = {
64
+ executionNoProgress: { reducer: executionNoProgressReducer, covers: [] },
65
+ summaryBoundary: { reducer: summaryBoundaryReducer, covers: ['summaryBoundary'] },
66
+ summaryRequests: { reducer: summaryRequestsReducer, covers: ['pendingSummaryRequests'] },
61
67
  ticketSourceHealth: { reducer: ticketSourceHealthReducer, covers: ['reduceTicketSourceHealth'] },
62
68
  build: { reducer: buildReducer, covers: ['reduceBuild'] },
63
69
  harvest: { reducer: harvestReducer, covers: ['reduceHarvest'] },
@@ -97,6 +103,10 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
97
103
  covers: ['openHarvestExecutions'],
98
104
  },
99
105
  openTick: { reducer: openTickReducer('run-a'), covers: ['hasOpenTick'] },
106
+ dispatchFailureBudget: {
107
+ reducer: dispatchFailureBudgetReducer(false),
108
+ covers: ['dispatchFailureBudget'],
109
+ },
100
110
  infrastructureFailureReset: {
101
111
  reducer: infrastructureFailureResetReducer,
102
112
  covers: ['infrastructureFailureResetSeq'],
@@ -113,6 +113,8 @@ export interface VerifyResult {
113
113
  attempt: number
114
114
  outcome: VerifyOutcome
115
115
  report?: ArtifactRef
116
+ /** Optional classification on kernel policy skips; historical skips omit it. */
117
+ skipCause?: 'post-reconcile-policy'
116
118
  /** Present exactly when `outcome === "skipped"`. */
117
119
  reason?: string
118
120
  /** seq of the `verify.completed` event — what `cycleSince` is compared to. */
@@ -457,6 +459,11 @@ function foldBuild(acc: BuildAcc, events: readonly AbEvent[]): void {
457
459
  break
458
460
  case 'workspace.provisioned':
459
461
  case 'workspace.released':
462
+ case 'pr-summary.requested':
463
+ case 'pr-summary.attempted':
464
+ case 'pr-summary.result':
465
+ case 'pr-summary.settled':
466
+ case 'finalize.step-started':
460
467
  case 'publication.requested':
461
468
  // Inert loss fact (AUT-328): the dispatcher released a workspace with a
462
469
  // pending publication. Recovery runs through the existing
@@ -689,6 +696,7 @@ function foldBuild(acc: BuildAcc, events: readonly AbEvent[]): void {
689
696
  outcome: result.outcome,
690
697
  report: result.report,
691
698
  ...(result.reason !== undefined ? { reason: result.reason } : {}),
699
+ ...(result.skipCause !== undefined ? { skipCause: result.skipCause } : {}),
692
700
  seq: event.seq,
693
701
  })
694
702
  if (verify.currentStep === result.step) {
@@ -108,6 +108,8 @@ export const policyEscalationCauseSchema = z.enum([
108
108
  'review-round-limit',
109
109
  'verify-failure-limit',
110
110
  'reconcile-no-progress',
111
+ 'dispatch-failure-limit',
112
+ 'execution-no-progress',
111
113
  'setup-failure-limit',
112
114
  'infrastructure-failure-limit',
113
115
  'phase-attempt-limit',
@@ -92,6 +92,18 @@ export const OPERATOR_TOOL_ANNOTATIONS = {
92
92
  idempotentHint: false,
93
93
  approval: 'default',
94
94
  },
95
+ 'tickets.states': {
96
+ readOnlyHint: true,
97
+ destructiveHint: false,
98
+ idempotentHint: true,
99
+ approval: 'never',
100
+ },
101
+ 'tickets.state.create': {
102
+ readOnlyHint: false,
103
+ destructiveHint: false,
104
+ idempotentHint: false,
105
+ approval: 'default',
106
+ },
95
107
  'tickets.list': {
96
108
  readOnlyHint: true,
97
109
  destructiveHint: false,
@@ -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,
@@ -125,3 +128,12 @@ export type { HarvestStatusView } from '../cli/harvest'
125
128
  export type { RepositoryStatus } from '../cli/repository-status'
126
129
  export type { BuildSummary } from '../cli/status'
127
130
  export type { SessionRecord } from '../store/types'
131
+
132
+ export {
133
+ effectiveConfig,
134
+ selectEffectiveConfig,
135
+ findEffectiveConfigRef,
136
+ decodeEffectiveConfig,
137
+ } from './query'
138
+
139
+ export { ticketStateCreateRequestSchema } from './protocol'
@@ -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
+ })
@@ -38,7 +38,7 @@ export type BuildListScope = 'active' | 'queued' | 'all'
38
38
 
39
39
  export class OperatorQueryError extends Error {
40
40
  constructor(
41
- readonly code: 'not-found' | 'effective-config-unavailable',
41
+ readonly code: 'not-found' | 'effective-config-unavailable' | 'external-ticket-source',
42
42
  message: string,
43
43
  ) {
44
44
  super(message)
@@ -121,48 +121,70 @@ export async function effectiveConfig(
121
121
  status: ReturnType<typeof reduceDispatchStatus>
122
122
  }> {
123
123
  const repositoryEvents = journal ?? (await readRepoEventsIfRecorded(store, repo))
124
+ const status = selectEffectiveConfig(repositoryEvents, repo)
125
+ const ref = status.effectiveConfig!
126
+ const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
127
+ if (artifact === null) {
128
+ throw new OperatorQueryError(
129
+ 'effective-config-unavailable',
130
+ `effective config ${ref.kind}@${ref.rev} is not retrievable`,
131
+ )
132
+ }
133
+ return {
134
+ config: decodeEffectiveConfig(artifact.content, `${ref.kind}@${ref.rev}`),
135
+ repositoryEvents,
136
+ status,
137
+ }
138
+ }
139
+
140
+ function latestDispatchConfigStatus(repositoryEvents: RepositoryEvent[]) {
124
141
  let latestRun: string | undefined
125
142
  for (const event of repositoryEvents) {
126
143
  if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
127
144
  }
128
- if (latestRun === undefined) {
145
+ return latestRun === undefined ? undefined : reduceDispatchStatus(repositoryEvents, latestRun)
146
+ }
147
+
148
+ /** Absence is distinct from a selected reference whose bytes cannot be read. */
149
+ export function findEffectiveConfigRef(repositoryEvents: RepositoryEvent[]) {
150
+ return latestDispatchConfigStatus(repositoryEvents)?.effectiveConfig
151
+ }
152
+
153
+ export function selectEffectiveConfig(repositoryEvents: RepositoryEvent[], repo: string) {
154
+ const status = latestDispatchConfigStatus(repositoryEvents)
155
+ if (status === undefined) {
129
156
  throw new OperatorQueryError(
130
157
  'effective-config-unavailable',
131
158
  `effective config unavailable for repository "${repo}": no dispatcher run is recorded`,
132
159
  )
133
160
  }
134
- const status = reduceDispatchStatus(repositoryEvents, latestRun)
135
- const ref = status.effectiveConfig
136
- if (ref === undefined) {
161
+ if (status.effectiveConfig === undefined) {
137
162
  throw new OperatorQueryError(
138
163
  'effective-config-unavailable',
139
164
  `effective config unavailable for repository "${repo}"`,
140
165
  )
141
166
  }
142
- const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
143
- if (artifact === null) {
144
- throw new OperatorQueryError(
145
- 'effective-config-unavailable',
146
- `effective config ${ref.kind}@${ref.rev} is not retrievable`,
147
- )
148
- }
167
+ return status
168
+ }
169
+
170
+ export function decodeEffectiveConfig(content: Uint8Array, label: string): Config {
149
171
  let raw: unknown
150
172
  try {
151
- raw = JSON.parse(new TextDecoder().decode(artifact.content))
173
+ raw = JSON.parse(new TextDecoder().decode(content))
152
174
  } catch {
153
175
  throw new OperatorQueryError(
154
176
  'effective-config-unavailable',
155
- `effective config ${ref.kind}@${ref.rev} is not valid JSON`,
177
+ `effective config ${label} is not valid JSON`,
156
178
  )
157
179
  }
158
180
  const parsed = configSchema.safeParse(raw)
159
181
  if (!parsed.success) {
160
182
  throw new OperatorQueryError(
161
183
  'effective-config-unavailable',
162
- `effective config ${ref.kind}@${ref.rev} is invalid: ${parsed.error.message}`,
184
+ `effective config ${label} is invalid: ${parsed.error.message}`,
163
185
  )
164
186
  }
165
- return { config: parsed.data, repositoryEvents, status }
187
+ return parsed.data
166
188
  }
167
189
 
168
190
  export interface OperatorBuildView {
@@ -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,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,33 +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.proposalState ?? defaultTriageState(options.config),
43
- ].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
+ )
44
41
  return [...new Set(states)]
45
42
  },
46
43
  }