@defrex/autobuild 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,6 +1,9 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
2
+ import { samplePinnedAssets, preparePinnedAssets } from '../store/ticket-assets'
3
+ import type { TicketRef } from '../ontology'
1
4
  import type { Config } from '../config/schema'
2
- import { reduceBuild } from '../kernel/reducer'
3
5
  import {
6
+ TicketOperationError,
4
7
  changeBlockers,
5
8
  createTicket,
6
9
  moveTicket,
@@ -9,7 +12,7 @@ import {
9
12
  } from '../ports/tickets/operations'
10
13
  import type { DependencyState, Ticket, TicketSource, TicketUpdate } from '../ports/types'
11
14
  import type { BuildStore } from '../store/types'
12
- import { defaultTriageState } from '../processes/dispatcher'
15
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
13
16
  import { effectiveConfig } from './query'
14
17
 
15
18
  export interface OperatorTicketContext {
@@ -21,6 +24,7 @@ export interface OperatorTicketContext {
21
24
  }
22
25
 
23
26
  export interface OperatorTicketBackend {
27
+ contextFor?(repo: string): Promise<OperatorTicketContext>
24
28
  sourceFor(context: OperatorTicketContext): TicketSource | Promise<TicketSource>
25
29
  statesFor(context: OperatorTicketContext, source: TicketSource): string[] | Promise<string[]>
26
30
  }
@@ -63,10 +67,11 @@ export async function openOperatorTickets(opts: {
63
67
  store: BuildStore
64
68
  repo: string
65
69
  backend: OperatorTicketBackend
66
- }): Promise<{ config: Config; context: OperatorTicketContext; source: TicketSource }> {
67
- const { config } = await effectiveConfig(opts.store, opts.repo)
68
- const context = contextFor(config, opts.repo)
69
- return { config, context, source: await opts.backend.sourceFor(context) }
70
+ }): Promise<{ context: OperatorTicketContext; source: TicketSource }> {
71
+ const context = opts.backend.contextFor
72
+ ? await opts.backend.contextFor(opts.repo)
73
+ : contextFor((await effectiveConfig(opts.store, opts.repo)).config, opts.repo)
74
+ return { context, source: await opts.backend.sourceFor(context) }
70
75
  }
71
76
 
72
77
  export async function listOperatorTickets(opts: {
@@ -76,9 +81,9 @@ export async function listOperatorTickets(opts: {
76
81
  state?: string
77
82
  labels?: string[]
78
83
  }): Promise<OperatorTicketQueue> {
79
- const { config, context, source } = await openOperatorTickets(opts)
80
- const triageState = defaultTriageState(config)
81
- const readyState = config.tickets.readyState
84
+ const { context, source } = await openOperatorTickets(opts)
85
+ const triageState = ticketLifecycle(context.tickets).triage
86
+ const readyState = context.tickets.readyState
82
87
  // The operator surface is a grooming queue. Unlike the CLI's ticket list,
83
88
  // labels narrow the default state instead of replacing its lifecycle gate.
84
89
  const criteria = {
@@ -103,14 +108,40 @@ async function matchingBuild(
103
108
  store: BuildStore,
104
109
  repo: string,
105
110
  id: string,
111
+ source: TicketSource,
112
+ configuredSource: string,
106
113
  ): Promise<OperatorTicketBuild | null> {
107
- const matches = (await store.listBuilds()).filter(
108
- (record) => record.repo === repo && record.ticket?.id === id,
114
+ // The service's database adapter implements the externally named hosted source.
115
+ const hosted =
116
+ source.name === 'hosted' || (configuredSource === 'hosted' && source.name === 'database')
117
+ const candidates = (await store.listBuilds()).filter(
118
+ (record) =>
119
+ record.repo === repo &&
120
+ (record.ticket?.source === source.name || (hosted && record.ticket?.source === 'hosted')),
109
121
  )
122
+ const matches = candidates.filter((record) => record.ticket!.id === id)
123
+ // The service database store always projects current ids, even when there is
124
+ // no matching build. Only a hosted client over an unprojected legacy store
125
+ // needs fallback lookups; failure must not undo a mutation.
126
+ if (matches.length === 0 && source.name === 'hosted') {
127
+ const identities = new Map<string, string>()
128
+ try {
129
+ for (const record of candidates) {
130
+ const raw = record.ticket!.id
131
+ if (!identities.has(raw)) {
132
+ const ticket = await source.get(raw)
133
+ identities.set(raw, ticket?.ref.source === source.name ? ticket.ref.id : raw)
134
+ }
135
+ if (identities.get(raw) === id) matches.push(record)
136
+ }
137
+ } catch {
138
+ // Build association is optional presentation; keep the successful ticket result.
139
+ }
140
+ }
110
141
  const projected = await Promise.all(
111
142
  matches.map(async (record) => ({
112
143
  record,
113
- state: reduceBuild(await store.getEvents(record.slug)),
144
+ state: (await readBuildFacts(store, record.slug)).value.state,
114
145
  })),
115
146
  )
116
147
  projected.sort((a, b) => {
@@ -136,10 +167,20 @@ export async function getOperatorTicket(opts: {
136
167
  backend: OperatorTicketBackend
137
168
  id: string
138
169
  }): Promise<OperatorTicketDetail> {
139
- const { source } = await openOperatorTickets(opts)
170
+ const { source, context } = await openOperatorTickets(opts)
140
171
  const ticket = await requireTicket(source, opts.id)
141
172
  const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
142
- return { ticket, blockers, build: await matchingBuild(opts.store, opts.repo, opts.id) }
173
+ return {
174
+ ticket,
175
+ blockers,
176
+ build: await matchingBuild(
177
+ opts.store,
178
+ opts.repo,
179
+ ticket.ref.id,
180
+ source,
181
+ context.tickets.source,
182
+ ),
183
+ }
143
184
  }
144
185
 
145
186
  export async function mutateOperatorTicket(opts: {
@@ -155,21 +196,90 @@ export async function mutateOperatorTicket(opts: {
155
196
  state?: string
156
197
  blockedBy?: string[]
157
198
  }
158
- | { kind: 'update'; id: string; patch: TicketUpdate }
159
- | { kind: 'move'; id: string; state: string }
160
- | { kind: 'block' | 'unblock'; id: string; blockerIds: string[] }
199
+ | { kind: 'update'; id: string; patch: TicketUpdate; expectedRevision?: string }
200
+ | { kind: 'move'; id: string; state: string; expectedRevision?: string }
201
+ | { kind: 'block' | 'unblock'; id: string; blockerIds: string[]; expectedRevision?: string }
161
202
  }): Promise<OperatorTicketDetail> {
162
- const { source } = await openOperatorTickets(opts)
203
+ const { source, context } = await openOperatorTickets(opts)
163
204
  const operation = opts.operation
164
205
  if (operation.kind === 'update') await requireTicket(source, operation.id)
165
206
  const ticket =
166
207
  operation.kind === 'create'
167
208
  ? await createTicket(source, operation)
168
209
  : operation.kind === 'update'
169
- ? (await updateTicket(source, operation.id, operation.patch))!
210
+ ? (await updateTicket(
211
+ source,
212
+ operation.id,
213
+ operation.patch,
214
+ true,
215
+ operation.expectedRevision,
216
+ ))!
170
217
  : operation.kind === 'move'
171
- ? await moveTicket(source, operation.id, operation.state)
172
- : await changeBlockers(source, operation.id, operation.blockerIds, operation.kind)
218
+ ? await moveTicket(source, operation.id, operation.state, operation.expectedRevision)
219
+ : await changeBlockers(
220
+ source,
221
+ operation.id,
222
+ operation.blockerIds,
223
+ operation.kind,
224
+ operation.expectedRevision,
225
+ )
173
226
  const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
174
- return { ticket, blockers, build: await matchingBuild(opts.store, opts.repo, ticket.ref.id) }
227
+ return {
228
+ ticket,
229
+ blockers,
230
+ build: await matchingBuild(
231
+ opts.store,
232
+ opts.repo,
233
+ ticket.ref.id,
234
+ source,
235
+ context.tickets.source,
236
+ ),
237
+ }
238
+ }
239
+
240
+ export async function prepareOperatorTicketAssets(opts: {
241
+ store: BuildStore
242
+ repo: string
243
+ backend?: OperatorTicketBackend
244
+ ref: TicketRef
245
+ }) {
246
+ if (!opts.backend) {
247
+ if (opts.ref.source !== 'hosted') return samplePinnedAssets(opts.store, opts.repo, opts.ref.id)
248
+ throw new Error('ticket operator backend is not configured')
249
+ }
250
+ const { context, source } = await openOperatorTickets({ ...opts, backend: opts.backend })
251
+ if (context.tickets.source !== opts.ref.source)
252
+ throw new Error('build ticket source differs from configured source')
253
+ return preparePinnedAssets(source, opts.store, opts.repo, opts.ref.id)
254
+ }
255
+
256
+ export async function listOperatorTicketStates(opts: {
257
+ store: BuildStore
258
+ repo: string
259
+ backend: OperatorTicketBackend
260
+ }) {
261
+ const { source } = await openOperatorTickets(opts)
262
+ if (!source.listStates)
263
+ throw new TicketOperationError('refusal', `state discovery is not supported by ${source.name}`)
264
+ return source.listStates()
265
+ }
266
+
267
+ export async function createOperatorTicketState(opts: {
268
+ store: BuildStore
269
+ repo: string
270
+ backend: OperatorTicketBackend
271
+ name: string
272
+ about?: string
273
+ }) {
274
+ const { source } = await openOperatorTickets(opts)
275
+ if (!source.addState)
276
+ throw new TicketOperationError('refusal', `state creation is not supported by ${source.name}`)
277
+ try {
278
+ return await source.addState(opts.name, opts.about === undefined ? {} : { about: opts.about })
279
+ } catch (error) {
280
+ throw new TicketOperationError(
281
+ 'refusal',
282
+ error instanceof Error ? error.message : String(error),
283
+ )
284
+ }
175
285
  }
@@ -152,6 +152,12 @@ export type {
152
152
 
153
153
  export { FakeTicketSource } from '../ports/tickets/fake'
154
154
  export { validateTicketUpdate } from '../ports/tickets/update'
155
+ export {
156
+ TicketRevisionConflict,
157
+ TicketRevisionUnsupported,
158
+ ticketRevision,
159
+ checkTicketRevision,
160
+ } from '../ports/tickets/revision'
155
161
  export { FakeWorkspaceProvider } from '../ports/workspace/fake'
156
162
  export { FakeForge } from '../ports/forge/fake'
157
163
  // ScriptContext + defaultTurnResult are the scripted fake's own API: the
@@ -223,3 +229,16 @@ export type {
223
229
  TicketAssetMeta,
224
230
  TicketAssetSummary,
225
231
  } from '../store/ticket-assets'
232
+
233
+ export {
234
+ readReducerValue,
235
+ resolveReducerValue,
236
+ reducerValueRequestSchema,
237
+ reducerValueSchema,
238
+ } from '../store/reducer-values'
239
+ export type {
240
+ ReducerValue,
241
+ ReducerValueRequest,
242
+ ReducerValueStorage,
243
+ ValueCacheRow,
244
+ } from '../store/reducer-values'
@@ -40,7 +40,11 @@ export async function materializePluginRuntimes(
40
40
  const merged = Object.assign(Object.create(null) as RuntimeRegistry, builtins)
41
41
  const config = Object.freeze({})
42
42
  const env = Object.freeze(
43
- Object.fromEntries(Object.entries(opts.env).filter(([key]) => key !== 'AB_TOKEN')),
43
+ Object.fromEntries(
44
+ Object.entries(opts.env).filter(
45
+ ([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
46
+ ),
47
+ ),
44
48
  )
45
49
  const context: PluginFactoryContext = {
46
50
  config,
@@ -21,6 +21,8 @@ export function sessionEnv(
21
21
  for (const [key, value] of Object.entries(ambient)) {
22
22
  if (value !== undefined) env[key] = value
23
23
  }
24
+ delete env.AB_TICKET_TOKEN
25
+ delete env.AB_TICKET_URL
24
26
  delete env.AB_TOKEN
25
27
  Object.assign(env, scoped)
26
28
 
@@ -1,3 +1,4 @@
1
+ import { updateTicket, moveTicket, changeBlockers } from './operations'
1
2
  import { describe, expect, test } from 'bun:test'
2
3
  import type {
3
4
  DependencyState,
@@ -116,6 +117,64 @@ export function describeTicketSourceContract(
116
117
  factory: TicketSourceContractFactory,
117
118
  ): void {
118
119
  describe(`TicketSource contract: ${name}`, () => {
120
+ test('revisions cover mutable content and guarded writes fail closed', async () => {
121
+ await withTicketSource(factory, async (harness) => {
122
+ const source = harness.source
123
+ const created = await createTracked(
124
+ harness,
125
+ { title: contractTicketTitle('revisions'), body: CONTRACT_TICKET_BODY },
126
+ { state: harness.states.ready },
127
+ )
128
+ const id = created.ref.id
129
+ expect(created.revision.trim()).not.toBe('')
130
+ expect((await source.get(id))?.revision).toBe(created.revision)
131
+ expect(
132
+ (await source.listReady({ state: harness.states.ready })).tickets.find(
133
+ (t) => t.ref.id === id,
134
+ )?.revision,
135
+ ).toBe(created.revision)
136
+ if (!source.mutateIfRevision) {
137
+ await expect(
138
+ updateTicket(source, id, { title: 'Never written' }, false, created.revision),
139
+ ).rejects.toThrow('does not support')
140
+ expect((await source.get(id))?.revision).toBe(created.revision)
141
+ return
142
+ }
143
+ let current = created
144
+ for (const patch of [
145
+ { title: contractTicketTitle('renamed') },
146
+ { body: 'Changed body' },
147
+ { labels: [harness.editableLabel] },
148
+ ]) {
149
+ await updateTicket(source, id, patch, false, current.revision)
150
+ const next = (await source.get(id))!
151
+ expect(next.revision).not.toBe(current.revision)
152
+ await expect(updateTicket(source, id, patch, false, current.revision)).rejects.toThrow(
153
+ 'current revision',
154
+ )
155
+ expect(await source.get(id)).toEqual(next)
156
+ current = next
157
+ }
158
+ await moveTicket(source, id, harness.states.completed, current.revision)
159
+ const moved = (await source.get(id))!
160
+ expect(moved.revision).not.toBe(current.revision)
161
+ await expect(
162
+ moveTicket(source, id, harness.states.ready, current.revision),
163
+ ).rejects.toThrow('current revision')
164
+ const blocker = await createTracked(harness, {
165
+ title: contractTicketTitle('blocker'),
166
+ body: CONTRACT_TICKET_BODY,
167
+ })
168
+ await changeBlockers(source, id, [blocker.ref.id], 'block', moved.revision)
169
+ const blocked = (await source.get(id))!
170
+ expect(blocked.revision).not.toBe(moved.revision)
171
+ await expect(
172
+ changeBlockers(source, id, [blocker.ref.id], 'unblock', moved.revision),
173
+ ).rejects.toThrow('current revision')
174
+ await changeBlockers(source, id, [blocker.ref.id], 'unblock', blocked.revision)
175
+ expect((await source.get(id))?.revision).not.toBe(blocked.revision)
176
+ })
177
+ })
119
178
  test('completion projections agree across create, get, listing, and transitions', async () => {
120
179
  await withTicketSource(factory, async (harness) => {
121
180
  const created = await createTracked(
@@ -156,7 +215,13 @@ export function describeTicketSourceContract(
156
215
  state: harness.states.ready,
157
216
  labels: [],
158
217
  })
159
- expect(await harness.source.get(created.ref.id)).toEqual(created)
218
+ const detail = await harness.source.get(created.ref.id)
219
+ expect(detail).not.toBeNull()
220
+ // Detail reads may expose structured history absent from write responses.
221
+ const { comments, ...record } = detail!
222
+ const { comments: createdComments, ...createdRecord } = created
223
+ expect(record).toEqual(createdRecord)
224
+ if (comments !== undefined) expect(comments).toEqual(createdComments ?? [])
160
225
  })
161
226
  })
162
227
 
@@ -16,6 +16,8 @@ import type { TicketSource } from '../types'
16
16
  import { DEFAULT_TICKETS_DIR, FileTicketSource } from './file'
17
17
  import { LinearTicketSource } from './linear'
18
18
  import { HostedTicketSource } from './remote'
19
+ import { ticketLifecycle } from './lifecycle'
20
+ import { normalizeServiceURL } from '../../connection'
19
21
 
20
22
  function reason(error: unknown): string {
21
23
  return error instanceof Error ? error.message : String(error)
@@ -32,26 +34,41 @@ export async function createTicketSource(
32
34
  plugins: PluginRegistry = createPluginRegistry(),
33
35
  ): Promise<TicketSource> {
34
36
  if (config.source === 'hosted') {
35
- const rawUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
37
+ const buildUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
38
+ const rawUrl = config.url ?? buildUrl
36
39
  if (!rawUrl) {
37
40
  throw new Error(
38
- 'AB_STORE is not set — required when [tickets].source = "hosted"; set it to the hosted service HTTP(S) URL',
41
+ 'Hosted tickets require [tickets] url when builds are local; alternatively set [store] url or AB_STORE to a service',
39
42
  )
40
43
  }
41
44
  let serviceUrl: URL
42
45
  try {
43
- serviceUrl = new URL(rawUrl)
46
+ serviceUrl = new URL(normalizeServiceURL(rawUrl, 'tickets.url'))
44
47
  } catch {
45
- throw new Error('AB_STORE must be a valid HTTP(S) URL when [tickets].source = "hosted"')
46
- }
47
- if (serviceUrl.protocol !== 'http:' && serviceUrl.protocol !== 'https:') {
48
- throw new Error('AB_STORE must be an HTTP(S) URL when [tickets].source = "hosted"')
48
+ throw new Error(
49
+ 'Hosted tickets require [tickets] url when [store] url / AB_STORE selects local builds',
50
+ )
49
51
  }
50
- const token = projectToken(
51
- rawUrl,
52
- (await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
53
- env,
52
+ const ticketSession = env.AB_TICKET_TOKEN !== undefined || env.AB_TICKET_URL !== undefined
53
+ if (
54
+ ticketSession &&
55
+ (!env.AB_TICKET_TOKEN ||
56
+ !env.AB_TICKET_URL ||
57
+ normalizeServiceURL(env.AB_TICKET_URL) !== normalizeServiceURL(rawUrl))
54
58
  )
59
+ throw new Error('Ticket session credential does not match the selected ticket service')
60
+ const token = ticketSession
61
+ ? env.AB_TICKET_TOKEN
62
+ : projectToken(
63
+ rawUrl,
64
+ (await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
65
+ config.url &&
66
+ (!buildUrl ||
67
+ !/^https?:/.test(buildUrl) ||
68
+ normalizeServiceURL(config.url) !== normalizeServiceURL(buildUrl))
69
+ ? { ...env, AB_TOKEN: undefined }
70
+ : env,
71
+ )
55
72
  if (!token) {
56
73
  throw new Error(
57
74
  'No project credential; run ab connect (or set AB_TOKEN) — a project credential is required when [tickets].source = "hosted"',
@@ -60,13 +77,32 @@ export async function createTicketSource(
60
77
  if (config.teamKey === undefined) {
61
78
  throw new Error('[tickets].source = "hosted" requires teamKey')
62
79
  }
63
- return new HostedTicketSource({
80
+ const source = new HostedTicketSource({
64
81
  url: serviceUrl.toString(),
65
82
  token,
66
83
  teamKey: config.teamKey,
67
84
  ...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
68
85
  ...(config.createState !== undefined ? { createState: config.createState } : {}),
69
86
  })
87
+ // Delegated sessions may read policy, but never redefine project authority.
88
+ if (!ticketSession && !env.AB_HARVEST && !env.AB_PHASE) {
89
+ const lifecycle = ticketLifecycle(config)
90
+ await source.synchronizePolicy({
91
+ version: 1,
92
+ tickets: {
93
+ source: 'hosted',
94
+ teamKey: config.teamKey,
95
+ readyState: lifecycle.ready,
96
+ triageState: lifecycle.triage,
97
+ claimedState: lifecycle.doing,
98
+ doneState: lifecycle.done,
99
+ createState: lifecycle.create,
100
+ proposalState: lifecycle.proposal,
101
+ ...(config.readyLabels !== undefined ? { readyLabels: config.readyLabels } : {}),
102
+ },
103
+ })
104
+ }
105
+ return source
70
106
  }
71
107
 
72
108
  if (config.source === 'linear') {
@@ -1,3 +1,5 @@
1
+ import { fileRequestedIds } from './requested-id'
2
+ import { ticketRevision, checkTicketRevision } from './revision'
1
3
  /**
2
4
  * In-memory TicketSource for seam tests (SPEC §3.2, §13). Mirrors the policy
3
5
  * shape of the real adapters: initiation (listReady/get/claim/create),
@@ -12,11 +14,13 @@ import type {
12
14
  TicketListing,
13
15
  TicketSource,
14
16
  TicketUpdate,
17
+ TicketMutation,
15
18
  } from '../types'
16
19
  import { validateTicketUpdate } from './update'
17
20
 
18
21
  function cloneTicket(ticket: Ticket, doneState: string): Ticket {
19
22
  return {
23
+ revision: ticketRevision(ticket),
20
24
  ref: { ...ticket.ref },
21
25
  ...(ticket.creationKey !== undefined ? { creationKey: ticket.creationKey } : {}),
22
26
  title: ticket.title,
@@ -29,6 +33,7 @@ function cloneTicket(ticket: Ticket, doneState: string): Ticket {
29
33
  }
30
34
 
31
35
  export class FakeTicketSource implements TicketSource {
36
+ readonly requestedIds = fileRequestedIds
32
37
  readonly name = 'fake'
33
38
 
34
39
  /** Journal of every projection sent outward (§13), in call order. */
@@ -111,6 +116,7 @@ export class FakeTicketSource implements TicketSource {
111
116
  const ticket = this.tickets.get(id)
112
117
  if (!ticket || ticket.state === this.doneState || this.claimed.has(id)) return false
113
118
  this.claimed.add(id)
119
+ ticket.state = 'Doing'
114
120
  return true
115
121
  }
116
122
 
@@ -131,8 +137,10 @@ export class FakeTicketSource implements TicketSource {
131
137
  const adopted = this.idempotency.get(opts.idempotencyKey)
132
138
  if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'), this.doneState)
133
139
  }
134
- const id = `fake-${this.nextId++}`
140
+ const id = opts.requestedId ?? `fake-${this.nextId++}`
141
+ if (this.tickets.has(id)) throw new Error(`requested ticket id already occupied: ${id}`)
135
142
  const ticket: Ticket = {
143
+ revision: 'pending',
136
144
  ref: { source: this.name, id, title: draft.title },
137
145
  ...(opts.idempotencyKey !== undefined ? { creationKey: opts.idempotencyKey } : {}),
138
146
  title: draft.title,
@@ -153,6 +161,27 @@ export class FakeTicketSource implements TicketSource {
153
161
  return cloneTicket(ticket, this.doneState)
154
162
  }
155
163
 
164
+ async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
165
+ const ticket = this.require(id, 'revision-checked write')
166
+ checkTicketRevision(cloneTicket(ticket, this.doneState), expected)
167
+ if (mutation.kind === 'update') await this.update(id, mutation.patch)
168
+ else if (mutation.kind === 'transition') await this.transition(id, mutation.state)
169
+ else {
170
+ for (const blocker of mutation.ids) {
171
+ this.require(blocker, 'blocker')
172
+ if (mutation.operation === 'block' && blocker === id)
173
+ throw new Error(`ticket "${id}" cannot block itself`)
174
+ }
175
+ // No await between validation and in-memory batch application.
176
+ const blockers = new Set(ticket.blockedBy ?? [])
177
+ for (const blocker of mutation.ids) {
178
+ if (mutation.operation === 'block') blockers.add(blocker)
179
+ else blockers.delete(blocker)
180
+ }
181
+ ticket.blockedBy = [...blockers]
182
+ }
183
+ }
184
+
156
185
  async update(id: string, patch: TicketUpdate): Promise<void> {
157
186
  const validated = validateTicketUpdate(patch)
158
187
  const ticket = this.require(id, 'update')