@defrex/autobuild 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { executionNoProgressReducer } from '../processes/execution-no-progress'
1
2
  import { summaryBoundaryReducer, summaryRequestsReducer } from './pr-summary'
2
3
  import { dispatchFailureBudgetReducer } from '../processes/dispatch-failure-budget'
3
4
  import { ticketSourceHealthReducer } from './ticket-source-health'
@@ -60,6 +61,7 @@ export interface RegisteredReducer {
60
61
  /** Parameterized reducers are registered with representative arguments; the
61
62
  * accumulator shape does not depend on them. */
62
63
  export const REDUCERS: Record<string, RegisteredReducer> = {
64
+ executionNoProgress: { reducer: executionNoProgressReducer, covers: [] },
63
65
  summaryBoundary: { reducer: summaryBoundaryReducer, covers: ['summaryBoundary'] },
64
66
  summaryRequests: { reducer: summaryRequestsReducer, covers: ['pendingSummaryRequests'] },
65
67
  ticketSourceHealth: { reducer: ticketSourceHealthReducer, covers: ['reduceTicketSourceHealth'] },
@@ -109,6 +109,7 @@ export const policyEscalationCauseSchema = z.enum([
109
109
  'verify-failure-limit',
110
110
  'reconcile-no-progress',
111
111
  'dispatch-failure-limit',
112
+ 'execution-no-progress',
112
113
  'setup-failure-limit',
113
114
  'infrastructure-failure-limit',
114
115
  'phase-attempt-limit',
@@ -92,6 +92,18 @@ export const OPERATOR_TOOL_ANNOTATIONS = {
92
92
  idempotentHint: false,
93
93
  approval: 'default',
94
94
  },
95
+ 'tickets.states': {
96
+ readOnlyHint: true,
97
+ destructiveHint: false,
98
+ idempotentHint: true,
99
+ approval: 'never',
100
+ },
101
+ 'tickets.state.create': {
102
+ readOnlyHint: false,
103
+ destructiveHint: false,
104
+ idempotentHint: false,
105
+ approval: 'default',
106
+ },
95
107
  'tickets.list': {
96
108
  readOnlyHint: true,
97
109
  destructiveHint: false,
@@ -92,6 +92,9 @@ export type {
92
92
  OperatorTicketQueue,
93
93
  } from './tickets'
94
94
  export {
95
+ prepareOperatorTicketAssets,
96
+ listOperatorTicketStates,
97
+ createOperatorTicketState,
95
98
  getOperatorTicket,
96
99
  listOperatorTickets,
97
100
  mutateOperatorTicket,
@@ -132,3 +135,5 @@ export {
132
135
  findEffectiveConfigRef,
133
136
  decodeEffectiveConfig,
134
137
  } from './query'
138
+
139
+ export { ticketStateCreateRequestSchema } from './protocol'
@@ -55,15 +55,27 @@ export const ticketCreateRequestSchema = z.strictObject({
55
55
  state: z.string().min(1).optional(),
56
56
  blockedBy: z.array(z.string().min(1)).optional(),
57
57
  })
58
+ const expectedRevisionSchema = z
59
+ .string()
60
+ .refine((v) => v.trim().length > 0, 'expectedRevision must be nonblank')
61
+ .optional()
58
62
  export const ticketUpdateRequestSchema = z
59
63
  .strictObject({
64
+ expectedRevision: expectedRevisionSchema,
60
65
  title: z.string().optional(),
61
66
  body: z.string().optional(),
62
67
  labels: z.array(z.string()).optional(),
63
68
  })
64
- .refine((value) => Object.keys(value).length > 0, 'update must name at least one field')
65
- export const ticketMoveRequestSchema = z.strictObject({ state: z.string().min(1) })
69
+ .refine(
70
+ (value) => value.title !== undefined || value.body !== undefined || value.labels !== undefined,
71
+ 'update must name at least one field',
72
+ )
73
+ export const ticketMoveRequestSchema = z.strictObject({
74
+ expectedRevision: expectedRevisionSchema,
75
+ state: z.string().min(1),
76
+ })
66
77
  export const ticketBlockerRequestSchema = z.strictObject({
78
+ expectedRevision: expectedRevisionSchema,
67
79
  blockerIds: z.array(z.string().min(1)).min(1),
68
80
  })
69
81
  export type OperatorTicketCreateRequest = z.infer<typeof ticketCreateRequestSchema>
@@ -96,3 +108,8 @@ export const sessionApprovalRequestSchema = z.strictObject({
96
108
  decision: z.enum(['approve', 'deny']),
97
109
  })
98
110
  export type OperatorSessionApprovalRequest = z.infer<typeof sessionApprovalRequestSchema>
111
+
112
+ export const ticketStateCreateRequestSchema = z.strictObject({
113
+ name: z.string(),
114
+ about: z.string().optional(),
115
+ })
@@ -1,3 +1,4 @@
1
+ import { TicketRevisionConflict, TicketRevisionUnsupported } from '../ports/tickets/revision'
1
2
  /**
2
3
  * The agent tool registry — one typed table that is the single source of
3
4
  * truth for the agent-facing operator surface (AUT-338).
@@ -58,6 +59,9 @@ import { answerAction, controlPrechecks, requireRouteBuild, RouteRefusalError }
58
59
  import { SandboxOperationError } from '../ports/workspace/operator-sandbox'
59
60
  import type { OperatorSandboxService } from './sandbox'
60
61
  import {
62
+ prepareOperatorTicketAssets,
63
+ listOperatorTicketStates,
64
+ createOperatorTicketState,
61
65
  getOperatorTicket,
62
66
  listOperatorTickets,
63
67
  mutateOperatorTicket,
@@ -598,7 +602,13 @@ export const TOOLS: readonly ToolEntry[] = [
598
602
  user: attributed(ctx),
599
603
  ...(ctx.via !== undefined ? { via: ctx.via } : {}),
600
604
  action,
601
- ...(readTicketBody !== undefined ? { readTicketBody } : {}),
605
+ ...(readTicketBody !== undefined
606
+ ? {
607
+ readTicketBody,
608
+ prepareTicketAssets: (ref, store, repo) =>
609
+ prepareOperatorTicketAssets({ ref, store, repo, backend: ctx.tickets }),
610
+ }
611
+ : {}),
602
612
  })
603
613
  },
604
614
  ),
@@ -691,6 +701,30 @@ export const TOOLS: readonly ToolEntry[] = [
691
701
  })
692
702
  },
693
703
  ),
704
+ defineTool(
705
+ 'tickets.states',
706
+ 'List all ticket states with ticket counts and optional purposes.',
707
+ z.strictObject({ repo: z.string().min(1) }),
708
+ 'Array of {name,tickets,about?}.',
709
+ async (raw, ctx) => {
710
+ const input = raw as { repo: string }
711
+ return listOperatorTicketStates({
712
+ store: ctx.store,
713
+ repo: input.repo,
714
+ backend: ticketsOf(ctx),
715
+ })
716
+ },
717
+ ),
718
+ defineTool(
719
+ 'tickets.state.create',
720
+ 'Create a custom ticket state with an optional one-line purpose. Hosted duplicates are refused.',
721
+ z.strictObject({ repo: z.string().min(1), name: z.string(), about: z.string().optional() }),
722
+ '{state: {name,tickets,about?},created}.',
723
+ async (raw, ctx) => {
724
+ const input = raw as { repo: string; name: string; about?: string }
725
+ return createOperatorTicketState({ store: ctx.store, backend: ticketsOf(ctx), ...input })
726
+ },
727
+ ),
694
728
  defineTool(
695
729
  'tickets.list',
696
730
  'List the repository’s ticket grooming queue from the configured ticket source.',
@@ -709,9 +743,9 @@ export const TOOLS: readonly ToolEntry[] = [
709
743
  ),
710
744
  defineTool(
711
745
  'tickets.get',
712
- 'Show one ticket with its blockers and any related build.',
746
+ 'Show one ticket with its hosted comments, blockers and any related build.',
713
747
  ticketDetailInput,
714
- '{ticket, blockers, build}: the ticket, its dependency states, and the most relevant build for it (null when none).',
748
+ '{ticket, blockers, build}: the ticket (including hosted ticket.comments when available), its dependency states, and the most relevant build for it (null when none).',
715
749
  async (raw, ctx) => {
716
750
  const input = raw as z.infer<typeof ticketDetailInput>
717
751
  return getOperatorTicket({
@@ -752,12 +786,12 @@ export const TOOLS: readonly ToolEntry[] = [
752
786
  '{ticket, blockers, build}: the updated ticket.',
753
787
  async (raw, ctx) => {
754
788
  const input = raw as z.infer<typeof ticketsUpdateInput>
755
- const { repo, id, ...patch } = input
789
+ const { repo, id, expectedRevision, ...patch } = input
756
790
  return mutateOperatorTicket({
757
791
  store: ctx.store,
758
792
  repo,
759
793
  backend: ticketsOf(ctx),
760
- operation: { kind: 'update', id, patch },
794
+ operation: { kind: 'update', id, patch, expectedRevision },
761
795
  })
762
796
  },
763
797
  ),
@@ -772,7 +806,12 @@ export const TOOLS: readonly ToolEntry[] = [
772
806
  store: ctx.store,
773
807
  repo: input.repo,
774
808
  backend: ticketsOf(ctx),
775
- operation: { kind: 'block', id: input.id, blockerIds: input.blockerIds },
809
+ operation: {
810
+ kind: 'block',
811
+ id: input.id,
812
+ blockerIds: input.blockerIds,
813
+ expectedRevision: input.expectedRevision,
814
+ },
776
815
  })
777
816
  },
778
817
  ),
@@ -787,7 +826,12 @@ export const TOOLS: readonly ToolEntry[] = [
787
826
  store: ctx.store,
788
827
  repo: input.repo,
789
828
  backend: ticketsOf(ctx),
790
- operation: { kind: 'unblock', id: input.id, blockerIds: input.blockerIds },
829
+ operation: {
830
+ kind: 'unblock',
831
+ id: input.id,
832
+ blockerIds: input.blockerIds,
833
+ expectedRevision: input.expectedRevision,
834
+ },
791
835
  })
792
836
  },
793
837
  ),
@@ -802,7 +846,12 @@ export const TOOLS: readonly ToolEntry[] = [
802
846
  store: ctx.store,
803
847
  repo: input.repo,
804
848
  backend: ticketsOf(ctx),
805
- operation: { kind: 'move', id: input.id, state: input.state },
849
+ operation: {
850
+ kind: 'move',
851
+ id: input.id,
852
+ state: input.state,
853
+ expectedRevision: input.expectedRevision,
854
+ },
806
855
  })
807
856
  },
808
857
  ),
@@ -964,6 +1013,10 @@ function mapDomainError(error: unknown): RegistryError {
964
1013
  error: error.refusal.error,
965
1014
  })
966
1015
  }
1016
+ if (error instanceof TicketRevisionConflict)
1017
+ return new RegistryError('domain', error.message, { kind: 'conflict', error: error.message })
1018
+ if (error instanceof TicketRevisionUnsupported)
1019
+ return new RegistryError('domain', error.message, { kind: 'refusal', error: error.message })
967
1020
  if (error instanceof TicketOperationError) {
968
1021
  return new RegistryError('domain', error.message, {
969
1022
  kind: error.code === 'not-found' ? 'not-found' : 'refusal',
@@ -1,3 +1,4 @@
1
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
1
2
  /**
2
3
  * A dispatcher ticket source as the registry's ticket backend (AUT-342):
3
4
  * the tick's turn runner takes an `OperatorToolRegistry`, and
@@ -14,34 +15,29 @@ import type { Config } from '../config/schema'
14
15
  import type { OperatorTicketBackend, OperatorTicketContext } from './tickets'
15
16
  import type { TicketSource } from '../ports/types'
16
17
 
17
- /** The dispatcher's resolved triage state (the same default
18
- * `processes/dispatcher.ts` applies); inlined rather than imported so this
19
- * operator-seam module does not pull the dispatcher's module graph. */
20
- function defaultTriageState(config: Config): string {
21
- return (
22
- config.tickets.triageState ??
23
- (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
24
- ? 'Backlog'
25
- : 'Triage')
26
- )
27
- }
28
-
29
18
  export function ticketBackendFromSource(options: {
30
19
  source: TicketSource
31
20
  config: Config
32
21
  }): OperatorTicketBackend {
33
22
  return {
34
23
  sourceFor: (_context: OperatorTicketContext) => options.source,
35
- statesFor: (_context: OperatorTicketContext, _source: TicketSource): string[] => {
24
+ statesFor: async (_context: OperatorTicketContext, source: TicketSource): Promise<string[]> => {
25
+ if (source.listStates) return (await source.listStates()).map((s) => s.name)
36
26
  const tickets = options.config.tickets
37
- const states = [
38
- tickets.triageState ?? defaultTriageState(options.config),
39
- tickets.readyState,
40
- tickets.createState,
41
- tickets.claimedState,
42
- tickets.doneState,
43
- tickets.proposalState ?? defaultTriageState(options.config),
44
- ].filter((state): state is string => typeof state === 'string' && state.trim().length > 0)
27
+ const lifecycle = ticketLifecycle(tickets)
28
+ const states =
29
+ tickets.source === 'hosted'
30
+ ? Object.values(lifecycle)
31
+ : [
32
+ lifecycle.triage,
33
+ tickets.readyState,
34
+ tickets.createState,
35
+ tickets.claimedState,
36
+ tickets.doneState,
37
+ lifecycle.proposal,
38
+ ].filter(
39
+ (state): state is string => typeof state === 'string' && state.trim().length > 0,
40
+ )
45
41
  return [...new Set(states)]
46
42
  },
47
43
  }
@@ -1,6 +1,9 @@
1
+ import { samplePinnedAssets, preparePinnedAssets } from '../store/ticket-assets'
2
+ import type { TicketRef } from '../ontology'
1
3
  import type { Config } from '../config/schema'
2
4
  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,10 +108,36 @@ 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,
@@ -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
@@ -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