@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
@@ -211,7 +211,7 @@ export class DashboardBuildPollCache {
211
211
  // both the cached reduction and projected row untouched.
212
212
  next.set(
213
213
  record.slug,
214
- configChanged
214
+ configChanged || current.build?.ticketId !== record.ticket?.id
215
215
  ? {
216
216
  ...current,
217
217
  build: await this.projectRow(record, current.state, current.events, config),
@@ -1,3 +1,4 @@
1
+ import { issueTicketGrant, ticketGrantRequest } from '../ports/tickets/grants'
1
2
  import { RemoteBuildStore } from '../store/remote/client'
2
3
  import { delegateCredential } from '../store/remote/credentials'
3
4
  import { isRemoteStoreRef } from './repo-state'
@@ -205,7 +206,8 @@ export function slugNamingPrompt(spec: string): string {
205
206
  function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
206
207
  const defined: Record<string, string> = {}
207
208
  for (const [key, value] of Object.entries(env)) {
208
- if (value !== undefined && key !== 'AB_TOKEN') defined[key] = value
209
+ if (value !== undefined && !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key))
210
+ defined[key] = value
209
211
  }
210
212
  return defined
211
213
  }
@@ -811,6 +813,7 @@ class DispatchLoop {
811
813
  forge: wiring.forge,
812
814
  config,
813
815
  getConfig: () => this.liveConfig.current().config,
816
+ repairBuildConfig: (slug) => this.publishBuildConfig(slug),
814
817
  repo: this.repoIdentity,
815
818
  // Origin mode: the forge answers remote base-branch questions; no
816
819
  // checkout git is probed for the served repository's origin.
@@ -2248,6 +2251,110 @@ class DispatchLoop {
2248
2251
 
2249
2252
  let tracked: Promise<void>
2250
2253
  tracked = (async () => {
2254
+ const ticketUrl =
2255
+ harvestDeps.config.tickets.source === 'hosted'
2256
+ ? (harvestDeps.config.tickets.url ?? (/^https?:/i.test(storeRef) ? storeRef : undefined))
2257
+ : undefined
2258
+ if (ticketUrl && ticketUrl.replace(/\/+$/, '') !== storeRef.replace(/\/+$/, '')) {
2259
+ const ticketToken = projectToken(ticketUrl, this.repoIdentity, {
2260
+ ...this.opts.env,
2261
+ AB_TOKEN: undefined,
2262
+ })
2263
+ if (!ticketToken)
2264
+ throw new Error('Independent hosted harvest requires a ticket-service project credential')
2265
+ const issued = await issueTicketGrant(
2266
+ ticketUrl,
2267
+ ticketToken,
2268
+ this.repoIdentity,
2269
+ harvestDeps.instance,
2270
+ 'runner',
2271
+ )
2272
+ let remoteRunnerStore: RemoteBuildStore | undefined
2273
+ let preparedRun: { run: string } | undefined
2274
+ let buildScoped: string | undefined
2275
+ let renew: ReturnType<typeof setInterval> | undefined
2276
+ try {
2277
+ if (/^https?:/i.test(storeRef)) {
2278
+ const prepared = await new HarvestRunner(harvestDeps).run(true)
2279
+ if (prepared.outcome !== 'prepared') {
2280
+ return prepared
2281
+ }
2282
+ preparedRun = { run: prepared.run }
2283
+ await this.repoView.appendRepo(this.repoIdentity, {
2284
+ actor: DISPATCHER,
2285
+ type: 'harvest.execution.authorized',
2286
+ payload: {
2287
+ execution: harvestDeps.instance,
2288
+ run: prepared.run,
2289
+ launch: prepared.launch,
2290
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2291
+ },
2292
+ })
2293
+ buildScoped = await (this.wiring.credentialIssuer ?? delegateCredential)(
2294
+ storeRef,
2295
+ token,
2296
+ {
2297
+ kind: 'harvest-runner',
2298
+ execution: harvestDeps.instance,
2299
+ repo: this.repoIdentity,
2300
+ id: prepared.run,
2301
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2302
+ },
2303
+ )
2304
+ remoteRunnerStore = new RemoteBuildStore({ url: storeRef, token: buildScoped })
2305
+ }
2306
+ const sessionEnv = {
2307
+ ...harvestDeps.sessionEnv,
2308
+ ...(buildScoped ? { AB_TOKEN: buildScoped } : {}),
2309
+ AB_TICKET_URL: ticketUrl,
2310
+ AB_TICKET_TOKEN: issued.token,
2311
+ }
2312
+ let renewalFailure: unknown
2313
+ renew = setInterval(() => {
2314
+ void ticketGrantRequest(ticketUrl, issued.token, {
2315
+ action: 'renew',
2316
+ repo: this.repoIdentity,
2317
+ id: issued.grant.id,
2318
+ }).catch((error) => {
2319
+ renewalFailure = error
2320
+ })
2321
+ }, 60_000)
2322
+ const runnerTickets = await createTicketSource(
2323
+ harvestDeps.config.tickets,
2324
+ { ...this.opts.env, ...sessionEnv, AB_TOKEN: undefined },
2325
+ this.opts.targetRepo,
2326
+ undefined,
2327
+ this.wiring.plugins,
2328
+ )
2329
+ const result = await new HarvestRunner({
2330
+ ...harvestDeps,
2331
+ ...(remoteRunnerStore ? { store: remoteRunnerStore } : {}),
2332
+ ...(preparedRun ? { preparedRun } : {}),
2333
+ tickets: runnerTickets,
2334
+ sessionEnv,
2335
+ }).run()
2336
+ if (renewalFailure) throw renewalFailure
2337
+ return result
2338
+ } finally {
2339
+ if (renew) clearInterval(renew)
2340
+ try {
2341
+ if (remoteRunnerStore && preparedRun) {
2342
+ await remoteRunnerStore.appendRepo(this.repoIdentity, {
2343
+ actor: DISPATCHER,
2344
+ type: 'harvest.execution.ended',
2345
+ payload: { execution: harvestDeps.instance, run: preparedRun.run },
2346
+ })
2347
+ await remoteRunnerStore.close()
2348
+ }
2349
+ } finally {
2350
+ await ticketGrantRequest(ticketUrl, ticketToken, {
2351
+ action: 'revoke',
2352
+ repo: this.repoIdentity,
2353
+ id: issued.grant.id,
2354
+ })
2355
+ }
2356
+ }
2357
+ }
2251
2358
  if (!/^https?:/i.test(storeRef)) return new HarvestRunner(harvestDeps).run()
2252
2359
  const prepared = await new HarvestRunner(harvestDeps).run(true)
2253
2360
  if (prepared.outcome !== 'prepared') return prepared
@@ -2274,7 +2381,12 @@ class DispatchLoop {
2274
2381
  harvestDeps.config.tickets.source === 'hosted'
2275
2382
  ? await createTicketSource(
2276
2383
  harvestDeps.config.tickets,
2277
- { ...this.opts.env, AB_STORE: storeRef, AB_TOKEN: scoped },
2384
+ {
2385
+ ...this.opts.env,
2386
+ AB_STORE: storeRef,
2387
+ AB_TOKEN: scoped,
2388
+ AB_HARVEST: prepared.run,
2389
+ },
2278
2390
  this.opts.targetRepo,
2279
2391
  resolveRepoStatePaths({
2280
2392
  repo: this.repoIdentity,
@@ -2369,6 +2481,8 @@ class DispatchLoop {
2369
2481
  const repo = this.repoIdentity
2370
2482
  let handle: BuildExecutionHandle | undefined
2371
2483
  let startedSeq: number | undefined
2484
+ let ticketAuthority: { url: string; token: string; id: string } | undefined
2485
+ let renewTickets: ReturnType<typeof setInterval> | undefined
2372
2486
  // Shared with `stopHostedHarvest` through the `hostedHarvest` entry: a
2373
2487
  // detach must stay observable to the completion chain even after the
2374
2488
  // entry is cleared, or the chain would classify and reap a live guest.
@@ -2438,6 +2552,36 @@ class DispatchLoop {
2438
2552
  ),
2439
2553
  }
2440
2554
  : undefined
2555
+ const ticketConfig = this.currentConfig().config.tickets
2556
+ if (
2557
+ ticketConfig.source === 'hosted' &&
2558
+ ticketConfig.url &&
2559
+ ticketConfig.url.replace(/\/+$/, '') !== storeRef.replace(/\/+$/, '')
2560
+ ) {
2561
+ const ticketToken = projectToken(ticketConfig.url, repo, {
2562
+ ...this.opts.env,
2563
+ AB_TOKEN: undefined,
2564
+ })
2565
+ if (!ticketToken || !launchAuth)
2566
+ throw new Error('Independent harvest requires scoped build and ticket credentials')
2567
+ const issued = await issueTicketGrant(
2568
+ ticketConfig.url,
2569
+ ticketToken,
2570
+ repo,
2571
+ execution,
2572
+ 'runner',
2573
+ )
2574
+ ticketAuthority = { url: ticketConfig.url, token: issued.token, id: issued.grant.id }
2575
+ renewTickets = setInterval(() => {
2576
+ void ticketGrantRequest(ticketConfig.url!, issued.token, {
2577
+ action: 'renew',
2578
+ repo,
2579
+ id: issued.grant.id,
2580
+ }).catch(() => {
2581
+ // Expiry fails subsequent reads closed; the guest cannot regain project authority.
2582
+ })
2583
+ }, 60_000)
2584
+ }
2441
2585
  handle = await harvestExecution.start(
2442
2586
  {
2443
2587
  preparedRun: { run: prepared.run },
@@ -2447,7 +2591,9 @@ class DispatchLoop {
2447
2591
  baseBranch: this.currentConfig().config.baseBranch,
2448
2592
  ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2449
2593
  },
2450
- launchAuth,
2594
+ launchAuth && ticketAuthority
2595
+ ? { ...launchAuth, tickets: { url: ticketAuthority.url, token: ticketAuthority.token } }
2596
+ : launchAuth,
2451
2597
  )
2452
2598
  const identity = handle.identity
2453
2599
  if (
@@ -2525,6 +2671,13 @@ class DispatchLoop {
2525
2671
  )
2526
2672
  }
2527
2673
  } finally {
2674
+ if (renewTickets) clearInterval(renewTickets)
2675
+ if (ticketAuthority && !detached.value)
2676
+ await ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
2677
+ action: 'revoke',
2678
+ repo,
2679
+ id: ticketAuthority.id,
2680
+ })
2528
2681
  this.hostedHarvest = undefined
2529
2682
  }
2530
2683
  }
@@ -184,10 +184,20 @@ export async function buildHarvestContext(deps: HarvestCliDeps): Promise<Harvest
184
184
  }
185
185
 
186
186
  async function readJsonFile(path: string): Promise<unknown> {
187
- const file = Bun.file(path)
188
- if (!(await file.exists())) throw new Error(`file not found: ${path}`)
187
+ let text: string
189
188
  try {
190
- return JSON.parse(await file.text())
189
+ text = await Bun.file(path).text()
190
+ } catch (error) {
191
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
192
+ throw new Error(`file not found: ${path}`, { cause: error })
193
+ }
194
+ throw new Error(
195
+ `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
196
+ { cause: error },
197
+ )
198
+ }
199
+ try {
200
+ return JSON.parse(text)
191
201
  } catch (error) {
192
202
  throw new Error(
193
203
  `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
@@ -74,14 +74,14 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
74
74
  audience: 'human',
75
75
  summary: 'Connect this project to a hosted service with browser approval.',
76
76
  detail:
77
- 'Usage: ab connect [url]\nWith a URL, approve the printed URL and code in your browser. Stores the credential in user configuration and adds [store] url to autobuild.toml. Without a URL, reports the saved credential status. Runs outside sessions.',
77
+ 'Usage: ab connect [--tickets] [url]\nWith a URL, approve the printed URL and code in your browser. Stores the credential in user configuration and adds [store] url to autobuild.toml. Use --tickets to save [tickets] url and keep builds local. Without a URL, reports every service connection and its uses. Runs outside sessions.',
78
78
  },
79
79
  {
80
80
  name: 'disconnect',
81
81
  audience: 'human',
82
82
  summary: 'Revoke and remove this project’s saved credential.',
83
83
  detail:
84
- 'Usage: ab disconnect\nRevokes the saved credential remotely before removing it locally. Keeps project registration, data and [store] url. Runs outside sessions.',
84
+ 'Usage: ab disconnect [--tickets | --builds]\nRevokes the saved credential remotely before removing it locally. Keeps project registration, data and [store] url. Runs outside sessions.',
85
85
  },
86
86
  {
87
87
  name: 'upgrade',
@@ -173,20 +173,26 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
173
173
  detail: page([
174
174
  'Usage:',
175
175
  ' ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json]',
176
- ' ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json]',
177
- ' ab ticket block <id> <blocker-id[,blocker-id...]> [--json]',
178
- ' ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json]',
176
+ ' ab ticket edit <id>',
177
+ ' ab ticket export <dir> [--store <ref>]',
178
+ ' ab ticket import <dir> [--store <ref>]',
179
+ ' ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--expected-revision <opaque>] [--json]',
180
+ ' ab ticket block <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json]',
181
+ ' ab ticket unblock <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json]',
179
182
  ' ab ticket list [--state <state>] [--labels a,b] [--json]',
180
183
  ' ab ticket show <id> [--store <ref>] [--json]',
181
- ' ab ticket move <id> <state> [--json]',
184
+ ' ab ticket move <id> <state> [--expected-revision <opaque>] [--json]',
182
185
  ' ab ticket states [--json]',
183
186
  ' ab ticket state create <name> [--about <text>] [--json]',
184
187
  ' ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json]',
185
188
  ' ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json]',
186
189
  ' ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json]',
187
190
  '',
188
- 'Operate on the configured [tickets] source (§8.8); all ticket commands run',
189
- 'outside sessions. create files a ticket and may establish initial blockers.',
191
+ 'Operate on the configured [tickets] source (§8.8); commands run outside sessions.',
192
+ 'Every JSON ticket read includes an opaque revision. update, move, block and',
193
+ 'unblock accept --expected-revision to refuse stale writes. Linear refuses checks.',
194
+ 'edit opens VISUAL or EDITOR, checks the opening revision, and preserves failed buffers.',
195
+ 'create files a ticket and may establish initial blockers.',
190
196
  '--state overrides [tickets].createState (or the provider default) for that',
191
197
  'create only; it is passed through unchanged and validated by the source.',
192
198
  'update partially replaces editable fields: omitted fields survive, --labels',
@@ -1,5 +1,5 @@
1
1
  import { stat, readFile, realpath } from 'node:fs/promises'
2
- import { join, resolve, sep } from 'node:path'
2
+ import { dirname, join, resolve, sep } from 'node:path'
3
3
  import semver from 'semver'
4
4
  import { PLUGIN_API_VERSION } from '../plugins/manifest'
5
5
  import { defaultDistRoot } from './init'
@@ -155,6 +155,57 @@ function ownerRootForPackage(identity: DistributionIdentity): string | undefined
155
155
  return owner === '' ? sep : owner
156
156
  }
157
157
 
158
+ /** Store-internal fallback links cannot establish project ownership. */
159
+ function inBunStore(path: string): boolean {
160
+ return path
161
+ .split(sep)
162
+ .some((part, index, parts) => part === '.bun' && parts[index - 1] === 'node_modules')
163
+ }
164
+
165
+ function ancestors(path: string): string[] {
166
+ const result: string[] = []
167
+ for (let current = resolve(path); ; current = dirname(current)) {
168
+ result.push(current)
169
+ if (dirname(current) === current) return result
170
+ }
171
+ }
172
+
173
+ async function discoverOwner(
174
+ identity: DistributionIdentity,
175
+ cwd: string,
176
+ ): Promise<string | undefined> {
177
+ const direct = ownerRootForPackage(identity)
178
+ if (direct !== undefined && !inBunStore(direct)) return direct
179
+ // Reject invalid package names before constructing candidate public paths.
180
+ if (identity.packageName.split('/').some((part) => part === '' || part === '.' || part === '..'))
181
+ return undefined
182
+ const candidates = new Set([...ancestors(identity.root), ...ancestors(cwd)])
183
+ for (const candidate of candidates) {
184
+ if (inBunStore(candidate)) continue
185
+ if (!(await samePath(join(candidate, 'node_modules', identity.packageName), identity.root)))
186
+ continue
187
+ const manifestPath = join(candidate, 'package.json')
188
+ if (!(await exists(manifestPath))) continue
189
+ const manifest = await parseJson(manifestPath)
190
+ if (declaredSection(manifest, identity.packageName) !== undefined) return candidate
191
+ }
192
+ return undefined
193
+ }
194
+
195
+ async function readMetadata(path: string): Promise<string> {
196
+ try {
197
+ return await readFile(path, 'utf8')
198
+ } catch (error) {
199
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
200
+ throw new Error(`${path} does not exist`, { cause: error })
201
+ }
202
+ throw new Error(
203
+ `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
204
+ { cause: error },
205
+ )
206
+ }
207
+ }
208
+
158
209
  function githubDependency(value: unknown): { owner: string; repository: string } | undefined {
159
210
  if (typeof value !== 'string') return undefined
160
211
  const match = /^github:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#([^\s]+))?$/.exec(value)
@@ -172,8 +223,9 @@ function registryDependency(value: unknown, installedVersion: string): boolean {
172
223
  }
173
224
 
174
225
  async function parseJson(path: string): Promise<Record<string, unknown>> {
226
+ const text = await readMetadata(path)
175
227
  try {
176
- return object(JSON.parse(await readFile(path, 'utf8'))) ?? {}
228
+ return object(JSON.parse(text)) ?? {}
177
229
  } catch (error) {
178
230
  throw new Error(
179
231
  `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
@@ -182,8 +234,9 @@ async function parseJson(path: string): Promise<Record<string, unknown>> {
182
234
  }
183
235
 
184
236
  async function parseJsonc(path: string): Promise<Record<string, unknown>> {
237
+ const text = await readMetadata(path)
185
238
  try {
186
- const value = Bun.JSONC.parse(await readFile(path, 'utf8'))
239
+ const value = Bun.JSONC.parse(text)
187
240
  return object(value) ?? {}
188
241
  } catch (error) {
189
242
  throw new Error(
@@ -241,6 +294,7 @@ async function samePath(left: string, right: string): Promise<boolean> {
241
294
  export async function inspectInstallation(options: {
242
295
  distRoot?: string
243
296
  globalBin: string
297
+ cwd?: string
244
298
  }): Promise<InstallationInspection> {
245
299
  const identity = await readDistributionIdentity(options.distRoot)
246
300
  if (identity.sourceCheckout) {
@@ -251,18 +305,18 @@ export async function inspectInstallation(options: {
251
305
  }
252
306
  }
253
307
 
254
- const ownerRoot = ownerRootForPackage(identity)
255
- if (ownerRoot === undefined) {
256
- return {
257
- kind: 'unknown',
258
- identity,
259
- reason: 'the distribution is not a direct Bun node_modules package',
308
+ try {
309
+ const ownerRoot = await discoverOwner(identity, options.cwd ?? process.cwd())
310
+ if (ownerRoot === undefined) {
311
+ return {
312
+ kind: 'unknown',
313
+ identity,
314
+ reason: 'the distribution is not a direct Bun node_modules package',
315
+ }
260
316
  }
261
- }
262
- const ownerManifest = join(ownerRoot, 'package.json')
263
- const ownerLock = join(ownerRoot, 'bun.lock')
317
+ const ownerManifest = join(ownerRoot, 'package.json')
318
+ const ownerLock = join(ownerRoot, 'bun.lock')
264
319
 
265
- try {
266
320
  const manifest = await parseJson(ownerManifest)
267
321
  const lock = await parseJsonc(ownerLock)
268
322
  const declared = declaredSection(manifest, identity.packageName)
@@ -1,3 +1,5 @@
1
+ import { preparePinnedAssets } from '../store/ticket-assets'
2
+ import type { PinnedAsset } from '../events/payloads'
1
3
  import { abConnect, abDisconnect } from './connect'
2
4
  /**
3
5
  * `ab` — the only channel between agents and the store, and the enforcement
@@ -308,6 +310,7 @@ async function runBuildControl(
308
310
  action: BuildControlAction,
309
311
  storeRef?: string,
310
312
  readTicketBody?: (ref: TicketRef) => Promise<string>,
313
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>,
311
314
  ): Promise<void> {
312
315
  if (deps.exec === undefined) {
313
316
  throw new Error(
@@ -322,6 +325,7 @@ async function runBuildControl(
322
325
  action,
323
326
  ...(storeRef !== undefined ? { storeRef } : {}),
324
327
  ...(readTicketBody !== undefined ? { readTicketBody } : {}),
328
+ ...(prepareTicketAssets ? { prepareTicketAssets } : {}),
325
329
  ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
326
330
  })
327
331
  deps.stdout(buildControlConfirmation(result))
@@ -451,9 +455,11 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
451
455
  case 'connect':
452
456
  case 'disconnect': {
453
457
  const usage = command === 'connect' ? 'usage: ab connect [url]' : 'usage: ab disconnect'
454
- const parsed = parseArgs(rest, {}, usage)
458
+ const parsed = parseArgs(rest, { tickets: 'boolean', builds: 'boolean' }, usage)
455
459
  if (parsed.positionals.length > (command === 'connect' ? 1 : 0)) throw new Error(usage)
456
460
  const options = {
461
+ tickets: parsed.flags.get('tickets') === true,
462
+ builds: parsed.flags.get('builds') === true,
457
463
  cwd: deps.workspacePath,
458
464
  env: deps.processEnv ?? process.env,
459
465
  exec: deps.exec ?? spawnExec,
@@ -1127,6 +1133,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1127
1133
  : parsed.flags.has('revise-spec-from-ticket')
1128
1134
  ? ({ kind: 'revise-spec', body: { kind: 'ticket' } } as const)
1129
1135
  : undefined
1136
+ let revisionTicketSource: import('../ports/types').TicketSource | undefined
1130
1137
  const readTicketBody =
1131
1138
  resolve?.kind === 'revise-spec' && resolve.body.kind === 'ticket'
1132
1139
  ? async (ref: TicketRef): Promise<string> => {
@@ -1140,6 +1147,7 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1140
1147
  },
1141
1148
  'ab answer --revise-spec-from-ticket',
1142
1149
  )
1150
+ revisionTicketSource = source
1143
1151
  if (source.name !== ref.source) {
1144
1152
  throw new Error(
1145
1153
  `build ticket source is "${ref.source}", but this repository is configured for "${source.name}"; refusing to read a same-id ticket from a different source`,
@@ -1151,7 +1159,10 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1151
1159
  `ticket "${ref.id}" was not found in the configured ${source.name} source`,
1152
1160
  )
1153
1161
  }
1154
- if (ticket.ref.source !== ref.source || ticket.ref.id !== ref.id) {
1162
+ if (
1163
+ ticket.ref.source !== ref.source ||
1164
+ (source.name !== 'hosted' && ticket.ref.id !== ref.id)
1165
+ ) {
1155
1166
  throw new Error(
1156
1167
  `ticket adapter returned ${ticket.ref.source}:${ticket.ref.id}, expected ${ref.source}:${ref.id}`,
1157
1168
  )
@@ -1170,6 +1181,13 @@ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<numbe
1170
1181
  },
1171
1182
  storeRef,
1172
1183
  readTicketBody,
1184
+ readTicketBody
1185
+ ? async (ref, store, repo) => {
1186
+ if (!revisionTicketSource)
1187
+ throw new Error('ticket body must be read before preparing assets')
1188
+ return preparePinnedAssets(revisionTicketSource, store, repo, ref.id)
1189
+ }
1190
+ : undefined,
1173
1191
  )
1174
1192
  return 0
1175
1193
  }
@@ -120,7 +120,8 @@ export async function buildMcpTicketBackend(
120
120
  }
121
121
  return source
122
122
  },
123
- statesFor: async () => states,
123
+ statesFor: async (_context, source) =>
124
+ source.listStates ? (await source.listStates()).map((s) => s.name) : states,
124
125
  }
125
126
  }
126
127
 
@@ -1,3 +1,4 @@
1
+ import { join } from 'node:path'
1
2
  import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
2
3
  import { readFile, writeFile } from 'node:fs/promises'
3
4
  import semver from 'semver'
@@ -363,6 +364,7 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
363
364
  const inspection = await inspectInstallation({
364
365
  distRoot,
365
366
  globalBin: globalBinResult.stdout.trim(),
367
+ cwd: options.targetRepo,
366
368
  })
367
369
  if (inspection.kind !== 'bun-forge' && inspection.kind !== 'npm-registry') {
368
370
  return explicit ? fail(options, inspection.reason) : warn(options, inspection.reason)
@@ -469,7 +471,9 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
469
471
 
470
472
  let updated: DistributionIdentity
471
473
  try {
472
- updated = await readDistributionIdentity(distRoot)
474
+ updated = await readDistributionIdentity(
475
+ join(install.ownerRoot, 'node_modules', ...install.packageName.split('/')),
476
+ )
473
477
  // Injected command fixtures do not rewrite disk; a real successful Bun
474
478
  // operation must. Keep the check injectable by accepting the old identity
475
479
  // only when the command seam itself was supplied.
@@ -42,6 +42,7 @@ export function ticketAssetNote(
42
42
  event: TicketAssetNoteEvent,
43
43
  meta: TicketAssetMeta,
44
44
  cli = 'ab',
45
+ hosted = false,
45
46
  ): string {
46
47
  const fileCount = meta.entries.filter((entry) => entry.type === 'file').length
47
48
  const label = `\`${meta.kind}/${meta.name}\``
@@ -52,7 +53,9 @@ export function ticketAssetNote(
52
53
  return [
53
54
  `Ticket asset attached: ${label} (revision ${meta.revision}, ${contents}).`,
54
55
  '',
55
- 'The contents live in the Autobuild store, not in this ticket.',
56
+ hosted
57
+ ? 'The contents live in the ticket service storage.'
58
+ : 'The contents live in the Autobuild store, not in this ticket.',
56
59
  `Download the latest revision: \`${get}\``,
57
60
  `Download this exact revision: \`${get} --rev ${meta.revision}\``,
58
61
  ].join('\n')
@@ -60,7 +63,9 @@ export function ticketAssetNote(
60
63
  return [
61
64
  `Ticket asset replaced: ${label} is now revision ${meta.revision} (${contents}).`,
62
65
  '',
63
- 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
66
+ hosted
67
+ ? 'The contents live in the ticket service storage. Earlier revisions stay retrievable.'
68
+ : 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
64
69
  `Download the latest revision: \`${get}\``,
65
70
  `Download this exact revision: \`${get} --rev ${meta.revision}\``,
66
71
  ].join('\n')
@@ -0,0 +1,82 @@
1
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import type { TicketSource, TicketUpdate } from '../ports/types'
5
+ import { parseTicketFile, serializeTicketFile } from '../ports/tickets/file-codec'
6
+ import { TicketRevisionUnsupported } from '../ports/tickets/revision'
7
+ import { requireTicket, updateTicket } from '../ports/tickets/operations'
8
+
9
+ export type TicketEditorRunner = (command: string, path: string) => Promise<number>
10
+
11
+ /** The configured editor is a shell command; only our generated path is quoted. */
12
+ export const runTicketEditor: TicketEditorRunner = async (command, path) => {
13
+ const quoted = `'${path.replaceAll("'", "'\\''")}'`
14
+ return Bun.spawn(['sh', '-c', `${command} ${quoted}`], {
15
+ stdin: 'inherit',
16
+ stdout: 'inherit',
17
+ stderr: 'inherit',
18
+ }).exited
19
+ }
20
+
21
+ export async function editTicket(
22
+ source: TicketSource,
23
+ id: string,
24
+ env: Record<string, string | undefined>,
25
+ stdout: (line: string) => void,
26
+ runner: TicketEditorRunner = runTicketEditor,
27
+ ): Promise<void> {
28
+ if (!source.mutateIfRevision) throw new TicketRevisionUnsupported(source.name)
29
+ const command = env.VISUAL?.trim() || env.EDITOR?.trim()
30
+ if (!command) throw new Error('set VISUAL or EDITOR to edit a ticket')
31
+ const ticket = await requireTicket(source, id)
32
+ const front = {
33
+ id,
34
+ title: ticket.title,
35
+ labels: [...ticket.labels],
36
+ blockedBy: [...(ticket.blockedBy ?? [])],
37
+ ...(ticket.creationKey !== undefined ? { idempotencyKey: ticket.creationKey } : {}),
38
+ }
39
+ // Reference comments remain part of the frontmatter, outside the editable keys.
40
+ const reference = `# Read-only reference\n# state = ${JSON.stringify(ticket.state ?? null)}\n# revision = ${JSON.stringify(ticket.revision)}\n`
41
+ const serialized = serializeTicketFile(front, ticket.body)
42
+ const original = serialized.replace('+++\n', `+++\n${reference}`)
43
+ const directory = await mkdtemp(join(tmpdir(), 'ab-ticket-edit-'))
44
+ const path = join(directory, 'ticket.md')
45
+ await writeFile(path, original, { mode: 0o600 })
46
+ try {
47
+ const code = await runner(command, path)
48
+ if (code !== 0) throw new Error(`editor exited with status ${code}`)
49
+ const edited = await readFile(path, 'utf8')
50
+ if (edited !== original) {
51
+ const parsed = parseTicketFile(path, edited)
52
+ const close = edited.indexOf('\n+++\n', 4)
53
+ const comments = edited
54
+ .slice(4, close)
55
+ .split('\n')
56
+ .filter((line) => line.trimStart().startsWith('#'))
57
+ .join('\n')
58
+ if (comments !== reference.trimEnd())
59
+ throw new Error('read-only reference comments cannot change')
60
+ if (
61
+ parsed.front.id !== id ||
62
+ parsed.front.idempotencyKey !== front.idempotencyKey ||
63
+ JSON.stringify(parsed.front.blockedBy ?? []) !== JSON.stringify(front.blockedBy)
64
+ )
65
+ throw new Error('id, idempotencyKey and blockedBy are read-only')
66
+ const patch: TicketUpdate = {}
67
+ if (parsed.front.title !== ticket.title) patch.title = parsed.front.title
68
+ if (parsed.body !== ticket.body) patch.body = parsed.body
69
+ if (JSON.stringify(parsed.front.labels) !== JSON.stringify(ticket.labels))
70
+ patch.labels = parsed.front.labels
71
+ if (Object.keys(patch).length > 0)
72
+ await updateTicket(source, id, patch, false, ticket.revision)
73
+ }
74
+ await rm(directory, { recursive: true })
75
+ stdout(`ticket edit complete: ${source.name}:${id}`)
76
+ } catch (error) {
77
+ throw new Error(
78
+ `${error instanceof Error ? error.message : String(error)}; edited text kept at ${path}`,
79
+ { cause: error },
80
+ )
81
+ }
82
+ }