@defrex/autobuild 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod'
2
+ import { validateTicketAssetInput, type TicketAssetInput } from '../../store/ticket-assets'
3
+
4
+ export const portableAssetSchema = z
5
+ .strictObject({
6
+ kind: z.string(),
7
+ name: z.string(),
8
+ layout: z.enum(['file', 'tree']),
9
+ entries: z.array(
10
+ z.discriminatedUnion('type', [
11
+ z.strictObject({ type: z.literal('dir'), path: z.string() }),
12
+ z.strictObject({
13
+ type: z.literal('file'),
14
+ path: z.string(),
15
+ base64: z
16
+ .string()
17
+ .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
18
+ }),
19
+ ]),
20
+ ),
21
+ })
22
+ .superRefine((value, ctx) => {
23
+ try {
24
+ validateTicketAssetInput(decodePortableAsset(value))
25
+ } catch (error) {
26
+ ctx.addIssue({ code: 'custom', message: String(error) })
27
+ }
28
+ })
29
+ export type PortableAsset = z.infer<typeof portableAssetSchema>
30
+ export function decodePortableAsset(value: PortableAsset): TicketAssetInput {
31
+ return {
32
+ kind: value.kind,
33
+ name: value.name,
34
+ layout: value.layout,
35
+ entries: value.entries.map((entry) =>
36
+ entry.type === 'dir'
37
+ ? entry
38
+ : { type: 'file', path: entry.path, content: Buffer.from(entry.base64, 'base64') },
39
+ ),
40
+ }
41
+ }
42
+ export function encodePortableAsset(input: TicketAssetInput): PortableAsset {
43
+ validateTicketAssetInput(input)
44
+ return {
45
+ kind: input.kind,
46
+ name: input.name,
47
+ layout: input.layout,
48
+ entries: input.entries.map((entry) =>
49
+ entry.type === 'dir'
50
+ ? entry
51
+ : { type: 'file', path: entry.path, base64: Buffer.from(entry.content).toString('base64') },
52
+ ),
53
+ }
54
+ }
@@ -1,16 +1,33 @@
1
1
  import { z } from 'zod'
2
2
  import { ticketRefSchema } from '../../ontology'
3
3
 
4
+ /** Explicit opt-in: released clients through 0.12 use strict decoders without revisions.
5
+ * Source checkouts can share their release version, so version alone is insufficient. */
6
+ export const TICKET_REVISIONS_HEADER = 'x-autobuild-ticket-revisions'
7
+ export const TICKET_REVISIONS_CAPABILITY = '1'
8
+
4
9
  /** Repository-owned policy sent with every hosted ticket operation. */
5
10
  export const hostedTicketContextSchema = z.strictObject({
6
11
  teamKey: z.string().min(1),
12
+ repo: z.string().min(1).optional(),
7
13
  claimedState: z.string().min(1).optional(),
8
14
  createState: z.string().min(1).optional(),
9
15
  })
10
16
  export type HostedTicketContext = z.infer<typeof hostedTicketContextSchema>
11
17
 
