@defrex/autobuild 0.10.1 → 0.11.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 +1 -1
- package/SPEC.md +15 -1
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/configuration.md +32 -3
- package/docs/operator-api.md +17 -0
- package/docs/remote-ticket-protocol.md +5 -0
- package/package.json +3 -3
- package/packages/core/src/cli/binary.ts +3 -3
- package/packages/core/src/cli/dashboard/model.ts +4 -0
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +64 -6
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +8 -5
- package/packages/core/src/cli/repository-status.ts +45 -5
- package/packages/core/src/cli/watch.ts +159 -14
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/schema.ts +9 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/repository.ts +51 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- package/packages/core/src/kernel/reducer-registry.ts +4 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -0
- package/packages/core/src/operator/query.ts +6 -3
- package/packages/core/src/ports/tickets/contract.ts +25 -0
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
- package/packages/core/src/ports/types.ts +17 -0
- package/packages/core/src/processes/dispatcher.ts +277 -73
- package/packages/core/src/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- package/packages/core/src/store/remote/protocol.ts +1 -0
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/skills/guide/SKILL.md +31 -0
- package/skills/operate/SKILL.md +39 -4
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { readDispatcherLiveness } from './dispatcher-liveness'
|
|
2
|
+
import { projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
|
|
3
|
+
import type { RepositoryRecord } from '../store/types'
|
|
1
4
|
/**
|
|
2
5
|
* `ab watch` — the sessionless, read-only streaming half of the status family
|
|
3
6
|
* (§8.2, §7.2). It follows the event logs of one, several, or all active
|
|
4
|
-
* builds in the repository
|
|
5
|
-
* record per matching event as it lands,
|
|
7
|
+
* builds in the repository, plus the repository journal by default with no
|
|
8
|
+
* slugs, and emits one record per matching event as it lands, carrying the durable
|
|
6
9
|
* envelope, the build's reduced state after that event, and an opaque cursor
|
|
7
10
|
* so a later `--since` invocation resumes exactly where this one stopped.
|
|
8
11
|
*
|
|
@@ -102,8 +105,8 @@ export const BUILD_ATTENTION_EVENTS = [
|
|
|
102
105
|
* Compile `--event` globs to anchored matchers: `*` matches any run of
|
|
103
106
|
* characters, `?` exactly one, everything else verbatim (so the dots inside
|
|
104
107
|
* event type names are escaped). Each glob must match at least one type in
|
|
105
|
-
* the build catalog
|
|
106
|
-
*
|
|
108
|
+
* the build catalog, or the repository catalog with no slugs or explicit
|
|
109
|
+
* `--repository` — otherwise a usage error naming the glob is thrown before any
|
|
107
110
|
* store read. Supplying any glob replaces the attention set entirely.
|
|
108
111
|
*
|
|
109
112
|
* The compiler itself lives in `events/globs.ts`, shared with the
|
|
@@ -133,6 +136,7 @@ interface CursorPayload {
|
|
|
133
136
|
/** The repository identity (normalized origin or checkout path). */
|
|
134
137
|
repo: string
|
|
135
138
|
/** Per-stream watch position: build slug → seq, `#repo` → seq. */
|
|
139
|
+
lostRuns?: string[]
|
|
136
140
|
streams: Record<string, number>
|
|
137
141
|
}
|
|
138
142
|
|
|
@@ -169,7 +173,13 @@ export function decodeCursor(
|
|
|
169
173
|
throw malformed()
|
|
170
174
|
}
|
|
171
175
|
if (typeof payload !== 'object' || payload === null) throw malformed()
|
|
172
|
-
const { v, store, repo, streams } = payload as Record<string, unknown>
|
|
176
|
+
const { v, store, repo, streams, lostRuns } = payload as Record<string, unknown>
|
|
177
|
+
if (
|
|
178
|
+
lostRuns !== undefined &&
|
|
179
|
+
(!Array.isArray(lostRuns) ||
|
|
180
|
+
lostRuns.some((run) => typeof run !== 'string' || run.length === 0))
|
|
181
|
+
)
|
|
182
|
+
throw malformed()
|
|
173
183
|
if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
|
|
174
184
|
if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
|
|
175
185
|
for (const seq of Object.values(streams)) {
|
|
@@ -187,7 +197,13 @@ export function decodeCursor(
|
|
|
187
197
|
`repository "${expected.repo}" — run from the repository the cursor came from`,
|
|
188
198
|
)
|
|
189
199
|
}
|
|
190
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
v: 1,
|
|
202
|
+
store,
|
|
203
|
+
repo,
|
|
204
|
+
streams: streams as Record<string, number>,
|
|
205
|
+
...(lostRuns === undefined ? {} : { lostRuns: lostRuns as string[] }),
|
|
206
|
+
}
|
|
191
207
|
}
|
|
192
208
|
|
|
193
209
|
// ── Durations and cadence ────────────────────────────────────────────────────
|
|
@@ -237,6 +253,25 @@ export interface WatchRecordState {
|
|
|
237
253
|
outcome: BuildOutcome | null
|
|
238
254
|
}
|
|
239
255
|
|
|
256
|
+
/** Lease-expiry evidence is an observation, never a fabricated journal event. */
|
|
257
|
+
export interface WatchObservationRecord {
|
|
258
|
+
repo: string
|
|
259
|
+
build: null
|
|
260
|
+
state: null
|
|
261
|
+
observation: {
|
|
262
|
+
type: 'dispatcher.lost'
|
|
263
|
+
run: string
|
|
264
|
+
observedAt: string
|
|
265
|
+
leaseExpiresAt: string
|
|
266
|
+
pid?: number | null
|
|
267
|
+
host?: string | null
|
|
268
|
+
startedAt?: string | null
|
|
269
|
+
heartbeatAt?: string | null
|
|
270
|
+
heartbeatAgeMs?: number | null
|
|
271
|
+
}
|
|
272
|
+
cursor: string
|
|
273
|
+
}
|
|
274
|
+
|
|
240
275
|
export interface WatchRecord {
|
|
241
276
|
/** The build slug, or null for a repository-journal event. */
|
|
242
277
|
build: string | null
|
|
@@ -340,6 +375,10 @@ export function summarizeEvent(event: AbEvent | RepositoryEvent, state: BuildSta
|
|
|
340
375
|
return `harvest run ${event.payload.run} escalated: ${singleLine(event.payload.reason)}`
|
|
341
376
|
case 'harvest.failed':
|
|
342
377
|
return `harvest run ${event.payload.run} failed at ${event.payload.step} (attempt ${event.payload.attempt}): ${singleLine(event.payload.error)}`
|
|
378
|
+
case 'dispatcher.ticket-source-degraded':
|
|
379
|
+
return `ticket source ${event.payload.source} degraded: ${event.payload.cause} (${event.payload.operation}) — ${singleLine(event.payload.message)}${'resetAt' in event.payload ? `; reset ${event.payload.resetAt}` : ''}`
|
|
380
|
+
case 'dispatcher.ticket-source-recovered':
|
|
381
|
+
return `ticket source ${event.payload.source} recovered: ${event.payload.cause} (${event.payload.operation})`
|
|
343
382
|
case 'dispatcher.tick-failed':
|
|
344
383
|
return `dispatcher tick failed: ${singleLine(event.payload.error)}`
|
|
345
384
|
case 'dispatcher.config-rejected':
|
|
@@ -451,6 +490,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
451
490
|
const sleep = opts.delay ?? defaultDelay
|
|
452
491
|
const slugs = [...(opts.slugs ?? [])]
|
|
453
492
|
const repository = opts.repository === true
|
|
493
|
+
const followRepository = repository || slugs.length === 0
|
|
454
494
|
|
|
455
495
|
const timeoutMs =
|
|
456
496
|
opts.timeout !== undefined
|
|
@@ -462,10 +502,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
462
502
|
: undefined
|
|
463
503
|
|
|
464
504
|
// Ambient scope, before any store access.
|
|
465
|
-
assertReadScope(slugs, { repository, env: opts.env })
|
|
505
|
+
assertReadScope(slugs, { repository: followRepository, env: opts.env })
|
|
466
506
|
|
|
467
507
|
// Glob validation needs only the catalogs — before any store access.
|
|
468
|
-
const filters = compileEventGlobs(opts.events ?? [], {
|
|
508
|
+
const filters = compileEventGlobs(opts.events ?? [], {
|
|
509
|
+
repository: followRepository,
|
|
510
|
+
additionalCatalog: followRepository ? ['dispatcher.lost'] : [],
|
|
511
|
+
usage: WATCH_USAGE,
|
|
512
|
+
})
|
|
469
513
|
|
|
470
514
|
await withAmbientReadStore(
|
|
471
515
|
{
|
|
@@ -497,6 +541,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
497
541
|
record.repo === identity ||
|
|
498
542
|
(record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
|
|
499
543
|
|
|
544
|
+
const lostRuns = new Set(
|
|
545
|
+
opts.since ? (decodeCursor(opts.since, { store: storeRef, repo }).lostRuns ?? []) : [],
|
|
546
|
+
)
|
|
547
|
+
let livenessEvents: RepositoryEvent[] = []
|
|
548
|
+
let livenessSeq = 0
|
|
549
|
+
let livenessInitialized = false
|
|
550
|
+
const observedOwners = new Map<string, RepositoryRecord>()
|
|
500
551
|
const streams = new Map<string, Stream>()
|
|
501
552
|
// Every stream's position — matching or not, delivered or baselined —
|
|
502
553
|
// lives here, so a resume never rescans or redelivers a filtered event.
|
|
@@ -514,7 +565,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
514
565
|
}
|
|
515
566
|
|
|
516
567
|
const encodeCurrent = (): string =>
|
|
517
|
-
encodeCursor({
|
|
568
|
+
encodeCursor({
|
|
569
|
+
v: 1,
|
|
570
|
+
store: storeRef,
|
|
571
|
+
repo,
|
|
572
|
+
streams: { ...positions },
|
|
573
|
+
lostRuns: [...lostRuns],
|
|
574
|
+
})
|
|
518
575
|
|
|
519
576
|
/** One record, flushed the moment it is projected. `cursor` is the watch
|
|
520
577
|
* position AFTER this event. */
|
|
@@ -534,6 +591,73 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
534
591
|
}
|
|
535
592
|
}
|
|
536
593
|
|
|
594
|
+
const checkLiveness = async (): Promise<void> => {
|
|
595
|
+
if (!followRepository || stop) return
|
|
596
|
+
if (!livenessInitialized) {
|
|
597
|
+
const input = await readDispatcherLiveness(store, repo, now())
|
|
598
|
+
livenessEvents = input.events
|
|
599
|
+
livenessSeq = input.events.at(-1)?.seq ?? 0
|
|
600
|
+
livenessInitialized = true
|
|
601
|
+
}
|
|
602
|
+
if ((await store.getRepo(repo)) === null) return
|
|
603
|
+
const fresh = await store.getRepoEvents(repo, livenessSeq)
|
|
604
|
+
livenessEvents.push(...fresh)
|
|
605
|
+
livenessSeq = fresh.at(-1)?.seq ?? livenessSeq
|
|
606
|
+
// Sample metadata after the journal. Recheck both immediately before
|
|
607
|
+
// reporting: a failed read is infrastructure, never evidence of death.
|
|
608
|
+
const record = await store.getRepo(repo)
|
|
609
|
+
if (record?.lease) observedOwners.set(record.lease.holder, record)
|
|
610
|
+
for (const [id, owner] of observedOwners) {
|
|
611
|
+
if (
|
|
612
|
+
stop ||
|
|
613
|
+
lostRuns.has(id) ||
|
|
614
|
+
!owner.lease ||
|
|
615
|
+
Date.parse(owner.lease.expiresAt) > now().getTime()
|
|
616
|
+
)
|
|
617
|
+
continue
|
|
618
|
+
const delta = await store.getRepoEvents(repo, livenessSeq)
|
|
619
|
+
livenessEvents.push(...delta)
|
|
620
|
+
livenessSeq = delta.at(-1)?.seq ?? livenessSeq
|
|
621
|
+
const current = await store.getRepo(repo)
|
|
622
|
+
if (current?.lease?.holder === id) {
|
|
623
|
+
observedOwners.set(id, current)
|
|
624
|
+
if (Date.parse(current.lease.expiresAt) > now().getTime()) continue
|
|
625
|
+
}
|
|
626
|
+
const evidence = projectDispatcherLiveness(
|
|
627
|
+
current?.lease?.holder === id ? current : owner,
|
|
628
|
+
livenessEvents,
|
|
629
|
+
now(),
|
|
630
|
+
)
|
|
631
|
+
if (
|
|
632
|
+
livenessEvents.some((e) => e.type === 'dispatcher.run-stopped' && e.payload.run === id)
|
|
633
|
+
) {
|
|
634
|
+
observedOwners.delete(id)
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
if (!(filters.length ? filters.some((f) => f.test('dispatcher.lost')) : true)) continue
|
|
638
|
+
if (stop) return
|
|
639
|
+
lostRuns.add(id)
|
|
640
|
+
const cursor = encodeCurrent()
|
|
641
|
+
const observation = {
|
|
642
|
+
...evidence.expiredHolder,
|
|
643
|
+
type: 'dispatcher.lost',
|
|
644
|
+
run: id,
|
|
645
|
+
observedAt: now().toISOString(),
|
|
646
|
+
leaseExpiresAt: (current?.lease?.holder === id ? current : owner).lease!.expiresAt,
|
|
647
|
+
}
|
|
648
|
+
opts.stdout(
|
|
649
|
+
opts.json
|
|
650
|
+
? JSON.stringify({ repo, build: null, state: null, observation, cursor })
|
|
651
|
+
: `dispatcher.lost ${id} — lease expired ${observation.leaseExpiresAt}`,
|
|
652
|
+
)
|
|
653
|
+
emitted += 1
|
|
654
|
+
if (opts.count !== undefined && emitted >= opts.count) {
|
|
655
|
+
countCursor = cursor
|
|
656
|
+
requestStop()
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
537
661
|
/** Record one event. `prefix` is the event prefix ENDING at `event` —
|
|
538
662
|
* the record's state is the reduction after THIS event, never a
|
|
539
663
|
* reduction over events that landed after it. The poll path passes the
|
|
@@ -678,6 +802,11 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
678
802
|
const tail = await store.getRepoEvents(repo, last)
|
|
679
803
|
last = tail.at(-1)?.seq ?? last
|
|
680
804
|
}
|
|
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)
|
|
681
810
|
stream.lastSeq = last
|
|
682
811
|
positions[REPO_STREAM_KEY] = last
|
|
683
812
|
}
|
|
@@ -752,7 +881,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
752
881
|
tickStreak.onFailure(error)
|
|
753
882
|
}
|
|
754
883
|
}
|
|
755
|
-
if (allReadsOk)
|
|
884
|
+
if (allReadsOk) {
|
|
885
|
+
try {
|
|
886
|
+
await checkLiveness()
|
|
887
|
+
tickStreak.onSuccess()
|
|
888
|
+
} catch (error) {
|
|
889
|
+
tickStreak.onFailure(error)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
756
892
|
}
|
|
757
893
|
|
|
758
894
|
// ── Initial scan ──
|
|
@@ -778,13 +914,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
778
914
|
await trackBuild(key, sinceStreams[key])
|
|
779
915
|
if (stop) break
|
|
780
916
|
}
|
|
781
|
-
if (
|
|
917
|
+
if (followRepository) {
|
|
782
918
|
if (!(await trackRepository())) {
|
|
783
919
|
makeFailureStreak('watch', opts.stderr).onFailure(
|
|
784
920
|
new Error(`could not read the repository journal for "${repo}"`),
|
|
785
921
|
)
|
|
786
922
|
}
|
|
787
|
-
}
|
|
923
|
+
}
|
|
924
|
+
if (!repository && slugs.length === 0) {
|
|
788
925
|
// Membership discovery also baselines the default watch; a failed
|
|
789
926
|
// discovery here is fatal — nothing has started yet.
|
|
790
927
|
try {
|
|
@@ -795,6 +932,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
795
932
|
}
|
|
796
933
|
}
|
|
797
934
|
|
|
935
|
+
if (followRepository && !stop) {
|
|
936
|
+
try {
|
|
937
|
+
await checkLiveness()
|
|
938
|
+
} catch (error) {
|
|
939
|
+
tickStreak.onFailure(error)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
798
942
|
// ── The poll loop ──
|
|
799
943
|
const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
|
|
800
944
|
const aborted = (): boolean => opts.signal?.aborted === true
|
|
@@ -875,10 +1019,11 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
875
1019
|
|
|
876
1020
|
for (const stream of streams.values()) if (!shouldStop) launch(stream)
|
|
877
1021
|
|
|
878
|
-
if (slugs.length === 0 && !shouldStop) {
|
|
1022
|
+
if ((slugs.length === 0 || followRepository) && !shouldStop) {
|
|
879
1023
|
void remoteRunner.runDiscovery({
|
|
880
1024
|
step: async (): Promise<void> => {
|
|
881
|
-
await discoverBuilds()
|
|
1025
|
+
if (slugs.length === 0) await discoverBuilds()
|
|
1026
|
+
await checkLiveness()
|
|
882
1027
|
},
|
|
883
1028
|
// Unconditional, after the catch — exactly where the inline
|
|
884
1029
|
// launch loop ran: streams registered before a mid-discovery
|
|
@@ -644,7 +644,7 @@ const ORCHESTRATOR_APPROVAL_ENTRY = /^[a-z][a-z0-9_.]*(:[a-z0-9_-]+)?$/
|
|
|
644
644
|
/** The attention set a new session's wake settings inherit when the
|
|
645
645
|
* repository does not override `wake`: the build events an operator or agent
|
|
646
646
|
* must wake up for (the same set `ab watch` filters on by default) plus the
|
|
647
|
-
* repository-journal attention events (the same set `ab watch
|
|
647
|
+
* repository-journal attention events (the same set `ab watch`
|
|
648
648
|
* follows), spelled as literal globs. Both halves of the attention set can
|
|
649
649
|
* wake a session. */
|
|
650
650
|
export const defaultOrchestratorWakeGlobs: readonly string[] = [
|
|
@@ -730,6 +730,7 @@ const configRootSchema = z.strictObject({
|
|
|
730
730
|
baseBranch: z.string().min(1).default('main'),
|
|
731
731
|
/** Concurrent builds for this repository (§16.1). */
|
|
732
732
|
capacity: z.number().int().positive().default(1),
|
|
733
|
+
ticketScanIntervalSeconds: z.number().int().positive().default(60),
|
|
733
734
|
/** Forge adapter selected from builtins and configured plugins. */
|
|
734
735
|
forge: z
|
|
735
736
|
.string()
|
|
@@ -769,7 +770,13 @@ const configRootSchema = z.strictObject({
|
|
|
769
770
|
})
|
|
770
771
|
|
|
771
772
|
/** Root metadata keeps strict error prose and documentation coverage honest. */
|
|
772
|
-
export const TOP_LEVEL_SCALARS = [
|
|
773
|
+
export const TOP_LEVEL_SCALARS = [
|
|
774
|
+
'baseBranch',
|
|
775
|
+
'capacity',
|
|
776
|
+
'ticketScanIntervalSeconds',
|
|
777
|
+
'forge',
|
|
778
|
+
'plugins',
|
|
779
|
+
] as const
|
|
773
780
|
export const TOP_LEVEL_TABLES = Object.keys(configRootSchema.shape).filter(
|
|
774
781
|
(key) => !(TOP_LEVEL_SCALARS as readonly string[]).includes(key),
|
|
775
782
|
)
|
|
@@ -10,8 +10,9 @@ import { EVENT_TYPES } from './payloads'
|
|
|
10
10
|
import { REPOSITORY_EVENT_TYPES } from './repository'
|
|
11
11
|
|
|
12
12
|
export interface CompileEventGlobsOptions {
|
|
13
|
-
/** Also accept matches in the repository-journal catalog (the
|
|
14
|
-
* behavior of `ab watch
|
|
13
|
+
/** Also accept matches in the repository-journal catalog (the no-slug or
|
|
14
|
+
* explicit `--repository` behavior of `ab watch`; wait remains build-only). */
|
|
15
|
+
additionalCatalog?: readonly string[]
|
|
15
16
|
repository?: boolean
|
|
16
17
|
usage: string
|
|
17
18
|
}
|
|
@@ -35,7 +36,11 @@ export function compileEventGlobs(
|
|
|
35
36
|
const inBuildCatalog = EVENT_TYPES.some((type) => regex.test(type))
|
|
36
37
|
const inRepositoryCatalog =
|
|
37
38
|
opts.repository === true && REPOSITORY_EVENT_TYPES.some((type) => regex.test(type))
|
|
38
|
-
if (
|
|
39
|
+
if (
|
|
40
|
+
!inBuildCatalog &&
|
|
41
|
+
!inRepositoryCatalog &&
|
|
42
|
+
!opts.additionalCatalog?.some((type) => regex.test(type))
|
|
43
|
+
) {
|
|
39
44
|
throw new Error(
|
|
40
45
|
`--event "${glob}" matches no known event type${
|
|
41
46
|
opts.repository ? ' in the build or repository catalogs' : ' in the build event catalog'
|
|
@@ -64,6 +64,7 @@ const tickCountersSchema = z.strictObject({
|
|
|
64
64
|
/** Added in plugin/API-era 1.4; optional so historical tick facts replay. */
|
|
65
65
|
creationWithheld: z.number().int().nonnegative().optional(),
|
|
66
66
|
dependencyBlocked: z.number().int().nonnegative(),
|
|
67
|
+
dependencyCheckFailed: z.number().int().nonnegative().optional(),
|
|
67
68
|
/** Foreign executions settled from durable facts plus provider liveness.
|
|
68
69
|
* Added with durable supervision; optional so historical tick facts replay. */
|
|
69
70
|
settled: z.number().int().nonnegative().optional(),
|
|
@@ -353,14 +354,30 @@ export const orchestratorSandboxEventPayloadSchemas = {
|
|
|
353
354
|
} as const
|
|
354
355
|
|
|
355
356
|
export const dispatcherStatusEventPayloadSchemas = {
|
|
357
|
+
/** Completed ticket-source measurement, independent of dispatcher runs. */
|
|
358
|
+
'dispatcher.ticket-scan-completed': z.strictObject({
|
|
359
|
+
startedAt: z.iso.datetime(),
|
|
360
|
+
readinessFingerprint: z.string().min(1),
|
|
361
|
+
criteriaFingerprint: z.string().min(1),
|
|
362
|
+
mode: z.enum(['admission', 'observation']),
|
|
363
|
+
queued: z.number().int().nonnegative(),
|
|
364
|
+
invalidTickets: z.number().int().nonnegative(),
|
|
365
|
+
ticketDiagnostics: boundedDiagnostics,
|
|
366
|
+
dependencyBlocked: z.number().int().nonnegative(),
|
|
367
|
+
dependencyDiagnostics: boundedDiagnostics,
|
|
368
|
+
creationWithheld: z.number().int().nonnegative(),
|
|
369
|
+
creationDiagnostics: boundedDiagnostics,
|
|
370
|
+
}),
|
|
356
371
|
/** A terminal frontend follows exactly one run. The referenced artifact is
|
|
357
372
|
* the composed Config the kernel is actually using, never an on-disk guess. */
|
|
358
373
|
'dispatcher.run-started': z.strictObject({
|
|
359
374
|
run: dispatchRun,
|
|
360
375
|
pid: z.number().int().positive(),
|
|
376
|
+
host: z.string().trim().min(1).optional(),
|
|
361
377
|
effectiveConfig: artifactRefSchema,
|
|
362
378
|
roleWarnings: boundedDiagnostics,
|
|
363
379
|
}),
|
|
380
|
+
'dispatcher.stop-requested': z.strictObject({ run: dispatchRun, signal: z.string().min(1) }),
|
|
364
381
|
'dispatcher.run-stopped': z.strictObject({
|
|
365
382
|
run: dispatchRun,
|
|
366
383
|
outcome: z.enum(['normal', 'abnormal', 'forced']),
|
|
@@ -449,7 +466,32 @@ export const dispatcherSettingEventPayloadSchemas = {
|
|
|
449
466
|
'dispatcher.auto-merge-default-set': dispatcherSetting,
|
|
450
467
|
} as const
|
|
451
468
|
|
|
469
|
+
const ticketSourceCondition = {
|
|
470
|
+
source: z.string().min(1),
|
|
471
|
+
operation: z.enum(['ready-scan', 'dependency-check']),
|
|
472
|
+
message: z.string().min(1),
|
|
473
|
+
}
|
|
474
|
+
export const ticketSourceHealthEventPayloadSchemas = {
|
|
475
|
+
/** An existing listing-limit episode also encountered a limited dependency
|
|
476
|
+
* read. Strengthens recovery evidence without closing or reopening it. */
|
|
477
|
+
'dispatcher.ticket-source-dependency-limited': z.strictObject({ source: z.string().min(1) }),
|
|
478
|
+
'dispatcher.ticket-source-degraded': z.discriminatedUnion('cause', [
|
|
479
|
+
z.strictObject({
|
|
480
|
+
...ticketSourceCondition,
|
|
481
|
+
cause: z.literal('rate-limited'),
|
|
482
|
+
resetAt: z.iso.datetime(),
|
|
483
|
+
}),
|
|
484
|
+
z.strictObject({ ...ticketSourceCondition, cause: z.literal('ready-scan-failed') }),
|
|
485
|
+
]),
|
|
486
|
+
'dispatcher.ticket-source-recovered': z.strictObject({
|
|
487
|
+
source: z.string().min(1),
|
|
488
|
+
cause: z.enum(['rate-limited', 'ready-scan-failed']),
|
|
489
|
+
operation: z.enum(['ready-scan', 'dependency-check']),
|
|
490
|
+
}),
|
|
491
|
+
} as const
|
|
492
|
+
|
|
452
493
|
export const repositoryEventPayloadSchemas = {
|
|
494
|
+
...ticketSourceHealthEventPayloadSchemas,
|
|
453
495
|
...harvestEventPayloadSchemas,
|
|
454
496
|
...orchestratorSandboxEventPayloadSchemas,
|
|
455
497
|
...dispatcherStatusEventPayloadSchemas,
|
|
@@ -461,11 +503,13 @@ export const REPOSITORY_EVENT_TYPES = Object.keys(
|
|
|
461
503
|
repositoryEventPayloadSchemas,
|
|
462
504
|
) as RepositoryEventType[]
|
|
463
505
|
|
|
464
|
-
/**
|
|
465
|
-
*
|
|
506
|
+
/** Repository attention followed by `ab watch` with no slugs or explicit
|
|
507
|
+
* `--repository`, and scanned by the orchestrator wake pass. */
|
|
466
508
|
export const REPOSITORY_ATTENTION_EVENTS = [
|
|
467
509
|
'harvest.escalated',
|
|
468
510
|
'harvest.failed',
|
|
511
|
+
'dispatcher.ticket-source-degraded',
|
|
512
|
+
'dispatcher.ticket-source-recovered',
|
|
469
513
|
'dispatcher.tick-failed',
|
|
470
514
|
'dispatcher.config-rejected',
|
|
471
515
|
'dispatcher.harvest-runner-failed',
|
|
@@ -539,12 +583,17 @@ const allowedActorKinds: Record<RepositoryEventType, readonly ActorKind[]> = {
|
|
|
539
583
|
'orchestrator.sandbox.published': ['human'],
|
|
540
584
|
'orchestrator.sandbox.publish-failed': ['human'],
|
|
541
585
|
'dispatcher.run-started': ['dispatcher'],
|
|
586
|
+
'dispatcher.stop-requested': ['dispatcher'],
|
|
542
587
|
'dispatcher.run-stopped': ['dispatcher'],
|
|
543
588
|
'dispatcher.tick-yielded': ['dispatcher'],
|
|
544
589
|
'dispatcher.config-rejected': ['dispatcher'],
|
|
545
590
|
'dispatcher.config-publication-failed': ['dispatcher'],
|
|
546
591
|
'dispatcher.tick-started': ['dispatcher'],
|
|
547
592
|
'dispatcher.tick-completed': ['dispatcher'],
|
|
593
|
+
'dispatcher.ticket-scan-completed': ['dispatcher'],
|
|
594
|
+
'dispatcher.ticket-source-dependency-limited': ['dispatcher'],
|
|
595
|
+
'dispatcher.ticket-source-degraded': ['dispatcher'],
|
|
596
|
+
'dispatcher.ticket-source-recovered': ['dispatcher'],
|
|
548
597
|
'dispatcher.tick-failed': ['dispatcher'],
|
|
549
598
|
'dispatcher.runner-settled': ['dispatcher'],
|
|
550
599
|
'dispatcher.harvest-runner-failed': ['dispatcher'],
|
|
@@ -124,6 +124,7 @@ export function readyTicket(id: string, over: Partial<Omit<Ticket, 'ref'>> = {})
|
|
|
124
124
|
title,
|
|
125
125
|
body: over.body ?? CONFORMING_BODY,
|
|
126
126
|
state: over.state ?? 'Ready',
|
|
127
|
+
resolved: false,
|
|
127
128
|
labels: over.labels ?? ['autobuild'],
|
|
128
129
|
}
|
|
129
130
|
}
|
|
@@ -369,7 +370,7 @@ export async function assertSnapshotEquivalence(store: BuildStore, repo: string)
|
|
|
369
370
|
dashboard: await attempt(() =>
|
|
370
371
|
getOperatorDashboard({ store: target, repo, clock: () => at }),
|
|
371
372
|
),
|
|
372
|
-
status: await attempt(() => getRepositoryStatus(target, repo)),
|
|
373
|
+
status: await attempt(() => getRepositoryStatus(target, repo, at)),
|
|
373
374
|
harvest: await attempt(() => getHarvestStatus(target, repo)),
|
|
374
375
|
lists: await Promise.all(
|
|
375
376
|
(['active', 'queued', 'all'] as const).map((scope) =>
|
|
@@ -236,6 +236,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
236
236
|
"$.lastEvent.payload.report.rev: number",
|
|
237
237
|
"$.lastEvent.payload.report: object",
|
|
238
238
|
"$.lastEvent.payload.request: number",
|
|
239
|
+
"$.lastEvent.payload.resetAt: string",
|
|
239
240
|
"$.lastEvent.payload.retryable: boolean",
|
|
240
241
|
"$.lastEvent.payload.role: string",
|
|
241
242
|
"$.lastEvent.payload.roleWarnings: array",
|
|
@@ -414,6 +415,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
414
415
|
"$.observations[].ts: string",
|
|
415
416
|
"$.observations[]: object",
|
|
416
417
|
"$.openInstance: null",
|
|
418
|
+
"$.readinessChangeSeq: number",
|
|
417
419
|
"$.reclaimed: array",
|
|
418
420
|
"$.terminal: string",
|
|
419
421
|
"$: object",
|
|
@@ -436,6 +438,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
436
438
|
"$.entries[].event: object",
|
|
437
439
|
"$.entries[].headSha: string",
|
|
438
440
|
"$.entries[].seq: number",
|
|
441
|
+
"$.entries[].seq: undefined",
|
|
439
442
|
"$.entries[].t: string",
|
|
440
443
|
"$.entries[]: object",
|
|
441
444
|
"$: object",
|
|
@@ -611,6 +614,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
611
614
|
"$.run: string",
|
|
612
615
|
"$.warningNotice: string",
|
|
613
616
|
"$.warningNotice: undefined",
|
|
617
|
+
"$.yieldedHolder: undefined",
|
|
614
618
|
"$: object",
|
|
615
619
|
],
|
|
616
620
|
"frozenPrImageHost": [
|
|
@@ -837,6 +841,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
837
841
|
"$.retained[].actor.user: string",
|
|
838
842
|
"$.retained[].actor: object",
|
|
839
843
|
"$.retained[].payload.attempt: number",
|
|
844
|
+
"$.retained[].payload.cause: string",
|
|
840
845
|
"$.retained[].payload.counters.claimed: number",
|
|
841
846
|
"$.retained[].payload.counters.closed: number",
|
|
842
847
|
"$.retained[].payload.counters.conflicted: number",
|
|
@@ -872,6 +877,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
872
877
|
"$.retained[].payload.observations[].build: string",
|
|
873
878
|
"$.retained[].payload.observations[].seq: number",
|
|
874
879
|
"$.retained[].payload.observations[]: object",
|
|
880
|
+
"$.retained[].payload.operation: string",
|
|
875
881
|
"$.retained[].payload.operator: string",
|
|
876
882
|
"$.retained[].payload.outcome: string",
|
|
877
883
|
"$.retained[].payload.pid: number",
|
|
@@ -881,6 +887,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
881
887
|
"$.retained[].payload.report.kind: string",
|
|
882
888
|
"$.retained[].payload.report.rev: number",
|
|
883
889
|
"$.retained[].payload.report: object",
|
|
890
|
+
"$.retained[].payload.resetAt: string",
|
|
884
891
|
"$.retained[].payload.roleWarnings: array",
|
|
885
892
|
"$.retained[].payload.roleWarnings[]: string",
|
|
886
893
|
"$.retained[].payload.run: string",
|
|
@@ -1103,6 +1110,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
1103
1110
|
"$.ledger[].request: number",
|
|
1104
1111
|
"$.ledger[].round: number",
|
|
1105
1112
|
"$.ledger[].seq: number",
|
|
1113
|
+
"$.ledger[].seq: undefined",
|
|
1106
1114
|
"$.ledger[].step: string",
|
|
1107
1115
|
"$.ledger[].t: string",
|
|
1108
1116
|
"$.ledger[]: object",
|
|
@@ -1245,6 +1253,7 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
1245
1253
|
"$.retained[].payload.report.rev: number",
|
|
1246
1254
|
"$.retained[].payload.report: object",
|
|
1247
1255
|
"$.retained[].payload.request: number",
|
|
1256
|
+
"$.retained[].payload.resetAt: string",
|
|
1248
1257
|
"$.retained[].payload.resolution: string",
|
|
1249
1258
|
"$.retained[].payload.retryable: boolean",
|
|
1250
1259
|
"$.retained[].payload.role: string",
|
|
@@ -1359,6 +1368,15 @@ exports[`reducer registry accumulator shapes 1`] = `
|
|
|
1359
1368
|
"$.failures[]: number",
|
|
1360
1369
|
"$: object",
|
|
1361
1370
|
],
|
|
1371
|
+
"ticketSourceHealth": [
|
|
1372
|
+
"$.active.cause: string",
|
|
1373
|
+
"$.active.message: string",
|
|
1374
|
+
"$.active.operation: string",
|
|
1375
|
+
"$.active.resetAt: string",
|
|
1376
|
+
"$.active.source: string",
|
|
1377
|
+
"$.active: object",
|
|
1378
|
+
"$: object",
|
|
1379
|
+
],
|
|
1362
1380
|
"verifyDiffBase": [
|
|
1363
1381
|
"$.pendingReconcileBase: string",
|
|
1364
1382
|
"$.promoted: string",
|
|
@@ -12,6 +12,7 @@ export interface DispatchStatus {
|
|
|
12
12
|
queued?: number
|
|
13
13
|
/** Standing count from newer tick facts; absent for historical events. */
|
|
14
14
|
creationWithheld?: number
|
|
15
|
+
yieldedHolder?: string
|
|
15
16
|
availableUpgrade?: string
|
|
16
17
|
roleWarnings: string[]
|
|
17
18
|
diagnostics: string[]
|
|
@@ -58,6 +59,7 @@ export function reduceDispatchStatus(
|
|
|
58
59
|
state.lastSeq = event.seq
|
|
59
60
|
switch (event.type) {
|
|
60
61
|
case 'dispatcher.run-started':
|
|
62
|
+
state.yieldedHolder = undefined
|
|
61
63
|
state.health = 'running'
|
|
62
64
|
state.effectiveConfig = event.payload.effectiveConfig
|
|
63
65
|
state.roleWarnings = [...event.payload.roleWarnings]
|
|
@@ -88,6 +90,12 @@ export function reduceDispatchStatus(
|
|
|
88
90
|
)
|
|
89
91
|
state.warningNotice = state.notice
|
|
90
92
|
break
|
|
93
|
+
case 'dispatcher.tick-yielded':
|
|
94
|
+
state.yieldedHolder = event.payload.holder
|
|
95
|
+
break
|
|
96
|
+
case 'dispatcher.tick-started':
|
|
97
|
+
state.yieldedHolder = undefined
|
|
98
|
+
break
|
|
91
99
|
case 'dispatcher.tick-completed':
|
|
92
100
|
state.queued = event.payload.queued
|
|
93
101
|
state.creationWithheld = event.payload.counters.creationWithheld
|
|
@@ -143,7 +151,7 @@ export function reduceDispatchStatus(
|
|
|
143
151
|
|
|
144
152
|
/** Bump when `DispatchStatus`'s shape or fold semantics change (it is its own
|
|
145
153
|
* accumulator: `lastSeq` makes the fold skip already-seen events). */
|
|
146
|
-
export const DISPATCH_STATUS_REDUCER_VERSION =
|
|
154
|
+
export const DISPATCH_STATUS_REDUCER_VERSION = 2
|
|
147
155
|
|
|
148
156
|
/** The incremental form of `reduceDispatchStatus` for one run. The run is a
|
|
149
157
|
* parameter, so this is a factory. */
|