@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.
- package/README.md +1 -1
- package/SPEC.md +119 -15
- package/docs/architecture.md +59 -3
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +7 -0
- package/docs/remote-store-protocol.md +10 -0
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +3 -3
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +156 -3
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +20 -2
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +164 -34
- package/packages/core/src/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/reducer-registry.ts +2 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +5 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +130 -20
- package/packages/core/src/plugin-sdk/index.ts +6 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- package/packages/core/src/processes/dispatcher.ts +110 -32
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-runner.ts +24 -4
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/skills/guide/SKILL.md +91 -11
- package/skills/guide/references/operator-api.md +7 -0
- package/skills/guide/references/remote-store-protocol.md +10 -0
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- 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 &&
|
|
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
|
-
{
|
|
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
|
-
|
|
188
|
-
if (!(await file.exists())) throw new Error(`file not found: ${path}`)
|
|
187
|
+
let text: string
|
|
189
188
|
try {
|
|
190
|
-
|
|
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
|
|
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
|
|
177
|
-
' ab ticket
|
|
178
|
-
' ab ticket
|
|
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);
|
|
189
|
-
'
|
|
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(
|
|
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(
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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 (
|
|
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 () =>
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|