18
+ export const TICKET_COMMENTS_HEADER = 'x-autobuild-ticket-comments'
19
+ export const TICKET_COMMENTS_CAPABILITY = '1'
20
+
21
+ export const ticketCommentWireSchema = z.strictObject({
22
+ body: z.string(),
23
+ createdAt: z.string(),
24
+ author: z.string().optional(),
25
+ })
26
+
12
27
  export const ticketWireSchema = z.strictObject({
28
+ revision: z.string().min(1),
13
29
  ref: ticketRefSchema,
30
+ comments: z.array(ticketCommentWireSchema).optional(),
14
31
  creationKey: z.string().optional(),
15
32
  title: z.string(),
16
33
  body: z.string(),
@@ -46,6 +63,7 @@ const draftSchema = z.strictObject({
46
63
  blockedBy: z.array(z.string()).optional(),
47
64
  })
48
65
  const createOptionsSchema = z.strictObject({
66
+ requestedId: z.string().min(1).optional(),
49
67
  state: z.string().optional(),
50
68
  idempotencyKey: z.string().optional(),
51
69
  })
@@ -61,8 +79,27 @@ function envelope<T extends z.ZodType>(input: T) {
61
79
  return z.strictObject({ context: hostedTicketContextSchema, input })
62
80
  }
63
81
 
82
+ const mutationSchema = z.discriminatedUnion('kind', [
83
+ z.strictObject({ kind: z.literal('update'), patch: updateSchema }),
84
+ z.strictObject({ kind: z.literal('transition'), state: z.string().min(1) }),
85
+ z.strictObject({
86
+ kind: z.literal('blockers'),
87
+ operation: z.enum(['block', 'unblock']),
88
+ ids: z.array(z.string().min(1)),
89
+ }),
90
+ ])
91
+
64
92
  export const hostedTicketRequestSchemas = {
93
+ 'mutate-if-revision': envelope(
94
+ z.strictObject({
95
+ id: z.string().min(1),
96
+ expectedRevision: z.string().refine((v) => v.trim().length > 0),
97
+ mutation: mutationSchema,
98
+ }),
99
+ ),
65
100
  'list-ready': envelope(criteriaSchema),
101
+ states: envelope(z.strictObject({})),
102
+ 'add-state': envelope(z.strictObject({ name: z.string(), about: z.string().optional() })),
66
103
  get: envelope(idSchema),
67
104
  claim: envelope(idSchema),
68
105
  comment: envelope(z.strictObject({ id: z.string().min(1), body: z.string() })),
@@ -80,3 +117,62 @@ export type HostedTicketOperation = keyof typeof hostedTicketRequestSchemas
80
117
  export const HOSTED_TICKET_OPERATIONS = Object.keys(
81
118
  hostedTicketRequestSchemas,
82
119
  ) as HostedTicketOperation[]
120
+
121
+ /** Project policy is separate from CRUD context and cannot be supplied by delegates. */
122
+ export const hostedTicketPolicySchema = z.strictObject({
123
+ version: z.literal(1),
124
+ tickets: z.strictObject({
125
+ source: z.literal('hosted'),
126
+ teamKey: z.string().min(1),
127
+ readyState: z
128
+ .string()
129
+ .min(1)
130
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
131
+ triageState: z
132
+ .string()
133
+ .min(1)
134
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
135
+ claimedState: z
136
+ .string()
137
+ .min(1)
138
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
139
+ doneState: z
140
+ .string()
141
+ .min(1)
142
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
143
+ createState: z
144
+ .string()
145
+ .min(1)
146
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
147
+ proposalState: z
148
+ .string()
149
+ .min(1)
150
+ .refine((value) => value.trim().length > 0, 'state must be nonblank'),
151
+ readyLabels: z.array(z.string().min(1)).optional(),
152
+ }),
153
+ })
154
+ export type HostedTicketPolicy = z.infer<typeof hostedTicketPolicySchema>
155
+
156
+ export const ticketGrantSchema = z.strictObject({
157
+ id: z.string().min(1),
158
+ repo: z.string().min(1),
159
+ execution: z.string().min(1),
160
+ kind: z.enum(['runner', 'phase']),
161
+ parent: z.string().nullable(),
162
+ expiresAt: z.string().datetime(),
163
+ })
164
+ export const ticketGrantResponseSchema = z.strictObject({
165
+ token: z.string().startsWith('abt_'),
166
+ grant: ticketGrantSchema,
167
+ })
168
+
169
+ export const ticketStateWireSchema = z.strictObject({
170
+ name: z.string(),
171
+ tickets: z.number().int().nonnegative(),
172
+ about: z.string().optional(),
173
+ })
174
+ export const ticketStatesWireSchema = z.array(ticketStateWireSchema)
175
+ export const ticketStateCreateWireSchema = z.strictObject({
176
+ state: ticketStateWireSchema,
177
+ created: z.boolean(),
178
+ })
@@ -1,3 +1,5 @@
1
+ import { hostedRequestedIds } from './requested-id'
2
+ import { TicketRevisionConflict } from './revision'
1
3
  import type {
2
4
  DependencyState,
3
5
  Ticket,
@@ -6,16 +8,25 @@ import type {
6
8
  TicketListing,
7
9
  TicketSource,
8
10
  TicketUpdate,
11
+ TicketMutation,
9
12
  } from '../types'
10
- import { AuthError } from '../../store/remote/client'
13
+ import { RemoteBuildStore, AuthError } from '../../store/remote/client'
11
14
  import { errorBodySchema } from '../../store/remote/protocol'
12
15
  import {
13
16
  claimWireSchema,
17
+ TICKET_REVISIONS_HEADER,
18
+ TICKET_REVISIONS_CAPABILITY,
19
+ hostedTicketPolicySchema,
20
+ type HostedTicketPolicy,
21
+ TICKET_COMMENTS_HEADER,
22
+ TICKET_COMMENTS_CAPABILITY,
14
23
  dependencyStatesWireSchema,
15
24
  type HostedTicketContext,
16
25
  successWireSchema,
17
26
  ticketListingWireSchema,
18
27
  ticketWireSchema,
28
+ ticketStatesWireSchema,
29
+ ticketStateCreateWireSchema,
19
30
  } from './remote-protocol'
20
31
  import {
21
32
  AUTOBUILD_VERSION,
@@ -38,22 +49,42 @@ export interface HostedTicketSourceOptions extends HostedTicketContext {
38
49
  /** Full TicketSource client for a hosted Autobuild service. */
39
50
  export class HostedTicketSource implements TicketSource {
40
51
  readonly name = 'hosted'
52
+ readonly requestedIds: ReturnType<typeof hostedRequestedIds>
53
+ readonly assets: import('../types').TicketAssetStore
41
54
  private readonly base: string
42
55
  private readonly token: string
43
56
  private readonly fetchFn: HostedTicketFetch
44
57
  private readonly context: HostedTicketContext
45
58
 
46
59
  constructor(options: HostedTicketSourceOptions) {
60
+ this.requestedIds = hostedRequestedIds(options.teamKey)
61
+ const client = new RemoteBuildStore({
62
+ url: options.url,
63
+ token: options.token,
64
+ ...(options.fetchFn ? { fetchFn: options.fetchFn as typeof fetch } : {}),
65
+ })
66
+ this.assets = {
67
+ ticketAssetLimits: client.ticketAssetLimits.bind(client),
68
+ putTicketAsset: client.putTicketAsset.bind(client),
69
+ getTicketAsset: client.getTicketAsset.bind(client),
70
+ listTicketAssets: client.listTicketAssets.bind(client),
71
+ removeTicketAsset: client.removeTicketAsset.bind(client),
72
+ }
47
73
  this.base = options.url.replace(/\/+$/, '')
48
74
  this.token = options.token
49
75
  this.fetchFn = options.fetchFn ?? fetch
50
76
  this.context = {
51
77
  teamKey: options.teamKey,
78
+ ...(options.repo !== undefined ? { repo: options.repo } : {}),
52
79
  ...(options.claimedState !== undefined ? { claimedState: options.claimedState } : {}),
53
80
  ...(options.createState !== undefined ? { createState: options.createState } : {}),
54
81
  }
55
82
  }
56
83
 
84
+ async synchronizePolicy(policy: HostedTicketPolicy): Promise<void> {
85
+ await this.request('policy', hostedTicketPolicySchema.parse(policy), successWireSchema)
86
+ }
87
+
57
88
  private project(ticket: Ticket): Ticket {
58
89
  return { ...ticket, ref: { ...ticket.ref, source: this.name } }
59
90
  }
@@ -67,7 +98,9 @@ export class HostedTicketSource implements TicketSource {
67
98
  method: 'POST',
68
99
  headers: {
69
100
  'content-type': 'application/json',
101
+ [TICKET_COMMENTS_HEADER]: TICKET_COMMENTS_CAPABILITY,
70
102
  authorization: `Bearer ${this.token}`,
103
+ [TICKET_REVISIONS_HEADER]: TICKET_REVISIONS_CAPABILITY,
71
104
  [AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
72
105
  [REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
73
106
  },
@@ -76,8 +109,17 @@ export class HostedTicketSource implements TicketSource {
76
109
  if (!response.ok) {
77
110
  let message = `ticket service responded ${response.status}`
78
111
  try {
79
- message = errorBodySchema.parse(await response.json()).error
80
- } catch {
112
+ const body = (await response.json()) as Record<string, unknown>
113
+ if (
114
+ response.status === 409 &&
115
+ typeof body.currentRevision === 'string' &&
116
+ typeof body.expectedRevision === 'string' &&
117
+ typeof body.id === 'string'
118
+ )
119
+ throw new TicketRevisionConflict(body.id, body.expectedRevision, body.currentRevision)
120
+ message = errorBodySchema.parse(body).error
121
+ } catch (error) {
122
+ if (error instanceof TicketRevisionConflict) throw error
81
123
  // Keep the status-only safe fallback for non-protocol responses.
82
124
  }
83
125
  if (response.status === 401 || response.status === 403) throw new AuthError(message)
@@ -98,6 +140,14 @@ export class HostedTicketSource implements TicketSource {
98
140
  return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
99
141
  }
100
142
 
143
+ async listStates() {
144
+ return this.request('states', {}, ticketStatesWireSchema)
145
+ }
146
+
147
+ async addState(name: string, opts: { about?: string } = {}) {
148
+ return this.request('add-state', { name, ...opts }, ticketStateCreateWireSchema)
149
+ }
150
+
101
151
  async get(id: string): Promise<Ticket | null> {
102
152
  const ticket = await this.request('get', { id }, ticketWireSchema.nullable())
103
153
  return ticket === null ? null : this.project(ticket)
@@ -119,6 +169,14 @@ export class HostedTicketSource implements TicketSource {
119
169
  return this.project(await this.request('create', { draft, options }, ticketWireSchema))
120
170
  }
121
171
 
172
+ async mutateIfRevision(
173
+ id: string,
174
+ expectedRevision: string,
175
+ mutation: TicketMutation,
176
+ ): Promise<void> {
177
+ await this.request('mutate-if-revision', { id, expectedRevision, mutation }, successWireSchema)
178
+ }
179
+
122
180
  async update(id: string, patch: TicketUpdate): Promise<void> {
123
181
  await this.request('update', { id, patch }, successWireSchema)
124
182
  }
@@ -0,0 +1,46 @@
1
+ /** Requested-id policies shared by adapter writes and migration preflight. */
2
+ export interface RequestedIdPolicy {
3
+ preserve(id: string): string | undefined
4
+ allocate(occupied: string[]): string
5
+ }
6
+
7
+ export function validateFileTicketId(id: string): string {
8
+ if (!id || id === '.' || /[/\\\0]/.test(id) || id.includes('..'))
9
+ throw new Error(`file ticket source: invalid ticket id "${id}"`)
10
+ return id
11
+ }
12
+
13
+ export const fileRequestedIds: RequestedIdPolicy = {
14
+ preserve: validateFileTicketId,
15
+ allocate(occupied) {
16
+ let n = 1
17
+ while (occupied.includes(`file-${n}`)) n++
18
+ return `file-${n}`
19
+ },
20
+ }
21
+
22
+ export const MAX_TICKET_NUMBER = 9223372036854775807n
23
+ export function hostedRequestedIds(teamKey: string): RequestedIdPolicy {
24
+ const numberOf = (id: string): bigint | undefined => {
25
+ if (!id.startsWith(`${teamKey}-`)) return undefined
26
+ const suffix = id.slice(teamKey.length + 1)
27
+ if (!/^[1-9][0-9]*$/.test(suffix)) return undefined
28
+ const n = BigInt(suffix)
29
+ if (n > MAX_TICKET_NUMBER) throw new Error(`ticket number exceeds PostgreSQL bigint: ${id}`)
30
+ return n
31
+ }
32
+ return {
33
+ preserve(id) {
34
+ return numberOf(id) === undefined ? undefined : id
35
+ },
36
+ allocate(occupied) {
37
+ let max = 0n
38
+ for (const id of occupied) {
39
+ const n = numberOf(id)
40
+ if (n !== undefined && n > max) max = n
41
+ }
42
+ if (max === MAX_TICKET_NUMBER) throw new Error('ticket numbers exhausted')
43
+ return `${teamKey}-${max + 1n}`
44
+ },
45
+ }
46
+ }
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { Ticket, TicketMutation, TicketSource } from '../types'
3
+
4
+ /** Content versions intentionally return to the same value after an exact revert. */
5
+ export function ticketRevision(
6
+ ticket: Pick<Ticket, 'title' | 'body' | 'labels' | 'state' | 'blockedBy'> & {
7
+ comments?: Ticket['comments']
8
+ attachments?: unknown
9
+ },
10
+ ): string {
11
+ return createHash('sha256')
12
+ .update(
13
+ JSON.stringify([
14
+ ticket.title,
15
+ ticket.body,
16
+ [...new Set(ticket.labels)].sort(),
17
+ ticket.state ?? null,
18
+ [...new Set(ticket.blockedBy ?? [])].sort(),
19
+ ...(ticket.comments !== undefined || ticket.attachments !== undefined
20
+ ? [ticket.comments ?? null, ticket.attachments ?? null]
21
+ : []),
22
+ ]),
23
+ )
24
+ .digest('hex')
25
+ }
26
+
27
+ export class TicketRevisionConflict extends Error {
28
+ constructor(
29
+ readonly id: string,
30
+ readonly expectedRevision: string,
31
+ readonly currentRevision: string,
32
+ ) {
33
+ super(
34
+ `ticket "${id}" changed: expected revision ${expectedRevision}, current revision ${currentRevision}`,
35
+ )
36
+ this.name = 'TicketRevisionConflict'
37
+ }
38
+ }
39
+
40
+ export class TicketRevisionUnsupported extends Error {
41
+ constructor(source: string) {
42
+ super(`${source} ticket source does not support revision-checked writes`)
43
+ this.name = 'TicketRevisionUnsupported'
44
+ }
45
+ }
46
+
47
+ export function checkTicketRevision(ticket: Ticket, expected: string): void {
48
+ if (!expected.trim()) throw new Error('expected revision must be nonblank')
49
+ if (ticket.revision !== expected)
50
+ throw new TicketRevisionConflict(ticket.ref.id, expected, ticket.revision)
51
+ }
52
+
53
+ export async function mutateTicketIfRevision(
54
+ source: TicketSource,
55
+ id: string,
56
+ expected: string,
57
+ mutation: TicketMutation,
58
+ ): Promise<void> {
59
+ if (!expected.trim()) throw new Error('expected revision must be nonblank')
60
+ if (!source.mutateIfRevision) throw new TicketRevisionUnsupported(source.name)
61
+ await source.mutateIfRevision(id, expected, mutation)
62
+ }
@@ -0,0 +1,61 @@
1
+ /** Shared state rules; configured names are deliberately not create-validated. */
2
+ export const STATE_NAME_RULE =
3
+ 'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
4
+
5
+ export function validateStateName(name: string, source = 'file ticket source'): void {
6
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) {
7
+ throw new Error(`${source}: invalid state name "${name}" — ${STATE_NAME_RULE}`)
8
+ }
9
+ }
10
+
11
+ export function validateStatePurpose(
12
+ value: string | undefined,
13
+ source = 'file ticket source',
14
+ ): string | undefined {
15
+ const about = value?.trim()
16
+ if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
17
+ throw new Error(`${source}: a state purpose must be one nonblank line`)
18
+ }
19
+ return about
20
+ }
21
+
22
+ export function orderStates(names: string[], lifecycle: readonly string[]): string[] {
23
+ const first = [...new Set(lifecycle)].filter((name) => names.includes(name))
24
+ return [...first, ...names.filter((name) => !first.includes(name)).sort()]
25
+ }
26
+
27
+ export function resolveState(
28
+ name: string,
29
+ existing: string[],
30
+ source = 'file ticket source',
31
+ ): string {
32
+ if (existing.includes(name)) return name
33
+ const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
34
+ if (matches.length === 1) return matches[0] as string
35
+ const directories = source === 'file ticket source'
36
+ if (matches.length > 1) {
37
+ throw new Error(
38
+ `${source}: ambiguous state "${name}" — it matches the ${directories ? 'directories' : 'states'} ${matches.join(', ')}`,
39
+ )
40
+ }
41
+ throw new Error(
42
+ `${source}: unknown state "${name}" — this tracker's states are ${directories ? 'the directories: ' : ''}${existing.join(', ')}`,
43
+ )
44
+ }
45
+
46
+ /** Portable directory vocabulary, without guessing lifecycle roles. */
47
+ export function normalizeStateName(name: string): string {
48
+ if (/^[a-z][a-z0-9-]{0,39}$/.test(name)) return name
49
+ const candidate = name
50
+ .trim()
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9-]+/g, '-')
53
+ .replace(/-+/g, '-')
54
+ .replace(/^-|-$/g, '')
55
+ try {
56
+ validateStateName(candidate)
57
+ } catch {
58
+ throw new Error(`invalid state "${name}" (normalized: "${candidate}")`)
59
+ }
60
+ return candidate
61
+ }
@@ -1,3 +1,4 @@
1
+ import type { BuildStore } from '../store/types'
1
2
  /**
2
3
  * Ports (SPEC §3.2): interfaces to the world, each with swappable adapters.
3
4
  * The kernel and processes depend only on these types; adapters live in
@@ -49,7 +50,19 @@ export interface SessionStreamSink extends SessionStreamEmitter {
49
50
  // never consulted mid-build and never used as artifact storage. A file-based
50
51
  // adapter with nowhere to put blobs must be fully workable.
51
52
 
53
+ /** A recorded comment, with its exact body and source timestamp. */
54
+ export interface TicketComment {
55
+ body: string
56
+ createdAt: string
57
+ author?: string
58
+ }
59
+
52
60
  export interface Ticket {
61
+ /** Opaque source version of title, body, labels, state and blockers. */
62
+ revision: string
63
+ /** Complete history oldest first when exposed by this read. Absent means
64
+ * unsupported; an empty array means no comments. Bodies are unchanged. */
65
+ comments?: TicketComment[]
53
66
  ref: TicketRef
54
67
  /** Stable source-native key used to correlate an externally created ticket
55
68
  * with Autobuild's durable creation reservation. It is separate from the
@@ -106,6 +119,8 @@ export interface DependencyState {
106
119
  }
107
120
 
108
121
  export interface TicketCreateOptions {
122
+ /** Requested source id. Unsupported adapters must reject it. */
123
+ requestedId?: string
109
124
  /** Explicit workflow state for this create (harvest always targets Triage). */
110
125
  state?: string
111
126
  /** Source-level adoption key for crash-safe external creation. File and fake
@@ -148,7 +163,29 @@ export class TicketSourceRateLimitError extends Error {
148
163
  }
149
164
  }
150
165
 
166
+ export type TicketAssetStore = {
167
+ /** Native ownership, including tombstones, prevents resurrection from legacy stores. */
168
+ hasTicketAssetHistory?(repo: string, id: string, kind: string, name: string): Promise<boolean>
169
+ } & Pick<
170
+ BuildStore,
171
+ | 'ticketAssetLimits'
172
+ | 'putTicketAsset'
173
+ | 'getTicketAsset'
174
+ | 'listTicketAssets'
175
+ | 'removeTicketAsset'
176
+ >
177
+
178
+ export type TicketMutation =
179
+ | { kind: 'update'; patch: TicketUpdate }
180
+ | { kind: 'transition'; state: string }
181
+ | { kind: 'blockers'; operation: 'block' | 'unblock'; ids: string[] }
182
+
151
183
  export interface TicketSource {
184
+ /** Import support: the same pure id policy used by create, for mutation-free preflight. */
185
+ readonly requestedIds?: import('./tickets/requested-id').RequestedIdPolicy
186
+ readonly assets?: TicketAssetStore
187
+ /** Check and mutate at the source boundary; absent means checks are unsupported. */
188
+ mutateIfRevision?(id: string, expectedRevision: string, mutation: TicketMutation): Promise<void>
152
189
  readonly name: string
153
190
  /** Ready tickets matching the dispatch criteria (label/state — §3.3), plus
154
191
  * actionable diagnostics for individual source records excluded as invalid.
@@ -193,9 +230,9 @@ export interface TicketSource {
193
230
  */
194
231
  listStates?(): Promise<TicketStateInfo[]>
195
232
  /**
196
- * Optional state creation (`ab ticket state create`). Idempotent: an existing
197
- * state is reported with `created: false`, and a supplied `about` replaces its
198
- * purpose. Rejects a malformed or case-colliding name without creating anything.
233
+ * Optional state creation (`ab ticket state create`). Duplicate policy is
234
+ * adapter-specific: file creation is idempotent and can replace purpose;
235
+ * hosted creation refuses existing names in any case. Rejects malformed names.
199
236
  * Named `addState` because adapters already hold a `createState` option field.
200
237
  */
201
238
  addState?(
@@ -114,8 +114,13 @@ export type ExecutionObservation =
114
114
  * provider may implement this by starting the fixed build process beside its
115
115
  * checkout; the kernel need not know where that is. Every provider owns full
116
116
  * teardown of the environment and descendants it starts before completion. */
117
+ export interface ExecutionLaunchAuth {
118
+ token: string
119
+ tickets?: { url: string; token: string }
120
+ }
121
+
117
122
  export interface BuildExecution {
118
- start(input: BuildExecutionStart): Promise<BuildExecutionHandle>
123
+ start(input: BuildExecutionStart, auth?: ExecutionLaunchAuth): Promise<BuildExecutionHandle>
119
124
  /** Optional liveness observation of a previously recorded execution, from
120
125
  * provider state alone. Absent = this provider cannot observe; callers
121
126
  * supervise exactly as before ("cannot observe" never means "act"). */
@@ -1,3 +1,4 @@
1
+ import { delegateCredential } from '../../store/remote/credentials'
1
2
  import { resolve } from 'node:path'
2
3
  import { SANDBOX_FORBIDDEN_ENV } from './operator-sandbox'
3
4
  import type { WorkspaceConfig } from '../../config/schema'
@@ -131,10 +132,22 @@ export async function createWorkspaceProvider(
131
132
  // receives through `opts` reach the plugin factory through the context.
132
133
  return await factory({
133
134
  config: config.config,
134
- env: opts.env,
135
+ env: Object.fromEntries(
136
+ Object.entries(opts.env).filter(
137
+ ([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
138
+ ),
139
+ ),
135
140
  repoRoot: resolve(opts.repoRoot),
136
141
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
137
- ...(opts.storeToken !== undefined ? { storeToken: opts.storeToken } : {}),
142
+ ...(opts.storeRef !== undefined && opts.storeToken !== undefined
143
+ ? {
144
+ delegateCredential: (
145
+ _ref: string,
146
+ _token: string | undefined,
147
+ target: Parameters<typeof delegateCredential>[2],
148
+ ) => delegateCredential(opts.storeRef!, opts.storeToken, target),
149
+ }
150
+ : {}),
138
151
  ...(opts.runtimeReferences !== undefined
139
152
  ? { runtimeReferences: opts.runtimeReferences }
140
153
  : {}),
@@ -18,11 +18,9 @@
18
18
  * version in both modes: origin mode's release-asset tag is the running
19
19
  * version by construction, and source mode packs `distributionRoot()`, whose
20
20
  * package.json is the same file `readDistributionIdentity()` reads. The hosted
21
- * remote store enforces exact version lockstep — a client whose
22
- * `x-autobuild-version` or `x-autobuild-protocol-version` differs from the
23
- * server's is rejected (409) — so guest, store, and dispatcher versions must
24
- * move together through a release cut (`tools/release.ts`) plus
25
- * store/dispatcher upgrades; there is no code path that safely mixes versions.
21
+ * remote store accepts only its published client release-line window and known
22
+ * protocol identities (409 outside that contract). Upgrade the service before
23
+ * a dispatcher moves beyond its window; projects can then upgrade independently.
26
24
  * Provisioning records the installed distribution's identity stamp
27
25
  * (`readDistributionIdentityStamp()`) in the guest's `.distribution-version`
28
26
  * marker and reinstalls the archive on a later mismatch, so an upgraded
@@ -2,6 +2,7 @@ import type {
2
2
  BuildExecutionHandle,
3
3
  BuildExecutionIdentity,
4
4
  ExecutionObservation,
5
+ ExecutionLaunchAuth,
5
6
  } from './build-execution'
6
7
 
7
8
  /** Guest environment variable carrying the serialized harvest launch
@@ -23,6 +24,7 @@ export interface HarvestExecutionEnvironment {
23
24
  * advisory. Run state, config, and outcomes are obtained only through the
24
25
  * repository journal in the Store. */
25
26
  export interface HarvestExecutionStart {
27
+ preparedRun?: { run: string }
26
28
  storeRef: string
27
29
  /** Repository identity (normalized origin) the guest serves. */
28
30
  repo: string
@@ -101,7 +103,7 @@ export function parseHarvestRunnerLaunch(value: unknown): HarvestRunnerLaunch |
101
103
  * observation types so supervision semantics (long-poll wait, teardown
102
104
  * outcome, provider liveness) are identical to builds. */
103
105
  export interface HarvestExecution {
104
- start(input: HarvestExecutionStart): Promise<BuildExecutionHandle>
106
+ start(input: HarvestExecutionStart, auth?: ExecutionLaunchAuth): Promise<BuildExecutionHandle>
105
107
  /** Optional liveness observation of a previously recorded execution, from
106
108
  * provider state alone. Absent = this provider cannot observe; callers
107
109
  * supervise exactly as before ("cannot observe" never means "act"). */
@@ -8,6 +8,7 @@ import type {
8
8
  BuildExecutionExit,
9
9
  BuildExecutionHandle,
10
10
  BuildExecutionStart,
11
+ ExecutionLaunchAuth,
11
12
  } from './build-execution'
12
13
 
13
14
  export const BUILD_RUNNER_OPTIONS_ENV = 'AB_BUILD_RUNNER_OPTIONS'
@@ -34,10 +35,16 @@ export class LocalBuildExecution implements BuildExecution {
34
35
  this.spawn = opts.spawn ?? Bun.spawn
35
36
  }
36
37
 
37
- async start(input: BuildExecutionStart): Promise<BuildExecutionHandle> {
38
+ async start(
39
+ input: BuildExecutionStart,
40
+ auth?: ExecutionLaunchAuth,
41
+ ): Promise<BuildExecutionHandle> {
42
+ if (/^https?:/i.test(input.storeRef) && !auth)
43
+ throw new Error('remote build launch requires delegated authority')
38
44
  const child = this.spawn([process.execPath, this.entrypoint], {
39
45
  env: {
40
46
  ...this.env,
47
+ AB_TOKEN: auth?.token,
41
48
  [BUILD_RUNNER_OPTIONS_ENV]: JSON.stringify({
42
49
  ...input,
43
50
  supervision: { kind: 'local-parent', parentPid: process.pid },