@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.
- package/README.md +3 -1
- package/SPEC.md +222 -22
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +105 -16
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +214 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +90 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +107 -19
- package/docs/setup.md +173 -16
- package/docs/ticket-dependencies.md +1 -1
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +261 -0
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dispatch.ts +183 -41
- package/packages/core/src/cli/help.ts +39 -10
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/main.ts +41 -15
- package/packages/core/src/cli/mcp.ts +7 -1
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +10 -0
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket.ts +8 -1
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +56 -9
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +10 -0
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +8 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/ticket-source-backend.ts +1 -0
- package/packages/core/src/operator/tickets.ts +6 -8
- package/packages/core/src/plugin-sdk/index.ts +1 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +3 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +1 -0
- package/packages/core/src/ports/tickets/create.ts +11 -3
- package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -0
- package/packages/core/src/ports/workspace/build-execution.ts +5 -1
- package/packages/core/src/ports/workspace/create.ts +11 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +59 -10
- package/packages/core/src/processes/harvest-child.ts +1 -0
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +142 -100
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +144 -26
- package/skills/guide/references/operator-api.md +135 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +189 -144
- package/skills/guide/references/remote-ticket-protocol.md +111 -18
- package/skills/guide/references/setup.md +173 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/operate/SKILL.md +126 -36
- package/templates/hosted-service/package.json +13 -0
- 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
|
|
46
|
-
import {
|
|
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<
|
|
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
|
-
|
|
671
|
-
emit(event,
|
|
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
|
-
|
|
678
|
-
|
|
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)
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
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
|
|
410
|
-
* subtables, so it cannot be a strictObject;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|