@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,161 @@
1
+ import type { Config } from '../../config/schema'
2
+ import { readyCriteria } from '../../processes/dispatcher'
3
+ import type { Ticket, TicketDraft, TicketSource, TicketUpdate } from '../types'
4
+
5
+ /** Expected ticket-operation failures which operator transports may expose verbatim. */
6
+ export class TicketOperationError extends Error {
7
+ constructor(
8
+ readonly code: 'not-found' | 'refusal',
9
+ message: string,
10
+ ) {
11
+ super(message)
12
+ this.name = 'TicketOperationError'
13
+ }
14
+ }
15
+
16
+ export function missingTicket(source: TicketSource, id: string): TicketOperationError {
17
+ return new TicketOperationError(
18
+ 'not-found',
19
+ `no ticket "${id}" in the configured ${source.name} ticket source — ticket ids are source-local`,
20
+ )
21
+ }
22
+
23
+ export async function requireTicket(source: TicketSource, id: string): Promise<Ticket> {
24
+ const ticket = await source.get(id)
25
+ if (ticket === null) throw missingTicket(source, id)
26
+ return ticket
27
+ }
28
+
29
+ export async function requirePostMutationTicket(
30
+ source: TicketSource,
31
+ id: string,
32
+ operation: string,
33
+ ): Promise<Ticket> {
34
+ const ticket = await source.get(id)
35
+ if (ticket === null) {
36
+ throw new TicketOperationError(
37
+ 'refusal',
38
+ `ticket "${id}" disappeared from the configured ${source.name} ticket source after ${operation}`,
39
+ )
40
+ }
41
+ return ticket
42
+ }
43
+
44
+ export function ticketListCriteria(
45
+ config: Config,
46
+ filters: { state?: string; labels?: string[] },
47
+ ): { state?: string; labels?: string[] } {
48
+ return filters.state === undefined && filters.labels === undefined
49
+ ? readyCriteria(config)
50
+ : filters
51
+ }
52
+
53
+ async function knownBlockers(
54
+ source: TicketSource,
55
+ blockerIds: string[],
56
+ prefix: string,
57
+ suffix = '',
58
+ ): Promise<string[]> {
59
+ const ids = [...new Set(blockerIds)]
60
+ if (ids.length === 0) return ids
61
+ const states = await source.dependencyStates(ids)
62
+ const unknown = states.filter((state) => !state.exists).map((state) => state.id)
63
+ if (unknown.length > 0) {
64
+ throw new TicketOperationError(
65
+ 'not-found',
66
+ `${prefix}no ticket ${unknown.map((id) => `"${id}"`).join(', ')} in the configured ${source.name} ticket source — blocker ids are source-local${suffix}`,
67
+ )
68
+ }
69
+ return ids
70
+ }
71
+
72
+ export async function createTicket(
73
+ source: TicketSource,
74
+ input: TicketDraft & { state?: string },
75
+ options: { blockerErrorPrefix?: string; blockerErrorSuffix?: string } = {},
76
+ ): Promise<Ticket> {
77
+ const blockedBy = await knownBlockers(
78
+ source,
79
+ input.blockedBy ?? [],
80
+ options.blockerErrorPrefix ?? '',
81
+ options.blockerErrorSuffix ?? '',
82
+ )
83
+ const draft: TicketDraft = {
84
+ title: input.title,
85
+ body: input.body,
86
+ ...(input.labels !== undefined ? { labels: [...input.labels] } : {}),
87
+ ...(blockedBy.length > 0 ? { blockedBy } : {}),
88
+ }
89
+ try {
90
+ return input.state === undefined
91
+ ? await source.create(draft)
92
+ : await source.create(draft, { state: input.state })
93
+ } catch (error) {
94
+ throw new TicketOperationError(
95
+ 'refusal',
96
+ error instanceof Error ? error.message : String(error),
97
+ )
98
+ }
99
+ }
100
+
101
+ export async function updateTicket(
102
+ source: TicketSource,
103
+ id: string,
104
+ patch: TicketUpdate,
105
+ readAfter = true,
106
+ ): Promise<Ticket | null> {
107
+ try {
108
+ await source.update(id, patch)
109
+ } catch (error) {
110
+ throw new TicketOperationError(
111
+ 'refusal',
112
+ error instanceof Error ? error.message : String(error),
113
+ )
114
+ }
115
+ return readAfter ? requirePostMutationTicket(source, id, 'the update') : null
116
+ }
117
+
118
+ export async function moveTicket(source: TicketSource, id: string, state: string): Promise<Ticket> {
119
+ await requireTicket(source, id)
120
+ try {
121
+ await source.transition(id, state)
122
+ } catch (error) {
123
+ throw new TicketOperationError(
124
+ 'refusal',
125
+ error instanceof Error ? error.message : String(error),
126
+ )
127
+ }
128
+ return requirePostMutationTicket(source, id, 'the move')
129
+ }
130
+
131
+ export async function changeBlockers(
132
+ source: TicketSource,
133
+ id: string,
134
+ blockerIds: string[],
135
+ operation: 'block' | 'unblock',
136
+ ): Promise<Ticket> {
137
+ await requireTicket(source, id)
138
+ const ids = await knownBlockers(source, blockerIds, '')
139
+ if (operation === 'block' && ids.includes(id)) {
140
+ throw new TicketOperationError(
141
+ 'refusal',
142
+ `ticket "${id}" in the configured ${source.name} ticket source cannot block itself`,
143
+ )
144
+ }
145
+ try {
146
+ for (const blockerId of ids) {
147
+ if (operation === 'block') await source.addBlocker(id, blockerId)
148
+ else await source.removeBlocker(id, blockerId)
149
+ }
150
+ } catch (error) {
151
+ throw new TicketOperationError(
152
+ 'refusal',
153
+ error instanceof Error ? error.message : String(error),
154
+ )
155
+ }
156
+ return requirePostMutationTicket(
157
+ source,
158
+ id,
159
+ operation === 'block' ? 'adding blockers' : 'removing blockers',
160
+ )
161
+ }
@@ -0,0 +1,13 @@
1
+ export { HostedTicketSource } from './remote'
2
+ export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
3
+ export { createTicketServer } from './remote-server'
4
+ export type { TicketServerOptions } from './remote-server'
5
+ export {
6
+ HOSTED_TICKET_OPERATIONS,
7
+ hostedTicketContextSchema,
8
+ hostedTicketRequestSchemas,
9
+ } from './remote-protocol'
10
+ export type { HostedTicketContext, HostedTicketOperation } from './remote-protocol'
11
+ // The hosted service composes the already live-proven Linear adapter; provider
12
+ // credentials remain server-side.
13
+ export { LinearTicketSource } from './linear'
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod'
2
+ import { ticketRefSchema } from '../../ontology'
3
+
4
+ /** Repository-owned policy sent with every hosted ticket operation. */
5
+ export const hostedTicketContextSchema = z.strictObject({
6
+ teamKey: z.string().min(1),
7
+ claimedState: z.string().min(1).optional(),
8
+ createState: z.string().min(1).optional(),
9
+ })
10
+ export type HostedTicketContext = z.infer<typeof hostedTicketContextSchema>
11
+
12
+ export const ticketWireSchema = z.strictObject({
13
+ ref: ticketRefSchema,
14
+ creationKey: z.string().optional(),
15
+ title: z.string(),
16
+ body: z.string(),
17
+ state: z.string().optional(),
18
+ labels: z.array(z.string()),
19
+ blockedBy: z.array(z.string()).optional(),
20
+ })
21
+ export const ticketListingWireSchema = z.strictObject({
22
+ tickets: z.array(ticketWireSchema),
23
+ diagnostics: z.array(z.string()),
24
+ })
25
+ export const dependencyStateWireSchema = z.strictObject({
26
+ id: z.string(),
27
+ exists: z.boolean(),
28
+ resolved: z.boolean(),
29
+ blockedBy: z.array(z.string()),
30
+ })
31
+ export const dependencyStatesWireSchema = z.array(dependencyStateWireSchema)
32
+ export const successWireSchema = z.strictObject({ ok: z.literal(true) })
33
+ export const claimWireSchema = z.strictObject({ claimed: z.boolean() })
34
+
35
+ const criteriaSchema = z.strictObject({
36
+ labels: z.array(z.string()).optional(),
37
+ state: z.string().optional(),
38
+ })
39
+ const idSchema = z.strictObject({ id: z.string().min(1) })
40
+ const draftSchema = z.strictObject({
41
+ title: z.string(),
42
+ body: z.string(),
43
+ labels: z.array(z.string()).optional(),
44
+ blockedBy: z.array(z.string()).optional(),
45
+ })
46
+ const createOptionsSchema = z.strictObject({
47
+ state: z.string().optional(),
48
+ idempotencyKey: z.string().optional(),
49
+ })
50
+ const updateSchema = z
51
+ .strictObject({
52
+ title: z.string().optional(),
53
+ body: z.string().optional(),
54
+ labels: z.array(z.string()).optional(),
55
+ })
56
+ .refine((value) => Object.keys(value).length > 0, 'update must name at least one field')
57
+
58
+ function envelope<T extends z.ZodType>(input: T) {
59
+ return z.strictObject({ context: hostedTicketContextSchema, input })
60
+ }
61
+
62
+ export const hostedTicketRequestSchemas = {
63
+ 'list-ready': envelope(criteriaSchema),
64
+ get: envelope(idSchema),
65
+ claim: envelope(idSchema),
66
+ comment: envelope(z.strictObject({ id: z.string().min(1), body: z.string() })),
67
+ transition: envelope(z.strictObject({ id: z.string().min(1), state: z.string().min(1) })),
68
+ create: envelope(z.strictObject({ draft: draftSchema, options: createOptionsSchema.optional() })),
69
+ update: envelope(z.strictObject({ id: z.string().min(1), patch: updateSchema })),
70
+ 'add-blocker': envelope(z.strictObject({ id: z.string().min(1), blockerId: z.string().min(1) })),
71
+ 'remove-blocker': envelope(
72
+ z.strictObject({ id: z.string().min(1), blockerId: z.string().min(1) }),
73
+ ),
74
+ 'dependency-states': envelope(z.strictObject({ ids: z.array(z.string().min(1)) })),
75
+ } as const
76
+
77
+ export type HostedTicketOperation = keyof typeof hostedTicketRequestSchemas
78
+ export const HOSTED_TICKET_OPERATIONS = Object.keys(
79
+ hostedTicketRequestSchemas,
80
+ ) as HostedTicketOperation[]
@@ -0,0 +1,185 @@
1
+ import type { TicketSource } from '../types'
2
+ import { systemClock, type Clock } from '../../store/types'
3
+ import type { ErrorBody, ErrorKind } from '../../store/remote/protocol'
4
+ import { tokenResource, verifyToken } from '../../store/remote/token'
5
+ import {
6
+ AUTOBUILD_VERSION,
7
+ AUTOBUILD_VERSION_HEADER,
8
+ REMOTE_STORE_PROTOCOL_VERSION,
9
+ REMOTE_STORE_PROTOCOL_VERSION_HEADER,
10
+ } from '../../store/remote/version'
11
+ import {
12
+ hostedTicketRequestSchemas,
13
+ type HostedTicketContext,
14
+ type HostedTicketOperation,
15
+ } from './remote-protocol'
16
+
17
+ export interface TicketServerOptions {
18
+ secret: string
19
+ sourceFor(context: HostedTicketContext): TicketSource | Promise<TicketSource>
20
+ clock?: Clock
21
+ /** Observes unexpected source failures without changing their wire response. */
22
+ onInternalError?: (error: unknown, request: Request) => unknown | Promise<unknown>
23
+ }
24
+
25
+ class TicketRequestError extends Error {
26
+ constructor(
27
+ readonly status: number,
28
+ readonly kind: ErrorKind,
29
+ message: string,
30
+ ) {
31
+ super(message)
32
+ }
33
+ }
34
+
35
+ function json(status: number, body: unknown): Response {
36
+ return Response.json(body, { status })
37
+ }
38
+
39
+ function failure(status: number, kind: ErrorKind, error: string): Response {
40
+ return json(status, { error, kind } satisfies ErrorBody)
41
+ }
42
+
43
+ /** HTTP face of the TicketSource port. Authentication deliberately runs before
44
+ * JSON parsing and source construction, so denied callers cannot probe either. */
45
+ export function createTicketServer(options: TicketServerOptions): {
46
+ fetch(req: Request): Promise<Response>
47
+ } {
48
+ const clock = options.clock ?? systemClock
49
+
50
+ function identify(req: Request): void {
51
+ const autobuild = req.headers.get(AUTOBUILD_VERSION_HEADER)
52
+ const protocol = req.headers.get(REMOTE_STORE_PROTOCOL_VERSION_HEADER)
53
+ if (autobuild !== AUTOBUILD_VERSION || protocol !== REMOTE_STORE_PROTOCOL_VERSION) {
54
+ throw new TicketRequestError(
55
+ 409,
56
+ 'conflict',
57
+ `remote store version mismatch: client Autobuild ${autobuild ?? '(missing)'} protocol ${protocol ?? '(missing)'}; server Autobuild ${AUTOBUILD_VERSION} protocol ${REMOTE_STORE_PROTOCOL_VERSION}`,
58
+ )
59
+ }
60
+ }
61
+
62
+ function authorize(req: Request): void {
63
+ const match = /^Bearer\s+(.+)$/i.exec(req.headers.get('authorization') ?? '')
64
+ if (!match) throw new TicketRequestError(401, 'auth', 'missing bearer token')
65
+ const scope = verifyToken(options.secret, match[1]!, clock())
66
+ if (scope === null) throw new TicketRequestError(401, 'auth', 'invalid or expired token')
67
+ const resource = tokenResource(scope)
68
+ if (resource.kind !== 'deployment') {
69
+ throw new TicketRequestError(
70
+ 403,
71
+ 'auth',
72
+ `token scoped to ${resource.kind} "${resource.id}" may not access ticket operations`,
73
+ )
74
+ }
75
+ }
76
+
77
+ async function body(req: Request): Promise<unknown> {
78
+ try {
79
+ return await req.json()
80
+ } catch {
81
+ throw new TicketRequestError(400, 'validation', 'request body is not valid JSON')
82
+ }
83
+ }
84
+
85
+ async function route(req: Request, operation: HostedTicketOperation): Promise<Response> {
86
+ const raw = await body(req)
87
+ const parsed = hostedTicketRequestSchemas[operation].safeParse(raw)
88
+ if (!parsed.success) {
89
+ throw new TicketRequestError(
90
+ 400,
91
+ 'validation',
92
+ `invalid request body: ${parsed.error.message}`,
93
+ )
94
+ }
95
+ const context = parsed.data.context
96
+ const source = await options.sourceFor(context)
97
+
98
+ // Parse again through the operation-specific schema to preserve precise
99
+ // inference in each branch while keeping the shared pre-source validation.
100
+ switch (operation) {
101
+ case 'list-ready': {
102
+ const request = hostedTicketRequestSchemas['list-ready'].parse(raw)
103
+ return json(200, await source.listReady(request.input))
104
+ }
105
+ case 'get': {
106
+ const request = hostedTicketRequestSchemas.get.parse(raw)
107
+ return json(200, await source.get(request.input.id))
108
+ }
109
+ case 'claim': {
110
+ const request = hostedTicketRequestSchemas.claim.parse(raw)
111
+ return json(200, { claimed: await source.claim(request.input.id) })
112
+ }
113
+ case 'comment': {
114
+ const request = hostedTicketRequestSchemas.comment.parse(raw)
115
+ await source.comment(request.input.id, request.input.body)
116
+ return json(200, { ok: true })
117
+ }
118
+ case 'transition': {
119
+ const request = hostedTicketRequestSchemas.transition.parse(raw)
120
+ await source.transition(request.input.id, request.input.state)
121
+ return json(200, { ok: true })
122
+ }
123
+ case 'create': {
124
+ const request = hostedTicketRequestSchemas.create.parse(raw)
125
+ return json(201, await source.create(request.input.draft, request.input.options))
126
+ }
127
+ case 'update': {
128
+ const request = hostedTicketRequestSchemas.update.parse(raw)
129
+ await source.update(request.input.id, request.input.patch)
130
+ return json(200, { ok: true })
131
+ }
132
+ case 'add-blocker': {
133
+ const request = hostedTicketRequestSchemas['add-blocker'].parse(raw)
134
+ await source.addBlocker(request.input.id, request.input.blockerId)
135
+ return json(200, { ok: true })
136
+ }
137
+ case 'remove-blocker': {
138
+ const request = hostedTicketRequestSchemas['remove-blocker'].parse(raw)
139
+ await source.removeBlocker(request.input.id, request.input.blockerId)
140
+ return json(200, { ok: true })
141
+ }
142
+ case 'dependency-states': {
143
+ const request = hostedTicketRequestSchemas['dependency-states'].parse(raw)
144
+ return json(200, await source.dependencyStates(request.input.ids))
145
+ }
146
+ }
147
+ }
148
+
149
+ return {
150
+ async fetch(req: Request): Promise<Response> {
151
+ try {
152
+ const url = new URL(req.url)
153
+ if (req.method !== 'POST' || !url.pathname.startsWith('/tickets/')) {
154
+ return failure(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
155
+ }
156
+ identify(req)
157
+ authorize(req)
158
+ const operation = url.pathname.slice('/tickets/'.length) as HostedTicketOperation
159
+ if (!Object.hasOwn(hostedTicketRequestSchemas, operation)) {
160
+ return failure(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
161
+ }
162
+ return await route(req, operation)
163
+ } catch (error) {
164
+ if (error instanceof TicketRequestError) {
165
+ return failure(error.status, error.kind, error.message)
166
+ }
167
+ // Only the message crosses the trust boundary; causes and stacks never do.
168
+ const message = error instanceof Error ? error.message : 'ticket backend failed'
169
+ const notFound = /unknown (?:ticket|issue)|not found/i.test(message)
170
+ // These are intentional domain feedback from TicketSource implementations.
171
+ // Their historical standalone mapping remains unchanged.
172
+ const expectedDomainFailure =
173
+ message.startsWith('invalid ticket update') || message.includes('cannot block itself')
174
+ if (!notFound && !expectedDomainFailure && options.onInternalError !== undefined) {
175
+ try {
176
+ await options.onInternalError(error, req)
177
+ } catch {
178
+ // Diagnostics must never replace the protocol response.
179
+ }
180
+ }
181
+ return failure(notFound ? 404 : 500, notFound ? 'not-found' : 'internal', message)
182
+ }
183
+ },
184
+ }
185
+ }
@@ -0,0 +1,130 @@
1
+ import type {
2
+ DependencyState,
3
+ Ticket,
4
+ TicketCreateOptions,
5
+ TicketDraft,
6
+ TicketListing,
7
+ TicketSource,
8
+ TicketUpdate,
9
+ } from '../types'
10
+ import { AuthError } from '../../store/remote/client'
11
+ import { errorBodySchema } from '../../store/remote/protocol'
12
+ import {
13
+ claimWireSchema,
14
+ dependencyStatesWireSchema,
15
+ type HostedTicketContext,
16
+ successWireSchema,
17
+ ticketListingWireSchema,
18
+ ticketWireSchema,
19
+ } from './remote-protocol'
20
+ import {
21
+ AUTOBUILD_VERSION,
22
+ AUTOBUILD_VERSION_HEADER,
23
+ REMOTE_STORE_PROTOCOL_VERSION,
24
+ REMOTE_STORE_PROTOCOL_VERSION_HEADER,
25
+ } from '../../store/remote/version'
26
+
27
+ export type HostedTicketFetch = (
28
+ input: string | URL | Request,
29
+ init?: RequestInit,
30
+ ) => Promise<Response>
31
+
32
+ export interface HostedTicketSourceOptions extends HostedTicketContext {
33
+ url: string
34
+ token: string
35
+ fetchFn?: HostedTicketFetch
36
+ }
37
+
38
+ /** Full TicketSource client for a hosted Autobuild service. */
39
+ export class HostedTicketSource implements TicketSource {
40
+ readonly name = 'hosted'
41
+ private readonly base: string
42
+ private readonly token: string
43
+ private readonly fetchFn: HostedTicketFetch
44
+ private readonly context: HostedTicketContext
45
+
46
+ constructor(options: HostedTicketSourceOptions) {
47
+ this.base = options.url.replace(/\/+$/, '')
48
+ this.token = options.token
49
+ this.fetchFn = options.fetchFn ?? fetch
50
+ this.context = {
51
+ teamKey: options.teamKey,
52
+ ...(options.claimedState !== undefined ? { claimedState: options.claimedState } : {}),
53
+ ...(options.createState !== undefined ? { createState: options.createState } : {}),
54
+ }
55
+ }
56
+
57
+ private project(ticket: Ticket): Ticket {
58
+ return { ...ticket, ref: { ...ticket.ref, source: this.name } }
59
+ }
60
+
61
+ private async request<T>(
62
+ operation: string,
63
+ input: unknown,
64
+ schema: { parse(value: unknown): T },
65
+ ): Promise<T> {
66
+ const response = await this.fetchFn(`${this.base}/tickets/${operation}`, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'content-type': 'application/json',
70
+ authorization: `Bearer ${this.token}`,
71
+ [AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
72
+ [REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
73
+ },
74
+ body: JSON.stringify({ context: this.context, input }),
75
+ })
76
+ if (!response.ok) {
77
+ let message = `ticket service responded ${response.status}`
78
+ try {
79
+ message = errorBodySchema.parse(await response.json()).error
80
+ } catch {
81
+ // Keep the status-only safe fallback for non-protocol responses.
82
+ }
83
+ if (response.status === 401 || response.status === 403) throw new AuthError(message)
84
+ throw new Error(message)
85
+ }
86
+ return schema.parse(await response.json())
87
+ }
88
+
89
+ async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
90
+ const listing = await this.request('list-ready', criteria, ticketListingWireSchema)
91
+ return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
92
+ }
93
+
94
+ async get(id: string): Promise<Ticket | null> {
95
+ const ticket = await this.request('get', { id }, ticketWireSchema.nullable())
96
+ return ticket === null ? null : this.project(ticket)
97
+ }
98
+
99
+ async claim(id: string): Promise<boolean> {
100
+ return (await this.request('claim', { id }, claimWireSchema)).claimed
101
+ }
102
+
103
+ async comment(id: string, body: string): Promise<void> {
104
+ await this.request('comment', { id, body }, successWireSchema)
105
+ }
106
+
107
+ async transition(id: string, state: string): Promise<void> {
108
+ await this.request('transition', { id, state }, successWireSchema)
109
+ }
110
+
111
+ async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
112
+ return this.project(await this.request('create', { draft, options }, ticketWireSchema))
113
+ }
114
+
115
+ async update(id: string, patch: TicketUpdate): Promise<void> {
116
+ await this.request('update', { id, patch }, successWireSchema)
117
+ }
118
+
119
+ async addBlocker(id: string, blockerId: string): Promise<void> {
120
+ await this.request('add-blocker', { id, blockerId }, successWireSchema)
121
+ }
122
+
123
+ async removeBlocker(id: string, blockerId: string): Promise<void> {
124
+ await this.request('remove-blocker', { id, blockerId }, successWireSchema)
125
+ }
126
+
127
+ dependencyStates(ids: string[]): Promise<DependencyState[]> {
128
+ return this.request('dependency-states', { ids }, dependencyStatesWireSchema)
129
+ }
130
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod'
2
+ import { expandIssues } from '../../zod-issues'
3
+ import type { TicketUpdate } from '../types'
4
+
5
+ const ticketUpdateSchema = z
6
+ .strictObject({
7
+ title: z
8
+ .string()
9
+ .refine((value) => value.trim().length > 0, 'title must not be blank')
10
+ .optional(),
11
+ body: z
12
+ .string()
13
+ .refine((value) => value.trim().length > 0, 'body must not be blank')
14
+ .optional(),
15
+ labels: z.array(z.string()).optional(),
16
+ })
17
+ .refine(
18
+ (patch) => patch.title !== undefined || patch.body !== undefined || patch.labels !== undefined,
19
+ 'update must name at least one of title, body, or labels',
20
+ )
21
+
22
+ /** Render ticket-update issues while retaining nested union branch detail. */
23
+ export function renderTicketUpdateIssues(issues: readonly z.core.$ZodIssue[]): string {
24
+ return expandIssues(issues)
25
+ .map((issue) => {
26
+ const path = issue.path.join('.')
27
+ return path === '' ? issue.message : `${path}: ${issue.message}`
28
+ })
29
+ .join('; ')
30
+ }
31
+
32
+ /**
33
+ * Validate the common partial-update contract before an adapter performs any
34
+ * write. Keeping this at the port boundary (rather than only in the CLI)
35
+ * gives direct callers and every provider identical required-field and strict
36
+ * key semantics.
37
+ */
38
+ export function validateTicketUpdate(patch: TicketUpdate): TicketUpdate {
39
+ const result = ticketUpdateSchema.safeParse(patch)
40
+ if (!result.success) {
41
+ throw new Error(`invalid ticket update — ${renderTicketUpdateIssues(result.error.issues)}`)
42
+ }
43
+
44
+ // Reconstruct the patch so arrays cannot be mutated behind an adapter's
45
+ // back and explicit `undefined` never masquerades as a named field.
46
+ return {
47
+ ...(result.data.title !== undefined ? { title: result.data.title } : {}),
48
+ ...(result.data.body !== undefined ? { body: result.data.body } : {}),
49
+ ...(result.data.labels !== undefined ? { labels: [...result.data.labels] } : {}),
50
+ }
51
+ }