@defrex/autobuild 0.9.0 → 0.10.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 +10 -0
- package/docs/architecture.md +109 -0
- package/docs/configuration.md +24 -3
- package/docs/remote-store-protocol.md +35 -0
- package/docs/website.md +4 -0
- package/package.json +6 -4
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +85 -147
- package/packages/core/src/cli/dashboard/poll.ts +7 -2
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +80 -37
- package/packages/core/src/cli/help.ts +15 -1
- package/packages/core/src/cli/init-validation.ts +18 -0
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +1 -1
- package/packages/core/src/cli/ticket.ts +146 -1
- package/packages/core/src/cli/wait.ts +10 -2
- package/packages/core/src/cli/watch.ts +10 -2
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/pipeline-source.ts +17 -5
- package/packages/core/src/config/schema.ts +21 -0
- package/packages/core/src/integration/harness.ts +113 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +43 -9
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +216 -74
- package/packages/core/src/operator/query.ts +68 -28
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +32 -1
- package/packages/core/src/processes/build-execution-state.ts +31 -15
- package/packages/core/src/processes/build-runner.ts +184 -90
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +270 -176
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +44 -3
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +26 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +10 -1
- package/packages/core/src/store/build-scope.ts +26 -0
- package/packages/core/src/store/contract.ts +337 -0
- package/packages/core/src/store/digest.ts +71 -18
- package/packages/core/src/store/local/schema.ts +17 -0
- package/packages/core/src/store/local/store.ts +111 -4
- package/packages/core/src/store/memory.ts +68 -1
- package/packages/core/src/store/phase-session.ts +23 -0
- package/packages/core/src/store/remote/client.ts +50 -1
- package/packages/core/src/store/remote/index.ts +2 -0
- package/packages/core/src/store/remote/protocol.ts +15 -0
- package/packages/core/src/store/repo-state-events.ts +72 -7
- package/packages/core/src/store/session-handle.ts +30 -0
- package/packages/core/src/store/session-reducer.ts +52 -11
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/ticket-assets.ts +41 -8
- package/packages/core/src/store/types.ts +38 -0
- package/skills/guide/SKILL.md +26 -7
- package/skills/guide/references/remote-store-protocol.md +35 -0
- package/skills/tickets/SKILL.md +57 -5
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
VerifyOutcome,
|
|
25
25
|
} from '../ontology'
|
|
26
26
|
import { verifyPhase } from '../ontology'
|
|
27
|
+
import { defineReducer, type IncrementalReducer } from './incremental'
|
|
27
28
|
|
|
28
29
|
/** An `escalation.raised` without a matching `escalation.answered` — the
|
|
29
30
|
* definition of `blocked` (§15.5). Matched by id, so answers may arrive in
|
|
@@ -299,49 +300,133 @@ export function discardInFlight(state: Pick<BuildState, 'discardRequest'>): bool
|
|
|
299
300
|
return state.discardRequest !== undefined
|
|
300
301
|
}
|
|
301
302
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
303
|
+
/** The reducer's carried accumulator: everything `reduceBuild` folds, before
|
|
304
|
+
* the final status derivation. Plain JSON-shaped data (the escalation and
|
|
305
|
+
* session maps are insertion-ordered entry arrays) so it survives a round
|
|
306
|
+
* trip through `JSON.stringify` — the foundation for incremental advancement
|
|
307
|
+
* and cached state. */
|
|
308
|
+
export interface BuildAcc {
|
|
309
|
+
attached: boolean
|
|
310
|
+
pausedFlag: boolean
|
|
311
|
+
terminal?: 'done' | 'aborted'
|
|
312
|
+
outcome?: BuildOutcome
|
|
313
|
+
round: number
|
|
314
|
+
currentPhase?: PhaseContext
|
|
315
|
+
lastCompletedPhase?: PhaseContext
|
|
316
|
+
openEscalations: OpenEscalation[]
|
|
317
|
+
answeredEscalations: AnsweredEscalation[]
|
|
318
|
+
reviewRoundCeilings: BuildState['reviewRoundCeilings']
|
|
319
|
+
pr: BuildState['pr']
|
|
320
|
+
prState?: PrLifecycle
|
|
321
|
+
autoMerge: AutoMergeProjection
|
|
322
|
+
specRev?: number
|
|
323
|
+
restartSince: number
|
|
324
|
+
pinnedAssets: PinnedAsset[]
|
|
325
|
+
finalizeCompletedSeq: number
|
|
326
|
+
finalizeSteps: BuildState['finalizeSteps']
|
|
327
|
+
plan: BuildState['plan']
|
|
328
|
+
implement: BuildState['implement']
|
|
329
|
+
codeReviewApproved: boolean
|
|
330
|
+
codeReviewApproval: BuildState['codeReviewApproval']
|
|
331
|
+
planReviewFindings: Finding[][]
|
|
332
|
+
codeReviewFindings: Finding[][]
|
|
333
|
+
verify: BuildState['verify']
|
|
334
|
+
reconcileAttempts: number
|
|
335
|
+
observations: ObservationRecord[]
|
|
336
|
+
setupFailures: BuildState['setupFailures']
|
|
337
|
+
setupFailure: BuildState['setupFailure']
|
|
338
|
+
dispatchFailures: BuildState['dispatchFailures']
|
|
339
|
+
executions: BuildState['executions']
|
|
340
|
+
infrastructureFailures: BuildState['infrastructureFailures']
|
|
341
|
+
infrastructureFailure: BuildState['infrastructureFailure']
|
|
342
|
+
cleanupAttempts: BuildState['cleanupAttempts']
|
|
343
|
+
discardRequest: BuildState['discardRequest']
|
|
344
|
+
pending: Record<PendingCommand['command'], PendingCommand[]>
|
|
345
|
+
openSessions: OpenSession[]
|
|
346
|
+
failures: Record<string, number>
|
|
347
|
+
lastEvent?: AbEvent
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Bump when `BuildAcc`'s shape or the fold's semantics change. */
|
|
351
|
+
export const BUILD_REDUCER_VERSION = 1
|
|
352
|
+
|
|
353
|
+
function initialBuildAcc(): BuildAcc {
|
|
354
|
+
return {
|
|
355
|
+
attached: false,
|
|
356
|
+
pausedFlag: false,
|
|
357
|
+
round: 0,
|
|
358
|
+
openEscalations: [],
|
|
359
|
+
answeredEscalations: [],
|
|
360
|
+
reviewRoundCeilings: {},
|
|
361
|
+
pr: undefined,
|
|
362
|
+
autoMerge: { requested: false },
|
|
363
|
+
restartSince: 0,
|
|
364
|
+
pinnedAssets: [],
|
|
365
|
+
finalizeCompletedSeq: 0,
|
|
366
|
+
finalizeSteps: [],
|
|
367
|
+
plan: { round: 0, approved: false },
|
|
368
|
+
implement: { round: 0 },
|
|
369
|
+
codeReviewApproved: false,
|
|
370
|
+
codeReviewApproval: undefined,
|
|
371
|
+
planReviewFindings: [],
|
|
372
|
+
codeReviewFindings: [],
|
|
373
|
+
verify: { maxAttemptSeen: 0, results: [], cycleSince: 0 },
|
|
374
|
+
reconcileAttempts: 0,
|
|
375
|
+
observations: [],
|
|
376
|
+
setupFailures: [],
|
|
377
|
+
setupFailure: undefined,
|
|
378
|
+
dispatchFailures: [],
|
|
379
|
+
executions: [],
|
|
380
|
+
infrastructureFailures: [],
|
|
381
|
+
infrastructureFailure: undefined,
|
|
382
|
+
cleanupAttempts: [],
|
|
383
|
+
discardRequest: undefined,
|
|
384
|
+
pending: { pause: [], resume: [], abort: [] },
|
|
385
|
+
openSessions: [],
|
|
386
|
+
failures: {},
|
|
342
387
|
}
|
|
343
|
-
|
|
344
|
-
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Fold `events` into `acc` in place. */
|
|
391
|
+
function foldBuild(acc: BuildAcc, events: readonly AbEvent[]): void {
|
|
392
|
+
let attached = acc.attached
|
|
393
|
+
let pausedFlag = acc.pausedFlag
|
|
394
|
+
let terminal = acc.terminal
|
|
395
|
+
let outcome = acc.outcome
|
|
396
|
+
let round = acc.round
|
|
397
|
+
let currentPhase = acc.currentPhase
|
|
398
|
+
let lastCompletedPhase = acc.lastCompletedPhase
|
|
399
|
+
const openEscalations = new Map(acc.openEscalations.map((e) => [e.id, e]))
|
|
400
|
+
const answeredEscalations = acc.answeredEscalations
|
|
401
|
+
const reviewRoundCeilings = acc.reviewRoundCeilings
|
|
402
|
+
let pr = acc.pr
|
|
403
|
+
let prState = acc.prState
|
|
404
|
+
const autoMerge = acc.autoMerge
|
|
405
|
+
let specRev = acc.specRev
|
|
406
|
+
let restartSince = acc.restartSince
|
|
407
|
+
let pinnedAssets = acc.pinnedAssets
|
|
408
|
+
let finalizeCompletedSeq = acc.finalizeCompletedSeq
|
|
409
|
+
const finalizeSteps = acc.finalizeSteps
|
|
410
|
+
const plan = acc.plan
|
|
411
|
+
const implement = acc.implement
|
|
412
|
+
let codeReviewApproved = acc.codeReviewApproved
|
|
413
|
+
let codeReviewApproval = acc.codeReviewApproval
|
|
414
|
+
const planReviewFindings = acc.planReviewFindings
|
|
415
|
+
const codeReviewFindings = acc.codeReviewFindings
|
|
416
|
+
const verify = acc.verify
|
|
417
|
+
let reconcileAttempts = acc.reconcileAttempts
|
|
418
|
+
const observations = acc.observations
|
|
419
|
+
const setupFailures = acc.setupFailures
|
|
420
|
+
let setupFailure = acc.setupFailure
|
|
421
|
+
const dispatchFailures = acc.dispatchFailures
|
|
422
|
+
const executions = acc.executions
|
|
423
|
+
const infrastructureFailures = acc.infrastructureFailures
|
|
424
|
+
let infrastructureFailure = acc.infrastructureFailure
|
|
425
|
+
const cleanupAttempts = acc.cleanupAttempts
|
|
426
|
+
let discardRequest = acc.discardRequest
|
|
427
|
+
const pending = acc.pending
|
|
428
|
+
const openSessions = new Map(acc.openSessions.map((s) => [s.session, s]))
|
|
429
|
+
const failures = acc.failures
|
|
345
430
|
|
|
346
431
|
const start = (ctx: PhaseContext): void => {
|
|
347
432
|
currentPhase = ctx
|
|
@@ -362,7 +447,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
362
447
|
per[r - 1] = findings
|
|
363
448
|
}
|
|
364
449
|
|
|
365
|
-
let lastEvent
|
|
450
|
+
let lastEvent = acc.lastEvent
|
|
366
451
|
for (const event of events) {
|
|
367
452
|
switch (event.type) {
|
|
368
453
|
// Facts the projection does not need (workspace liveness is the
|
|
@@ -737,55 +822,112 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
737
822
|
lastEvent = event
|
|
738
823
|
}
|
|
739
824
|
|
|
825
|
+
acc.attached = attached
|
|
826
|
+
acc.pausedFlag = pausedFlag
|
|
827
|
+
acc.terminal = terminal
|
|
828
|
+
acc.outcome = outcome
|
|
829
|
+
acc.round = round
|
|
830
|
+
acc.currentPhase = currentPhase
|
|
831
|
+
acc.lastCompletedPhase = lastCompletedPhase
|
|
832
|
+
acc.openEscalations = [...openEscalations.values()]
|
|
833
|
+
acc.pr = pr
|
|
834
|
+
acc.prState = prState
|
|
835
|
+
acc.specRev = specRev
|
|
836
|
+
acc.restartSince = restartSince
|
|
837
|
+
acc.pinnedAssets = pinnedAssets
|
|
838
|
+
acc.finalizeCompletedSeq = finalizeCompletedSeq
|
|
839
|
+
acc.codeReviewApproved = codeReviewApproved
|
|
840
|
+
acc.codeReviewApproval = codeReviewApproval
|
|
841
|
+
acc.reconcileAttempts = reconcileAttempts
|
|
842
|
+
acc.setupFailure = setupFailure
|
|
843
|
+
acc.infrastructureFailure = infrastructureFailure
|
|
844
|
+
acc.discardRequest = discardRequest
|
|
845
|
+
acc.openSessions = [...openSessions.values()]
|
|
846
|
+
acc.lastEvent = lastEvent
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function finishBuild(acc: BuildAcc): BuildState {
|
|
850
|
+
const {
|
|
851
|
+
terminal,
|
|
852
|
+
pausedFlag,
|
|
853
|
+
attached,
|
|
854
|
+
currentPhase,
|
|
855
|
+
lastCompletedPhase,
|
|
856
|
+
lastEvent,
|
|
857
|
+
discardRequest,
|
|
858
|
+
pending,
|
|
859
|
+
} = acc
|
|
860
|
+
|
|
740
861
|
// §15.5 precedence: aborted/done (terminal, latest wins) > paused >
|
|
741
862
|
// blocked > running > queued. See BuildState.status for the paused+blocked
|
|
742
863
|
// overlap rule.
|
|
743
864
|
const status: BuildStatus =
|
|
744
865
|
terminal ??
|
|
745
|
-
(pausedFlag
|
|
866
|
+
(pausedFlag
|
|
867
|
+
? 'paused'
|
|
868
|
+
: acc.openEscalations.length > 0
|
|
869
|
+
? 'blocked'
|
|
870
|
+
: attached
|
|
871
|
+
? 'running'
|
|
872
|
+
: 'queued')
|
|
746
873
|
|
|
747
874
|
const active = currentPhase ?? lastCompletedPhase
|
|
748
875
|
|
|
749
876
|
return {
|
|
750
877
|
status,
|
|
751
|
-
outcome,
|
|
878
|
+
outcome: acc.outcome,
|
|
752
879
|
phase: active?.phase,
|
|
753
|
-
round,
|
|
880
|
+
round: acc.round,
|
|
754
881
|
currentPhase,
|
|
755
882
|
lastCompletedPhase,
|
|
756
|
-
openEscalations: [...openEscalations
|
|
757
|
-
answeredEscalations,
|
|
758
|
-
reviewRoundCeilings,
|
|
759
|
-
pr,
|
|
760
|
-
prState,
|
|
761
|
-
autoMerge,
|
|
762
|
-
finalizeCompletedSeq,
|
|
763
|
-
finalizeSteps,
|
|
883
|
+
openEscalations: [...acc.openEscalations],
|
|
884
|
+
answeredEscalations: acc.answeredEscalations,
|
|
885
|
+
reviewRoundCeilings: acc.reviewRoundCeilings,
|
|
886
|
+
pr: acc.pr,
|
|
887
|
+
prState: acc.prState,
|
|
888
|
+
autoMerge: acc.autoMerge,
|
|
889
|
+
finalizeCompletedSeq: acc.finalizeCompletedSeq,
|
|
890
|
+
finalizeSteps: acc.finalizeSteps,
|
|
764
891
|
lastEvent,
|
|
765
892
|
lastSeq: lastEvent?.seq ?? 0,
|
|
766
|
-
pinnedAssets,
|
|
767
|
-
specRev,
|
|
768
|
-
restartSince,
|
|
769
|
-
plan,
|
|
770
|
-
implement,
|
|
771
|
-
codeReviewApproved,
|
|
772
|
-
codeReviewApproval,
|
|
773
|
-
reviewFindings: { planReview: planReviewFindings, codeReview: codeReviewFindings },
|
|
774
|
-
verify,
|
|
775
|
-
reconcileAttempts,
|
|
776
|
-
observations,
|
|
777
|
-
setupFailures,
|
|
778
|
-
setupFailure,
|
|
779
|
-
dispatchFailures,
|
|
780
|
-
executions,
|
|
781
|
-
infrastructureFailures,
|
|
782
|
-
infrastructureFailure,
|
|
783
|
-
cleanupAttempts,
|
|
893
|
+
pinnedAssets: acc.pinnedAssets,
|
|
894
|
+
specRev: acc.specRev,
|
|
895
|
+
restartSince: acc.restartSince,
|
|
896
|
+
plan: acc.plan,
|
|
897
|
+
implement: acc.implement,
|
|
898
|
+
codeReviewApproved: acc.codeReviewApproved,
|
|
899
|
+
codeReviewApproval: acc.codeReviewApproval,
|
|
900
|
+
reviewFindings: { planReview: acc.planReviewFindings, codeReview: acc.codeReviewFindings },
|
|
901
|
+
verify: acc.verify,
|
|
902
|
+
reconcileAttempts: acc.reconcileAttempts,
|
|
903
|
+
observations: acc.observations,
|
|
904
|
+
setupFailures: acc.setupFailures,
|
|
905
|
+
setupFailure: acc.setupFailure,
|
|
906
|
+
dispatchFailures: acc.dispatchFailures,
|
|
907
|
+
executions: acc.executions,
|
|
908
|
+
infrastructureFailures: acc.infrastructureFailures,
|
|
909
|
+
infrastructureFailure: acc.infrastructureFailure,
|
|
910
|
+
cleanupAttempts: acc.cleanupAttempts,
|
|
784
911
|
...(terminal === undefined && discardRequest !== undefined ? { discardRequest } : {}),
|
|
785
912
|
pendingCommands: [...pending.pause, ...pending.resume, ...pending.abort].sort(
|
|
786
913
|
(a, b) => a.seq - b.seq,
|
|
787
914
|
),
|
|
788
|
-
sessions: { open: [...openSessions
|
|
789
|
-
failures,
|
|
915
|
+
sessions: { open: [...acc.openSessions] },
|
|
916
|
+
failures: acc.failures,
|
|
790
917
|
}
|
|
791
918
|
}
|
|
919
|
+
|
|
920
|
+
/** The incremental form of `reduceBuild`: advance a carried accumulator with
|
|
921
|
+
* only the events newer than it, then derive the state. */
|
|
922
|
+
export const buildReducer: IncrementalReducer<BuildAcc, AbEvent, BuildState> = defineReducer({
|
|
923
|
+
version: BUILD_REDUCER_VERSION,
|
|
924
|
+
initial: initialBuildAcc,
|
|
925
|
+
fold: foldBuild,
|
|
926
|
+
finish: finishBuild,
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
export function reduceBuild(events: AbEvent[]): BuildState {
|
|
930
|
+
const acc = initialBuildAcc()
|
|
931
|
+
foldBuild(acc, events)
|
|
932
|
+
return finishBuild(acc)
|
|
933
|
+
}
|
|
@@ -7,13 +7,20 @@ import {
|
|
|
7
7
|
parseEffectiveBuildConfig,
|
|
8
8
|
} from '../processes/build-execution-state'
|
|
9
9
|
import type { Artifact } from '../store/types'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
detail,
|
|
12
|
+
statusFilter,
|
|
13
|
+
summarizeFrom,
|
|
14
|
+
type BuildDetail,
|
|
15
|
+
type BuildSummary,
|
|
16
|
+
} from '../cli/status'
|
|
11
17
|
import { projectRepositoryStatus, type RepositoryStatus } from '../cli/repository-status'
|
|
12
18
|
import { projectHarvestStatus, type HarvestStatusView } from '../cli/harvest'
|
|
13
19
|
import {
|
|
14
20
|
buildDashboardFromProjected,
|
|
15
21
|
effectiveStatus,
|
|
16
22
|
projectBuild,
|
|
23
|
+
projectBuildFromFacts,
|
|
17
24
|
type DashboardBuild,
|
|
18
25
|
type DashboardModel,
|
|
19
26
|
} from '../cli/dashboard/model'
|
|
@@ -21,6 +28,8 @@ import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
|
21
28
|
import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
|
|
22
29
|
import { reduceDispatchStatus } from '../kernel/dispatch-status'
|
|
23
30
|
import { readRepoEventsIfRecorded, unclaimedObservationCount } from '../processes/harvest'
|
|
31
|
+
import type { RepoViewStore } from '../processes/repo-view'
|
|
32
|
+
import { withOperatorView } from './read-view'
|
|
24
33
|
import type { RepositoryEvent } from '../events/repository'
|
|
25
34
|
import type { BuildStore, Clock } from '../store/types'
|
|
26
35
|
|
|
@@ -42,6 +51,13 @@ export async function listOperatorBuilds(opts: {
|
|
|
42
51
|
scope: BuildListScope
|
|
43
52
|
now: Date
|
|
44
53
|
}): Promise<BuildSummary[]> {
|
|
54
|
+
return withOperatorView(opts.store, opts.repo, 'builds', (view) => listFromView(view, opts))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listFromView(
|
|
58
|
+
view: RepoViewStore,
|
|
59
|
+
opts: { repo: string; scope: BuildListScope; now: Date },
|
|
60
|
+
): Promise<BuildSummary[]> {
|
|
45
61
|
const statuses = new Set(statusFilter(opts.scope === 'all', opts.scope === 'queued'))
|
|
46
62
|
// Records are read and held before the digest read: builds are never
|
|
47
63
|
// deleted, so every record iterated below is guaranteed an entry in the
|
|
@@ -50,14 +66,14 @@ export async function listOperatorBuilds(opts: {
|
|
|
50
66
|
// check can only ever catch a genuine adapter bug, never a concurrent
|
|
51
67
|
// dispatch. The reverse order would let a concurrent creation abort the
|
|
52
68
|
// whole listing (AUT-488 finding f_3cb67aba).
|
|
53
|
-
const records = await
|
|
54
|
-
// One digest read gates the per-build
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
const digests = opts.scope === 'all' ? undefined : await
|
|
69
|
+
const records = await view.listBuilds()
|
|
70
|
+
// One digest read gates the per-build reads (AUT-488): the active and queued
|
|
71
|
+
// scopes never show a terminal build, so a build whose digest already
|
|
72
|
+
// carries a terminal fact is skipped and the listing's cost stays flat as
|
|
73
|
+
// finished builds accumulate. The `--all` scope includes terminal statuses,
|
|
74
|
+
// so it takes no digest read and skips nothing; its terminal builds are
|
|
75
|
+
// restored from the reducer snapshots the dispatcher leaves at settlement.
|
|
76
|
+
const digests = opts.scope === 'all' ? undefined : await view.getRepoBuildDigests(opts.repo)
|
|
61
77
|
const output: BuildSummary[] = []
|
|
62
78
|
for (const record of records) {
|
|
63
79
|
if (record.repo !== opts.repo) continue
|
|
@@ -74,10 +90,10 @@ export async function listOperatorBuilds(opts: {
|
|
|
74
90
|
// cleared — pinned against `reduceBuild` in store/digest.test.ts), so
|
|
75
91
|
// `terminal !== undefined` is exactly reduced status `done`/`aborted`,
|
|
76
92
|
// which the active and queued status sets never include. Skipping the
|
|
77
|
-
//
|
|
93
|
+
// build therefore cannot change the emitted summaries.
|
|
78
94
|
if (digest.terminal !== undefined) continue
|
|
79
95
|
}
|
|
80
|
-
const projected =
|
|
96
|
+
const projected = summarizeFrom(record, (await view.buildFacts(record.slug)).state, opts.now)
|
|
81
97
|
if (statuses.has(projected.status)) output.push(projected)
|
|
82
98
|
}
|
|
83
99
|
return output.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
@@ -95,12 +111,15 @@ async function requireBuild(store: BuildStore, repo: string, slug: string) {
|
|
|
95
111
|
export async function effectiveConfig(
|
|
96
112
|
store: BuildStore,
|
|
97
113
|
repo: string,
|
|
114
|
+
/** The bounded journal, when the caller already holds it (a repository
|
|
115
|
+
* view); otherwise it is read from the store. */
|
|
116
|
+
journal?: RepositoryEvent[],
|
|
98
117
|
): Promise<{
|
|
99
118
|
config: Config
|
|
100
119
|
repositoryEvents: RepositoryEvent[]
|
|
101
120
|
status: ReturnType<typeof reduceDispatchStatus>
|
|
102
121
|
}> {
|
|
103
|
-
const repositoryEvents = await readRepoEventsIfRecorded(store, repo)
|
|
122
|
+
const repositoryEvents = journal ?? (await readRepoEventsIfRecorded(store, repo))
|
|
104
123
|
let latestRun: string | undefined
|
|
105
124
|
for (const event of repositoryEvents) {
|
|
106
125
|
if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
|
|
@@ -237,14 +256,18 @@ export async function getRepositoryStatus(
|
|
|
237
256
|
store: BuildStore,
|
|
238
257
|
repo: string,
|
|
239
258
|
): Promise<RepositoryStatus> {
|
|
240
|
-
return
|
|
259
|
+
return withOperatorView(store, repo, 'journal', async (view) =>
|
|
260
|
+
projectRepositoryStatus(repo, view.recordedJournal()),
|
|
261
|
+
)
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
export async function getHarvestStatus(
|
|
244
265
|
store: BuildStore,
|
|
245
266
|
repo: string,
|
|
246
267
|
): Promise<HarvestStatusView> {
|
|
247
|
-
return
|
|
268
|
+
return withOperatorView(store, repo, 'journal', async (view) =>
|
|
269
|
+
projectHarvestStatus(repo, view.recordedJournal()),
|
|
270
|
+
)
|
|
248
271
|
}
|
|
249
272
|
|
|
250
273
|
export interface OperatorDashboardSnapshot {
|
|
@@ -263,15 +286,26 @@ export async function getOperatorDashboard(opts: {
|
|
|
263
286
|
repo: string
|
|
264
287
|
clock: Clock
|
|
265
288
|
}): Promise<OperatorDashboardSnapshot> {
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
return withOperatorView(opts.store, opts.repo, 'builds', (view) => dashboardFromView(view, opts))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function dashboardFromView(
|
|
293
|
+
view: RepoViewStore,
|
|
294
|
+
opts: { repo: string; clock: Clock },
|
|
295
|
+
): Promise<OperatorDashboardSnapshot> {
|
|
296
|
+
const { config, repositoryEvents, status } = await effectiveConfig(
|
|
297
|
+
view,
|
|
298
|
+
opts.repo,
|
|
299
|
+
view.recordedJournal(),
|
|
300
|
+
)
|
|
301
|
+
const records = await view.listBuilds()
|
|
268
302
|
// One digest read covers every build of the repository (AUT-487): the
|
|
269
|
-
// row gate (a `done` digest means no row) and the observation count no
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
const digests = await
|
|
303
|
+
// row gate (a `done` digest means no row) and the observation count need no
|
|
304
|
+
// per-build histories, so the snapshot's traffic stays flat as finished
|
|
305
|
+
// builds accumulate. Row-rendering builds derive from the repository view's
|
|
306
|
+
// reducer snapshots plus a delta read (AUT-650); every snapshot is a cache
|
|
307
|
+
// the event log regenerates, so the response equals a full replay's.
|
|
308
|
+
const digests = await view.getRepoBuildDigests(opts.repo)
|
|
275
309
|
const projected: DashboardBuild[] = []
|
|
276
310
|
let activeCount = 0
|
|
277
311
|
for (const record of records) {
|
|
@@ -295,12 +329,18 @@ export async function getOperatorDashboard(opts: {
|
|
|
295
329
|
// could never be surfaced. The digest already excluded `done` builds
|
|
296
330
|
// above; the gate stays as the authority for the row projection so the
|
|
297
331
|
// digest and the reducer cannot silently diverge. Every row-rendering
|
|
298
|
-
// build still gets its
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
332
|
+
// build still gets its facts and pinned pipeline.
|
|
333
|
+
const facts = await view.buildFacts(record.slug)
|
|
334
|
+
if (projectsNoDashboardRow(facts.state)) continue
|
|
335
|
+
const pinned = await readPinnedConfig(view, record.slug, config)
|
|
336
|
+
const row = projectBuildFromFacts(
|
|
337
|
+
record,
|
|
338
|
+
facts.state,
|
|
339
|
+
{ dashboard: facts.dashboard, log: facts.log, deferral: facts.deferral },
|
|
340
|
+
config,
|
|
341
|
+
undefined,
|
|
342
|
+
pinned.config,
|
|
343
|
+
)
|
|
304
344
|
if (row !== null) projected.push(decorateWithPinnedMeta(row, pinned))
|
|
305
345
|
}
|
|
306
346
|
// The unclaimed-observation count comes from the digests and the journal
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator read paths' repository view: every poll-driven route (dashboard,
|
|
3
|
+
* repository and harvest status, build listings) derives its state from a
|
|
4
|
+
* `RepoViewStore` cold-started from stored reducer snapshots and advanced with
|
|
5
|
+
* delta reads, so a poll's event-row cost follows the events appended since the
|
|
6
|
+
* last one, never the history, the open tabs, or the watchers.
|
|
7
|
+
*
|
|
8
|
+
* Passes are single-flight per `(store, repo)` within one serving process: at
|
|
9
|
+
* most one pass runs at a time, and requests arriving while it runs join the
|
|
10
|
+
* one trailing pass (each needs state at least as new as its arrival, which the
|
|
11
|
+
* running pass cannot promise). That trailing pass restores from the snapshot
|
|
12
|
+
* the previous pass persisted, so N concurrent pollers cost one delta read per
|
|
13
|
+
* pass, not N. The coordination is scheduling inside this process; every datum
|
|
14
|
+
* still comes from durable state, and a second serving process simply reads the
|
|
15
|
+
* same delta once itself (duplicate reads are bounded by the process count).
|
|
16
|
+
*/
|
|
17
|
+
import { RepoViewStore } from '../processes/repo-view'
|
|
18
|
+
import type { BuildStore } from '../store/types'
|
|
19
|
+
|
|
20
|
+
/** What a request reads: the journal alone, or the builds beside it. */
|
|
21
|
+
export type OperatorViewNeed = 'journal' | 'builds'
|
|
22
|
+
|
|
23
|
+
interface Waiter {
|
|
24
|
+
need: OperatorViewNeed
|
|
25
|
+
run: (view: RepoViewStore) => Promise<unknown>
|
|
26
|
+
resolve: (value: unknown) => void
|
|
27
|
+
reject: (error: unknown) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Flight {
|
|
31
|
+
running: boolean
|
|
32
|
+
queue: Waiter[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const flights = new WeakMap<BuildStore, Map<string, Flight>>()
|
|
36
|
+
|
|
37
|
+
function flightFor(store: BuildStore, repo: string): Flight {
|
|
38
|
+
let byRepo = flights.get(store)
|
|
39
|
+
if (byRepo === undefined) {
|
|
40
|
+
byRepo = new Map()
|
|
41
|
+
flights.set(store, byRepo)
|
|
42
|
+
}
|
|
43
|
+
let flight = byRepo.get(repo)
|
|
44
|
+
if (flight === undefined) {
|
|
45
|
+
flight = { running: false, queue: [] }
|
|
46
|
+
byRepo.set(repo, flight)
|
|
47
|
+
}
|
|
48
|
+
return flight
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pass(store: BuildStore, repo: string, waiters: Waiter[]): Promise<void> {
|
|
52
|
+
const view = new RepoViewStore(store, { repo })
|
|
53
|
+
try {
|
|
54
|
+
if (waiters.some((waiter) => waiter.need === 'builds')) await view.refresh()
|
|
55
|
+
else await view.startJournal()
|
|
56
|
+
} catch (error) {
|
|
57
|
+
for (const waiter of waiters) waiter.reject(error)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
await Promise.all(waiters.map((waiter) => waiter.run(view).then(waiter.resolve, waiter.reject)))
|
|
61
|
+
// Leave the advanced state behind for the next pass (this process's or
|
|
62
|
+
// another's). `held` also writes a settled build read for a listing. The
|
|
63
|
+
// cache never fails a read.
|
|
64
|
+
try {
|
|
65
|
+
await view.persistSnapshots({ force: true, held: true })
|
|
66
|
+
} catch {
|
|
67
|
+
// A cache: the next pass retries.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function drain(store: BuildStore, repo: string, flight: Flight): Promise<void> {
|
|
72
|
+
flight.running = true
|
|
73
|
+
try {
|
|
74
|
+
while (flight.queue.length > 0) {
|
|
75
|
+
const waiters = flight.queue
|
|
76
|
+
flight.queue = []
|
|
77
|
+
await pass(store, repo, waiters)
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
flight.running = false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run `run` against a freshly advanced repository view. The view is only valid
|
|
85
|
+
* inside `run`; the response must not hold it. */
|
|
86
|
+
export function withOperatorView<T>(
|
|
87
|
+
store: BuildStore,
|
|
88
|
+
repo: string,
|
|
89
|
+
need: OperatorViewNeed,
|
|
90
|
+
run: (view: RepoViewStore) => Promise<T>,
|
|
91
|
+
): Promise<T> {
|
|
92
|
+
const flight = flightFor(store, repo)
|
|
93
|
+
const result = new Promise<T>((resolve, reject) => {
|
|
94
|
+
flight.queue.push({ need, run, resolve: resolve as (value: unknown) => void, reject })
|
|
95
|
+
})
|
|
96
|
+
if (!flight.running) void drain(store, repo, flight)
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
@@ -79,7 +79,11 @@ export class FakeTicketSource implements TicketSource {
|
|
|
79
79
|
this.tickets.set(ticket.ref.id, cloneTicket(ticket))
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
async listReady(criteria: {
|
|
82
|
+
async listReady(criteria: {
|
|
83
|
+
labels?: string[]
|
|
84
|
+
state?: string
|
|
85
|
+
assignee?: string
|
|
86
|
+
}): Promise<TicketListing> {
|
|
83
87
|
const labels = criteria.labels ?? []
|
|
84
88
|
return {
|
|
85
89
|
tickets: [...this.tickets.values()]
|