@defrex/autobuild 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,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 { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
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 linked-worktree identity, config, selected local state, and adapter
174
- * exactly once for one ticket command or another sessionless caller. */
175
- export async function openTicketSource(
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<ResolvedTicketCommand> {
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(source, opts.id, blockerIds, 'unblock')
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 withTicketStore(opts, ({ store, repo }) =>
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 withTicketStore(opts, async ({ store, repo }) => {
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(store, repo, opts.id) }
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(opts.id, ticketAssetNote(stored.replacing ? 'replaced' : 'attached', meta))
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 asset = await withTicketStore(opts, ({ store, repo }) =>
491
- store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev),
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 withTicketStore(opts, async ({ store, repo }) => ({
515
- removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
516
- active: await activeBuildsFor(store, repo, opts.id),
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(opts.id, ticketAssetNote('removed', removed))
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>]` — idempotent. */
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 = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
655
- const UNBLOCK_USAGE = 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
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 = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
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
- { title: 'value', body: 'value', labels: 'value', json: 'boolean' },
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(args, { json: 'boolean' }, TICKET_USAGE)
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 = { ...opts, id, blockerIds, json: parsed.flags.has('json') }
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(args, { json: 'boolean' }, TICKET_USAGE)
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 ticketsSchema = z.strictObject({
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 Done. */
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('store.url must be an absolute HTTPS service URL')
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
- 'store.url must be HTTPS (HTTP is allowed on loopback), without credentials, query or fragment',
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 { diagnosticArtifact } from '../processes/build-execution-state'
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
- { instance, workspaceRef: ref, outcome: pick(rand, ['completed', 'stopped', 'lost']) },
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
- ? { policyCause: pick(rand, ['infrastructure-failure-limit', 'setup-failure-limit']) }
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',