@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,103 @@
1
+ /**
2
+ * Session-stream part builders (SPEC §9): the one place the `data-ab-*`
3
+ * extension vocabulary is defined and the AI SDK UI Message Stream parts are
4
+ * shaped. Pure functions returning fresh objects — the batching writer owns
5
+ * buffering, truncation, and store delivery; the adapters own only when to
6
+ * call these.
7
+ *
8
+ * Everything here is the protocol of `ai-ui-message-stream/v1` plus the
9
+ * `data-ab-*` data parts Autobuild defines on top of it. Nothing encodes
10
+ * this repository's specifics.
11
+ */
12
+ import type { StreamPart } from '../../store/streams/types'
13
+ import type { SessionStreamInfo } from './runtime'
14
+
15
+ /**
16
+ * Documented per-part size bound (SPEC §9): text, reasoning, tool inputs,
17
+ * and tool outputs serialized above this many UTF-8 bytes are truncated by
18
+ * the writer and followed by a `data-ab-truncation` part naming the omitted
19
+ * byte count. The writer, not the adapter, enforces it.
20
+ */
21
+ export const STREAM_PART_MAX_BYTES = 65_536
22
+
23
+ /** The stream's first part, always: the bracket it belongs to. */
24
+ export function sessionPart(info: SessionStreamInfo): StreamPart {
25
+ return {
26
+ type: 'data-ab-session',
27
+ data: {
28
+ session: info.session,
29
+ role: info.role,
30
+ runner: info.runner,
31
+ ...(info.model !== undefined ? { model: info.model } : {}),
32
+ phase: info.phase,
33
+ ...(info.round !== undefined ? { round: info.round } : {}),
34
+ },
35
+ }
36
+ }
37
+
38
+ /** A turn's prompt: the skill invocation on turn 1, the continuation
39
+ * message on later rounds — both sides of the exchange are visible. */
40
+ export function promptPart(text: string): StreamPart {
41
+ return { type: 'data-ab-prompt', data: { text } }
42
+ }
43
+
44
+ /** Serialized bytes omitted by the writer's truncation, as its own part. */
45
+ export function truncationPart(omittedBytes: number): StreamPart {
46
+ return { type: 'data-ab-truncation', data: { omittedBytes } }
47
+ }
48
+
49
+ export function startPart(messageId: string): StreamPart {
50
+ return { type: 'start', messageId }
51
+ }
52
+
53
+ export function finishPart(): StreamPart {
54
+ return { type: 'finish', finishReason: 'stop' }
55
+ }
56
+
57
+ export function errorPart(errorText: string): StreamPart {
58
+ return { type: 'error', errorText }
59
+ }
60
+
61
+ export function abortPart(reason: string): StreamPart {
62
+ return { type: 'abort', reason }
63
+ }
64
+
65
+ export function startStepPart(): StreamPart {
66
+ return { type: 'start-step' }
67
+ }
68
+
69
+ export function finishStepPart(): StreamPart {
70
+ return { type: 'finish-step' }
71
+ }
72
+
73
+ export function textStartPart(id: string): StreamPart {
74
+ return { type: 'text-start', id }
75
+ }
76
+
77
+ export function textDeltaPart(id: string, delta: string): StreamPart {
78
+ return { type: 'text-delta', id, delta }
79
+ }
80
+
81
+ export function textEndPart(id: string): StreamPart {
82
+ return { type: 'text-end', id }
83
+ }
84
+
85
+ export function reasoningStartPart(id: string): StreamPart {
86
+ return { type: 'reasoning-start', id }
87
+ }
88
+
89
+ export function reasoningDeltaPart(id: string, delta: string): StreamPart {
90
+ return { type: 'reasoning-delta', id, delta }
91
+ }
92
+
93
+ export function reasoningEndPart(id: string): StreamPart {
94
+ return { type: 'reasoning-end', id }
95
+ }
96
+
97
+ export function toolInputPart(toolCallId: string, toolName: string, input: unknown): StreamPart {
98
+ return { type: 'tool-input-available', toolCallId, toolName, input }
99
+ }
100
+
101
+ export function toolOutputPart(toolCallId: string, output: unknown): StreamPart {
102
+ return { type: 'tool-output-available', toolCallId, output }
103
+ }
@@ -0,0 +1,570 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type {
3
+ DependencyState,
4
+ Ticket,
5
+ TicketCreateOptions,
6
+ TicketDraft,
7
+ TicketSource,
8
+ TicketUpdate,
9
+ } from '../types'
10
+
11
+ /** Lifecycle names supplied by an adapter's contract fixture. */
12
+ export interface TicketSourceContractStates {
13
+ /** A state from which the fixture's source can claim a ticket. */
14
+ ready: string
15
+ /** The state configured as the source's claim destination. */
16
+ claimed: string
17
+ /** A state the source considers resolved for dependency checks. */
18
+ completed: string
19
+ }
20
+
21
+ export interface TicketSourceContractHarness {
22
+ source: TicketSource
23
+ states: TicketSourceContractStates
24
+ /** A fixture label known to be writable in this source/team. */
25
+ editableLabel: string
26
+ /** Allocate a label name that does not yet exist in the source. Live
27
+ * providers may use this seam to track provider-side labels for cleanup. */
28
+ freshLabel?: (purpose: string) => Promise<string> | string
29
+ /** Called before a reserved external id is used, so live cleanup remains
30
+ * possible even if creation commits and the adapter call then fails. */
31
+ beforeCreate?: (idempotencyKey: string) => Promise<void> | void
32
+ /** Fixture-only bookkeeping, such as attaching a Linear issue to the
33
+ * explicitly configured scratch project. */
34
+ afterCreate?: (ticket: Ticket, idempotencyKey: string) => Promise<void> | void
35
+ cleanup?: () => Promise<void>
36
+ }
37
+
38
+ export type TicketSourceContractFactory = () => Promise<TicketSourceContractHarness>
39
+
40
+ /** Shared live-safe fixtures. UUID suffixes prevent collisions in scratch
41
+ * providers while keeping failures recognizable to an operator. */
42
+ export const CONTRACT_TICKET_BODY =
43
+ '# Port contract fixture\n\nCreated by the Autobuild TicketSource contract suite.\n'
44
+
45
+ export function contractTicketTitle(purpose: string): string {
46
+ return `[ab contract] ${purpose} ${crypto.randomUUID()}`
47
+ }
48
+
49
+ export function contractIdempotencyKey(): string {
50
+ return crypto.randomUUID()
51
+ }
52
+
53
+ export function contractLabelName(purpose: string): string {
54
+ const slug = purpose
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9]+/g, '-')
57
+ .replace(/^-|-$/g, '')
58
+ const suffix = crypto.randomUUID().replaceAll('-', '').slice(0, 16)
59
+ return `ab-contract-${slug}-${suffix}`
60
+ }
61
+
62
+ async function freshLabel(harness: TicketSourceContractHarness, purpose: string): Promise<string> {
63
+ return (await harness.freshLabel?.(purpose)) ?? contractLabelName(purpose)
64
+ }
65
+
66
+ async function withTicketSource(
67
+ factory: TicketSourceContractFactory,
68
+ run: (harness: TicketSourceContractHarness) => Promise<void>,
69
+ ): Promise<void> {
70
+ const harness = await factory()
71
+ let failure: unknown
72
+ try {
73
+ await run(harness)
74
+ } catch (error) {
75
+ failure = error
76
+ }
77
+
78
+ try {
79
+ await harness.cleanup?.()
80
+ } catch (cleanupError) {
81
+ if (failure !== undefined) {
82
+ throw new AggregateError(
83
+ [failure, cleanupError],
84
+ 'TicketSource contract assertion and cleanup both failed',
85
+ )
86
+ }
87
+ throw cleanupError
88
+ }
89
+ if (failure !== undefined) throw failure
90
+ }
91
+
92
+ /** Every contract-created ticket carries a UUID-v4 reservation. Linear uses
93
+ * it as the issue id; file and fake adapters treat the same value opaquely. */
94
+ async function createTracked(
95
+ harness: TicketSourceContractHarness,
96
+ draft: TicketDraft,
97
+ opts: TicketCreateOptions = {},
98
+ ): Promise<Ticket> {
99
+ const idempotencyKey = opts.idempotencyKey ?? contractIdempotencyKey()
100
+ await harness.beforeCreate?.(idempotencyKey)
101
+ const created = await harness.source.create(draft, {
102
+ ...opts,
103
+ idempotencyKey,
104
+ })
105
+ await harness.afterCreate?.(created, idempotencyKey)
106
+ return created
107
+ }
108
+
109
+ /**
110
+ * Reusable TicketSource semantics. Adapter-specific transport and formatting
111
+ * tests stay beside each implementation; this suite is the common behavioral
112
+ * floor every fake, local adapter, and live provider must run unchanged.
113
+ */
114
+ export function describeTicketSourceContract(
115
+ name: string,
116
+ factory: TicketSourceContractFactory,
117
+ ): void {
118
+ describe(`TicketSource contract: ${name}`, () => {
119
+ test('create/get round-trips common fields and honors an explicit state', async () => {
120
+ await withTicketSource(factory, async (harness) => {
121
+ const title = contractTicketTitle('round trip')
122
+ const created = await createTracked(
123
+ harness,
124
+ { title, body: CONTRACT_TICKET_BODY, labels: [] },
125
+ { state: harness.states.ready },
126
+ )
127
+
128
+ expect(created).toMatchObject({
129
+ ref: { source: harness.source.name, title },
130
+ creationKey: expect.any(String),
131
+ title,
132
+ body: CONTRACT_TICKET_BODY,
133
+ state: harness.states.ready,
134
+ labels: [],
135
+ })
136
+ expect(await harness.source.get(created.ref.id)).toEqual(created)
137
+ })
138
+ })
139
+
140
+ test('create accepts a previously unknown label and round-trips it through get', async () => {
141
+ await withTicketSource(factory, async (harness) => {
142
+ const label = await freshLabel(harness, 'create')
143
+ const created = await createTracked(
144
+ harness,
145
+ {
146
+ title: contractTicketTitle('fresh create label'),
147
+ body: CONTRACT_TICKET_BODY,
148
+ labels: [label],
149
+ },
150
+ { state: harness.states.ready },
151
+ )
152
+
153
+ expect(created.labels).toEqual([label])
154
+ expect((await harness.source.get(created.ref.id))?.labels).toEqual([label])
155
+ })
156
+ })
157
+
158
+ test('update accepts a previously unknown label and round-trips it through get', async () => {
159
+ await withTicketSource(factory, async (harness) => {
160
+ const created = await createTracked(
161
+ harness,
162
+ {
163
+ title: contractTicketTitle('fresh update label'),
164
+ body: CONTRACT_TICKET_BODY,
165
+ },
166
+ { state: harness.states.ready },
167
+ )
168
+ const label = await freshLabel(harness, 'update')
169
+
170
+ await harness.source.update(created.ref.id, { labels: [label] })
171
+
172
+ expect((await harness.source.get(created.ref.id))?.labels).toEqual([label])
173
+ })
174
+ })
175
+
176
+ test('concurrent writes safely share one previously unknown label', async () => {
177
+ await withTicketSource(factory, async (harness) => {
178
+ const first = await createTracked(
179
+ harness,
180
+ {
181
+ title: contractTicketTitle('concurrent label first'),
182
+ body: CONTRACT_TICKET_BODY,
183
+ },
184
+ { state: harness.states.ready },
185
+ )
186
+ const second = await createTracked(
187
+ harness,
188
+ {
189
+ title: contractTicketTitle('concurrent label second'),
190
+ body: CONTRACT_TICKET_BODY,
191
+ },
192
+ { state: harness.states.ready },
193
+ )
194
+ const label = await freshLabel(harness, 'concurrent')
195
+
196
+ await Promise.all([
197
+ harness.source.update(first.ref.id, { labels: [label] }),
198
+ harness.source.update(second.ref.id, { labels: [label] }),
199
+ ])
200
+
201
+ expect((await harness.source.get(first.ref.id))?.labels).toEqual([label])
202
+ expect((await harness.source.get(second.ref.id))?.labels).toEqual([label])
203
+ })
204
+ })
205
+
206
+ test('get returns null for a valid nonexistent id', async () => {
207
+ await withTicketSource(factory, async ({ source }) => {
208
+ expect(await source.get(contractIdempotencyKey())).toBeNull()
209
+ })
210
+ })
211
+
212
+ test('claim succeeds once, rejects an immediate repeat, and resets after Ready handback', async () => {
213
+ await withTicketSource(factory, async (harness) => {
214
+ const created = await createTracked(
215
+ harness,
216
+ {
217
+ title: contractTicketTitle('claim'),
218
+ body: CONTRACT_TICKET_BODY,
219
+ },
220
+ { state: harness.states.ready },
221
+ )
222
+
223
+ expect(await harness.source.claim(created.ref.id)).toBe(true)
224
+ expect(await harness.source.claim(created.ref.id)).toBe(false)
225
+ await harness.source.transition(created.ref.id, harness.states.ready)
226
+ expect(await harness.source.claim(created.ref.id)).toBe(true)
227
+ })
228
+ })
229
+
230
+ test('claim rejects a completed ticket without reopening it', async () => {
231
+ await withTicketSource(factory, async (harness) => {
232
+ const created = await createTracked(
233
+ harness,
234
+ {
235
+ title: contractTicketTitle('completed claim'),
236
+ body: CONTRACT_TICKET_BODY,
237
+ },
238
+ { state: harness.states.completed },
239
+ )
240
+
241
+ expect(await harness.source.claim(created.ref.id)).toBe(false)
242
+ expect((await harness.source.get(created.ref.id))?.state).toBe(harness.states.completed)
243
+ })
244
+ })
245
+
246
+ test('comment accepts known tickets and rejects unknown tickets', async () => {
247
+ await withTicketSource(factory, async (harness) => {
248
+ const created = await createTracked(
249
+ harness,
250
+ { title: contractTicketTitle('comment'), body: CONTRACT_TICKET_BODY },
251
+ { state: harness.states.ready },
252
+ )
253
+ await expect(
254
+ harness.source.comment(created.ref.id, 'contract comment'),
255
+ ).resolves.toBeUndefined()
256
+ const unknown = contractIdempotencyKey()
257
+ await expect(harness.source.comment(unknown, 'missing')).rejects.toThrow(unknown)
258
+ })
259
+ })
260
+
261
+ test('transition is visible through get and state-filtered listReady', async () => {
262
+ await withTicketSource(factory, async (harness) => {
263
+ const created = await createTracked(
264
+ harness,
265
+ {
266
+ title: contractTicketTitle('transition'),
267
+ body: CONTRACT_TICKET_BODY,
268
+ },
269
+ { state: harness.states.ready },
270
+ )
271
+ const initiallyReady = await harness.source.listReady({
272
+ state: harness.states.ready,
273
+ })
274
+ expect(initiallyReady.diagnostics).toEqual([])
275
+ expect(initiallyReady.tickets.some((ticket) => ticket.ref.id === created.ref.id)).toBe(true)
276
+
277
+ await harness.source.transition(created.ref.id, harness.states.completed)
278
+ expect((await harness.source.get(created.ref.id))?.state).toBe(harness.states.completed)
279
+ const stillReady = await harness.source.listReady({
280
+ state: harness.states.ready,
281
+ })
282
+ expect(stillReady.diagnostics).toEqual([])
283
+ expect(stillReady.tickets.some((ticket) => ticket.ref.id === created.ref.id)).toBe(false)
284
+ })
285
+ })
286
+
287
+ test('dependencyStates covers requested ids in order with native resolution and blockers', async () => {
288
+ await withTicketSource(factory, async (harness) => {
289
+ const unresolved = await createTracked(
290
+ harness,
291
+ {
292
+ title: contractTicketTitle('unresolved dependency'),
293
+ body: CONTRACT_TICKET_BODY,
294
+ },
295
+ { state: harness.states.ready },
296
+ )
297
+ const completed = await createTracked(
298
+ harness,
299
+ {
300
+ title: contractTicketTitle('completed dependency'),
301
+ body: CONTRACT_TICKET_BODY,
302
+ },
303
+ { state: harness.states.completed },
304
+ )
305
+ const dependent = await createTracked(
306
+ harness,
307
+ {
308
+ title: contractTicketTitle('declared blocker'),
309
+ body: CONTRACT_TICKET_BODY,
310
+ blockedBy: [unresolved.ref.id],
311
+ },
312
+ { state: harness.states.ready },
313
+ )
314
+ const unknown = contractIdempotencyKey()
315
+
316
+ const expected: DependencyState[] = [
317
+ { id: unknown, exists: false, resolved: false, blockedBy: [] },
318
+ {
319
+ id: dependent.ref.id,
320
+ exists: true,
321
+ resolved: false,
322
+ blockedBy: [unresolved.ref.id],
323
+ },
324
+ { id: completed.ref.id, exists: true, resolved: true, blockedBy: [] },
325
+ { id: unresolved.ref.id, exists: true, resolved: false, blockedBy: [] },
326
+ ]
327
+ expect(await harness.source.dependencyStates(expected.map((state) => state.id))).toEqual(
328
+ expected,
329
+ )
330
+ })
331
+ })
332
+
333
+ test('partial updates replace only named editable fields and never state or blockers', async () => {
334
+ await withTicketSource(factory, async (harness) => {
335
+ const blocker = await createTracked(
336
+ harness,
337
+ {
338
+ title: contractTicketTitle('update blocker'),
339
+ body: CONTRACT_TICKET_BODY,
340
+ },
341
+ { state: harness.states.ready },
342
+ )
343
+ const originalTitle = contractTicketTitle('partial update')
344
+ const created = await createTracked(
345
+ harness,
346
+ {
347
+ title: originalTitle,
348
+ body: CONTRACT_TICKET_BODY,
349
+ labels: [harness.editableLabel],
350
+ blockedBy: [blocker.ref.id],
351
+ },
352
+ { state: harness.states.ready },
353
+ )
354
+
355
+ const replacementBody = `${CONTRACT_TICKET_BODY}\nUpdated body.\n`
356
+ await harness.source.update(created.ref.id, { body: replacementBody })
357
+ expect(await harness.source.get(created.ref.id)).toMatchObject({
358
+ ref: { title: originalTitle },
359
+ title: originalTitle,
360
+ body: replacementBody,
361
+ state: harness.states.ready,
362
+ labels: [harness.editableLabel],
363
+ blockedBy: [blocker.ref.id],
364
+ })
365
+
366
+ const replacementTitle = contractTicketTitle('renamed')
367
+ await harness.source.update(created.ref.id, { title: replacementTitle })
368
+ expect(await harness.source.get(created.ref.id)).toMatchObject({
369
+ ref: { title: replacementTitle },
370
+ title: replacementTitle,
371
+ body: replacementBody,
372
+ state: harness.states.ready,
373
+ labels: [harness.editableLabel],
374
+ blockedBy: [blocker.ref.id],
375
+ })
376
+
377
+ // An explicit empty list is a replacement, not omission.
378
+ await harness.source.update(created.ref.id, { labels: [] })
379
+ expect(await harness.source.get(created.ref.id)).toMatchObject({
380
+ title: replacementTitle,
381
+ body: replacementBody,
382
+ state: harness.states.ready,
383
+ labels: [],
384
+ blockedBy: [blocker.ref.id],
385
+ })
386
+ expect(await harness.source.dependencyStates([created.ref.id])).toEqual([
387
+ {
388
+ id: created.ref.id,
389
+ exists: true,
390
+ resolved: false,
391
+ blockedBy: [blocker.ref.id],
392
+ },
393
+ ])
394
+ })
395
+ })
396
+
397
+ test('update rejects empty/unknown/state patches without changing the ticket', async () => {
398
+ await withTicketSource(factory, async (harness) => {
399
+ const created = await createTracked(
400
+ harness,
401
+ {
402
+ title: contractTicketTitle('strict update'),
403
+ body: CONTRACT_TICKET_BODY,
404
+ labels: [harness.editableLabel],
405
+ },
406
+ { state: harness.states.ready },
407
+ )
408
+ const before = await harness.source.get(created.ref.id)
409
+
410
+ await expect(harness.source.update(created.ref.id, {})).rejects.toThrow('at least one')
411
+ await expect(
412
+ harness.source.update(created.ref.id, {
413
+ state: harness.states.completed,
414
+ } as unknown as TicketUpdate),
415
+ ).rejects.toThrow('state')
416
+ expect(await harness.source.get(created.ref.id)).toEqual(before)
417
+ })
418
+ })
419
+
420
+ test('update failures name unknown tickets and blank required fields and are atomic', async () => {
421
+ await withTicketSource(factory, async (harness) => {
422
+ const created = await createTracked(
423
+ harness,
424
+ {
425
+ title: contractTicketTitle('required update fields'),
426
+ body: CONTRACT_TICKET_BODY,
427
+ },
428
+ { state: harness.states.ready },
429
+ )
430
+ const before = await harness.source.get(created.ref.id)
431
+
432
+ for (const [field, patch] of [
433
+ ['title', { title: ' ' }],
434
+ ['body', { body: '\n\t' }],
435
+ ] as const) {
436
+ await expect(harness.source.update(created.ref.id, patch)).rejects.toThrow(field)
437
+ expect(await harness.source.get(created.ref.id)).toEqual(before)
438
+ }
439
+
440
+ const unknown = contractIdempotencyKey()
441
+ await expect(
442
+ harness.source.update(unknown, { title: contractTicketTitle('unknown') }),
443
+ ).rejects.toThrow(unknown)
444
+ expect(await harness.source.get(created.ref.id)).toEqual(before)
445
+ })
446
+ })
447
+
448
+ test('blocker add/remove round-trips through get and dependencyStates and retries are no-ops', async () => {
449
+ await withTicketSource(factory, async (harness) => {
450
+ const blocker = await createTracked(
451
+ harness,
452
+ {
453
+ title: contractTicketTitle('post-create blocker'),
454
+ body: CONTRACT_TICKET_BODY,
455
+ },
456
+ { state: harness.states.ready },
457
+ )
458
+ const dependent = await createTracked(
459
+ harness,
460
+ {
461
+ title: contractTicketTitle('post-create dependent'),
462
+ body: CONTRACT_TICKET_BODY,
463
+ },
464
+ { state: harness.states.ready },
465
+ )
466
+
467
+ await harness.source.addBlocker(dependent.ref.id, blocker.ref.id)
468
+ await harness.source.addBlocker(dependent.ref.id, blocker.ref.id)
469
+ expect((await harness.source.get(dependent.ref.id))?.blockedBy).toEqual([blocker.ref.id])
470
+ expect(await harness.source.dependencyStates([dependent.ref.id])).toEqual([
471
+ {
472
+ id: dependent.ref.id,
473
+ exists: true,
474
+ resolved: false,
475
+ blockedBy: [blocker.ref.id],
476
+ },
477
+ ])
478
+
479
+ await harness.source.removeBlocker(dependent.ref.id, blocker.ref.id)
480
+ await harness.source.removeBlocker(dependent.ref.id, blocker.ref.id)
481
+ // Removing an unrelated/missing blocker has the same intended state.
482
+ await harness.source.removeBlocker(dependent.ref.id, contractIdempotencyKey())
483
+ expect((await harness.source.get(dependent.ref.id))?.blockedBy).toBeUndefined()
484
+ expect(await harness.source.dependencyStates([dependent.ref.id])).toEqual([
485
+ {
486
+ id: dependent.ref.id,
487
+ exists: true,
488
+ resolved: false,
489
+ blockedBy: [],
490
+ },
491
+ ])
492
+ })
493
+ })
494
+
495
+ test('addBlocker rejects self-blocks and unknown blockers without changing dependencies', async () => {
496
+ await withTicketSource(factory, async (harness) => {
497
+ const dependent = await createTracked(
498
+ harness,
499
+ {
500
+ title: contractTicketTitle('invalid blocker'),
501
+ body: CONTRACT_TICKET_BODY,
502
+ },
503
+ { state: harness.states.ready },
504
+ )
505
+
506
+ await expect(harness.source.addBlocker(dependent.ref.id, dependent.ref.id)).rejects.toThrow(
507
+ dependent.ref.id,
508
+ )
509
+ const missing = contractIdempotencyKey()
510
+ await expect(harness.source.addBlocker(dependent.ref.id, missing)).rejects.toThrow(missing)
511
+ expect((await harness.source.get(dependent.ref.id))?.blockedBy).toBeUndefined()
512
+ })
513
+ })
514
+
515
+ test('blocker writes require the target ticket and name it when unknown', async () => {
516
+ await withTicketSource(factory, async (harness) => {
517
+ const blocker = await createTracked(
518
+ harness,
519
+ {
520
+ title: contractTicketTitle('known blocker'),
521
+ body: CONTRACT_TICKET_BODY,
522
+ },
523
+ { state: harness.states.ready },
524
+ )
525
+ const missingTarget = contractIdempotencyKey()
526
+
527
+ await expect(harness.source.addBlocker(missingTarget, blocker.ref.id)).rejects.toThrow(
528
+ missingTarget,
529
+ )
530
+ await expect(harness.source.removeBlocker(missingTarget, blocker.ref.id)).rejects.toThrow(
531
+ missingTarget,
532
+ )
533
+ })
534
+ })
535
+
536
+ test('a repeated idempotent create adopts the original instead of duplicating', async () => {
537
+ await withTicketSource(factory, async (harness) => {
538
+ const idempotencyKey = contractIdempotencyKey()
539
+ const originalTitle = contractTicketTitle('idempotent original')
540
+ const first = await createTracked(
541
+ harness,
542
+ { title: originalTitle, body: CONTRACT_TICKET_BODY },
543
+ { state: harness.states.ready, idempotencyKey },
544
+ )
545
+ const retry = await createTracked(
546
+ harness,
547
+ {
548
+ title: contractTicketTitle('idempotent retry must not win'),
549
+ body: 'replacement content must not win\n',
550
+ },
551
+ { state: harness.states.completed, idempotencyKey },
552
+ )
553
+
554
+ expect(first.creationKey).toBe(idempotencyKey)
555
+ expect(retry.ref.id).toBe(first.ref.id)
556
+ expect(retry.creationKey).toBe(idempotencyKey)
557
+ expect(retry.title).toBe(originalTitle)
558
+ expect(retry.body).toBe(CONTRACT_TICKET_BODY)
559
+ expect(retry.state).toBe(harness.states.ready)
560
+ const listing = await harness.source.listReady({
561
+ state: harness.states.ready,
562
+ })
563
+ expect(listing.diagnostics).toEqual([])
564
+ const matching = listing.tickets.filter((ticket) => ticket.ref.id === first.ref.id)
565
+ expect(matching).toHaveLength(1)
566
+ expect(matching[0]?.creationKey).toBe(idempotencyKey)
567
+ })
568
+ })
569
+ })
570
+ }