@defrex/autobuild 0.9.0 → 0.10.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 (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
@@ -140,6 +140,7 @@ import {
140
140
  type LaunchRunnerResult,
141
141
  type TickReport,
142
142
  } from '../processes/dispatcher'
143
+ import { RepoViewStore } from '../processes/repo-view'
143
144
  import {
144
145
  BuildControlError,
145
146
  buildControlUser,
@@ -597,6 +598,8 @@ interface ActiveBuildExecution {
597
598
  * running, so the completion chain must append no `execution.ended`, release
598
599
  * no lease, settle no publication, and record no wait failure. */
599
600
  detaching?: boolean
601
+ /** A wait failure being recorded; teardown lets it finish rather than drop it. */
602
+ recording?: Promise<void>
600
603
  }
601
604
 
602
605
  /** The dispatch loop owns deterministic decisions and supervises one
@@ -604,6 +607,10 @@ interface ActiveBuildExecution {
604
607
  * Store; child completion is liveness evidence used for reaping/single-flight. */
605
608
  class DispatchLoop {
606
609
  private readonly dispatcher: Dispatcher
610
+ /** The incremental repository view (AUT-647) the dispatcher and this
611
+ * frontend's tick-path journal reads and writes share. Resident for a
612
+ * watch loop; `--once` builds it cold for its single tick. */
613
+ private readonly repoView: RepoViewStore
607
614
  private readonly host = hostname()
608
615
  private readonly maxHarvestRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
609
616
  /** In-flight build and harvest runs (fire-and-forget) — awaited before a
@@ -749,6 +756,8 @@ class DispatchLoop {
749
756
  private readonly liveConfig: LiveConfig,
750
757
  private readonly wiring: DispatchWiring,
751
758
  private readonly opts: DispatchOpts,
759
+ /** The view `abDispatch` already used for its lifecycle appends. */
760
+ view?: RepoViewStore,
752
761
  ) {
753
762
  const config = liveConfig.current().config
754
763
  // Minted exactly once per invocation: `wiring.ids` is `randomIds()` and a
@@ -785,8 +794,16 @@ class DispatchLoop {
785
794
  return result.text
786
795
  }
787
796
 
797
+ // `abDispatch` hands over a view it already used for the lifecycle appends,
798
+ // so those rows are folded, not re-read, and one flush covers them all.
799
+ this.repoView =
800
+ view ??
801
+ new RepoViewStore(wiring.store, {
802
+ repo: this.repoIdentity,
803
+ resident: opts.once !== true,
804
+ })
788
805
  this.dispatcher = new Dispatcher({
789
- store: wiring.store,
806
+ store: this.repoView,
790
807
  tickets: wiring.tickets,
791
808
  workspaces: wiring.workspaces,
792
809
  ...(wiring.retiredWorkspaces === undefined
@@ -839,7 +856,7 @@ class DispatchLoop {
839
856
  // the record's `branch` (when present) is the same fact. Using the event
840
857
  // keeps this read to the events the caller already needs and avoids an
841
858
  // extra build-record lookup on the hot launch path.
842
- const events = await this.wiring.store.getEvents(slug)
859
+ const events = await this.repoView.getEvents(slug)
843
860
  const workspace = selectOpenWorkspace(events)
844
861
  const source = await resolvePipelineSource({
845
862
  slug,
@@ -1019,7 +1036,7 @@ class DispatchLoop {
1019
1036
  }
1020
1037
 
1021
1038
  private async appendStatus(event: RepositoryEventWrite): Promise<void> {
1022
- await this.wiring.store.appendRepo(this.repoIdentity, event)
1039
+ await this.repoView.appendRepo(this.repoIdentity, event)
1023
1040
  }
1024
1041
 
1025
1042
  private async refreshConfig(): Promise<void> {
@@ -1084,9 +1101,10 @@ class DispatchLoop {
1084
1101
  }
1085
1102
 
1086
1103
  private async readDispatchSettings(): Promise<ReturnType<typeof reduceDispatchSettings>> {
1087
- // Bounded read (AUT-489): the settings reducer consumes durable types only.
1088
- const events = await this.wiring.store.getRepoStateEvents(this.repoIdentity)
1089
- return reduceDispatchSettings(events)
1104
+ // The view's bounded subset (AUT-489): the settings reducer consumes
1105
+ // durable types only. `refresh()` ran first in the tick, so this is the
1106
+ // delta-current journal, never a store read.
1107
+ return reduceDispatchSettings(this.repoView.recordedJournal())
1090
1108
  }
1091
1109
 
1092
1110
  private dispatcherTick(resumeCurrent: boolean): Promise<Awaited<ReturnType<Dispatcher['tick']>>> {
@@ -1127,6 +1145,7 @@ class DispatchLoop {
1127
1145
 
1128
1146
  // Sample inside the serialized tick, not at process startup. Every
1129
1147
  // dispatcher therefore gates claims from the latest repository facts.
1148
+ await this.repoView.refresh()
1130
1149
  const settings = await this.readDispatchSettings()
1131
1150
  const readyObservation =
1132
1151
  settings.intake || (!this.dashboard && this.opts.kernelRunId === undefined)
@@ -1134,6 +1153,7 @@ class DispatchLoop {
1134
1153
  : await this.dispatcher.observeReady()
1135
1154
  const report = await this.dispatcher.tick({
1136
1155
  resumeCurrent,
1156
+ viewRefreshed: true,
1137
1157
  acceptNewWork: settings.intake,
1138
1158
  defaultAutoMerge: settings.defaultAutoMerge,
1139
1159
  autoMergeUser: buildControlUser(this.opts.env),
@@ -2286,10 +2306,11 @@ class DispatchLoop {
2286
2306
  // the pure Store-read pressure check; the guest rescans authoritatively
2287
2307
  // once provisioned, so a threshold crossing between gate and guest scan
2288
2308
  // is never suppressed by this check.
2289
- const record = await store.getRepo(repo)
2290
- // Bounded read (AUT-489): the harvest control decision reduces durable
2291
- // types only.
2292
- const events = record === null ? [] : await store.getRepoStateEvents(repo)
2309
+ // The view's bounded subset (AUT-489): the harvest control decision
2310
+ // reduces durable types only. Take the journal delta first — guests and
2311
+ // other dispatchers write harvest facts between ticks.
2312
+ await this.repoView.refreshJournal()
2313
+ const events = this.repoView.recordedJournal()
2293
2314
  const state = reduceHarvest(events)
2294
2315
  const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
2295
2316
  const resumePending =
@@ -2331,7 +2352,7 @@ class DispatchLoop {
2331
2352
  )
2332
2353
  }
2333
2354
  this.hostedHarvest = { execution, handle, detached }
2334
- const started = await store.appendRepo(repo, {
2355
+ const started = await this.repoView.appendRepo(repo, {
2335
2356
  actor: DISPATCHER,
2336
2357
  type: 'harvest.execution.started',
2337
2358
  payload: {
@@ -2406,8 +2427,10 @@ class DispatchLoop {
2406
2427
  startedSeq: number,
2407
2428
  ): Promise<HarvestRunnerResult> {
2408
2429
  const { store } = this.wiring
2409
- // Bounded read (AUT-489): classifyHarvestOutcome reduces harvest facts only.
2410
- const events = await store.getRepoStateEvents(repo)
2430
+ // Bounded subset (AUT-489): classifyHarvestOutcome reduces harvest facts
2431
+ // only. A guest wrote the facts through its own handle, so read the delta.
2432
+ await this.repoView.refreshJournal()
2433
+ const events = this.repoView.recordedJournal()
2411
2434
  const record = await store.getRepo(repo)
2412
2435
  return classifyHarvestOutcome(events, {
2413
2436
  executionStartedSeq: startedSeq,
@@ -2433,7 +2456,7 @@ class DispatchLoop {
2433
2456
  branch: '',
2434
2457
  }
2435
2458
  const reap = await this.reapHostedWorkspace(workspaceHandle)
2436
- await this.wiring.store.appendRepo(repo, {
2459
+ await this.repoView.appendRepo(repo, {
2437
2460
  actor: DISPATCHER,
2438
2461
  type: 'harvest.execution.released',
2439
2462
  payload: {
@@ -2590,9 +2613,11 @@ class DispatchLoop {
2590
2613
  }
2591
2614
 
2592
2615
  private async settlePendingPublication(slug: string): Promise<void> {
2616
+ // The guest appended through its own handle; settle against fresh state.
2617
+ await this.repoView.refreshBuild(slug)
2593
2618
  await settleWorkspacePublication(
2594
2619
  {
2595
- store: this.wiring.store,
2620
+ store: this.repoView,
2596
2621
  storeRef: this.wiring.storeRef,
2597
2622
  publication: this.wiring.workspaces.publication,
2598
2623
  forge: this.wiring.forge,
@@ -2615,11 +2640,13 @@ class DispatchLoop {
2615
2640
  cleanupPending: boolean
2616
2641
  identity?: BuildExecutionHandle['identity']
2617
2642
  }): Promise<void> {
2643
+ // The failed executor may have appended through its own handle.
2644
+ await this.repoView.refreshBuild(input.slug)
2618
2645
  await appendInfrastructureFailure(
2619
- { store: this.wiring.store, ids: this.wiring.ids },
2646
+ { store: this.repoView, ids: this.wiring.ids },
2620
2647
  {
2621
2648
  slug: input.slug,
2622
- events: await this.wiring.store.getEvents(input.slug),
2649
+ events: await this.repoView.getEvents(input.slug),
2623
2650
  maxAttempts: this.currentConfig().config.policy.maxInfrastructureAttempts,
2624
2651
  provider: input.identity?.provider ?? this.wiring.workspaces.name,
2625
2652
  workspaceRef: input.workspaceRef,
@@ -2650,11 +2677,7 @@ class DispatchLoop {
2650
2677
 
2651
2678
  try {
2652
2679
  await this.publishBuildConfig(slug)
2653
- leaseClaimed = await this.wiring.store.claimLease(
2654
- slug,
2655
- instance,
2656
- BUILD_EXECUTION_LEASE_TTL_MS,
2657
- )
2680
+ leaseClaimed = await this.repoView.claimLease(slug, instance, BUILD_EXECUTION_LEASE_TTL_MS)
2658
2681
  if (!leaseClaimed) {
2659
2682
  this.activeBuildRuns.delete(slug)
2660
2683
  await this.appendStatus({
@@ -2665,7 +2688,7 @@ class DispatchLoop {
2665
2688
  this.failureNotice(`build ${slug} already held by another runner — skipped`)
2666
2689
  return 'already-active'
2667
2690
  }
2668
- const launchEvents = await this.wiring.store.getEvents(slug)
2691
+ const launchEvents = await this.repoView.getEvents(slug)
2669
2692
  let workspaceRef: string | undefined
2670
2693
  let workspaceProvider: string | undefined
2671
2694
  for (const event of launchEvents) {
@@ -2709,7 +2732,7 @@ class DispatchLoop {
2709
2732
  provider: owning.name,
2710
2733
  workspaceRef,
2711
2734
  }
2712
- await this.wiring.store.append(slug, {
2735
+ await this.repoView.append(slug, {
2713
2736
  actor: DISPATCHER,
2714
2737
  type: 'execution.started',
2715
2738
  payload: { ...identity, instance },
@@ -2724,7 +2747,7 @@ class DispatchLoop {
2724
2747
  // supervisor settles the execution from the Store.
2725
2748
  if (active.detaching) return
2726
2749
  try {
2727
- await this.wiring.store.append(slug, {
2750
+ await this.repoView.append(slug, {
2728
2751
  actor: DISPATCHER,
2729
2752
  type: 'execution.ended',
2730
2753
  payload: {
@@ -2746,7 +2769,8 @@ class DispatchLoop {
2746
2769
  return
2747
2770
  }
2748
2771
 
2749
- const state = reduceBuild(await this.wiring.store.getEvents(slug))
2772
+ await this.repoView.refreshBuild(slug)
2773
+ const state = reduceBuild(await this.repoView.getEvents(slug))
2750
2774
  if (diagnostic === null && exit.exitCode === 0) {
2751
2775
  await this.appendStatus({
2752
2776
  actor: DISPATCHER,
@@ -2772,7 +2796,7 @@ class DispatchLoop {
2772
2796
  })
2773
2797
  this.warn(`build ${slug} runner failed: ${detail}`)
2774
2798
  } finally {
2775
- await this.wiring.store.releaseLease(slug, instance)
2799
+ await this.repoView.releaseLease(slug, instance)
2776
2800
  await this.settlePendingPublication(slug)
2777
2801
  }
2778
2802
  },
@@ -2783,7 +2807,7 @@ class DispatchLoop {
2783
2807
  // A rejected executor completion cannot prove the remote VM was
2784
2808
  // stopped. Keep the lease until expiry: recovery fences and reaps
2785
2809
  // the exact recorded identity before authorizing a replacement.
2786
- await this.recordInfrastructureFailure({
2810
+ active.recording = this.recordInfrastructureFailure({
2787
2811
  slug,
2788
2812
  instance,
2789
2813
  workspaceRef,
@@ -2792,6 +2816,7 @@ class DispatchLoop {
2792
2816
  cleanupPending: true,
2793
2817
  identity,
2794
2818
  })
2819
+ await active.recording
2795
2820
  },
2796
2821
  )
2797
2822
  .finally(() => {
@@ -2810,7 +2835,9 @@ class DispatchLoop {
2810
2835
  this.activeBuildRuns.delete(slug)
2811
2836
  }
2812
2837
  if (leaseClaimed) {
2813
- const events = await this.wiring.store.getEvents(slug)
2838
+ // The runner appended through its own handle: take the delta.
2839
+ await this.repoView.refreshBuild(slug)
2840
+ const events = await this.repoView.getEvents(slug)
2814
2841
  let workspaceRef = ''
2815
2842
  for (const event of events) {
2816
2843
  if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
@@ -2829,7 +2856,7 @@ class DispatchLoop {
2829
2856
  // Local start failures have no possible remote holder. Remote failures
2830
2857
  // retain the lease until its TTL fences an ambiguous start.
2831
2858
  if (this.wiring.workspaces.recovery === undefined) {
2832
- await this.wiring.store.releaseLease(slug, instance)
2859
+ await this.repoView.releaseLease(slug, instance)
2833
2860
  }
2834
2861
  }
2835
2862
  await this.appendStatus({
@@ -2925,6 +2952,9 @@ class DispatchLoop {
2925
2952
  } catch {
2926
2953
  // Detach is best-effort: an unresolvable wait still exits.
2927
2954
  }
2955
+ // A wait failure already being recorded is durable work, not
2956
+ // supervision: let it land before the process exits.
2957
+ await entry.recording?.catch(() => {})
2928
2958
  this.activeBuildRuns.delete(slug)
2929
2959
  if (entry.settled !== undefined) {
2930
2960
  this.inFlight.delete(entry.settled)
@@ -3409,7 +3439,7 @@ class DispatchLoop {
3409
3439
  const holder = record?.lease?.holder
3410
3440
  if (holder === undefined || this.yieldedTo === holder) return
3411
3441
  this.yieldedTo = holder
3412
- await this.wiring.store.appendRepo(this.repoIdentity, {
3442
+ await this.repoView.appendRepo(this.repoIdentity, {
3413
3443
  actor: DISPATCHER,
3414
3444
  type: 'dispatcher.tick-yielded',
3415
3445
  payload: {
@@ -3890,23 +3920,32 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3890
3920
  // another dispatcher cannot clobber the latest operator choice with a value
3891
3921
  // it inferred at startup. Fresh-repository fallbacks live in the reducer.
3892
3922
  await wiring.store.ensureRepo(state.repo)
3923
+ // Every own lifecycle append goes through one view, restored from snapshots
3924
+ // before the first of them, so a quiet invocation reads none of its own
3925
+ // rows back and the final flush covers `run-stopped` too.
3926
+ const view = new RepoViewStore(wiring.store, {
3927
+ // The loop's store-keyed identity (an explicit `repo` pins it).
3928
+ repo: resolvedOpts.repo ?? resolvedOpts.targetRepo,
3929
+ resident: resolvedOpts.once !== true,
3930
+ })
3931
+ await view.startJournal()
3893
3932
  const actor = humanActor(buildControlUser(resolvedOpts.env))
3894
3933
  if (resolvedOpts.intake !== undefined) {
3895
- await wiring.store.appendRepo(state.repo, {
3934
+ await view.appendRepo(state.repo, {
3896
3935
  actor,
3897
3936
  type: 'dispatcher.intake-set',
3898
3937
  payload: { enabled: resolvedOpts.intake, run: runId },
3899
3938
  })
3900
3939
  }
3901
3940
  if (resolvedOpts.defaultAutoMerge !== undefined) {
3902
- await wiring.store.appendRepo(state.repo, {
3941
+ await view.appendRepo(state.repo, {
3903
3942
  actor,
3904
3943
  type: 'dispatcher.auto-merge-default-set',
3905
3944
  payload: { enabled: resolvedOpts.defaultAutoMerge, run: runId },
3906
3945
  })
3907
3946
  }
3908
3947
 
3909
- await wiring.store.appendRepoWithArtifacts(
3948
+ await view.appendRepoWithArtifacts(
3910
3949
  state.repo,
3911
3950
  [
3912
3951
  {
@@ -3931,10 +3970,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3931
3970
  },
3932
3971
  )
3933
3972
 
3934
- const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts)
3973
+ const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
3935
3974
  try {
3936
3975
  await loop.run()
3937
- await wiring.store.appendRepo(state.repo, {
3976
+ await view.appendRepo(state.repo, {
3938
3977
  actor: DISPATCHER,
3939
3978
  type: 'dispatcher.run-stopped',
3940
3979
  payload: {
@@ -3946,7 +3985,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3946
3985
  })
3947
3986
  } catch (error) {
3948
3987
  try {
3949
- await wiring.store.appendRepo(state.repo, {
3988
+ await view.appendRepo(state.repo, {
3950
3989
  actor: DISPATCHER,
3951
3990
  type: 'dispatcher.run-stopped',
3952
3991
  payload: {
@@ -3961,6 +4000,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3961
4000
  }
3962
4001
  throw error
3963
4002
  } finally {
4003
+ // Every append this invocation made, `run-stopped` included, is in the
4004
+ // view; leave the reduced state behind so the next cold process reads
4005
+ // only what is newer than it.
4006
+ await view.persistSnapshots({ force: true })
3964
4007
  if (resolvedOpts.wire === undefined) await wiring.store.close()
3965
4008
  }
3966
4009
  }
@@ -165,6 +165,8 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
165
165
  ' ab ticket list [--state <state>] [--labels a,b] [--json]',
166
166
  ' ab ticket show <id> [--store <ref>] [--json]',
167
167
  ' ab ticket move <id> <state> [--json]',
168
+ ' ab ticket states [--json]',
169
+ ' ab ticket state create <name> [--about <text>] [--json]',
168
170
  ' ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json]',
169
171
  ' ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json]',
170
172
  ' ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json]',
@@ -194,9 +196,21 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
194
196
  'its source naming the asset and how to download it. Assets over the store',
195
197
  'limit (25 MiB by default, lower on a hosted deployment) are refused.',
196
198
  '',
199
+ 'states lists every state of the source: lifecycle states first (triage, ready,',
200
+ 'doing, done), then custom ones alphabetically, each with the roles it plays',
201
+ 'under the current config (create, ready, claimed, triage, proposal, done, or',
202
+ 'none), its ticket count, and its purpose when set. --json emits one bare',
203
+ 'array of {name, roles, tickets, about?}. state create makes a state, e.g.',
204
+ 'icebox, and is idempotent: an existing state is reported, and --about (one',
205
+ 'line) records or replaces its purpose. A name is one to forty lowercase',
206
+ 'letters, digits, and hyphens, starting with a letter, and may not match an',
207
+ 'existing state case-insensitively. There is no rename or delete. Only the file',
208
+ 'source supports both; others exit nonzero naming the source. A state with no',
209
+ 'purpose and no tickets is an empty directory that git does not track.',
210
+ '',
197
211
  'Ticket ids are source-local (for example AUT-8 or file-1). For block and',
198
212
  'unblock, the first id is always the ticket being changed. State names and',
199
- 'unknown-id errors come from the configured source.',
213
+ 'unknown-id errors come from the configured source; ab ticket states lists them.',
200
214
  ]),
201
215
  },
202
216
  {
@@ -22,6 +22,7 @@ import type { VercelSandboxFacade } from '../ports/workspace/vercel-sandbox'
22
22
  import { diagnosePlugins, loadPlugins } from '../plugins/load'
23
23
  import { materializePluginRuntimes } from '../plugins/runtimes'
24
24
  import { createTicketSource } from '../ports/tickets/create'
25
+ import { LinearTicketSource } from '../ports/tickets/linear'
25
26
  import { inspectLocalStoreSnapshot } from '../store/local/store'
26
27
  import type { StoreOpener } from './store-opening'
27
28
  import { openProductionStore } from './store-opening'
@@ -402,6 +403,7 @@ export async function validateInitReadiness(opts: {
402
403
  providerConfig !== undefined ? (builtinCaps?.guestEnvNames?.(providerConfig) ?? []) : [],
403
404
  )
404
405
  let caps: WorkspaceProviderCapabilities | undefined
406
+ let assigneeLine: string | undefined
405
407
  try {
406
408
  hostPreflight(config, opts.env)
407
409
  // Ticket acquisition is a host responsibility for both workspace providers.
@@ -460,7 +462,22 @@ export async function validateInitReadiness(opts: {
460
462
  await ticketSource.listReady({
461
463
  ...(config.tickets.readyLabels !== undefined ? { labels: config.tickets.readyLabels } : {}),
462
464
  state: config.tickets.readyState,
465
+ ...(config.tickets.readyAssignee !== undefined
466
+ ? { assignee: config.tickets.readyAssignee }
467
+ : {}),
463
468
  })
469
+ const assignee = config.tickets.readyAssignee
470
+ if (assignee !== undefined && ticketSource instanceof LinearTicketSource) {
471
+ const user = await ticketSource.resolveAssignee(assignee)
472
+ if (user === null) {
473
+ throw new Error(
474
+ assignee === 'me'
475
+ ? '[tickets].readyAssignee = "me" but LINEAR_API_KEY did not resolve to a Linear user — check the key'
476
+ : `[tickets].readyAssignee "${assignee}" matches no user in the Linear workspace — check the address or use "me"`,
477
+ )
478
+ }
479
+ assigneeLine = `Ticket assignee gate: ${assignee} → ${user.name} <${user.email}>`
480
+ }
464
481
  }
465
482
  } catch (error) {
466
483
  throw new Error(redact(error))
@@ -586,6 +603,7 @@ export async function validateInitReadiness(opts: {
586
603
  stdout(`Readiness: ${report.context} (${report.provider})`)
587
604
  stdout(`Store: ${state.storeRef}`)
588
605
  stdout(`Forge: ${config.forge}`)
606
+ if (assigneeLine !== undefined) stdout(assigneeLine)
589
607
  // Same raw `[workspace.config]` fallback guestEnvNames gets (f_55ba7591): a
590
608
  // provider that declares describeEnvironment without a configSchema still
591
609
  // receives a defined argument — the raw table — never undefined.
@@ -21,13 +21,25 @@ export type InstallScope = 'local' | 'global'
21
21
  * forge dependency, or a version range satisfied from the npm registry. */
22
22
  export type InstallChannel = 'github' | 'npm'
23
23
 
24
+ /** The owning manifest sections a self-update target may be declared in. */
25
+ export type DependencySection = 'dependencies' | 'devDependencies'
26
+
27
+ /** Searched in this order; when a manifest declares the package in both, the
28
+ * first wins (the runtime declaration, which a plain `bun add` would rewrite). */
29
+ const DEPENDENCY_SECTIONS: readonly DependencySection[] = ['dependencies', 'devDependencies']
30
+ const SEARCHED_SECTIONS = DEPENDENCY_SECTIONS.join(' or ')
31
+
24
32
  interface ManagedInstallationBase extends DistributionIdentity {
25
33
  sourceCheckout: false
26
34
  ownerRoot: string
27
35
  ownerManifest: string
28
36
  ownerLock: string
29
- /** The owning manifest's direct dependency value, byte for byte. */
37
+ /** The owning manifest's direct dependency value in `section`, byte for byte. */
30
38
  dependency: string
39
+ /** The manifest section the dependency was chosen from. */
40
+ section: DependencySection
41
+ /** The manifest also declares the package in the other section. */
42
+ dual: boolean
31
43
  scope: InstallScope
32
44
  }
33
45
 
@@ -180,9 +192,27 @@ async function parseJsonc(path: string): Promise<Record<string, unknown>> {
180
192
  }
181
193
  }
182
194
 
183
- function lockDependency(lock: Record<string, unknown>, packageName: string): unknown {
195
+ function lockDependency(
196
+ lock: Record<string, unknown>,
197
+ packageName: string,
198
+ section: DependencySection,
199
+ ): unknown {
184
200
  const workspace = object(object(lock.workspaces)?.[''])
185
- return object(workspace?.dependencies)?.[packageName]
201
+ return object(workspace?.[section])?.[packageName]
202
+ }
203
+
204
+ /** Pick the section that declares the package: a pure function of the
205
+ * manifest, in fixed `DEPENDENCY_SECTIONS` order. */
206
+ function declaredSection(
207
+ manifest: Record<string, unknown>,
208
+ packageName: string,
209
+ ): { section: DependencySection; value: string; dual: boolean } | undefined {
210
+ const declared = DEPENDENCY_SECTIONS.flatMap((section) => {
211
+ const value = object(manifest[section])?.[packageName]
212
+ return typeof value === 'string' ? [{ section, value }] : []
213
+ })
214
+ const first = declared[0]
215
+ return first === undefined ? undefined : { ...first, dual: declared.length > 1 }
186
216
  }
187
217
 
188
218
  function lockPackageRecord(
@@ -235,21 +265,29 @@ export async function inspectInstallation(options: {
235
265
  try {
236
266
  const manifest = await parseJson(ownerManifest)
237
267
  const lock = await parseJsonc(ownerLock)
238
- const dependency = object(manifest.dependencies)?.[identity.packageName]
268
+ const declared = declaredSection(manifest, identity.packageName)
269
+ if (declared === undefined) {
270
+ return {
271
+ kind: 'unknown',
272
+ identity,
273
+ reason: `${ownerManifest} does not declare ${identity.packageName} under ${SEARCHED_SECTIONS} as a direct github: dependency or an npm registry version range satisfied by ${identity.version}`,
274
+ }
275
+ }
276
+ const { section, value: dependency, dual } = declared
239
277
  const repository = githubDependency(dependency)
240
278
  const registry = repository === undefined && registryDependency(dependency, identity.version)
241
- if ((repository === undefined && !registry) || typeof dependency !== 'string') {
279
+ if (repository === undefined && !registry) {
242
280
  return {
243
281
  kind: 'unknown',
244
282
  identity,
245
- reason: `${ownerManifest} does not declare ${identity.packageName} as a direct github: dependency or an npm registry version range satisfied by ${identity.version}`,
283
+ reason: `${ownerManifest} declares ${identity.packageName} under ${section} as "${dependency}", which is neither a direct github: dependency nor an npm registry version range satisfied by ${identity.version}`,
246
284
  }
247
285
  }
248
- if (lockDependency(lock, identity.packageName) !== dependency) {
286
+ if (lockDependency(lock, identity.packageName, section) !== dependency) {
249
287
  return {
250
288
  kind: 'unknown',
251
289
  identity,
252
- reason: `${ownerLock} does not agree with the direct dependency in ${ownerManifest}`,
290
+ reason: `${ownerLock} does not agree with the direct ${section} entry in ${ownerManifest}`,
253
291
  }
254
292
  }
255
293
  const scope: InstallScope = (await samePath(join(options.globalBin, 'ab'), identity.binaryPath))
@@ -280,6 +318,8 @@ export async function inspectInstallation(options: {
280
318
  ownerManifest,
281
319
  ownerLock,
282
320
  dependency,
321
+ section,
322
+ dual,
283
323
  scope,
284
324
  },
285
325
  }
@@ -320,6 +360,8 @@ export async function inspectInstallation(options: {
320
360
  owner: repository.owner,
321
361
  repository: repository.repository,
322
362
  dependency,
363
+ section,
364
+ dual,
323
365
  scope,
324
366
  },
325
367
  }
@@ -1,3 +1,4 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
1
2
  import semver from 'semver'
2
3
  import type { ExecResult } from '../ports/workspace/git-worktree'
3
4
  import { registryBaseUrl, registryVersionUrl } from '../registry'
@@ -230,6 +231,36 @@ export function updateDependency(install: ManagedInstallation, version: string):
230
231
  : `${install.packageName}@${version}`
231
232
  }
232
233
 
234
+ /** The specifier written into package.json for a resolved version: what
235
+ * `bun add <pkg>@<version>` writes (the exact version) for the registry, the tag for a forge. */
236
+ function manifestSpecifier(install: ManagedInstallation, version: string): string {
237
+ return install.channel === 'github'
238
+ ? `github:${install.owner}/${install.repository}#v${version}`
239
+ : version
240
+ }
241
+
242
+ /** Set the package's specifier in every section that declares it, preserving
243
+ * key order, indentation, and the trailing newline. Returns the new bytes. */
244
+ export function rewriteDeclaredSpecifiers(
245
+ manifestText: string,
246
+ packageName: string,
247
+ specifier: string,
248
+ ): string {
249
+ const manifest = JSON.parse(manifestText) as Record<string, unknown>
250
+ for (const section of ['dependencies', 'devDependencies']) {
251
+ const entries = manifest[section]
252
+ if (
253
+ typeof entries === 'object' &&
254
+ entries !== null &&
255
+ typeof (entries as Record<string, unknown>)[packageName] === 'string'
256
+ ) {
257
+ ;(entries as Record<string, unknown>)[packageName] = specifier
258
+ }
259
+ }
260
+ const indent = /^([ \t]+)"/m.exec(manifestText)?.[1] ?? 2
261
+ return JSON.stringify(manifest, null, indent) + (manifestText.endsWith('\n') ? '\n' : '')
262
+ }
263
+
233
264
  /** Silently determine whether this installation has a newer published release.
234
265
  * This read-only courtesy intentionally collapses every unsupported install,
235
266
  * command failure, malformed response, and cancellation to no result. */
@@ -244,7 +275,9 @@ export async function availableRelease(
244
275
  const identity = await readDistributionIdentity(distRoot)
245
276
  if (identity.sourceCheckout || signalAborted(options.signal)) return undefined
246
277
 
247
- const globalBin = await command(['bun', 'pm', 'bin', '-g'], { signal: options.signal })
278
+ const globalBin = await command(['bun', 'pm', 'bin', '-g'], {
279
+ signal: options.signal,
280
+ })
248
281
  if (
249
282
  globalBin.exitCode !== 0 ||
250
283
  globalBin.stdout.trim() === '' ||
@@ -369,11 +402,53 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
369
402
  options.stdout(
370
403
  `Updating Autobuild to v${resolved} in ${install.ownerRoot}; Bun will rewrite ${install.ownerManifest} and ${install.ownerLock}.`,
371
404
  )
372
- updateCommand = ['bun', 'add', '--cwd', install.ownerRoot, dependency]
405
+ if (install.dual) {
406
+ // Bun cannot reconcile two differing specifiers through `bun add`: it
407
+ // exits 0 yet keeps the old version. Bring both entries to the new
408
+ // specifier and let `bun install` apply it.
409
+ options.stdout(
410
+ `${install.packageName} is declared in both dependencies and devDependencies; updating both entries.`,
411
+ )
412
+ updateCommand = ['bun', 'install', '--cwd', install.ownerRoot]
413
+ } else {
414
+ updateCommand = [
415
+ 'bun',
416
+ 'add',
417
+ '--cwd',
418
+ install.ownerRoot,
419
+ ...(install.section === 'devDependencies' ? ['-d'] : []),
420
+ dependency,
421
+ ]
422
+ }
423
+ }
424
+
425
+ let originalManifest: string | undefined
426
+ if (install.scope === 'local' && install.dual) {
427
+ try {
428
+ originalManifest = await readFile(install.ownerManifest, 'utf8')
429
+ await writeFile(
430
+ install.ownerManifest,
431
+ rewriteDeclaredSpecifiers(
432
+ originalManifest,
433
+ install.packageName,
434
+ manifestSpecifier(install, resolved),
435
+ ),
436
+ )
437
+ } catch (error) {
438
+ const reason = `rewriting ${install.ownerManifest}: ${error instanceof Error ? error.message : String(error)}`
439
+ if (explicit) {
440
+ options.stderr(`self-update failed: ${reason}`)
441
+ return { kind: 'failed' }
442
+ }
443
+ return warn(options, reason)
444
+ }
373
445
  }
374
446
 
375
447
  const update = await invoke(updateCommand, {})
376
448
  if (update.exitCode !== 0) {
449
+ if (originalManifest !== undefined) {
450
+ await writeFile(install.ownerManifest, originalManifest).catch(() => undefined)
451
+ }
377
452
  const reason = commandFailure(`installing v${resolved}`, update)
378
453
  if (explicit) {
379
454
  options.stderr(`self-update failed: ${reason}`)
@@ -205,7 +205,7 @@ function leaseInfo(record: BuildRecord, now: Date): LeaseInfo {
205
205
  * The summary over an ALREADY-reduced state — so `detail` reduces the log
206
206
  * once and builds both projections from it, rather than reducing twice.
207
207
  */
208
- function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
208
+ export function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
209
209
  // `phase`, `round`, and `attempt` describe ONE phase occurrence, so they come
210
210
  // from ONE source: the in-flight phase, else the last completed one — the
211
211
  // same `currentPhase ?? lastCompletedPhase` the reducer uses to derive