@defrex/autobuild 0.12.0 → 0.14.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts, readTerminalGuard, type CurrentBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * The three terminal commands — `ab done`, `ab verdict`, `ab escalate`
3
4
  * (SPEC §8.4 D5, §8.5 D6, §8.6 D7).
@@ -18,7 +19,7 @@
18
19
  import { join } from 'node:path'
19
20
  import { z } from 'zod'
20
21
  import { loadConfig } from '../config/load'
21
- import type { AbEvent, EventEnvelope } from '../events/catalog'
22
+ import type { EventEnvelope } from '../events/catalog'
22
23
  import { agentActor, KERNEL } from '../events/envelope'
23
24
  import { isRemoteWorkspace } from '../events/workspace-remote'
24
25
  import type { IdSource } from '../ids'
@@ -29,7 +30,6 @@ import {
29
30
  } from '../kernel/auto-merge'
30
31
  import { phaseSpecFor } from '../kernel/phases'
31
32
  import { resolvePlanVerifySteps } from '../kernel/plan-verify-selection'
32
- import { reduceBuild } from '../kernel/reducer'
33
33
  import {
34
34
  findingDraftSchema,
35
35
  isVerifyPhase,
@@ -75,114 +75,9 @@ export interface TerminalDeps {
75
75
  * round, so a still-in-flight CLI process from the failed session must not
76
76
  * land a terminal the runner will misattribute to the retry).
77
77
  */
78
- function assertNoPriorTerminal(events: AbEvent[], env: CliEnv): void {
79
- const spec = phaseSpecFor(env.phase)
80
- // §6.3 restart boundary: verify attempts and finalize completion RESET
81
- // across a spec.revised (decideNext ignores pre-restart results and
82
- // re-runs the pipeline from plan), so a pre-restart verify.completed or
83
- // finalize.completed must not shadow the rebuilt pipeline's terminal.
84
- // Loop-phase rounds continue monotonically across restarts (engine
85
- // LoopIndex.maxRoundEver) and never collide, so they match over the full
86
- // log; the reconcile epilogue is restart-orthogonal (§15.7).
87
- let restartSeq = 0
88
- for (const event of events) {
89
- if (event.type === 'spec.revised') restartSeq = event.seq
90
- }
91
- // reconcile.completed carries no attempt (§15.3); pair it with the latest
92
- // reconcile.started seen before it.
93
- let reconcileAttempt = 0
94
- let sessionStartedSeq: number | undefined
95
- for (const event of events) {
96
- if (event.type === 'reconcile.started') reconcileAttempt = event.payload.attempt
97
- if (event.type === 'session.started' && event.payload.session === env.session) {
98
- sessionStartedSeq = event.seq
99
- }
100
- if (event.type === 'session.ended' && event.payload.session === env.session) {
101
- throw new Error(
102
- `terminal rejected (D5): session "${env.session}" already ended ` +
103
- `(session.ended at seq ${event.seq}) — a session that ended without ` +
104
- 'a terminal was failed and retried; only the live retry may complete ' +
105
- 'this phase round (§8.4).',
106
- )
107
- }
108
- if (
109
- sessionStartedSeq !== undefined &&
110
- event.type === 'phase.failed' &&
111
- event.payload.phase === env.phase &&
112
- event.payload.round === env.round &&
113
- event.seq > sessionStartedSeq
114
- ) {
115
- throw new Error(
116
- `terminal rejected (D5): ${env.phase}@${env.round} already failed after ` +
117
- `this session started (phase.failed at seq ${event.seq}) — the retry ` +
118
- 'session owns this round now (§8.4).',
119
- )
120
- }
121
- if (
122
- event.type === 'escalation.raised' &&
123
- event.actor.kind === 'agent' &&
124
- event.actor.session === env.session
125
- ) {
126
- throw new Error(
127
- `second terminal call rejected (D5): this session already escalated — ` +
128
- `escalation.raised "${event.payload.id}" at seq ${event.seq}. ` +
129
- 'Every phase ends with exactly one terminal command (§8.4).',
130
- )
131
- }
132
- if (
133
- event.type === 'publication.requested' &&
134
- event.actor.kind === 'agent' &&
135
- event.actor.session === env.session &&
136
- ((env.phase === 'implement' &&
137
- event.payload.operation === 'implement' &&
138
- event.payload.round === env.round) ||
139
- (env.phase === 'reconcile' && event.payload.operation === 'reconcile') ||
140
- (env.phase === 'finalize' && event.payload.operation === 'finalize'))
141
- ) {
142
- throw new Error(
143
- `second terminal call rejected (D5): ${event.type} for ` +
144
- `${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
145
- 'Remote publication is pending; do not run ab done again (§8.4).',
146
- )
147
- }
148
- if (event.type !== spec.terminalEvent) continue
149
- if (
150
- (event.type === 'verify.completed' || event.type === 'finalize.completed') &&
151
- event.seq <= restartSeq
152
- ) {
153
- continue
154
- }
155
- if (!terminalMatchesRound(event, env, reconcileAttempt)) continue
156
- throw new Error(
157
- `second terminal call rejected (D5): ${event.type} for ` +
158
- `${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
159
- 'Every phase ends with exactly one terminal command (§8.4).',
160
- )
161
- }
162
- }
163
-
164
- function terminalMatchesRound(event: AbEvent, env: CliEnv, reconcileAttempt: number): boolean {
165
- switch (event.type) {
166
- case 'plan.completed':
167
- case 'plan-review.verdict':
168
- case 'implement.completed':
169
- case 'code-review.verdict':
170
- return event.payload.round === env.round
171
- case 'verify.completed':
172
- return (
173
- isVerifyPhase(env.phase) &&
174
- event.payload.step === verifyStep(env.phase) &&
175
- event.payload.attempt === env.round
176
- )
177
- case 'finalize.completed':
178
- // finalize runs once per build (§5) — any prior completion counts.
179
- return true
180
- case 'reconcile.completed':
181
- // No started event before it → conservative match (total over any log).
182
- return reconcileAttempt === env.round || reconcileAttempt === 0
183
- default:
184
- return false
185
- }
78
+ async function assertNoPriorTerminal(store: BuildStore, env: CliEnv): Promise<void> {
79
+ const result = await readTerminalGuard(store, env.build, env.phase, env.round, env.session)
80
+ if (result.diagnostic) throw new Error(result.diagnostic)
186
81
  }
187
82
 
188
83
  // ── Small helpers ────────────────────────────────────────────────────────────
@@ -240,28 +135,21 @@ async function assertCleanWorktree(deps: TerminalDeps): Promise<void> {
240
135
  }
241
136
  }
242
137
 
243
- function baseBranchOf(events: AbEvent[]): string {
244
- for (const event of events) {
245
- if (event.type === 'build.created') return event.payload.baseBranch
246
- }
138
+ function baseBranchOf(events: CurrentBuildFacts): string {
139
+ if (events.created) return events.created.payload.baseBranch
247
140
  throw new Error('this build has no build.created event — cannot resolve its baseBranch (§15.3)')
248
141
  }
249
142
 
250
- function requireImplementationProvisioning(events: AbEvent[]): void {
251
- if (events.some((event) => event.type === 'workspace.provisioned')) return
143
+ function requireImplementationProvisioning(events: CurrentBuildFacts): void {
144
+ if (events.workspaceGenerations > 0) return
252
145
  throw new Error(
253
146
  "implement's 'ab done' requires workspace.provisioned provenance — " +
254
147
  'this build has no workspace.provisioned event (§7.4, §15.3)',
255
148
  )
256
149
  }
257
150
 
258
- function remotePublication(events: readonly AbEvent[]): boolean {
259
- let remote = false
260
- for (const event of events) {
261
- if (event.type === 'workspace.provisioned') remote = isRemoteWorkspace(event.payload)
262
- else if (event.type === 'workspace.released') remote = false
263
- }
264
- return remote
151
+ function remotePublication(events: CurrentBuildFacts): boolean {
152
+ return events.workspace !== null && isRemoteWorkspace(events.workspace)
265
153
  }
266
154
 
267
155
  async function buildBranch(deps: TerminalDeps): Promise<string> {
@@ -281,7 +169,7 @@ async function buildBranch(deps: TerminalDeps): Promise<string> {
281
169
  */
282
170
  async function implementationReviewBoundary(
283
171
  deps: TerminalDeps,
284
- events: AbEvent[],
172
+ events: CurrentBuildFacts,
285
173
  ): Promise<{ branch: string; base: string; head: string }> {
286
174
  const diagnostic = 'cannot establish implementation review boundary'
287
175
  try {
@@ -384,7 +272,7 @@ export interface FinalizePrInput {
384
272
  * requested head before entering this idempotent PR-side sequence. */
385
273
  export async function completeFinalizePr(
386
274
  deps: TerminalDeps,
387
- events: AbEvent[],
275
+ events: CurrentBuildFacts,
388
276
  input: FinalizePrInput,
389
277
  ): Promise<EventEnvelope> {
390
278
  const { env, store } = deps
@@ -404,8 +292,8 @@ export async function completeFinalizePr(
404
292
 
405
293
  await preparePrAttachments(deps, events, pr.url)
406
294
 
407
- const latest = await store.getEvents(env.build)
408
- const autoMerge = pendingAutoMerge(reduceBuild(latest))
295
+ const latest = (await readBuildFacts(store, env.build)).value
296
+ const autoMerge = pendingAutoMerge(latest.state)
409
297
  const autoMergeResult =
410
298
  autoMerge === undefined
411
299
  ? undefined
@@ -452,8 +340,8 @@ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<Eve
452
340
  `${spec.kind} phase; use 'ab verdict' (§8.2)`,
453
341
  )
454
342
  }
455
- const events = await store.getEvents(env.build)
456
- assertNoPriorTerminal(events, env)
343
+ const events = (await readBuildFacts(store, env.build)).value
344
+ await assertNoPriorTerminal(store, env)
457
345
  const actor = agentActor(env.phase, env.session)
458
346
 
459
347
  switch (spec.name) {
@@ -466,12 +354,7 @@ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<Eve
466
354
  // still the already-reviewed plan, re-entering review as if revised.
467
355
  const revs = await store.listArtifacts(env.build, 'plan')
468
356
  const latest = revs[revs.length - 1]
469
- let lastCited = -1
470
- for (const event of events) {
471
- if (event.type === 'plan.completed') {
472
- lastCited = Math.max(lastCited, event.payload.artifact.rev)
473
- }
474
- }
357
+ const lastCited = events.maxPlanRevision
475
358
  if (latest === undefined || latest.revision <= lastCited) {
476
359
  throw new Error(
477
360
  `plan@${env.round} 'ab done' requires this round's plan deposit: found ` +
@@ -704,8 +587,8 @@ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<Ev
704
587
  `accept pass|fail|skip. ${env.phase} accepts: ${vocabulary.join('|')}`,
705
588
  )
706
589
  }
707
- const events = await store.getEvents(env.build)
708
- assertNoPriorTerminal(events, env)
590
+ const events = (await readBuildFacts(store, env.build)).value
591
+ await assertNoPriorTerminal(store, env)
709
592
  const actor = agentActor(env.phase, env.session)
710
593
 
711
594
  if (spec.kind === 'agent-verify') {
@@ -717,7 +600,7 @@ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<Ev
717
600
  async function reviewVerdict(
718
601
  deps: TerminalDeps,
719
602
  opts: VerdictOpts,
720
- events: AbEvent[],
603
+ events: CurrentBuildFacts,
721
604
  actor: ReturnType<typeof agentActor>,
722
605
  ): Promise<EventEnvelope[]> {
723
606
  const { env, store } = deps
@@ -816,7 +699,7 @@ export function renderFindingsIssues(issues: readonly z.core.$ZodIssue[]): strin
816
699
  async function parseFindings(
817
700
  deps: TerminalDeps,
818
701
  path: string,
819
- events: AbEvent[],
702
+ events: CurrentBuildFacts,
820
703
  ): Promise<Finding[]> {
821
704
  const raw = await readTextFile(path, '--findings')
822
705
  let json: unknown
@@ -841,7 +724,7 @@ async function parseFindings(
841
724
  // `persists` may only reference ids from prior rounds of THIS loop (§15.4;
842
725
  // ids are deposit-stamped, so a reviewer can only ever see rounds it was
843
726
  // shown — §8.3).
844
- const state = reduceBuild(events)
727
+ const state = events.state
845
728
  const loop =
846
729
  deps.env.phase === 'plan-review'
847
730
  ? state.reviewFindings.planReview
@@ -969,8 +852,8 @@ export async function escalate(
969
852
  if (opts.question.trim() === '') {
970
853
  throw new Error("'ab escalate' requires a question — it is what the human answers (§11)")
971
854
  }
972
- const events = await store.getEvents(env.build)
973
- assertNoPriorTerminal(events, env)
855
+ const _events = (await readBuildFacts(store, env.build)).value
856
+ await assertNoPriorTerminal(store, env)
974
857
  return store.append(env.build, {
975
858
  actor: agentActor(env.phase, env.session),
976
859
  type: 'escalation.raised',
@@ -42,6 +42,7 @@ export function ticketAssetNote(
42
42
  event: TicketAssetNoteEvent,
43
43
  meta: TicketAssetMeta,
44
44
  cli = 'ab',
45
+ hosted = false,
45
46
  ): string {
46
47
  const fileCount = meta.entries.filter((entry) => entry.type === 'file').length
47
48
  const label = `\`${meta.kind}/${meta.name}\``
@@ -52,7 +53,9 @@ export function ticketAssetNote(
52
53
  return [
53
54
  `Ticket asset attached: ${label} (revision ${meta.revision}, ${contents}).`,
54
55
  '',
55
- 'The contents live in the Autobuild store, not in this ticket.',
56
+ hosted
57
+ ? 'The contents live in the ticket service storage.'
58
+ : 'The contents live in the Autobuild store, not in this ticket.',
56
59
  `Download the latest revision: \`${get}\``,
57
60
  `Download this exact revision: \`${get} --rev ${meta.revision}\``,
58
61
  ].join('\n')
@@ -60,7 +63,9 @@ export function ticketAssetNote(
60
63
  return [
61
64
  `Ticket asset replaced: ${label} is now revision ${meta.revision} (${contents}).`,
62
65
  '',
63
- 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
66
+ hosted
67
+ ? 'The contents live in the ticket service storage. Earlier revisions stay retrievable.'
68
+ : 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
64
69
  `Download the latest revision: \`${get}\``,
65
70
  `Download this exact revision: \`${get} --rev ${meta.revision}\``,
66
71
  ].join('\n')
@@ -0,0 +1,82 @@
1
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import type { TicketSource, TicketUpdate } from '../ports/types'
5
+ import { parseTicketFile, serializeTicketFile } from '../ports/tickets/file-codec'
6
+ import { TicketRevisionUnsupported } from '../ports/tickets/revision'
7
+ import { requireTicket, updateTicket } from '../ports/tickets/operations'
8
+
9
+ export type TicketEditorRunner = (command: string, path: string) => Promise<number>
10
+
11
+ /** The configured editor is a shell command; only our generated path is quoted. */
12
+ export const runTicketEditor: TicketEditorRunner = async (command, path) => {
13
+ const quoted = `'${path.replaceAll("'", "'\\''")}'`
14
+ return Bun.spawn(['sh', '-c', `${command} ${quoted}`], {
15
+ stdin: 'inherit',
16
+ stdout: 'inherit',
17
+ stderr: 'inherit',
18
+ }).exited
19
+ }
20
+
21
+ export async function editTicket(
22
+ source: TicketSource,
23
+ id: string,
24
+ env: Record<string, string | undefined>,
25
+ stdout: (line: string) => void,
26
+ runner: TicketEditorRunner = runTicketEditor,
27
+ ): Promise<void> {
28
+ if (!source.mutateIfRevision) throw new TicketRevisionUnsupported(source.name)
29
+ const command = env.VISUAL?.trim() || env.EDITOR?.trim()
30
+ if (!command) throw new Error('set VISUAL or EDITOR to edit a ticket')
31
+ const ticket = await requireTicket(source, id)
32
+ const front = {
33
+ id,
34
+ title: ticket.title,
35
+ labels: [...ticket.labels],
36
+ blockedBy: [...(ticket.blockedBy ?? [])],
37
+ ...(ticket.creationKey !== undefined ? { idempotencyKey: ticket.creationKey } : {}),
38
+ }
39
+ // Reference comments remain part of the frontmatter, outside the editable keys.
40
+ const reference = `# Read-only reference\n# state = ${JSON.stringify(ticket.state ?? null)}\n# revision = ${JSON.stringify(ticket.revision)}\n`
41
+ const serialized = serializeTicketFile(front, ticket.body)
42
+ const original = serialized.replace('+++\n', `+++\n${reference}`)
43
+ const directory = await mkdtemp(join(tmpdir(), 'ab-ticket-edit-'))
44
+ const path = join(directory, 'ticket.md')
45
+ await writeFile(path, original, { mode: 0o600 })
46
+ try {
47
+ const code = await runner(command, path)
48
+ if (code !== 0) throw new Error(`editor exited with status ${code}`)
49
+ const edited = await readFile(path, 'utf8')
50
+ if (edited !== original) {
51
+ const parsed = parseTicketFile(path, edited)
52
+ const close = edited.indexOf('\n+++\n', 4)
53
+ const comments = edited
54
+ .slice(4, close)
55
+ .split('\n')
56
+ .filter((line) => line.trimStart().startsWith('#'))
57
+ .join('\n')
58
+ if (comments !== reference.trimEnd())
59
+ throw new Error('read-only reference comments cannot change')
60
+ if (
61
+ parsed.front.id !== id ||
62
+ parsed.front.idempotencyKey !== front.idempotencyKey ||
63
+ JSON.stringify(parsed.front.blockedBy ?? []) !== JSON.stringify(front.blockedBy)
64
+ )
65
+ throw new Error('id, idempotencyKey and blockedBy are read-only')
66
+ const patch: TicketUpdate = {}
67
+ if (parsed.front.title !== ticket.title) patch.title = parsed.front.title
68
+ if (parsed.body !== ticket.body) patch.body = parsed.body
69
+ if (JSON.stringify(parsed.front.labels) !== JSON.stringify(ticket.labels))
70
+ patch.labels = parsed.front.labels
71
+ if (Object.keys(patch).length > 0)
72
+ await updateTicket(source, id, patch, false, ticket.revision)
73
+ }
74
+ await rm(directory, { recursive: true })
75
+ stdout(`ticket edit complete: ${source.name}:${id}`)
76
+ } catch (error) {
77
+ throw new Error(
78
+ `${error instanceof Error ? error.message : String(error)}; edited text kept at ${path}`,
79
+ { cause: error },
80
+ )
81
+ }
82
+ }