@defrex/autobuild 0.11.0 → 0.12.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 (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. package/templates/hosted-service/vercel.json +7 -0
@@ -42,8 +42,15 @@ import type { RepositoryRecord } from '../store/types'
42
42
  */
43
43
  import type { AbEvent } from '../events/catalog'
44
44
  import type { EventType } from '../events/payloads'
45
- import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
46
- import { REPOSITORY_ATTENTION_EVENTS } from '../events/repository'
45
+ import type { RepositoryEvent } from '../events/repository'
46
+ import {
47
+ mergeDecision,
48
+ mergeIdentity,
49
+ RepositoryAttention,
50
+ repositoryNeedsAttention,
51
+ type MergeDecision,
52
+ type PersistenceObservation,
53
+ } from './attention'
47
54
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
48
55
  import { reduceBuild } from '../kernel/reducer'
49
56
  import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
@@ -84,20 +91,7 @@ const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
84
91
  * a watch emits exactly these — deciding what to do about a blocker or a
85
92
  * merge stays with the caller's skill.
86
93
  */
87
- export const BUILD_ATTENTION_EVENTS = [
88
- 'escalation.raised',
89
- 'phase.failed',
90
- 'infrastructure.failed',
91
- 'runner.setup-failed',
92
- 'dispatch.failed',
93
- 'publication.lost',
94
- 'finalize.completed',
95
- 'pr.conflicted',
96
- 'pr.merged',
97
- 'pr.closed',
98
- 'build.completed',
99
- 'build.aborted',
100
- ] as const satisfies readonly EventType[]
94
+ export const BUILD_ATTENTION_EVENTS = ['escalation.raised'] as const satisfies readonly EventType[]
101
95
 
102
96
  // ── Glob compilation and matching ────────────────────────────────────────────
103
97
 
@@ -121,12 +115,6 @@ function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolea
121
115
  : (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
122
116
  }
123
117
 
124
- function matchesRepositoryFilter(type: RepositoryEventType, filters: readonly RegExp[]): boolean {
125
- return filters.length > 0
126
- ? filters.some((regex) => regex.test(type))
127
- : (REPOSITORY_ATTENTION_EVENTS as readonly string[]).includes(type)
128
- }
129
-
130
118
  // ── The cursor ───────────────────────────────────────────────────────────────
131
119
 
132
120
  interface CursorPayload {
@@ -136,10 +124,29 @@ interface CursorPayload {
136
124
  /** The repository identity (normalized origin or checkout path). */
137
125
  repo: string
138
126
  /** Per-stream watch position: build slug → seq, `#repo` → seq. */
127
+ deliveredAttention?: string[]
139
128
  lostRuns?: string[]
140
129
  streams: Record<string, number>
141
130
  }
142
131
 
132
+ function validAttentionIdentity(id: unknown): boolean {
133
+ if (typeof id !== 'string') return false
134
+ if (/^(source|tick):[1-9][0-9]*$/.test(id)) return true
135
+ try {
136
+ const value: unknown = JSON.parse(id)
137
+ return (
138
+ Array.isArray(value) &&
139
+ value.length === 2 &&
140
+ typeof value[0] === 'string' &&
141
+ value[0].length > 0 &&
142
+ Number.isSafeInteger(value[1]) &&
143
+ value[1] > 0
144
+ )
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
143
150
  const CURSOR_PREFIX = 'v1.'
144
151
 
145
152
  /** Opaque to callers; the format is internal, not contract. */
@@ -173,13 +180,22 @@ export function decodeCursor(
173
180
  throw malformed()
174
181
  }
175
182
  if (typeof payload !== 'object' || payload === null) throw malformed()
176
- const { v, store, repo, streams, lostRuns } = payload as Record<string, unknown>
183
+ const { v, store, repo, streams, lostRuns, deliveredAttention } = payload as Record<
184
+ string,
185
+ unknown
186
+ >
177
187
  if (
178
188
  lostRuns !== undefined &&
179
189
  (!Array.isArray(lostRuns) ||
180
190
  lostRuns.some((run) => typeof run !== 'string' || run.length === 0))
181
191
  )
182
192
  throw malformed()
193
+ if (
194
+ deliveredAttention !== undefined &&
195
+ (!Array.isArray(deliveredAttention) ||
196
+ deliveredAttention.some((id) => !validAttentionIdentity(id)))
197
+ )
198
+ throw malformed()
183
199
  if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
184
200
  if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
185
201
  for (const seq of Object.values(streams)) {
@@ -202,6 +218,9 @@ export function decodeCursor(
202
218
  store,
203
219
  repo,
204
220
  streams: streams as Record<string, number>,
221
+ ...(deliveredAttention === undefined
222
+ ? {}
223
+ : { deliveredAttention: deliveredAttention as string[] }),
205
224
  ...(lostRuns === undefined ? {} : { lostRuns: lostRuns as string[] }),
206
225
  }
207
226
  }
@@ -272,6 +291,36 @@ export interface WatchObservationRecord {
272
291
  cursor: string
273
292
  }
274
293
 
294
+ export interface AttentionObservationRecord {
295
+ repo?: string
296
+ build: string | null
297
+ state: WatchRecordState | null
298
+ observation: MergeDecision | PersistenceObservation
299
+ cursor: string
300
+ }
301
+
302
+ export function projectAttentionRecord(
303
+ observation: MergeDecision | PersistenceObservation,
304
+ state: BuildState | null,
305
+ cursor: string,
306
+ scope: { build: string } | { repo: string },
307
+ ): AttentionObservationRecord {
308
+ return {
309
+ ...scope,
310
+ build: 'build' in scope ? scope.build : null,
311
+ state: state ? projectState(state) : null,
312
+ observation,
313
+ cursor,
314
+ }
315
+ }
316
+
317
+ export function renderAttentionLine(
318
+ observation: MergeDecision | PersistenceObservation,
319
+ label: string,
320
+ ): string {
321
+ return `${observation.observedAt} ${singleLine(label)} ${observation.type} ${singleLine(JSON.stringify(observation))}`
322
+ }
323
+
275
324
  export interface WatchRecord {
276
325
  /** The build slug, or null for a repository-journal event. */
277
326
  build: string | null
@@ -544,6 +593,12 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
544
593
  const lostRuns = new Set(
545
594
  opts.since ? (decodeCursor(opts.since, { store: storeRef, repo }).lostRuns ?? []) : [],
546
595
  )
596
+ const resumedCursor = opts.since
597
+ ? decodeCursor(opts.since, { store: storeRef, repo })
598
+ : undefined
599
+ const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
600
+ const repositoryAttention = new RepositoryAttention()
601
+ let attentionInitialized = false
547
602
  let livenessEvents: RepositoryEvent[] = []
548
603
  let livenessSeq = 0
549
604
  let livenessInitialized = false
@@ -571,6 +626,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
571
626
  repo,
572
627
  streams: { ...positions },
573
628
  lostRuns: [...lostRuns],
629
+ deliveredAttention: [...deliveredAttention],
574
630
  })
575
631
 
576
632
  /** One record, flushed the moment it is projected. `cursor` is the watch
@@ -591,6 +647,44 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
591
647
  }
592
648
  }
593
649
 
650
+ const emitObservation = (
651
+ observation: MergeDecision | PersistenceObservation,
652
+ state: BuildState | null,
653
+ scope: { build: string } | { repo: string },
654
+ identity: string,
655
+ ): void => {
656
+ if (stop || deliveredAttention.has(identity)) return
657
+ deliveredAttention.add(identity)
658
+ const cursor = encodeCurrent()
659
+ opts.stdout(
660
+ opts.json
661
+ ? JSON.stringify(projectAttentionRecord(observation, state, cursor, scope))
662
+ : renderAttentionLine(observation, 'build' in scope ? scope.build : '(repository)'),
663
+ )
664
+ emitted += 1
665
+ if (opts.count !== undefined && emitted >= opts.count) {
666
+ countCursor = cursor
667
+ requestStop()
668
+ }
669
+ }
670
+ const checkPersistence = (): void => {
671
+ if (filters.length || !attentionInitialized || stop) return
672
+ for (const { identity, observation } of repositoryAttention.pending(now())) {
673
+ emitObservation(observation, null, { repo }, identity)
674
+ if (stop) return
675
+ }
676
+ }
677
+ const baselineMerge = (slug: string, events: AbEvent[]): void => {
678
+ // Old cursors already consumed finalize attention. Infer every eligible
679
+ // historical prefix, even if consent was subsequently armed again.
680
+ const prefix: AbEvent[] = []
681
+ for (const event of events) {
682
+ prefix.push(event)
683
+ const observation = mergeDecision(prefix, now())
684
+ if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
685
+ }
686
+ }
687
+
594
688
  const checkLiveness = async (): Promise<void> => {
595
689
  if (!followRepository || stop) return
596
690
  if (!livenessInitialized) {
@@ -665,17 +759,34 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
665
759
  * into a scratch prefix so a resumed record never leaks state from
666
760
  * events that landed after it. */
667
761
  const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
762
+ if (stop) return
668
763
  stream.lastSeq = event.seq
669
764
  positions[stream.slug] = event.seq
670
- if (!matchesBuildFilter(event.type, filters)) return
671
- emit(event, reduceBuild(prefix))
765
+ const state = reduceBuild(prefix)
766
+ if (matchesBuildFilter(event.type, filters)) emit(event, state)
767
+ if (!filters.length && !stop) {
768
+ const observation = mergeDecision(prefix, now(), state)
769
+ if (observation)
770
+ emitObservation(
771
+ observation,
772
+ state,
773
+ { build: stream.slug },
774
+ mergeIdentity(stream.slug, observation.pr.number),
775
+ )
776
+ }
672
777
  }
673
778
 
674
779
  const processRepositoryEvent = (stream: RepositoryStream, event: RepositoryEvent): void => {
780
+ if (stop) return
675
781
  stream.lastSeq = event.seq
676
782
  positions[REPO_STREAM_KEY] = event.seq
677
- if (!matchesRepositoryFilter(event.type, filters)) return
678
- emit(event, null)
783
+ repositoryAttention.fold(event)
784
+ if (
785
+ filters.length
786
+ ? filters.some((regex) => regex.test(event.type))
787
+ : repositoryNeedsAttention(event)
788
+ )
789
+ emit(event, null)
679
790
  }
680
791
 
681
792
  /**
@@ -705,7 +816,16 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
705
816
  }
706
817
  streams.set(slug, stream)
707
818
  positions[slug] = stream.lastSeq
708
- if (resumeSeq === undefined) return true
819
+ if (resumeSeq === undefined) {
820
+ baselineMerge(slug, events)
821
+ return true
822
+ }
823
+ positions[slug] = resumeSeq
824
+ if (resumedCursor?.deliveredAttention === undefined)
825
+ baselineMerge(
826
+ slug,
827
+ events.filter((event) => event.seq <= resumeSeq),
828
+ )
709
829
  stream.lastSeq = resumeSeq
710
830
  // Replay the backlog into a scratch prefix: each record's state is
711
831
  // the reduction of the prefix ENDING at that event, never the
@@ -771,6 +891,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
771
891
  status,
772
892
  }
773
893
  streams.set(record.slug, stream)
894
+ baselineMerge(record.slug, events)
774
895
  positions[record.slug] = stream.lastSeq
775
896
  }
776
897
  }
@@ -781,35 +902,22 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
781
902
  streams.set(REPO_STREAM_KEY, stream)
782
903
  positions[REPO_STREAM_KEY] = stream.lastSeq
783
904
  try {
784
- // A fresh store has no repository row; do not create one.
785
- if (resumeSeq === undefined && (await store.getRepo(repo)) !== null) {
786
- // Bounded baseline (AUT-534): the baseline needs only the
787
- // journal's end position, and AUT-489's bounded read answers that
788
- // whenever the journal contains a `dispatcher.run-started` — the
789
- // tail from the latest anchor runs to the journal end, so the
790
- // subset's max seq equals the journal's max seq.
791
- const subset = await store.getRepoStateEvents(repo)
792
- let last = subset.at(-1)?.seq ?? 0
793
- if (!subset.some((event) => event.type === 'dispatcher.run-started')) {
794
- // Anchor-less journal: the subset may have dropped run-scoped
795
- // facts appended before any anchor, so one bounded delta read
796
- // recovers the true end; its events are baselined silently,
797
- // exactly as the full read baselines them. The skip condition
798
- // is exact: a run-started is its own anchor or lies in a later
799
- // anchor's tail, so "no run-started in the subset" means "no
800
- // run-started in the journal" — the only case where the subset
801
- // can trail.
802
- const tail = await store.getRepoEvents(repo, last)
803
- last = tail.at(-1)?.seq ?? last
905
+ if ((await store.getRepo(repo)) !== null) {
906
+ // Full bootstrap is intentional: degradation can predate run anchors.
907
+ const history = await store.getRepoEvents(repo)
908
+ const baseline = resumeSeq ?? history.at(-1)?.seq ?? 0
909
+ for (const event of history) {
910
+ if (event.seq <= baseline) repositoryAttention.fold(event)
804
911
  }
805
- const input = await readDispatcherLiveness(store, repo, now(), subset)
806
- livenessEvents = input.events
807
- livenessSeq = Math.max(last, input.events.at(-1)?.seq ?? 0)
808
- livenessInitialized = true
809
- if (input.record?.lease) observedOwners.set(input.record.lease.holder, input.record)
810
- stream.lastSeq = last
811
- positions[REPO_STREAM_KEY] = last
812
- }
912
+ attentionInitialized = true
913
+ stream.lastSeq = baseline
914
+ positions[REPO_STREAM_KEY] = baseline
915
+ for (const event of history) {
916
+ if (event.seq <= baseline || stop) continue
917
+ processRepositoryEvent(stream, event)
918
+ }
919
+ if (!stop) checkPersistence()
920
+ } else attentionInitialized = true
813
921
  return true
814
922
  } catch {
815
923
  return false
@@ -822,6 +930,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
822
930
  ): Promise<boolean> => {
823
931
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
824
932
  for (const event of fresh) {
933
+ if (stop) break
825
934
  stream.events.push(event)
826
935
  processBuildEvent(stream, event, stream.events)
827
936
  if (stop) break
@@ -832,17 +941,43 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
832
941
  return true
833
942
  }
834
943
 
944
+ let repositoryQueue = Promise.resolve()
945
+ let repositoryInitialization: Promise<void> | undefined
835
946
  const pollRepository = async (
836
947
  stream: RepositoryStream,
837
948
  readOpts?: RemotePollReadOpts,
838
949
  ): Promise<boolean> => {
839
950
  // A repository row may be created mid-watch by the first dispatch.
840
951
  if ((await store.getRepo(repo)) === null) return true
841
- const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
842
- for (const event of fresh) {
843
- processRepositoryEvent(stream, event)
844
- if (stop) break
952
+ if (!attentionInitialized) {
953
+ repositoryInitialization ??= (async () => {
954
+ const history = await store.getRepoEvents(repo)
955
+ const baseline = sinceStreams[REPO_STREAM_KEY] ?? history.at(-1)?.seq ?? 0
956
+ for (const event of history) {
957
+ if (event.seq <= baseline) repositoryAttention.fold(event)
958
+ }
959
+ stream.lastSeq = baseline
960
+ positions[REPO_STREAM_KEY] = baseline
961
+ attentionInitialized = true
962
+ })()
963
+ try {
964
+ await repositoryInitialization
965
+ } catch (error) {
966
+ repositoryInitialization = undefined
967
+ throw error
968
+ }
845
969
  }
970
+ const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
971
+ const apply = repositoryQueue.then(() => {
972
+ for (const event of fresh) {
973
+ if (stop) break
974
+ if (event.seq <= stream.lastSeq) continue
975
+ processRepositoryEvent(stream, event)
976
+ }
977
+ if (!stop) checkPersistence()
978
+ })
979
+ repositoryQueue = apply.catch(() => {})
980
+ await apply
846
981
  return true
847
982
  }
848
983
 
@@ -1023,6 +1158,8 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
1023
1158
  void remoteRunner.runDiscovery({
1024
1159
  step: async (): Promise<void> => {
1025
1160
  if (slugs.length === 0) await discoverBuilds()
1161
+ const repositoryStream = streams.get(REPO_STREAM_KEY)
1162
+ if (repositoryStream?.kind === 'repo' && !stop) await pollRepository(repositoryStream)
1026
1163
  await checkLiveness()
1027
1164
  },
1028
1165
  // Unconditional, after the catch — exactly where the inline
@@ -12,6 +12,7 @@ export const DISPATCHER_CONFIG_ARTIFACT = 'dispatcher-config'
12
12
  * behavior.
13
13
  */
14
14
  export const CONFIG_RELOAD_CLASSIFICATION = {
15
+ store: 'restart',
15
16
  baseBranch: 'hot',
16
17
  capacity: 'hot',
17
18
  ticketScanIntervalSeconds: 'hot',
@@ -49,6 +50,7 @@ export const CONFIG_RELOAD_CLASSIFICATION = {
49
50
  }
50
51
 
51
52
  export const RESTART_REQUIRED_CONFIG_PATHS = [
53
+ 'store',
52
54
  'forge',
53
55
  'plugins',
54
56
  'workspace.provider',
@@ -118,6 +120,7 @@ export function composeBuildConfig(pipeline: Config, deployment: Config): Config
118
120
  export function composeReloadedConfig(startup: Config, candidate: Config): Config {
119
121
  return {
120
122
  ...candidate,
123
+ store: startup.store,
121
124
  forge: startup.forge,
122
125
  plugins: startup.plugins,
123
126
  workspace: startup.workspace,
@@ -12,6 +12,7 @@
12
12
  * validated.
13
13
  */
14
14
  import { z } from 'zod'
15
+ import { normalizeServiceURL } from '../connection'
15
16
  import { prImageHostSchema } from '../ontology'
16
17
  import { SANDBOX_FORBIDDEN_ENV } from '../ports/workspace/operator-sandbox'
17
18
  import {
@@ -60,14 +61,22 @@ function stepSection<T, C>(
60
61
  label: string,
61
62
  stepsSchema: z.ZodType<string[]>,
62
63
  tableSchema: z.ZodType<T>,
63
- compose: (steps: string[], stepConfigs: Record<string, T>) => C,
64
+ compose: (steps: string[], stepConfigs: Record<string, T>, afterReconcile: boolean) => C,
65
+ allowAfterReconcile = false,
64
66
  ) {
65
67
  return z
66
68
  .unknown()
67
69
  .transform((input, ctx): C => {
68
70
  const stepConfigs: Record<string, T> = Object.create(null)
69
71
  let steps: string[] = []
72
+ let afterReconcile = true
70
73
  for (const [key, raw] of ownEntries(input, label, ctx)) {
74
+ if (key === 'afterReconcile' && allowAfterReconcile) {
75
+ const parsed = z.boolean().safeParse(raw)
76
+ if (parsed.success) afterReconcile = parsed.data
77
+ else forwardIssues(parsed.error.issues, ctx, [key])
78
+ continue
79
+ }
71
80
  if (key === 'steps') {
72
81
  const parsed = stepsSchema.safeParse(raw)
73
82
  if (parsed.success) steps = parsed.data
@@ -77,7 +86,7 @@ function stepSection<T, C>(
77
86
  const table = parseEntry(tableSchema, raw, key, ctx)
78
87
  if (table.ok) defineEntry(stepConfigs, key, table.value)
79
88
  }
80
- return compose(steps, stepConfigs)
89
+ return compose(steps, stepConfigs, afterReconcile)
81
90
  })
82
91
  .prefault({})
83
92
  }
@@ -367,6 +376,7 @@ export const verifyPathGlobSchema = z.string().superRefine((pattern, ctx) => {
367
376
  })
368
377
 
369
378
  const verifyApplicabilityShape = {
379
+ afterReconcile: z.boolean().optional(),
370
380
  /** Any-match positive path selectors; absent means unconditional. */
371
381
  paths: z
372
382
  .array(verifyPathGlobSchema)
@@ -399,6 +409,7 @@ export const verifyStepConfigSchema = z.discriminatedUnion('kind', [
399
409
  export type VerifyStepConfig = z.infer<typeof verifyStepConfigSchema>
400
410
 
401
411
  export interface VerifyConfig {
412
+ afterReconcile: boolean
402
413
  /** Step order — each entry names a `verify:<step>` phase (§15.2.1). */
403
414
  steps: string[]
404
415
  /** The [verify.<step>] tables, keyed by step name. */
@@ -406,8 +417,8 @@ export interface VerifyConfig {
406
417
  }
407
418
 
408
419
  /**
409
- * [verify] mixes one known key (`steps`) with an open set of per-step
410
- * subtables, so it cannot be a strictObject; instead every non-`steps` key is
420
+ * [verify] mixes known scalars (`steps`, `afterReconcile`) with per-step
421
+ * subtables, so it cannot be a strictObject; every other key is
411
422
  * validated as a step table — nothing passes through loosely, and no step name
412
423
  * is lost to the prototype chain (see `stepSection`).
413
424
  */
@@ -415,7 +426,8 @@ export const verifySectionSchema = stepSection(
415
426
  '[verify]',
416
427
  z.array(z.string().min(1)).default([]),
417
428
  verifyStepConfigSchema,
418
- (steps, stepConfigs): VerifyConfig => ({ steps, stepConfigs }),
429
+ (steps, stepConfigs, afterReconcile): VerifyConfig => ({ steps, stepConfigs, afterReconcile }),
430
+ true,
419
431
  )
420
432
 
421
433
  // ── [finalize.<step>] ────────────────────────────────────────────────────────
@@ -500,6 +512,8 @@ export const policySchema = z.strictObject({
500
512
  /** Same-finding survival threshold before auto-escalate (§10, §15.4). */
501
513
  stallRounds: z.number().int().positive().default(3),
502
514
  maxVerifyAttempts: z.number().int().positive().default(3),
515
+ /** Consecutive dispatch failures before human intervention is required. */
516
+ maxDispatchAttempts: z.number().int().positive().default(3),
503
517
  /** Consecutive workspace setup failures before human intervention is required. */
504
518
  maxSetupAttempts: z.number().int().positive().default(3),
505
519
  /** Consecutive provider lifecycle failures before recovery requires a human retry. */
@@ -573,15 +587,33 @@ export const ticketsSchema = z.strictObject({
573
587
  /** Backend team key (e.g. "ENG") — required for linear and hosted. */
574
588
  teamKey: z.string().min(1).optional(),
575
589
  /** Workflow state claim() moves an issue to (§12); Linear and hosted. */
576
- claimedState: z.string().min(1).optional(),
590
+ claimedState: z
591
+ .string()
592
+ .min(1)
593
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
594
+ .optional(),
595
+ /** Hosted project completion state; defaults to Done. */
596
+ doneState: z
597
+ .string()
598
+ .min(1)
599
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
600
+ .optional(),
577
601
  /** State create() files new tickets into. Absent = the provider's default
578
602
  * (Linear: the team's default state, e.g. Backlog; file: Triage). */
579
- createState: z.string().min(1).optional(),
603
+ createState: z
604
+ .string()
605
+ .min(1)
606
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
607
+ .optional(),
580
608
  /** State the dispatcher hands tickets back to for human triage — spec-gate
581
609
  * bounces (§6.3), aborted builds, closed-unmerged PRs. Absent = the
582
610
  * provider's default (Linear: Backlog; file: Triage) — resolved by
583
611
  * defaultTriageState in src/processes/dispatcher.ts. */
584
- triageState: z.string().min(1).optional(),
612
+ triageState: z
613
+ .string()
614
+ .min(1)
615
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
616
+ .optional(),
585
617
  /** State harvest files its synthesized proposals into (§12). Absent = the
586
618
  * triage state, which keeps the constitutional grooming gate: a proposal
587
619
  * waits for a human before it can be dispatched. Setting it to readyState
@@ -590,7 +622,11 @@ export const ticketsSchema = z.strictObject({
590
622
  * does not also redirect spec-gate bounces, aborts, and closed-unmerged PRs
591
623
  * into the ready state, where a bounce would re-dispatch itself. Resolved by
592
624
  * defaultProposalState in src/processes/dispatcher.ts. */
593
- proposalState: z.string().min(1).optional(),
625
+ proposalState: z
626
+ .string()
627
+ .min(1)
628
+ .refine((value) => value.trim().length > 0, 'state must be nonblank')
629
+ .optional(),
594
630
  /** Directory of state dirs (`triage/ ready/ doing/ done/`) holding `<id>.md`
595
631
  * ticket files; optional — defaults to `.autobuild/tickets`, resolved
596
632
  * relative to the repo. Kept schema-optional (not `.default()`) so the
@@ -725,7 +761,18 @@ export type OrchestratorConfig = z.infer<typeof orchestratorSchema>
725
761
 
726
762
  // ── Whole file ───────────────────────────────────────────────────────────────
727
763
 
764
+ export const storeSchema = z.strictObject({
765
+ url: z.string().transform((value, ctx) => {
766
+ try {
767
+ return normalizeServiceURL(value)
768
+ } catch (error) {
769
+ ctx.addIssue({ code: 'custom', message: (error as Error).message })
770
+ return z.NEVER
771
+ }
772
+ }),
773
+ })
728
774
  const configRootSchema = z.strictObject({
775
+ store: storeSchema.optional(),
729
776
  /** Base branch builds cut from and target (§16.1). */
730
777
  baseBranch: z.string().min(1).default('main'),
731
778
  /** Concurrent builds for this repository (§16.1). */
@@ -0,0 +1,86 @@
1
+ import { normalizeGitRemoteUrl } from './kernel/origin'
2
+ /** Public device-approval protocol. Secrets travel only in capability POST bodies. */
3
+ export type ConnectionStatus = 'pending' | 'approved' | 'denied' | 'expired'
4
+ export interface ConnectionProposal {
5
+ repo: string
6
+ credentialId: string
7
+ digest: string
8
+ }
9
+ export interface ConnectionOutcome {
10
+ repo: string
11
+ credentialId: string
12
+ status: ConnectionStatus
13
+ }
14
+ export interface ConnectionStarted extends ConnectionOutcome {
15
+ id: string
16
+ capability: string
17
+ code: string
18
+ verificationURL: string
19
+ interval: number
20
+ expiresAt: string
21
+ }
22
+ export function normalizeServiceURL(raw: string): string {
23
+ let url: URL
24
+ try {
25
+ url = new URL(raw)
26
+ } catch {
27
+ throw new Error('store.url must be an absolute HTTPS service URL')
28
+ }
29
+ if (
30
+ !raw.trim() ||
31
+ raw !== raw.trim() ||
32
+ url.username ||
33
+ url.password ||
34
+ url.search ||
35
+ url.hash ||
36
+ !(
37
+ url.protocol === 'https:' ||
38
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
39
+ )
40
+ ) {
41
+ throw new Error(
42
+ 'store.url must be HTTPS (HTTP is allowed on loopback), without credentials, query or fragment',
43
+ )
44
+ }
45
+ return url.href.replace(/\/+$/, '')
46
+ }
47
+
48
+ export function normalizeConnectionRepository(raw: string): string {
49
+ const value = raw.trim()
50
+ // SSH's conventional git user is transport syntax; URL passwords and HTTPS
51
+ // userinfo are never part of an identity (normalization would discard them).
52
+ if (/[\s\\?#]/.test(value) || !value || (/^[^/]*:[^/]*@/.test(value) && !value.includes('://')))
53
+ throw new Error('invalid repository identity')
54
+ if (value.includes('://')) {
55
+ let input: URL
56
+ try {
57
+ input = new URL(value)
58
+ } catch {
59
+ throw new Error('invalid repository identity')
60
+ }
61
+ if (input.password || (input.username && !['ssh:', 'git+ssh:'].includes(input.protocol))) {
62
+ throw new Error('repository identity must not contain credentials')
63
+ }
64
+ if (input.search || input.hash) throw new Error('invalid repository identity')
65
+ }
66
+ const normalized = normalizeGitRemoteUrl(value)
67
+ let url: URL
68
+ try {
69
+ url = new URL(normalized)
70
+ } catch {
71
+ throw new Error('invalid repository identity')
72
+ }
73
+ if (
74
+ url.protocol !== 'https:' ||
75
+ !url.hostname ||
76
+ url.username ||
77
+ url.password ||
78
+ url.search ||
79
+ url.hash ||
80
+ url.pathname === '/' ||
81
+ /%|\\/.test(url.pathname)
82
+ ) {
83
+ throw new Error('repository identity must be a normalized HTTPS repository origin')
84
+ }
85
+ return normalized
86
+ }