@defrex/autobuild 0.11.0 → 0.13.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 +335 -31
- package/docs/architecture.md +125 -7
- package/docs/configuration.md +142 -21
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +240 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +97 -7
- package/docs/remote-store-protocol.md +199 -144
- package/docs/remote-ticket-protocol.md +249 -19
- package/docs/setup.md +199 -16
- package/docs/ticket-dependencies.md +1 -1
- package/docs/ticket-migration.md +64 -0
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +364 -0
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +336 -41
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +51 -16
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +60 -16
- package/packages/core/src/cli/mcp.ts +9 -2
- 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 +15 -1
- 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-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +172 -35
- 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 +90 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +11 -2
- package/packages/core/src/events/payloads.ts +50 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +11 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- 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 +10 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +2 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +12 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -20
- package/packages/core/src/operator/tickets.ts +136 -28
- package/packages/core/src/plugin-sdk/index.ts +7 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +7 -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 +3 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +54 -10
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
- package/packages/core/src/ports/tickets/remote.ts +61 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +6 -1
- package/packages/core/src/ports/workspace/create.ts +15 -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/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -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 +168 -41
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +31 -4
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +166 -104
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +2 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -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/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +232 -34
- package/skills/guide/references/operator-api.md +142 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +199 -144
- package/skills/guide/references/remote-ticket-protocol.md +253 -18
- package/skills/guide/references/setup.md +199 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/operate/SKILL.md +126 -36
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* Read-only: it appends no event, claims or renews no lease, and creates no
|
|
32
32
|
* build or repository record. Output carries no ANSI, ever, in either form.
|
|
33
33
|
*/
|
|
34
|
+
import { buildNeedsAttention, mergeDecision, mergeIdentity } from './attention'
|
|
34
35
|
import type { AbEvent } from '../events/catalog'
|
|
35
36
|
import type { EventType } from '../events/payloads'
|
|
36
37
|
import type { BuildState, PrLifecycle } from '../kernel/reducer'
|
|
@@ -54,6 +55,8 @@ import {
|
|
|
54
55
|
encodeCursor,
|
|
55
56
|
parseDurationMs,
|
|
56
57
|
projectRecord,
|
|
58
|
+
projectAttentionRecord,
|
|
59
|
+
renderAttentionLine,
|
|
57
60
|
renderWatchLine,
|
|
58
61
|
watchSelection,
|
|
59
62
|
} from './watch'
|
|
@@ -153,8 +156,8 @@ export function parseConditions(
|
|
|
153
156
|
return { conditions, globs }
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
/** Does one condition body hold over a reduction? Attention
|
|
157
|
-
function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
|
|
159
|
+
/** Does one condition body hold over a reduction? Attention includes current escalation and merge state. */
|
|
160
|
+
function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[] = []): boolean {
|
|
158
161
|
switch (cond.kind) {
|
|
159
162
|
case 'status':
|
|
160
163
|
return state.status === cond.status
|
|
@@ -165,7 +168,7 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
|
|
|
165
168
|
case 'phase':
|
|
166
169
|
return (state.phase ?? null) === cond.phase
|
|
167
170
|
case 'attention':
|
|
168
|
-
return
|
|
171
|
+
return buildNeedsAttention(events, state)
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -174,9 +177,13 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
|
|
|
174
177
|
* the start-of-command and start-of-registration checks — never during a
|
|
175
178
|
* backlog replay, where only event conditions may fire.
|
|
176
179
|
*/
|
|
177
|
-
export function matchState(
|
|
180
|
+
export function matchState(
|
|
181
|
+
conditions: readonly WaitCondition[],
|
|
182
|
+
state: BuildState,
|
|
183
|
+
events: AbEvent[] = [],
|
|
184
|
+
): string | null {
|
|
178
185
|
for (const { text, cond } of conditions) {
|
|
179
|
-
if (stateSatisfies(cond, state)) return text
|
|
186
|
+
if (stateSatisfies(cond, state, events)) return text
|
|
180
187
|
}
|
|
181
188
|
return null
|
|
182
189
|
}
|
|
@@ -188,9 +195,15 @@ export function matchEvent(
|
|
|
188
195
|
event: Pick<AbEvent, 'type'>,
|
|
189
196
|
conditions: readonly WaitCondition[],
|
|
190
197
|
globs: readonly EventGlob[],
|
|
198
|
+
events: AbEvent[] = [],
|
|
191
199
|
): string | null {
|
|
192
200
|
for (const { text, cond } of conditions) {
|
|
193
|
-
if (
|
|
201
|
+
if (
|
|
202
|
+
cond.kind === 'attention' &&
|
|
203
|
+
(isAttentionEvent(event.type) ||
|
|
204
|
+
(events.length > 0 && mergeDecision(events, new Date(0)) !== null))
|
|
205
|
+
)
|
|
206
|
+
return text
|
|
194
207
|
}
|
|
195
208
|
for (const { regex, text } of globs) {
|
|
196
209
|
if (regex.test(event.type)) return text
|
|
@@ -209,10 +222,16 @@ function matchFirst(
|
|
|
209
222
|
state: BuildState,
|
|
210
223
|
conditions: readonly WaitCondition[],
|
|
211
224
|
globs: readonly EventGlob[],
|
|
225
|
+
events: AbEvent[] = [],
|
|
212
226
|
): string | null {
|
|
213
227
|
for (const { text, cond } of conditions) {
|
|
214
|
-
if (stateSatisfies(cond, state)) return text
|
|
215
|
-
if (
|
|
228
|
+
if (stateSatisfies(cond, state, events)) return text
|
|
229
|
+
if (
|
|
230
|
+
cond.kind === 'attention' &&
|
|
231
|
+
(isAttentionEvent(event.type) ||
|
|
232
|
+
(events.length > 0 && mergeDecision(events, new Date(0)) !== null))
|
|
233
|
+
)
|
|
234
|
+
return text
|
|
216
235
|
}
|
|
217
236
|
for (const { regex, text } of globs) {
|
|
218
237
|
if (regex.test(event.type)) return text
|
|
@@ -305,11 +324,12 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
305
324
|
const interval = intervalMs ?? watchSelection(storeRef).intervalMs
|
|
306
325
|
|
|
307
326
|
// Decode --since before any read: a foreign cursor is rejected outright.
|
|
308
|
-
const
|
|
327
|
+
const resumedCursor =
|
|
309
328
|
opts.since !== undefined
|
|
310
329
|
? decodeCursor(opts.since, { store: storeRef, repo }, { verb: 'wait', usage: WAIT_USAGE })
|
|
311
|
-
|
|
312
|
-
|
|
330
|
+
: undefined
|
|
331
|
+
const sinceStreams = resumedCursor?.streams ?? {}
|
|
332
|
+
const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
|
|
313
333
|
|
|
314
334
|
const identity = normalizeGitRemoteUrl(repo)
|
|
315
335
|
const mine = (record: BuildRecord): boolean =>
|
|
@@ -322,17 +342,40 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
322
342
|
const positions: Record<string, number> = { ...sinceStreams }
|
|
323
343
|
|
|
324
344
|
const encodeCurrent = (): string =>
|
|
325
|
-
encodeCursor({
|
|
345
|
+
encodeCursor({
|
|
346
|
+
v: 1,
|
|
347
|
+
store: storeRef,
|
|
348
|
+
repo,
|
|
349
|
+
streams: { ...positions },
|
|
350
|
+
...(resumedCursor?.lostRuns ? { lostRuns: resumedCursor.lostRuns } : {}),
|
|
351
|
+
deliveredAttention: [...deliveredAttention],
|
|
352
|
+
})
|
|
326
353
|
|
|
327
354
|
/** The one success/terminal record. Exit 0 is contractually tied to
|
|
328
355
|
* exactly one condition-satisfying record; the exit-2 record carries
|
|
329
356
|
* `condition` null so the caller can see how the build ended. */
|
|
330
357
|
const finish = (condition: string | null, event: AbEvent, state: BuildState): number => {
|
|
331
|
-
const
|
|
358
|
+
const observation =
|
|
359
|
+
condition === 'attention' && event.type !== 'escalation.raised'
|
|
360
|
+
? mergeDecision(
|
|
361
|
+
streams.get(event.build)?.events.filter((e) => e.seq <= event.seq) ?? [],
|
|
362
|
+
now(),
|
|
363
|
+
state,
|
|
364
|
+
)
|
|
365
|
+
: null
|
|
366
|
+
if (observation) deliveredAttention.add(mergeIdentity(event.build, observation.pr.number))
|
|
367
|
+
const record = {
|
|
368
|
+
...(observation
|
|
369
|
+
? projectAttentionRecord(observation, state, encodeCurrent(), { build: event.build })
|
|
370
|
+
: projectRecord(event, state, encodeCurrent())),
|
|
371
|
+
condition,
|
|
372
|
+
}
|
|
332
373
|
if (json) {
|
|
333
374
|
opts.stdout(JSON.stringify(record))
|
|
334
375
|
} else {
|
|
335
|
-
const line =
|
|
376
|
+
const line = observation
|
|
377
|
+
? renderAttentionLine(observation, event.build)
|
|
378
|
+
: renderWatchLine(event, state)
|
|
336
379
|
opts.stdout(condition === null ? line : `${line} (${condition})`)
|
|
337
380
|
}
|
|
338
381
|
return condition === null ? 2 : 0
|
|
@@ -351,6 +394,15 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
351
394
|
if (satisfied === null) satisfied = { event, state, condition }
|
|
352
395
|
}
|
|
353
396
|
|
|
397
|
+
const eventConditions = (event: AbEvent, prefix: AbEvent[]): readonly WaitCondition[] => {
|
|
398
|
+
if (event.type === 'escalation.raised') return conditions
|
|
399
|
+
const observation = mergeDecision(prefix, now())
|
|
400
|
+
return observation &&
|
|
401
|
+
!deliveredAttention.has(mergeIdentity(event.build, observation.pr.number))
|
|
402
|
+
? conditions
|
|
403
|
+
: conditions.filter(({ cond }) => cond.kind !== 'attention')
|
|
404
|
+
}
|
|
405
|
+
|
|
354
406
|
const registerStream = (slug: string, events: AbEvent[], named: boolean): WaitStream => {
|
|
355
407
|
const state = reduceBuild(events)
|
|
356
408
|
const stream: WaitStream = {
|
|
@@ -370,7 +422,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
370
422
|
* latest event. Not cursor-relative — `--since` does not suppress it. */
|
|
371
423
|
const checkRegistrationState = (stream: WaitStream): void => {
|
|
372
424
|
const state = reduceBuild(stream.events)
|
|
373
|
-
const condition = matchState(conditions, state)
|
|
425
|
+
const condition = matchState(conditions, state, stream.events)
|
|
374
426
|
if (condition !== null && stream.events.length > 0) {
|
|
375
427
|
match(stream.events.at(-1)!, state, condition)
|
|
376
428
|
}
|
|
@@ -401,12 +453,20 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
401
453
|
// the reduction of the prefix ENDING at that event, never the
|
|
402
454
|
// reduction of the full loaded prefix.
|
|
403
455
|
const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
|
|
456
|
+
if (resumedCursor?.deliveredAttention === undefined) {
|
|
457
|
+
const historical: AbEvent[] = []
|
|
458
|
+
for (const event of replay) {
|
|
459
|
+
historical.push(event)
|
|
460
|
+
const observation = mergeDecision(historical, now())
|
|
461
|
+
if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
|
|
462
|
+
}
|
|
463
|
+
}
|
|
404
464
|
for (const event of events) {
|
|
405
465
|
if (event.seq <= resumeSeq) continue
|
|
406
466
|
replay.push(event)
|
|
407
467
|
stream.lastSeq = event.seq
|
|
408
468
|
positions[slug] = event.seq
|
|
409
|
-
const condition = matchEvent(event,
|
|
469
|
+
const condition = matchEvent(event, eventConditions(event, replay), globs, replay)
|
|
410
470
|
if (condition !== null) {
|
|
411
471
|
match(event, reduceBuild(replay), condition)
|
|
412
472
|
break
|
|
@@ -470,7 +530,13 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
470
530
|
positions[stream.slug] = event.seq
|
|
471
531
|
const state = reduceBuild(stream.events)
|
|
472
532
|
stream.status = state.status
|
|
473
|
-
const condition = matchFirst(
|
|
533
|
+
const condition = matchFirst(
|
|
534
|
+
event,
|
|
535
|
+
state,
|
|
536
|
+
eventConditions(event, stream.events),
|
|
537
|
+
globs,
|
|
538
|
+
stream.events,
|
|
539
|
+
)
|
|
474
540
|
if (condition !== null) {
|
|
475
541
|
match(event, state, condition)
|
|
476
542
|
return
|
|
@@ -564,6 +630,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
|
|
|
564
630
|
// Cursor streams that are no longer named resume anyway: finishing
|
|
565
631
|
// their backlog is what makes a resumed wait lossless.
|
|
566
632
|
for (const key of Object.keys(sinceStreams)) {
|
|
633
|
+
if (key === '#repo') continue
|
|
567
634
|
await trackBuild(key, sinceStreams[key])
|
|
568
635
|
if (hasMatch()) return finishSatisfied()
|
|
569
636
|
}
|
|
@@ -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,
|