@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
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { exportTickets, importTickets } from '../ports/tickets/migration'
|
|
2
|
+
import { composeTicketAssets } from '../store/ticket-assets'
|
|
3
|
+
import { editTicket, type TicketEditorRunner } from './ticket-editor'
|
|
1
4
|
/**
|
|
2
5
|
* Source-agnostic pre-build ticket operations (SPEC §8.8). These commands run
|
|
3
6
|
* outside build sessions and resolve the repository's configured TicketSource,
|
|
@@ -19,7 +22,13 @@ import {
|
|
|
19
22
|
ticketListCriteria,
|
|
20
23
|
updateTicket,
|
|
21
24
|
} from '../ports/tickets/operations'
|
|
22
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
TicketAssetStore,
|
|
27
|
+
Ticket,
|
|
28
|
+
TicketSource,
|
|
29
|
+
TicketStateInfo,
|
|
30
|
+
TicketUpdate,
|
|
31
|
+
} from '../ports/types'
|
|
23
32
|
import { reduceBuild } from '../kernel/reducer'
|
|
24
33
|
import type { BuildStore } from '../store/types'
|
|
25
34
|
import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
|
|
@@ -45,6 +54,8 @@ export type TicketSourceFactory = (
|
|
|
45
54
|
|
|
46
55
|
export interface TicketCommandOpts {
|
|
47
56
|
targetRepo: string
|
|
57
|
+
expectedRevision?: string
|
|
58
|
+
editorRunner?: TicketEditorRunner
|
|
48
59
|
/** Process environment — store selection and adapter secrets. */
|
|
49
60
|
env: Record<string, string | undefined>
|
|
50
61
|
/** Git seam supplied by the CLI; omitted direct callers use targetRepo. */
|
|
@@ -152,10 +163,13 @@ export interface TicketStateCreateOpts extends TicketCommandOpts {
|
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
type TicketCommandName =
|
|
166
|
+
| 'export'
|
|
167
|
+
| 'import'
|
|
155
168
|
| 'states'
|
|
156
169
|
| 'state create'
|
|
157
170
|
| 'create'
|
|
158
171
|
| 'update'
|
|
172
|
+
| 'edit'
|
|
159
173
|
| 'block'
|
|
160
174
|
| 'unblock'
|
|
161
175
|
| 'list'
|
|
@@ -170,12 +184,11 @@ export interface ResolvedTicketCommand {
|
|
|
170
184
|
source: TicketSource
|
|
171
185
|
}
|
|
172
186
|
|
|
173
|
-
/** Resolve
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
opts: TicketCommandOpts & { storeRef?: string },
|
|
187
|
+
/** Resolve the main checkout and config without requiring provider credentials. */
|
|
188
|
+
async function resolveTicketConfig(
|
|
189
|
+
opts: TicketCommandOpts,
|
|
177
190
|
label: string,
|
|
178
|
-
): Promise<
|
|
191
|
+
): Promise<{ targetRepo: string; config: Config }> {
|
|
179
192
|
const targetRepo =
|
|
180
193
|
opts.exec === undefined
|
|
181
194
|
? resolve(opts.targetRepo)
|
|
@@ -194,6 +207,21 @@ export async function openTicketSource(
|
|
|
194
207
|
throw error
|
|
195
208
|
}
|
|
196
209
|
|
|
210
|
+
return { targetRepo, config }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Resolve configuration and construct the configured ticket adapter. */
|
|
214
|
+
export async function openTicketSource(
|
|
215
|
+
opts: TicketCommandOpts & { storeRef?: string },
|
|
216
|
+
label: string,
|
|
217
|
+
): Promise<ResolvedTicketCommand> {
|
|
218
|
+
return createTicketCommandSource(opts, await resolveTicketConfig(opts, label))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function createTicketCommandSource(
|
|
222
|
+
opts: TicketCommandOpts,
|
|
223
|
+
{ targetRepo, config }: { targetRepo: string; config: Config },
|
|
224
|
+
): Promise<ResolvedTicketCommand> {
|
|
197
225
|
// Ticket commands use the same trusted plugin catalog as dispatch. Loading
|
|
198
226
|
// and registration finish before any adapter is constructed or called.
|
|
199
227
|
const plugins = await loadPlugins(config.plugins, targetRepo)
|
|
@@ -313,18 +341,18 @@ export async function abTicketUpdate(opts: TicketUpdateOpts): Promise<void> {
|
|
|
313
341
|
...(opts.labels !== undefined ? { labels: [...opts.labels] } : {}),
|
|
314
342
|
}
|
|
315
343
|
if (opts.json === true) {
|
|
316
|
-
const updated = await updateTicket(source, opts.id, patch)
|
|
344
|
+
const updated = await updateTicket(source, opts.id, patch, true, opts.expectedRevision)
|
|
317
345
|
if (updated !== null) emitTicketJson(opts, updated)
|
|
318
346
|
return
|
|
319
347
|
}
|
|
320
|
-
await updateTicket(source, opts.id, patch, false)
|
|
348
|
+
await updateTicket(source, opts.id, patch, false, opts.expectedRevision)
|
|
321
349
|
opts.stdout(`ticket updated: ${source.name}:${opts.id}`)
|
|
322
350
|
}
|
|
323
351
|
|
|
324
352
|
export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
|
|
325
353
|
const { source } = await resolveTicketCommand(opts, 'block')
|
|
326
354
|
const blockerIds = [...new Set(opts.blockerIds)]
|
|
327
|
-
const updated = await changeBlockers(source, opts.id, blockerIds, 'block')
|
|
355
|
+
const updated = await changeBlockers(source, opts.id, blockerIds, 'block', opts.expectedRevision)
|
|
328
356
|
if (opts.json === true) {
|
|
329
357
|
emitTicketJson(opts, updated)
|
|
330
358
|
return
|
|
@@ -337,7 +365,13 @@ export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
|
|
|
337
365
|
export async function abTicketUnblock(opts: TicketBlockerOpts): Promise<void> {
|
|
338
366
|
const { source } = await resolveTicketCommand(opts, 'unblock')
|
|
339
367
|
const blockerIds = [...new Set(opts.blockerIds)]
|
|
340
|
-
const updated = await changeBlockers(
|
|
368
|
+
const updated = await changeBlockers(
|
|
369
|
+
source,
|
|
370
|
+
opts.id,
|
|
371
|
+
blockerIds,
|
|
372
|
+
'unblock',
|
|
373
|
+
opts.expectedRevision,
|
|
374
|
+
)
|
|
341
375
|
if (opts.json === true) {
|
|
342
376
|
emitTicketJson(opts, updated)
|
|
343
377
|
return
|
|
@@ -372,7 +406,7 @@ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
|
|
|
372
406
|
const { source } = await resolveTicketCommand(opts, 'show')
|
|
373
407
|
const ticket = await requireTicket(source, opts.id)
|
|
374
408
|
// An asset-listing failure fails the command rather than hiding the assets.
|
|
375
|
-
const assets = await
|
|
409
|
+
const assets = await withTicketAssets(opts, source, ({ store, repo }) =>
|
|
376
410
|
store.listTicketAssets(repo, opts.id),
|
|
377
411
|
)
|
|
378
412
|
if (opts.json === true) {
|
|
@@ -399,6 +433,12 @@ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
|
|
|
399
433
|
// Keep the adapter-provided body untouched. It may itself be multiline and
|
|
400
434
|
// may intentionally end (or not end) with a newline.
|
|
401
435
|
opts.stdout(ticket.body)
|
|
436
|
+
for (const comment of ticket.comments ?? []) {
|
|
437
|
+
opts.stdout(
|
|
438
|
+
`Comment ${comment.createdAt}${comment.author !== undefined ? ` — ${comment.author}` : ''}`,
|
|
439
|
+
)
|
|
440
|
+
opts.stdout(comment.body)
|
|
441
|
+
}
|
|
402
442
|
}
|
|
403
443
|
|
|
404
444
|
function withTicketStore<T>(
|
|
@@ -417,6 +457,19 @@ function withTicketStore<T>(
|
|
|
417
457
|
)
|
|
418
458
|
}
|
|
419
459
|
|
|
460
|
+
function withTicketAssets<T>(
|
|
461
|
+
opts: TicketCommandOpts,
|
|
462
|
+
source: TicketSource,
|
|
463
|
+
use: (context: { store: TicketAssetStore; buildStore: BuildStore; repo: string }) => Promise<T>,
|
|
464
|
+
): Promise<T> {
|
|
465
|
+
if (source.name === 'hosted' && !source.assets) {
|
|
466
|
+
throw new Error('hosted ticket source has no asset capability')
|
|
467
|
+
}
|
|
468
|
+
return withTicketStore(opts, ({ store, repo }) =>
|
|
469
|
+
use({ store: composeTicketAssets(source, store), buildStore: store, repo }),
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
420
473
|
/** Slugs of this repo's non-terminal builds for the ticket. A build freezes its
|
|
421
474
|
* ticket assets at the claim (SPEC §6.3), so a change to them does not reach
|
|
422
475
|
* it. The notice is advisory: a failed lookup yields none and never blocks. */
|
|
@@ -455,18 +508,26 @@ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
|
|
|
455
508
|
await requireTicket(source, opts.id)
|
|
456
509
|
const walk = await walkAssetPath(opts.path)
|
|
457
510
|
const name = opts.name ?? walk.basename
|
|
458
|
-
const stored = await
|
|
511
|
+
const stored = await withTicketAssets(opts, source, async ({ store, buildStore, repo }) => {
|
|
459
512
|
const limits = await store.ticketAssetLimits(repo)
|
|
460
513
|
const input = await buildAssetInput(walk, { kind: opts.kind, name }, limits)
|
|
461
514
|
const live = await store.listTicketAssets(repo, opts.id)
|
|
462
515
|
const replacing = live.some((asset) => asset.kind === opts.kind && asset.name === name)
|
|
463
516
|
const meta = await store.putTicketAsset(repo, opts.id, input)
|
|
464
|
-
return { meta, replacing, active: await activeBuildsFor(
|
|
517
|
+
return { meta, replacing, active: await activeBuildsFor(buildStore, repo, opts.id) }
|
|
465
518
|
})
|
|
466
519
|
const { meta } = stored
|
|
467
520
|
warnActiveBuilds(opts, stored.active)
|
|
468
521
|
try {
|
|
469
|
-
await source.comment(
|
|
522
|
+
await source.comment(
|
|
523
|
+
opts.id,
|
|
524
|
+
ticketAssetNote(
|
|
525
|
+
stored.replacing ? 'replaced' : 'attached',
|
|
526
|
+
meta,
|
|
527
|
+
'ab',
|
|
528
|
+
source.name === 'hosted',
|
|
529
|
+
),
|
|
530
|
+
)
|
|
470
531
|
} catch (error) {
|
|
471
532
|
throw new Error(
|
|
472
533
|
`ticket asset ${meta.kind}/${meta.name} was stored as revision ${meta.revision}, but the note on ` +
|
|
@@ -487,9 +548,17 @@ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
|
|
|
487
548
|
/** `ab ticket asset get <id> <kind> <name> <dest>` — exact bytes at the
|
|
488
549
|
* latest or a named revision. */
|
|
489
550
|
export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void> {
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
551
|
+
const resolved = await resolveTicketConfig(opts, 'ab ticket asset get')
|
|
552
|
+
const readAsset = ({ store, repo }: { store: TicketAssetStore; repo: string }) =>
|
|
553
|
+
store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev)
|
|
554
|
+
const asset =
|
|
555
|
+
resolved.config.tickets.source === 'linear'
|
|
556
|
+
? await withTicketStore(opts, readAsset)
|
|
557
|
+
: await withTicketAssets(
|
|
558
|
+
opts,
|
|
559
|
+
(await createTicketCommandSource(opts, resolved)).source,
|
|
560
|
+
readAsset,
|
|
561
|
+
)
|
|
493
562
|
if (asset === null) {
|
|
494
563
|
const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
|
|
495
564
|
throw new Error(
|
|
@@ -511,16 +580,23 @@ export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void>
|
|
|
511
580
|
* (earlier revisions stay retrievable) and note the removal on the ticket. */
|
|
512
581
|
export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
|
|
513
582
|
const { source } = await resolveTicketCommand(opts, 'asset rm')
|
|
514
|
-
const { removed, active } = await
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
583
|
+
const { removed, active } = await withTicketAssets(
|
|
584
|
+
opts,
|
|
585
|
+
source,
|
|
586
|
+
async ({ store, buildStore, repo }) => ({
|
|
587
|
+
removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
|
|
588
|
+
active: await activeBuildsFor(buildStore, repo, opts.id),
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
518
591
|
if (removed === null) {
|
|
519
592
|
throw new Error(`ticket ${opts.id} has no current asset ${opts.kind}/${opts.name} to remove`)
|
|
520
593
|
}
|
|
521
594
|
warnActiveBuilds(opts, active)
|
|
522
595
|
try {
|
|
523
|
-
await source.comment(
|
|
596
|
+
await source.comment(
|
|
597
|
+
opts.id,
|
|
598
|
+
ticketAssetNote('removed', removed, 'ab', source.name === 'hosted'),
|
|
599
|
+
)
|
|
524
600
|
} catch (error) {
|
|
525
601
|
throw new Error(
|
|
526
602
|
`ticket asset ${opts.kind}/${opts.name} was removed, but the note on ${source.name}:${opts.id} ` +
|
|
@@ -537,7 +613,7 @@ export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
|
|
|
537
613
|
/** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
|
|
538
614
|
export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
|
|
539
615
|
const { source } = await resolveTicketCommand(opts, 'move')
|
|
540
|
-
const moved = await moveTicket(source, opts.id, opts.state)
|
|
616
|
+
const moved = await moveTicket(source, opts.id, opts.state, opts.expectedRevision)
|
|
541
617
|
if (opts.json === true) {
|
|
542
618
|
opts.stdout(JSON.stringify(moved, null, 2))
|
|
543
619
|
return
|
|
@@ -566,10 +642,10 @@ export function stateRoles(
|
|
|
566
642
|
const named: Array<[TicketStateRole, string]> = [
|
|
567
643
|
['create', tickets.createState ?? 'triage'],
|
|
568
644
|
['ready', tickets.readyState],
|
|
569
|
-
['claimed', 'doing'],
|
|
645
|
+
['claimed', tickets.source === 'hosted' ? (tickets.claimedState ?? 'doing') : 'doing'],
|
|
570
646
|
['triage', triage],
|
|
571
647
|
['proposal', tickets.proposalState ?? triage],
|
|
572
|
-
['done', 'done'],
|
|
648
|
+
['done', tickets.source === 'hosted' ? (tickets.doneState ?? 'done') : 'done'],
|
|
573
649
|
]
|
|
574
650
|
const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
|
|
575
651
|
for (const [role, target] of named) {
|
|
@@ -620,7 +696,7 @@ export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
|
|
|
620
696
|
})
|
|
621
697
|
}
|
|
622
698
|
|
|
623
|
-
/** `ab ticket state create <name> [--about <text>]` —
|
|
699
|
+
/** `ab ticket state create <name> [--about <text>]` — duplicate policy belongs to the source. */
|
|
624
700
|
export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
|
|
625
701
|
const { config, source } = await resolveTicketCommand(opts, 'state create')
|
|
626
702
|
if (typeof source.addState !== 'function') {
|
|
@@ -650,9 +726,11 @@ export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<
|
|
|
650
726
|
const CREATE_USAGE =
|
|
651
727
|
'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
|
|
652
728
|
const UPDATE_USAGE =
|
|
653
|
-
'usage: ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json] (§8.8)'
|
|
654
|
-
const BLOCK_USAGE =
|
|
655
|
-
|
|
729
|
+
'usage: ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--expected-revision <opaque>] [--json] (§8.8)'
|
|
730
|
+
const BLOCK_USAGE =
|
|
731
|
+
'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json] (§8.8)'
|
|
732
|
+
const UNBLOCK_USAGE =
|
|
733
|
+
'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json] (§8.8)'
|
|
656
734
|
const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
|
|
657
735
|
const SHOW_USAGE = 'usage: ab ticket show <id> [--store <ref>] [--json] (§8.8)'
|
|
658
736
|
const ATTACH_USAGE =
|
|
@@ -661,11 +739,15 @@ const ASSET_GET_USAGE =
|
|
|
661
739
|
'usage: ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json] (§8.8)'
|
|
662
740
|
const ASSET_RM_USAGE =
|
|
663
741
|
'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
|
|
664
|
-
const MOVE_USAGE =
|
|
742
|
+
const MOVE_USAGE =
|
|
743
|
+
'usage: ab ticket move <id> <state> [--expected-revision <opaque>] [--json] (§8.8)'
|
|
665
744
|
const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
|
|
666
745
|
const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
|
|
667
746
|
export const TICKET_USAGE = [
|
|
747
|
+
'usage: ab ticket export <dir> [--store <ref>]',
|
|
748
|
+
'usage: ab ticket import <dir> [--store <ref>]',
|
|
668
749
|
CREATE_USAGE,
|
|
750
|
+
'usage: ab ticket edit <id> (§8.8)',
|
|
669
751
|
UPDATE_USAGE,
|
|
670
752
|
BLOCK_USAGE,
|
|
671
753
|
UNBLOCK_USAGE,
|
|
@@ -696,6 +778,25 @@ interface TicketCliOpts extends TicketCommandOpts {
|
|
|
696
778
|
export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<void> {
|
|
697
779
|
const [command, ...args] = argv
|
|
698
780
|
switch (command) {
|
|
781
|
+
case 'export':
|
|
782
|
+
case 'import': {
|
|
783
|
+
const parsed = parseArgs(args, { store: 'value' }, TICKET_USAGE)
|
|
784
|
+
const [dir, ...extra] = parsed.positionals
|
|
785
|
+
if (!dir?.trim() || extra.length) throw new Error(TICKET_USAGE)
|
|
786
|
+
const migrationOpts = { ...opts, storeRef: stringFlag(parsed, 'store') ?? opts.storeRef }
|
|
787
|
+
const { source } = await resolveTicketCommand(migrationOpts, command)
|
|
788
|
+
await withTicketAssets(migrationOpts, source, ({ store, repo }) =>
|
|
789
|
+
(command === 'export' ? exportTickets : importTickets)({
|
|
790
|
+
source,
|
|
791
|
+
assets: store,
|
|
792
|
+
repo,
|
|
793
|
+
dir,
|
|
794
|
+
print: opts.stdout,
|
|
795
|
+
}),
|
|
796
|
+
)
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
699
800
|
case 'create': {
|
|
700
801
|
const parsed = parseArgs(
|
|
701
802
|
args,
|
|
@@ -723,10 +824,23 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
723
824
|
return
|
|
724
825
|
}
|
|
725
826
|
|
|
827
|
+
case 'edit': {
|
|
828
|
+
if (args.length !== 1 || !args[0]?.trim()) throw new Error('usage: ab ticket edit <id>')
|
|
829
|
+
const { source } = await resolveTicketCommand(opts, 'edit')
|
|
830
|
+
await editTicket(source, args[0], opts.env, opts.stdout, opts.editorRunner)
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
|
|
726
834
|
case 'update': {
|
|
727
835
|
const parsed = parseArgs(
|
|
728
836
|
args,
|
|
729
|
-
{
|
|
837
|
+
{
|
|
838
|
+
title: 'value',
|
|
839
|
+
body: 'value',
|
|
840
|
+
labels: 'value',
|
|
841
|
+
'expected-revision': 'value',
|
|
842
|
+
json: 'boolean',
|
|
843
|
+
},
|
|
730
844
|
TICKET_USAGE,
|
|
731
845
|
)
|
|
732
846
|
const [id, ...extra] = parsed.positionals
|
|
@@ -743,6 +857,7 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
743
857
|
await abTicketUpdate({
|
|
744
858
|
...opts,
|
|
745
859
|
id,
|
|
860
|
+
expectedRevision: stringFlag(parsed, 'expected-revision'),
|
|
746
861
|
...(title !== undefined ? { title } : {}),
|
|
747
862
|
...(bodyFile !== undefined ? { bodyFile } : {}),
|
|
748
863
|
...(labels !== undefined ? { labels: commaList(labels) } : {}),
|
|
@@ -753,7 +868,11 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
753
868
|
|
|
754
869
|
case 'block':
|
|
755
870
|
case 'unblock': {
|
|
756
|
-
const parsed = parseArgs(
|
|
871
|
+
const parsed = parseArgs(
|
|
872
|
+
args,
|
|
873
|
+
{ json: 'boolean', 'expected-revision': 'value' },
|
|
874
|
+
TICKET_USAGE,
|
|
875
|
+
)
|
|
757
876
|
const [id, blockerList, ...extra] = parsed.positionals
|
|
758
877
|
if (
|
|
759
878
|
id === undefined ||
|
|
@@ -766,7 +885,13 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
766
885
|
}
|
|
767
886
|
const blockerIds = commaList(blockerList)
|
|
768
887
|
if (blockerIds.length === 0) throw new Error(TICKET_USAGE)
|
|
769
|
-
const blockerOpts = {
|
|
888
|
+
const blockerOpts = {
|
|
889
|
+
...opts,
|
|
890
|
+
id,
|
|
891
|
+
blockerIds,
|
|
892
|
+
expectedRevision: stringFlag(parsed, 'expected-revision'),
|
|
893
|
+
json: parsed.flags.has('json'),
|
|
894
|
+
}
|
|
770
895
|
if (command === 'block') await abTicketBlock(blockerOpts)
|
|
771
896
|
else await abTicketUnblock(blockerOpts)
|
|
772
897
|
return
|
|
@@ -910,7 +1035,11 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
910
1035
|
}
|
|
911
1036
|
|
|
912
1037
|
case 'move': {
|
|
913
|
-
const parsed = parseArgs(
|
|
1038
|
+
const parsed = parseArgs(
|
|
1039
|
+
args,
|
|
1040
|
+
{ json: 'boolean', 'expected-revision': 'value' },
|
|
1041
|
+
TICKET_USAGE,
|
|
1042
|
+
)
|
|
914
1043
|
const [id, state, ...extra] = parsed.positionals
|
|
915
1044
|
if (
|
|
916
1045
|
id === undefined ||
|
|
@@ -925,6 +1054,7 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
925
1054
|
...opts,
|
|
926
1055
|
id,
|
|
927
1056
|
state,
|
|
1057
|
+
expectedRevision: stringFlag(parsed, 'expected-revision'),
|
|
928
1058
|
json: parsed.flags.has('json'),
|
|
929
1059
|
})
|
|
930
1060
|
return
|
|
@@ -535,7 +535,8 @@ export type PolicyConfig = z.infer<typeof policySchema>
|
|
|
535
535
|
// API key comes from the LINEAR_API_KEY environment variable, never from this
|
|
536
536
|
// file.
|
|
537
537
|
|
|
538
|
-
export const
|
|
538
|
+
export const ticketsInputSchema = z.strictObject({
|
|
539
|
+
url: z.string().optional(),
|
|
539
540
|
/** Builtin or plugin-registered TicketSource name. Registry membership is
|
|
540
541
|
* validated after configured plugins load. */
|
|
541
542
|
source: z
|
|
@@ -569,7 +570,8 @@ export const ticketsSchema = z.strictObject({
|
|
|
569
570
|
.refine(
|
|
570
571
|
(s) => s.trim().length > 0,
|
|
571
572
|
'[tickets].readyState must not be blank — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
|
|
572
|
-
)
|
|
573
|
+
)
|
|
574
|
+
.optional(),
|
|
573
575
|
/**
|
|
574
576
|
* Narrows readiness to tickets assigned to one Linear user — a third
|
|
575
577
|
* conjunctive gate beside readyState and readyLabels. `"me"` is the owner of
|
|
@@ -592,7 +594,7 @@ export const ticketsSchema = z.strictObject({
|
|
|
592
594
|
.min(1)
|
|
593
595
|
.refine((value) => value.trim().length > 0, 'state must be nonblank')
|
|
594
596
|
.optional(),
|
|
595
|
-
/** Hosted project completion state; defaults to
|
|
597
|
+
/** Hosted project completion state; defaults to done. */
|
|
596
598
|
doneState: z
|
|
597
599
|
.string()
|
|
598
600
|
.min(1)
|
|
@@ -634,6 +636,36 @@ export const ticketsSchema = z.strictObject({
|
|
|
634
636
|
* still tell a defaulted dir from an explicit one. */
|
|
635
637
|
dir: z.string().min(1).optional(),
|
|
636
638
|
})
|
|
639
|
+
export const ticketsSchema = ticketsInputSchema
|
|
640
|
+
.superRefine((tickets, ctx) => {
|
|
641
|
+
if (tickets.source === 'hosted' && tickets.url !== undefined) {
|
|
642
|
+
try {
|
|
643
|
+
normalizeServiceURL(tickets.url, 'tickets.url')
|
|
644
|
+
} catch (error) {
|
|
645
|
+
ctx.addIssue({ code: 'custom', path: ['url'], message: (error as Error).message })
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (tickets.source !== 'hosted' && tickets.readyState === undefined)
|
|
649
|
+
ctx.addIssue({
|
|
650
|
+
code: 'custom',
|
|
651
|
+
path: ['readyState'],
|
|
652
|
+
message:
|
|
653
|
+
'[tickets].readyState is required — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
|
|
654
|
+
})
|
|
655
|
+
if (['file', 'linear'].includes(tickets.source) && tickets.url !== undefined)
|
|
656
|
+
ctx.addIssue({
|
|
657
|
+
code: 'custom',
|
|
658
|
+
path: ['url'],
|
|
659
|
+
message: '[tickets].url applies only to source = "hosted"',
|
|
660
|
+
})
|
|
661
|
+
})
|
|
662
|
+
.transform((tickets) => ({
|
|
663
|
+
...tickets,
|
|
664
|
+
readyState: tickets.readyState ?? 'ready',
|
|
665
|
+
...(tickets.source === 'hosted' && tickets.url !== undefined
|
|
666
|
+
? { url: normalizeServiceURL(tickets.url, 'tickets.url') }
|
|
667
|
+
: {}),
|
|
668
|
+
}))
|
|
637
669
|
export type TicketsConfig = z.infer<typeof ticketsSchema>
|
|
638
670
|
|
|
639
671
|
// ── [orchestrator] ──────────────────────────────────────────────────────────
|
|
@@ -19,12 +19,12 @@ export interface ConnectionStarted extends ConnectionOutcome {
|
|
|
19
19
|
interval: number
|
|
20
20
|
expiresAt: string
|
|
21
21
|
}
|
|
22
|
-
export function normalizeServiceURL(raw: string): string {
|
|
22
|
+
export function normalizeServiceURL(raw: string, setting = 'store.url'): string {
|
|
23
23
|
let url: URL
|
|
24
24
|
try {
|
|
25
25
|
url = new URL(raw)
|
|
26
26
|
} catch {
|
|
27
|
-
throw new Error(
|
|
27
|
+
throw new Error(`${setting} must be an absolute HTTPS service URL`)
|
|
28
28
|
}
|
|
29
29
|
if (
|
|
30
30
|
!raw.trim() ||
|
|
@@ -39,7 +39,7 @@ export function normalizeServiceURL(raw: string): string {
|
|
|
39
39
|
)
|
|
40
40
|
) {
|
|
41
41
|
throw new Error(
|
|
42
|
-
|
|
42
|
+
`${setting} must be HTTPS (HTTP is allowed on loopback), without credentials, query or fragment`,
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
45
|
return url.href.replace(/\/+$/, '')
|
|
@@ -185,10 +185,11 @@ export function validateEventWrite(input: {
|
|
|
185
185
|
actor.kind === 'dispatcher' &&
|
|
186
186
|
(escalation.source !== 'policy' ||
|
|
187
187
|
(escalation.policyCause !== 'infrastructure-failure-limit' &&
|
|
188
|
-
escalation.policyCause !== 'dispatch-failure-limit'
|
|
188
|
+
escalation.policyCause !== 'dispatch-failure-limit' &&
|
|
189
|
+
escalation.policyCause !== 'execution-no-progress'))
|
|
189
190
|
) {
|
|
190
191
|
throw new EventValidationError(
|
|
191
|
-
'invalid dispatcher escalation: only policy/infrastructure-failure-limit or policy/dispatch-failure-limit may be raised outside the kernel',
|
|
192
|
+
'invalid dispatcher escalation: only policy/infrastructure-failure-limit or policy/dispatch-failure-limit or policy/execution-no-progress may be raised outside the kernel',
|
|
192
193
|
)
|
|
193
194
|
}
|
|
194
195
|
if (escalation.source === 'policy' && escalation.policyCause === undefined) {
|
|
@@ -196,6 +196,12 @@ const finalizeStepCompletedPayloadSchema = z.discriminatedUnion('ok', [
|
|
|
196
196
|
/** One ticket asset revision a build froze (SPEC §6.3): the identity and
|
|
197
197
|
* shape of what `listTicketAssets` returned at the pinning moment. */
|
|
198
198
|
export const pinnedAssetSchema = z.strictObject({
|
|
199
|
+
snapshot: z
|
|
200
|
+
.strictObject({
|
|
201
|
+
kind: z.string().regex(/^ticket-snapshot-[a-f0-9-]+$/),
|
|
202
|
+
revision: z.number().int().nonnegative(),
|
|
203
|
+
})
|
|
204
|
+
.optional(),
|
|
199
205
|
kind: z.string().min(1),
|
|
200
206
|
name: z.string().min(1),
|
|
201
207
|
revision: z.number().int().nonnegative(),
|
|
@@ -57,7 +57,13 @@ import type {
|
|
|
57
57
|
import { GitWorktreeProvider, spawnExec } from '../ports/workspace/git-worktree'
|
|
58
58
|
import { InProcessBuildExecution } from '../ports/workspace/in-process-build-execution'
|
|
59
59
|
import { BuildRunner, LeaseHeldError, SetupFailureError } from '../processes/build-runner'
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
diagnosticArtifact,
|
|
62
|
+
BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
63
|
+
effectiveBuildConfigContent,
|
|
64
|
+
parseEffectiveBuildConfig,
|
|
65
|
+
} from '../processes/build-execution-state'
|
|
66
|
+
import { composeBuildConfig } from '../config/live'
|
|
61
67
|
import { Dispatcher, type LaunchRunnerResult } from '../processes/dispatcher'
|
|
62
68
|
import {
|
|
63
69
|
getHarvestStatus,
|
|
@@ -120,6 +126,7 @@ export const CONFORMING_BODY = [
|
|
|
120
126
|
export function readyTicket(id: string, over: Partial<Omit<Ticket, 'ref'>> = {}): Ticket {
|
|
121
127
|
const title = over.title ?? 'Add rate limiting'
|
|
122
128
|
return {
|
|
129
|
+
revision: 'fixture',
|
|
123
130
|
ref: { source: 'fake', id, title },
|
|
124
131
|
title,
|
|
125
132
|
body: over.body ?? CONFORMING_BODY,
|
|
@@ -398,6 +405,10 @@ export async function makeHarness(opts: {
|
|
|
398
405
|
* CONFIG_TOML, so every existing scenario is untouched; a scenario proving
|
|
399
406
|
* two-axis routing (§9) supplies its own `[roles.default]`/phase roles. */
|
|
400
407
|
configToml?: string
|
|
408
|
+
/** Force a routing disagreement while retaining the runner's branch config. */
|
|
409
|
+
dispatcherConfig?: (config: Config) => Config
|
|
410
|
+
pinnedConfig?: boolean
|
|
411
|
+
getConfig?: () => Config
|
|
401
412
|
/** Forge gate existence is independent from current PR mergeability. */
|
|
402
413
|
gatePresence?: 'present' | 'absent'
|
|
403
414
|
/** Optional adapter-level runtime composition for focused integration tests.
|
|
@@ -602,6 +613,20 @@ export async function makeHarness(opts: {
|
|
|
602
613
|
runner: new BuildRunner({
|
|
603
614
|
store: activeStore.scopeBuild(slug),
|
|
604
615
|
config,
|
|
616
|
+
...(opts.pinnedConfig
|
|
617
|
+
? {
|
|
618
|
+
getConfig: async () => {
|
|
619
|
+
const artifact = await activeStore.getArtifact(
|
|
620
|
+
slug,
|
|
621
|
+
BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
622
|
+
)
|
|
623
|
+
return composeBuildConfig(
|
|
624
|
+
artifact === null ? config : parseEffectiveBuildConfig(artifact),
|
|
625
|
+
opts.getConfig?.() ?? config,
|
|
626
|
+
)
|
|
627
|
+
},
|
|
628
|
+
}
|
|
629
|
+
: {}),
|
|
605
630
|
// Two-axis registry (§9): every runtime is backed by the SAME scripted
|
|
606
631
|
// runner instance, so the `s_1…s_N` session numbering scenarios rely on
|
|
607
632
|
// is preserved regardless of which runtime a role selects. `scripted`
|
|
@@ -629,6 +654,15 @@ export async function makeHarness(opts: {
|
|
|
629
654
|
}
|
|
630
655
|
}
|
|
631
656
|
const launchRunner = async (slug: string): Promise<LaunchRunnerResult> => {
|
|
657
|
+
if (
|
|
658
|
+
opts.pinnedConfig &&
|
|
659
|
+
(await activeStore.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)) === null
|
|
660
|
+
) {
|
|
661
|
+
await activeStore.putArtifact(slug, {
|
|
662
|
+
kind: BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
663
|
+
content: effectiveBuildConfigContent(config),
|
|
664
|
+
})
|
|
665
|
+
}
|
|
632
666
|
launched.push({ slug, ...(await makeRunner(slug)) })
|
|
633
667
|
return 'scheduled'
|
|
634
668
|
}
|
|
@@ -638,10 +672,11 @@ export async function makeHarness(opts: {
|
|
|
638
672
|
tickets: ticketSource,
|
|
639
673
|
workspaces,
|
|
640
674
|
forge: selectedForge,
|
|
641
|
-
config,
|
|
675
|
+
config: opts.dispatcherConfig?.(structuredClone(config)) ?? config,
|
|
642
676
|
repo: origin,
|
|
643
677
|
exec: spawnExec,
|
|
644
678
|
launchRunner,
|
|
679
|
+
...(opts.getConfig ? { getConfig: opts.getConfig } : {}),
|
|
645
680
|
ids,
|
|
646
681
|
clock,
|
|
647
682
|
})
|
|
@@ -621,6 +621,21 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
621
621
|
"$.yieldedHolder: undefined",
|
|
622
622
|
"$: object",
|
|
623
623
|
],
|
|
624
|
+
"executionNoProgress": [
|
|
625
|
+
"$.active.eligible: boolean",
|
|
626
|
+
"$.active.instance: string",
|
|
627
|
+
"$.active.seq: number",
|
|
628
|
+
"$.active: null",
|
|
629
|
+
"$.active: object",
|
|
630
|
+
"$.count: number",
|
|
631
|
+
"$.latest.instance: string",
|
|
632
|
+
"$.latest.seq: number",
|
|
633
|
+
"$.latest: null",
|
|
634
|
+
"$.latest: object",
|
|
635
|
+
"$.open: array",
|
|
636
|
+
"$.open[]: string",
|
|
637
|
+
"$: object",
|
|
638
|
+
],
|
|
624
639
|
"frozenPrImageHost": [
|
|
625
640
|
"$.created.seq: number",
|
|
626
641
|
"$.created: object",
|
|
@@ -151,7 +151,7 @@ function randomWrite(rand: () => number): EventWrite {
|
|
|
151
151
|
id,
|
|
152
152
|
phase: 'plan',
|
|
153
153
|
source: 'policy',
|
|
154
|
-
policyCause: 'infrastructure-failure-limit',
|
|
154
|
+
policyCause: round % 2 === 0 ? 'execution-no-progress' : 'infrastructure-failure-limit',
|
|
155
155
|
question: 'q',
|
|
156
156
|
}),
|
|
157
157
|
() =>
|
|
@@ -99,7 +99,12 @@ export function randomBuildItem(rand: Rand): readonly [string, Record<string, un
|
|
|
99
99
|
],
|
|
100
100
|
() => [
|
|
101
101
|
'execution.ended',
|
|
102
|
-
{
|
|
102
|
+
{
|
|
103
|
+
instance,
|
|
104
|
+
workspaceRef: ref,
|
|
105
|
+
outcome: pick(rand, ['completed', 'stopped', 'lost']),
|
|
106
|
+
...(rand() < 0.5 ? { exitCode: pick(rand, [0, 1, null]) } : {}),
|
|
107
|
+
},
|
|
103
108
|
],
|
|
104
109
|
() => [
|
|
105
110
|
'workspace.provisioned',
|
|
@@ -132,7 +137,13 @@ export function randomBuildItem(rand: Rand): readonly [string, Record<string, un
|
|
|
132
137
|
phase: pick(rand, ['setup', 'plan', 'implement', 'verify:unit']),
|
|
133
138
|
source: pick(rand, ['policy', 'agent']),
|
|
134
139
|
...(rand() < 0.7
|
|
135
|
-
? {
|
|
140
|
+
? {
|
|
141
|
+
policyCause: pick(rand, [
|
|
142
|
+
'infrastructure-failure-limit',
|
|
143
|
+
'setup-failure-limit',
|
|
144
|
+
'execution-no-progress',
|
|
145
|
+
]),
|
|
146
|
+
}
|
|
136
147
|
: {}),
|
|
137
148
|
...(rand() < 0.5 ? { round: 1 } : {}),
|
|
138
149
|
question: 'q',
|