@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,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,12 +49,27 @@ 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
@@ -55,6 +81,10 @@ export class HostedTicketSource implements TicketSource {
55
81
  }
56
82
  }
57
83
 
84
+ async synchronizePolicy(policy: HostedTicketPolicy): Promise<void> {
85
+ await this.request('policy', hostedTicketPolicySchema.parse(policy), successWireSchema)
86
+ }
87
+
58
88
  private project(ticket: Ticket): Ticket {
59
89
  return { ...ticket, ref: { ...ticket.ref, source: this.name } }
60
90
  }
@@ -68,7 +98,9 @@ export class HostedTicketSource implements TicketSource {
68
98
  method: 'POST',
69
99
  headers: {
70
100
  'content-type': 'application/json',
101
+ [TICKET_COMMENTS_HEADER]: TICKET_COMMENTS_CAPABILITY,
71
102
  authorization: `Bearer ${this.token}`,
103
+ [TICKET_REVISIONS_HEADER]: TICKET_REVISIONS_CAPABILITY,
72
104
  [AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
73
105
  [REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
74
106
  },
@@ -77,8 +109,17 @@ export class HostedTicketSource implements TicketSource {
77
109
  if (!response.ok) {
78
110
  let message = `ticket service responded ${response.status}`
79
111
  try {
80
- message = errorBodySchema.parse(await response.json()).error
81
- } 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
82
123
  // Keep the status-only safe fallback for non-protocol responses.
83
124
  }
84
125
  if (response.status === 401 || response.status === 403) throw new AuthError(message)
@@ -99,6 +140,14 @@ export class HostedTicketSource implements TicketSource {
99
140
  return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
100
141
  }
101
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
+
102
151
  async get(id: string): Promise<Ticket | null> {
103
152
  const ticket = await this.request('get', { id }, ticketWireSchema.nullable())
104
153
  return ticket === null ? null : this.project(ticket)
@@ -120,6 +169,14 @@ export class HostedTicketSource implements TicketSource {
120
169
  return this.project(await this.request('create', { draft, options }, ticketWireSchema))
121
170
  }
122
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
+
123
180
  async update(id: string, patch: TicketUpdate): Promise<void> {
124
181
  await this.request('update', { id, patch }, successWireSchema)
125
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?(
@@ -116,6 +116,7 @@ export type ExecutionObservation =
116
116
  * teardown of the environment and descendants it starts before completion. */
117
117
  export interface ExecutionLaunchAuth {
118
118
  token: string
119
+ tickets?: { url: string; token: string }
119
120
  }
120
121
 
121
122
  export interface BuildExecution {
@@ -132,7 +132,11 @@ export async function createWorkspaceProvider(
132
132
  // receives through `opts` reach the plugin factory through the context.
133
133
  return await factory({
134
134
  config: config.config,
135
- env: Object.fromEntries(Object.entries(opts.env).filter(([key]) => key !== 'AB_TOKEN')),
135
+ env: Object.fromEntries(
136
+ Object.entries(opts.env).filter(
137
+ ([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
138
+ ),
139
+ ),
136
140
  repoRoot: resolve(opts.repoRoot),
137
141
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
138
142
  ...(opts.storeRef !== undefined && opts.storeToken !== undefined
@@ -27,6 +27,8 @@
27
27
  export const SANDBOX_FORBIDDEN_ENV: readonly string[] = [
28
28
  'AB_STORE',
29
29
  'AB_TOKEN',
30
+ 'AB_TICKET_TOKEN',
31
+ 'AB_TICKET_URL',
30
32
  'GITHUB_TOKEN',
31
33
  'GH_TOKEN',
32
34
  'LINEAR_API_KEY',
@@ -641,7 +641,7 @@ function runtimeEnvironment(
641
641
  ): Record<string, string> {
642
642
  return Object.fromEntries(
643
643
  config.environmentVariables
644
- .filter((name) => name !== 'AB_TOKEN')
644
+ .filter((name) => !['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(name))
645
645
  .map((name) => [name, requireVercelEnvironmentValue(env, name)]),
646
646
  )
647
647
  }
@@ -947,7 +947,7 @@ export async function validateVercelSandbox(
947
947
  ['AB_STORE', options.storeRef],
948
948
  ['AB_TOKEN', options.storeToken],
949
949
  ...config.environmentVariables
950
- .filter((name) => name !== 'AB_TOKEN')
950
+ .filter((name) => !['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(name))
951
951
  .map((name) => [name, requireVercelEnvironmentValue(options.env, name)]),
952
952
  ]),
953
953
  }),
@@ -1602,7 +1602,8 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1602
1602
  }),
1603
1603
  }
1604
1604
  for (const name of this.options.config.environmentVariables)
1605
- if (name !== 'AB_TOKEN') env[name] = requireVercelEnvironmentValue(this.options.env, name)
1605
+ if (!['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(name))
1606
+ env[name] = requireVercelEnvironmentValue(this.options.env, name)
1606
1607
  const command = (await sandbox.runCommand({
1607
1608
  cmd: 'sh',
1608
1609
  args: [
@@ -1906,10 +1907,13 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1906
1907
  const env: Record<string, string> = {
1907
1908
  AB_STORE: this.options.storeRef,
1908
1909
  AB_TOKEN: scopedToken,
1910
+ ...(auth?.tickets
1911
+ ? { AB_TICKET_URL: auth.tickets.url, AB_TICKET_TOKEN: auth.tickets.token }
1912
+ : {}),
1909
1913
  [HARVEST_RUNNER_OPTIONS_ENV]: JSON.stringify(envelope),
1910
1914
  }
1911
1915
  for (const envName of this.options.config.environmentVariables)
1912
- if (envName !== 'AB_TOKEN')
1916
+ if (!['AB_TOKEN', 'AB_TICKET_URL', 'AB_TICKET_TOKEN'].includes(envName))
1913
1917
  env[envName] = requireVercelEnvironmentValue(this.options.env, envName)
1914
1918
  const command = (await sandbox.runCommand({
1915
1919
  cmd: 'sh',