@defrex/autobuild 0.10.1 → 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 +237 -23
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +137 -19
- 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 +107 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +111 -18
- 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/binary.ts +3 -3
- 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 +8 -5
- 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 +247 -47
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +45 -13
- 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/repository-status.ts +45 -5
- 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 +350 -68
- package/packages/core/src/config/live.ts +4 -0
- package/packages/core/src/config/schema.ts +65 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +64 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
- 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/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 +12 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -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 +44 -19
- 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/contract.ts +25 -0
- package/packages/core/src/ports/tickets/create.ts +11 -3
- 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/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -0
- package/packages/core/src/ports/types.ts +17 -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 +336 -83
- 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/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/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 +26 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +175 -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 +159 -34
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -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
|
*
|
|
@@ -39,8 +42,15 @@
|
|
|
39
42
|
*/
|
|
40
43
|
import type { AbEvent } from '../events/catalog'
|
|
41
44
|
import type { EventType } from '../events/payloads'
|
|
42
|
-
import type { RepositoryEvent
|
|
43
|
-
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'
|
|
44
54
|
import type { BuildState, PrLifecycle } from '../kernel/reducer'
|
|
45
55
|
import { reduceBuild } from '../kernel/reducer'
|
|
46
56
|
import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
|
|
@@ -81,20 +91,7 @@ const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
|
|
|
81
91
|
* a watch emits exactly these — deciding what to do about a blocker or a
|
|
82
92
|
* merge stays with the caller's skill.
|
|
83
93
|
*/
|
|
84
|
-
export const BUILD_ATTENTION_EVENTS = [
|
|
85
|
-
'escalation.raised',
|
|
86
|
-
'phase.failed',
|
|
87
|
-
'infrastructure.failed',
|
|
88
|
-
'runner.setup-failed',
|
|
89
|
-
'dispatch.failed',
|
|
90
|
-
'publication.lost',
|
|
91
|
-
'finalize.completed',
|
|
92
|
-
'pr.conflicted',
|
|
93
|
-
'pr.merged',
|
|
94
|
-
'pr.closed',
|
|
95
|
-
'build.completed',
|
|
96
|
-
'build.aborted',
|
|
97
|
-
] as const satisfies readonly EventType[]
|
|
94
|
+
export const BUILD_ATTENTION_EVENTS = ['escalation.raised'] as const satisfies readonly EventType[]
|
|
98
95
|
|
|
99
96
|
// ── Glob compilation and matching ────────────────────────────────────────────
|
|
100
97
|
|
|
@@ -102,8 +99,8 @@ export const BUILD_ATTENTION_EVENTS = [
|
|
|
102
99
|
* Compile `--event` globs to anchored matchers: `*` matches any run of
|
|
103
100
|
* characters, `?` exactly one, everything else verbatim (so the dots inside
|
|
104
101
|
* event type names are escaped). Each glob must match at least one type in
|
|
105
|
-
* the build catalog
|
|
106
|
-
*
|
|
102
|
+
* the build catalog, or the repository catalog with no slugs or explicit
|
|
103
|
+
* `--repository` — otherwise a usage error naming the glob is thrown before any
|
|
107
104
|
* store read. Supplying any glob replaces the attention set entirely.
|
|
108
105
|
*
|
|
109
106
|
* The compiler itself lives in `events/globs.ts`, shared with the
|
|
@@ -118,12 +115,6 @@ function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolea
|
|
|
118
115
|
: (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
|
|
119
116
|
}
|
|
120
117
|
|
|
121
|
-
function matchesRepositoryFilter(type: RepositoryEventType, filters: readonly RegExp[]): boolean {
|
|
122
|
-
return filters.length > 0
|
|
123
|
-
? filters.some((regex) => regex.test(type))
|
|
124
|
-
: (REPOSITORY_ATTENTION_EVENTS as readonly string[]).includes(type)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
118
|
// ── The cursor ───────────────────────────────────────────────────────────────
|
|
128
119
|
|
|
129
120
|
interface CursorPayload {
|
|
@@ -133,9 +124,29 @@ interface CursorPayload {
|
|
|
133
124
|
/** The repository identity (normalized origin or checkout path). */
|
|
134
125
|
repo: string
|
|
135
126
|
/** Per-stream watch position: build slug → seq, `#repo` → seq. */
|
|
127
|
+
deliveredAttention?: string[]
|
|
128
|
+
lostRuns?: string[]
|
|
136
129
|
streams: Record<string, number>
|
|
137
130
|
}
|
|
138
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
|
+
|
|
139
150
|
const CURSOR_PREFIX = 'v1.'
|
|
140
151
|
|
|
141
152
|
/** Opaque to callers; the format is internal, not contract. */
|
|
@@ -169,7 +180,22 @@ export function decodeCursor(
|
|
|
169
180
|
throw malformed()
|
|
170
181
|
}
|
|
171
182
|
if (typeof payload !== 'object' || payload === null) throw malformed()
|
|
172
|
-
const { v, store, repo, streams } = payload as Record<
|
|
183
|
+
const { v, store, repo, streams, lostRuns, deliveredAttention } = payload as Record<
|
|
184
|
+
string,
|
|
185
|
+
unknown
|
|
186
|
+
>
|
|
187
|
+
if (
|
|
188
|
+
lostRuns !== undefined &&
|
|
189
|
+
(!Array.isArray(lostRuns) ||
|
|
190
|
+
lostRuns.some((run) => typeof run !== 'string' || run.length === 0))
|
|
191
|
+
)
|
|
192
|
+
throw malformed()
|
|
193
|
+
if (
|
|
194
|
+
deliveredAttention !== undefined &&
|
|
195
|
+
(!Array.isArray(deliveredAttention) ||
|
|
196
|
+
deliveredAttention.some((id) => !validAttentionIdentity(id)))
|
|
197
|
+
)
|
|
198
|
+
throw malformed()
|
|
173
199
|
if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
|
|
174
200
|
if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
|
|
175
201
|
for (const seq of Object.values(streams)) {
|
|
@@ -187,7 +213,16 @@ export function decodeCursor(
|
|
|
187
213
|
`repository "${expected.repo}" — run from the repository the cursor came from`,
|
|
188
214
|
)
|
|
189
215
|
}
|
|
190
|
-
return {
|
|
216
|
+
return {
|
|
217
|
+
v: 1,
|
|
218
|
+
store,
|
|
219
|
+
repo,
|
|
220
|
+
streams: streams as Record<string, number>,
|
|
221
|
+
...(deliveredAttention === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { deliveredAttention: deliveredAttention as string[] }),
|
|
224
|
+
...(lostRuns === undefined ? {} : { lostRuns: lostRuns as string[] }),
|
|
225
|
+
}
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
// ── Durations and cadence ────────────────────────────────────────────────────
|
|
@@ -237,6 +272,55 @@ export interface WatchRecordState {
|
|
|
237
272
|
outcome: BuildOutcome | null
|
|
238
273
|
}
|
|
239
274
|
|
|
275
|
+
/** Lease-expiry evidence is an observation, never a fabricated journal event. */
|
|
276
|
+
export interface WatchObservationRecord {
|
|
277
|
+
repo: string
|
|
278
|
+
build: null
|
|
279
|
+
state: null
|
|
280
|
+
observation: {
|
|
281
|
+
type: 'dispatcher.lost'
|
|
282
|
+
run: string
|
|
283
|
+
observedAt: string
|
|
284
|
+
leaseExpiresAt: string
|
|
285
|
+
pid?: number | null
|
|
286
|
+
host?: string | null
|
|
287
|
+
startedAt?: string | null
|
|
288
|
+
heartbeatAt?: string | null
|
|
289
|
+
heartbeatAgeMs?: number | null
|
|
290
|
+
}
|
|
291
|
+
cursor: string
|
|
292
|
+
}
|
|
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
|
+
|
|
240
324
|
export interface WatchRecord {
|
|
241
325
|
/** The build slug, or null for a repository-journal event. */
|
|
242
326
|
build: string | null
|
|
@@ -340,6 +424,10 @@ export function summarizeEvent(event: AbEvent | RepositoryEvent, state: BuildSta
|
|
|
340
424
|
return `harvest run ${event.payload.run} escalated: ${singleLine(event.payload.reason)}`
|
|
341
425
|
case 'harvest.failed':
|
|
342
426
|
return `harvest run ${event.payload.run} failed at ${event.payload.step} (attempt ${event.payload.attempt}): ${singleLine(event.payload.error)}`
|
|
427
|
+
case 'dispatcher.ticket-source-degraded':
|
|
428
|
+
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}` : ''}`
|
|
429
|
+
case 'dispatcher.ticket-source-recovered':
|
|
430
|
+
return `ticket source ${event.payload.source} recovered: ${event.payload.cause} (${event.payload.operation})`
|
|
343
431
|
case 'dispatcher.tick-failed':
|
|
344
432
|
return `dispatcher tick failed: ${singleLine(event.payload.error)}`
|
|
345
433
|
case 'dispatcher.config-rejected':
|
|
@@ -451,6 +539,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
451
539
|
const sleep = opts.delay ?? defaultDelay
|
|
452
540
|
const slugs = [...(opts.slugs ?? [])]
|
|
453
541
|
const repository = opts.repository === true
|
|
542
|
+
const followRepository = repository || slugs.length === 0
|
|
454
543
|
|
|
455
544
|
const timeoutMs =
|
|
456
545
|
opts.timeout !== undefined
|
|
@@ -462,10 +551,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
462
551
|
: undefined
|
|
463
552
|
|
|
464
553
|
// Ambient scope, before any store access.
|
|
465
|
-
assertReadScope(slugs, { repository, env: opts.env })
|
|
554
|
+
assertReadScope(slugs, { repository: followRepository, env: opts.env })
|
|
466
555
|
|
|
467
556
|
// Glob validation needs only the catalogs — before any store access.
|
|
468
|
-
const filters = compileEventGlobs(opts.events ?? [], {
|
|
557
|
+
const filters = compileEventGlobs(opts.events ?? [], {
|
|
558
|
+
repository: followRepository,
|
|
559
|
+
additionalCatalog: followRepository ? ['dispatcher.lost'] : [],
|
|
560
|
+
usage: WATCH_USAGE,
|
|
561
|
+
})
|
|
469
562
|
|
|
470
563
|
await withAmbientReadStore(
|
|
471
564
|
{
|
|
@@ -497,6 +590,19 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
497
590
|
record.repo === identity ||
|
|
498
591
|
(record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
|
|
499
592
|
|
|
593
|
+
const lostRuns = new Set(
|
|
594
|
+
opts.since ? (decodeCursor(opts.since, { store: storeRef, repo }).lostRuns ?? []) : [],
|
|
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
|
|
602
|
+
let livenessEvents: RepositoryEvent[] = []
|
|
603
|
+
let livenessSeq = 0
|
|
604
|
+
let livenessInitialized = false
|
|
605
|
+
const observedOwners = new Map<string, RepositoryRecord>()
|
|
500
606
|
const streams = new Map<string, Stream>()
|
|
501
607
|
// Every stream's position — matching or not, delivered or baselined —
|
|
502
608
|
// lives here, so a resume never rescans or redelivers a filtered event.
|
|
@@ -514,7 +620,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
514
620
|
}
|
|
515
621
|
|
|
516
622
|
const encodeCurrent = (): string =>
|
|
517
|
-
encodeCursor({
|
|
623
|
+
encodeCursor({
|
|
624
|
+
v: 1,
|
|
625
|
+
store: storeRef,
|
|
626
|
+
repo,
|
|
627
|
+
streams: { ...positions },
|
|
628
|
+
lostRuns: [...lostRuns],
|
|
629
|
+
deliveredAttention: [...deliveredAttention],
|
|
630
|
+
})
|
|
518
631
|
|
|
519
632
|
/** One record, flushed the moment it is projected. `cursor` is the watch
|
|
520
633
|
* position AFTER this event. */
|
|
@@ -534,6 +647,111 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
534
647
|
}
|
|
535
648
|
}
|
|
536
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
|
+
|
|
688
|
+
const checkLiveness = async (): Promise<void> => {
|
|
689
|
+
if (!followRepository || stop) return
|
|
690
|
+
if (!livenessInitialized) {
|
|
691
|
+
const input = await readDispatcherLiveness(store, repo, now())
|
|
692
|
+
livenessEvents = input.events
|
|
693
|
+
livenessSeq = input.events.at(-1)?.seq ?? 0
|
|
694
|
+
livenessInitialized = true
|
|
695
|
+
}
|
|
696
|
+
if ((await store.getRepo(repo)) === null) return
|
|
697
|
+
const fresh = await store.getRepoEvents(repo, livenessSeq)
|
|
698
|
+
livenessEvents.push(...fresh)
|
|
699
|
+
livenessSeq = fresh.at(-1)?.seq ?? livenessSeq
|
|
700
|
+
// Sample metadata after the journal. Recheck both immediately before
|
|
701
|
+
// reporting: a failed read is infrastructure, never evidence of death.
|
|
702
|
+
const record = await store.getRepo(repo)
|
|
703
|
+
if (record?.lease) observedOwners.set(record.lease.holder, record)
|
|
704
|
+
for (const [id, owner] of observedOwners) {
|
|
705
|
+
if (
|
|
706
|
+
stop ||
|
|
707
|
+
lostRuns.has(id) ||
|
|
708
|
+
!owner.lease ||
|
|
709
|
+
Date.parse(owner.lease.expiresAt) > now().getTime()
|
|
710
|
+
)
|
|
711
|
+
continue
|
|
712
|
+
const delta = await store.getRepoEvents(repo, livenessSeq)
|
|
713
|
+
livenessEvents.push(...delta)
|
|
714
|
+
livenessSeq = delta.at(-1)?.seq ?? livenessSeq
|
|
715
|
+
const current = await store.getRepo(repo)
|
|
716
|
+
if (current?.lease?.holder === id) {
|
|
717
|
+
observedOwners.set(id, current)
|
|
718
|
+
if (Date.parse(current.lease.expiresAt) > now().getTime()) continue
|
|
719
|
+
}
|
|
720
|
+
const evidence = projectDispatcherLiveness(
|
|
721
|
+
current?.lease?.holder === id ? current : owner,
|
|
722
|
+
livenessEvents,
|
|
723
|
+
now(),
|
|
724
|
+
)
|
|
725
|
+
if (
|
|
726
|
+
livenessEvents.some((e) => e.type === 'dispatcher.run-stopped' && e.payload.run === id)
|
|
727
|
+
) {
|
|
728
|
+
observedOwners.delete(id)
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
if (!(filters.length ? filters.some((f) => f.test('dispatcher.lost')) : true)) continue
|
|
732
|
+
if (stop) return
|
|
733
|
+
lostRuns.add(id)
|
|
734
|
+
const cursor = encodeCurrent()
|
|
735
|
+
const observation = {
|
|
736
|
+
...evidence.expiredHolder,
|
|
737
|
+
type: 'dispatcher.lost',
|
|
738
|
+
run: id,
|
|
739
|
+
observedAt: now().toISOString(),
|
|
740
|
+
leaseExpiresAt: (current?.lease?.holder === id ? current : owner).lease!.expiresAt,
|
|
741
|
+
}
|
|
742
|
+
opts.stdout(
|
|
743
|
+
opts.json
|
|
744
|
+
? JSON.stringify({ repo, build: null, state: null, observation, cursor })
|
|
745
|
+
: `dispatcher.lost ${id} — lease expired ${observation.leaseExpiresAt}`,
|
|
746
|
+
)
|
|
747
|
+
emitted += 1
|
|
748
|
+
if (opts.count !== undefined && emitted >= opts.count) {
|
|
749
|
+
countCursor = cursor
|
|
750
|
+
requestStop()
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
537
755
|
/** Record one event. `prefix` is the event prefix ENDING at `event` —
|
|
538
756
|
* the record's state is the reduction after THIS event, never a
|
|
539
757
|
* reduction over events that landed after it. The poll path passes the
|
|
@@ -541,17 +759,34 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
541
759
|
* into a scratch prefix so a resumed record never leaks state from
|
|
542
760
|
* events that landed after it. */
|
|
543
761
|
const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
|
|
762
|
+
if (stop) return
|
|
544
763
|
stream.lastSeq = event.seq
|
|
545
764
|
positions[stream.slug] = event.seq
|
|
546
|
-
|
|
547
|
-
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
|
+
}
|
|
548
777
|
}
|
|
549
778
|
|
|
550
779
|
const processRepositoryEvent = (stream: RepositoryStream, event: RepositoryEvent): void => {
|
|
780
|
+
if (stop) return
|
|
551
781
|
stream.lastSeq = event.seq
|
|
552
782
|
positions[REPO_STREAM_KEY] = event.seq
|
|
553
|
-
|
|
554
|
-
|
|
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)
|
|
555
790
|
}
|
|
556
791
|
|
|
557
792
|
/**
|
|
@@ -581,7 +816,16 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
581
816
|
}
|
|
582
817
|
streams.set(slug, stream)
|
|
583
818
|
positions[slug] = stream.lastSeq
|
|
584
|
-
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
|
+
)
|
|
585
829
|
stream.lastSeq = resumeSeq
|
|
586
830
|
// Replay the backlog into a scratch prefix: each record's state is
|
|
587
831
|
// the reduction of the prefix ENDING at that event, never the
|
|
@@ -647,6 +891,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
647
891
|
status,
|
|
648
892
|
}
|
|
649
893
|
streams.set(record.slug, stream)
|
|
894
|
+
baselineMerge(record.slug, events)
|
|
650
895
|
positions[record.slug] = stream.lastSeq
|
|
651
896
|
}
|
|
652
897
|
}
|
|
@@ -657,30 +902,22 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
657
902
|
streams.set(REPO_STREAM_KEY, stream)
|
|
658
903
|
positions[REPO_STREAM_KEY] = stream.lastSeq
|
|
659
904
|
try {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// subset's max seq equals the journal's max seq.
|
|
667
|
-
const subset = await store.getRepoStateEvents(repo)
|
|
668
|
-
let last = subset.at(-1)?.seq ?? 0
|
|
669
|
-
if (!subset.some((event) => event.type === 'dispatcher.run-started')) {
|
|
670
|
-
// Anchor-less journal: the subset may have dropped run-scoped
|
|
671
|
-
// facts appended before any anchor, so one bounded delta read
|
|
672
|
-
// recovers the true end; its events are baselined silently,
|
|
673
|
-
// exactly as the full read baselines them. The skip condition
|
|
674
|
-
// is exact: a run-started is its own anchor or lies in a later
|
|
675
|
-
// anchor's tail, so "no run-started in the subset" means "no
|
|
676
|
-
// run-started in the journal" — the only case where the subset
|
|
677
|
-
// can trail.
|
|
678
|
-
const tail = await store.getRepoEvents(repo, last)
|
|
679
|
-
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)
|
|
680
911
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
684
921
|
return true
|
|
685
922
|
} catch {
|
|
686
923
|
return false
|
|
@@ -693,6 +930,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
693
930
|
): Promise<boolean> => {
|
|
694
931
|
const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
|
|
695
932
|
for (const event of fresh) {
|
|
933
|
+
if (stop) break
|
|
696
934
|
stream.events.push(event)
|
|
697
935
|
processBuildEvent(stream, event, stream.events)
|
|
698
936
|
if (stop) break
|
|
@@ -703,17 +941,43 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
703
941
|
return true
|
|
704
942
|
}
|
|
705
943
|
|
|
944
|
+
let repositoryQueue = Promise.resolve()
|
|
945
|
+
let repositoryInitialization: Promise<void> | undefined
|
|
706
946
|
const pollRepository = async (
|
|
707
947
|
stream: RepositoryStream,
|
|
708
948
|
readOpts?: RemotePollReadOpts,
|
|
709
949
|
): Promise<boolean> => {
|
|
710
950
|
// A repository row may be created mid-watch by the first dispatch.
|
|
711
951
|
if ((await store.getRepo(repo)) === null) return true
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
}
|
|
716
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
|
|
717
981
|
return true
|
|
718
982
|
}
|
|
719
983
|
|
|
@@ -752,7 +1016,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
752
1016
|
tickStreak.onFailure(error)
|
|
753
1017
|
}
|
|
754
1018
|
}
|
|
755
|
-
if (allReadsOk)
|
|
1019
|
+
if (allReadsOk) {
|
|
1020
|
+
try {
|
|
1021
|
+
await checkLiveness()
|
|
1022
|
+
tickStreak.onSuccess()
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
tickStreak.onFailure(error)
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
756
1027
|
}
|
|
757
1028
|
|
|
758
1029
|
// ── Initial scan ──
|
|
@@ -778,13 +1049,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
778
1049
|
await trackBuild(key, sinceStreams[key])
|
|
779
1050
|
if (stop) break
|
|
780
1051
|
}
|
|
781
|
-
if (
|
|
1052
|
+
if (followRepository) {
|
|
782
1053
|
if (!(await trackRepository())) {
|
|
783
1054
|
makeFailureStreak('watch', opts.stderr).onFailure(
|
|
784
1055
|
new Error(`could not read the repository journal for "${repo}"`),
|
|
785
1056
|
)
|
|
786
1057
|
}
|
|
787
|
-
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!repository && slugs.length === 0) {
|
|
788
1060
|
// Membership discovery also baselines the default watch; a failed
|
|
789
1061
|
// discovery here is fatal — nothing has started yet.
|
|
790
1062
|
try {
|
|
@@ -795,6 +1067,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
795
1067
|
}
|
|
796
1068
|
}
|
|
797
1069
|
|
|
1070
|
+
if (followRepository && !stop) {
|
|
1071
|
+
try {
|
|
1072
|
+
await checkLiveness()
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
tickStreak.onFailure(error)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
798
1077
|
// ── The poll loop ──
|
|
799
1078
|
const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
|
|
800
1079
|
const aborted = (): boolean => opts.signal?.aborted === true
|
|
@@ -875,10 +1154,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
|
|
|
875
1154
|
|
|
876
1155
|
for (const stream of streams.values()) if (!shouldStop) launch(stream)
|
|
877
1156
|
|
|
878
|
-
if (slugs.length === 0 && !shouldStop) {
|
|
1157
|
+
if ((slugs.length === 0 || followRepository) && !shouldStop) {
|
|
879
1158
|
void remoteRunner.runDiscovery({
|
|
880
1159
|
step: async (): Promise<void> => {
|
|
881
|
-
await discoverBuilds()
|
|
1160
|
+
if (slugs.length === 0) await discoverBuilds()
|
|
1161
|
+
const repositoryStream = streams.get(REPO_STREAM_KEY)
|
|
1162
|
+
if (repositoryStream?.kind === 'repo' && !stop) await pollRepository(repositoryStream)
|
|
1163
|
+
await checkLiveness()
|
|
882
1164
|
},
|
|
883
1165
|
// Unconditional, after the catch — exactly where the inline
|
|
884
1166
|
// launch loop ran: streams registered before a mid-discovery
|
|
@@ -12,8 +12,10 @@ 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',
|
|
18
|
+
ticketScanIntervalSeconds: 'hot',
|
|
17
19
|
forge: 'restart',
|
|
18
20
|
plugins: 'restart',
|
|
19
21
|
pr: 'hot',
|
|
@@ -48,6 +50,7 @@ export const CONFIG_RELOAD_CLASSIFICATION = {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export const RESTART_REQUIRED_CONFIG_PATHS = [
|
|
53
|
+
'store',
|
|
51
54
|
'forge',
|
|
52
55
|
'plugins',
|
|
53
56
|
'workspace.provider',
|
|
@@ -117,6 +120,7 @@ export function composeBuildConfig(pipeline: Config, deployment: Config): Config
|
|
|
117
120
|
export function composeReloadedConfig(startup: Config, candidate: Config): Config {
|
|
118
121
|
return {
|
|
119
122
|
...candidate,
|
|
123
|
+
store: startup.store,
|
|
120
124
|
forge: startup.forge,
|
|
121
125
|
plugins: startup.plugins,
|
|
122
126
|
workspace: startup.workspace,
|