@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's pure projection: `BuildState` → what the operator sees.
|
|
3
|
+
*
|
|
4
|
+
* DISPLAY-ONLY — nothing consults this. The engine decides transitions from
|
|
5
|
+
* `decideNext` (§2.1's determinism half); a progress row is not a transition
|
|
6
|
+
* table and must never become one.
|
|
7
|
+
*
|
|
8
|
+
* But display-only is NOT a licence to disagree with the engine. The display
|
|
9
|
+
* IS the deliverable: a row that says `verify:lint [x]` for code the engine is
|
|
10
|
+
* about to re-verify is a wrong answer, not a cosmetic one. So every row obeys
|
|
11
|
+
* one rule:
|
|
12
|
+
*
|
|
13
|
+
* >>> A step is `done` iff the engine will not re-run it. <<<
|
|
14
|
+
*
|
|
15
|
+
* Which means every `done` predicate is expressed against a fact the engine
|
|
16
|
+
* ALSO routes on — a seq or a round — never a full-log "latest wins" boolean.
|
|
17
|
+
* `plan.approved`, `codeReviewApproved` and `prState !== undefined` all look
|
|
18
|
+
* like the right answer and all lie across a restart, because `spec.revised`
|
|
19
|
+
* never touches them.
|
|
20
|
+
*
|
|
21
|
+
* `provisional` is a separate, display-only fact: this occurrence produced a
|
|
22
|
+
* terminal output in the current round/cycle, but the engine may still re-run
|
|
23
|
+
* it. It never participates in routing and never changes the `done` rule. The
|
|
24
|
+
* rules this file mirrors, and the code that owns them:
|
|
25
|
+
*
|
|
26
|
+
* engine.ts:275 the verify cycle boundary (§15.6-A)
|
|
27
|
+
* engine.ts:347-355 a verify failure reopens the CODE loop
|
|
28
|
+
* engine.ts:466 a spec restart re-runs both loops (§6.3)
|
|
29
|
+
* engine.ts:707-708 a spec restart re-runs finalize and its post-steps
|
|
30
|
+
* engine.ts:221-227 an unlanded revise-spec answer parks the whole pipeline
|
|
31
|
+
*
|
|
32
|
+
* Display-only inferences live here rather than in the kernel when the spec
|
|
33
|
+
* asks for pixels without changing lifecycle semantics: build effective status
|
|
34
|
+
* (blocked overrides paused), the pending-restart boundary, and harvest-row
|
|
35
|
+
* attention visibility. All derive from retained facts; neither reducer nor
|
|
36
|
+
* engine is touched.
|
|
37
|
+
*/
|
|
38
|
+
import type { AbEvent } from '../../events/catalog'
|
|
39
|
+
import type { RepositoryEvent } from '../../events/repository'
|
|
40
|
+
import type { Config } from '../../config/schema'
|
|
41
|
+
import type { BuildState, PhaseContext, PrLifecycle } from '../../kernel/reducer'
|
|
42
|
+
import { currentAutoMergeDeferral } from '../../kernel/auto-merge'
|
|
43
|
+
import { decideNext } from '../../kernel/engine'
|
|
44
|
+
import { verifyPhase } from '../../ontology'
|
|
45
|
+
import type { BuildRecord, StreamRecord } from '../../store/types'
|
|
46
|
+
import { reduceDispatchSettings } from '../../kernel/dispatch-settings'
|
|
47
|
+
import { projectSessions, type DashboardSession } from './detail'
|
|
48
|
+
import type { TranscriptPresentation } from './transcript'
|
|
49
|
+
import type { UIMessage } from 'ai'
|
|
50
|
+
import type { StreamOutcome, StreamPart } from '../../store/streams/types'
|
|
51
|
+
export { dashboardBuildControl } from './actions'
|
|
52
|
+
export type { DashboardBuildControl } from './actions'
|
|
53
|
+
import {
|
|
54
|
+
DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
|
|
55
|
+
openHarvestRun,
|
|
56
|
+
reduceHarvest,
|
|
57
|
+
type HarvestRunState,
|
|
58
|
+
type HarvestState,
|
|
59
|
+
} from '../../kernel/harvest'
|
|
60
|
+
|
|
61
|
+
/** Every capacity-bearing build plus acknowledged abort cleanup is listed.
|
|
62
|
+
* `aborting` and `cleaning` are display-only projections over durable abort
|
|
63
|
+
* facts; neither extends the reducer's lifecycle vocabulary. */
|
|
64
|
+
export type EffectiveStatus =
|
|
65
|
+
| 'queued'
|
|
66
|
+
| 'running'
|
|
67
|
+
| 'pausing'
|
|
68
|
+
| 'paused'
|
|
69
|
+
| 'resuming'
|
|
70
|
+
| 'blocked'
|
|
71
|
+
| 'aborting'
|
|
72
|
+
| 'cleaning'
|
|
73
|
+
|
|
74
|
+
export type StepState = 'done' | 'current' | 'provisional' | 'pending'
|
|
75
|
+
|
|
76
|
+
/** Human intent versus correlated forge application, rendered non-color-only. */
|
|
77
|
+
export type AutoMergeDisplay = 'off' | 'requested' | 'enabled' | 'cancelling'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A step's wall-clock timing, now-INDEPENDENT so the model can be cached and
|
|
81
|
+
* repainted against a moving clock (the elapsed ticks in the renderer, not
|
|
82
|
+
* here). `accumulatedMs` is the cumulative duration of every CLOSED occurrence
|
|
83
|
+
* in scope; `runningSince` is the start epoch-ms of an open occurrence — the
|
|
84
|
+
* renderer adds `now - runningSince` on top of `accumulatedMs`. A frozen build
|
|
85
|
+
* (paused/blocked) has its open occurrence closed into `accumulatedMs`, so
|
|
86
|
+
* `runningSince` is absent and its timer never advances (AC 10).
|
|
87
|
+
*/
|
|
88
|
+
export interface StepTiming {
|
|
89
|
+
accumulatedMs: number
|
|
90
|
+
runningSince?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface PipelineStep {
|
|
94
|
+
label: string
|
|
95
|
+
state: StepState
|
|
96
|
+
/** Display-only and never consulted for routing. `failed` or `skipped` on
|
|
97
|
+
* a verify/finalize step preserves its outcome alongside lifecycle `state`;
|
|
98
|
+
* `waiting` annotates merge. Rendered inside the `(…)` note, so outcome
|
|
99
|
+
* distinctions never rely on color alone. */
|
|
100
|
+
qualifier?: 'failed' | 'skipped' | 'waiting'
|
|
101
|
+
/** Round (plan/implement loops) or attempt (verify/reconcile). Rendered as
|
|
102
|
+
* `/n` riding the elapsed time when > 1 — supersedes the old `r2`/`a2`. */
|
|
103
|
+
count?: number
|
|
104
|
+
/** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
|
|
105
|
+
* shown (AC 6). The renderer composes the elapsed segment from this. */
|
|
106
|
+
timing?: StepTiming
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type DashboardSelection =
|
|
110
|
+
| { kind: 'global' }
|
|
111
|
+
| { kind: 'harvest' }
|
|
112
|
+
| { kind: 'build'; slug: string }
|
|
113
|
+
|
|
114
|
+
export type HarvestRunAction = 'resume' | 'acknowledge'
|
|
115
|
+
|
|
116
|
+
export interface DashboardHarvest {
|
|
117
|
+
kind: 'harvest'
|
|
118
|
+
/** A row always represents this concrete run; the repository gate lives in
|
|
119
|
+
* the header even when no run exists. */
|
|
120
|
+
run: string
|
|
121
|
+
status: 'running' | 'paused' | 'escalated' | 'failed'
|
|
122
|
+
steps: PipelineStep[]
|
|
123
|
+
observations: number
|
|
124
|
+
rounds: number
|
|
125
|
+
detail?: string
|
|
126
|
+
/** Display-only action currently safe for `p`. Absent for running rows,
|
|
127
|
+
* acknowledged pauses, and while a resume request awaits the kernel. */
|
|
128
|
+
action?: HarvestRunAction
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface DashboardBuild {
|
|
132
|
+
slug: string
|
|
133
|
+
status: EffectiveStatus
|
|
134
|
+
/** True when the build is BOTH paused and blocked: blocked wins the status
|
|
135
|
+
* (the spec's visual override), and this keeps the pause visible so no
|
|
136
|
+
* information is lost. */
|
|
137
|
+
alsoPaused: boolean
|
|
138
|
+
ticketId?: string
|
|
139
|
+
steps: PipelineStep[]
|
|
140
|
+
/** Queued-only explanation of the dispatcher boundary currently pending. */
|
|
141
|
+
dispatch?: string
|
|
142
|
+
/** Abort-stage explanation. Present instead of pipeline progress so the two
|
|
143
|
+
* destructive-action stages remain explicit without manufacturing work. */
|
|
144
|
+
abortProgress?: string
|
|
145
|
+
/** Current durable setup error, attributed to this build and retained until
|
|
146
|
+
* a later successful attachment proves recovery. */
|
|
147
|
+
setupError?: string
|
|
148
|
+
/** Every unresolved blocker's question. Resolved ones drop out by
|
|
149
|
+
* construction — the reducer moves them to answeredEscalations. */
|
|
150
|
+
blockers: string[]
|
|
151
|
+
/** Current-spec per-loop review budget overrides. */
|
|
152
|
+
reviewRoundCeilings?: { plan?: number; code?: number }
|
|
153
|
+
/** Native auto-merge desired/applied state. */
|
|
154
|
+
autoMerge: AutoMergeDisplay
|
|
155
|
+
pr?: { url: string; state: PrLifecycle }
|
|
156
|
+
/** Chronological session history projected from the retained raw log. */
|
|
157
|
+
sessions?: DashboardSession[]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type DashboardView =
|
|
161
|
+
| {
|
|
162
|
+
kind: 'detail'
|
|
163
|
+
slug: string
|
|
164
|
+
/** Process-local offset into the fully wrapped detail body. */
|
|
165
|
+
scroll: number
|
|
166
|
+
sessionId?: string
|
|
167
|
+
/** Retained display content. Raising it is a one-shot controller reveal;
|
|
168
|
+
* its continued presence never implies a standing follow mode. */
|
|
169
|
+
message?: string
|
|
170
|
+
/** Message validity fence for facts that can change on the next poll. */
|
|
171
|
+
messageWhileSessionOpen?: string
|
|
172
|
+
}
|
|
173
|
+
| {
|
|
174
|
+
kind: 'transcript'
|
|
175
|
+
slug: string
|
|
176
|
+
sessionId: string
|
|
177
|
+
transcript: TranscriptPresentation
|
|
178
|
+
scroll: number
|
|
179
|
+
}
|
|
180
|
+
| {
|
|
181
|
+
/** The read-only live/closed stream view. Pure display state: the feed
|
|
182
|
+
* only appends freshly read chunks to the accumulated array, and stored
|
|
183
|
+
* parts are never altered. */
|
|
184
|
+
kind: 'session'
|
|
185
|
+
slug: string
|
|
186
|
+
sessionId: string
|
|
187
|
+
stream: string
|
|
188
|
+
status: 'open' | 'closed'
|
|
189
|
+
outcome?: StreamOutcome
|
|
190
|
+
/** The content source: the accumulated part sequence while chunks are
|
|
191
|
+
* readable, or the finalized `UIMessage[]` document once chunks are
|
|
192
|
+
* pruned (or the closed session never had a chunk log). */
|
|
193
|
+
source:
|
|
194
|
+
| { kind: 'parts'; parts: StreamPart[]; lastSeq: number }
|
|
195
|
+
| { kind: 'document'; document: UIMessage[] }
|
|
196
|
+
/** The last failed read's message, shown in place of content until a
|
|
197
|
+
* retry succeeds. */
|
|
198
|
+
error?: string
|
|
199
|
+
/** Tail-follow mode. True while the operator keeps the viewport at the
|
|
200
|
+
* bottom; Up away from the bottom pauses it, back to the bottom
|
|
201
|
+
* resumes. While true the render pins `scroll` to the limit. */
|
|
202
|
+
follow: boolean
|
|
203
|
+
scroll: number
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type SessionDashboardView = Extract<DashboardView, { kind: 'session' }>
|
|
207
|
+
|
|
208
|
+
export interface ResumeInputView {
|
|
209
|
+
/** The prompt stays bound to this build even while polling re-sorts rows. */
|
|
210
|
+
slug: string
|
|
211
|
+
/** Full operator input, line structure included. Rendering may scroll it,
|
|
212
|
+
* submission never truncates it. */
|
|
213
|
+
value: string
|
|
214
|
+
/** Caret position as a UTF-16 grapheme-boundary offset into `value` — the
|
|
215
|
+
* composer's one string coordinate the controller owns. */
|
|
216
|
+
cursor: number
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface AbortConfirmationView {
|
|
220
|
+
/** Captured build identity; confirmation never retargets after re-sorting. */
|
|
221
|
+
slug: string
|
|
222
|
+
}
|
|
223
|
+
export interface DashboardCounter {
|
|
224
|
+
current: number
|
|
225
|
+
limit: number
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface DashboardModel {
|
|
229
|
+
repo: string
|
|
230
|
+
/** Ready tickets waiting for a slot — the dispatcher's standing queue depth. */
|
|
231
|
+
queued: number
|
|
232
|
+
/** Every nonterminal build consumes one configured dispatch capacity slot. */
|
|
233
|
+
active: DashboardCounter
|
|
234
|
+
/** Recorded observation occurrences not claimed by a Harvest snapshot,
|
|
235
|
+
* against the configured count-pressure trigger. */
|
|
236
|
+
observations: DashboardCounter
|
|
237
|
+
/** Durable repository intake state (`true` means claims are disabled). */
|
|
238
|
+
drained: boolean
|
|
239
|
+
/** Durable repository-wide hold (`true` means queued builds cannot launch). */
|
|
240
|
+
repositoryPaused: boolean
|
|
241
|
+
/** Durable repository claim-time default for newly claimed builds. */
|
|
242
|
+
defaultAutoMerge: boolean
|
|
243
|
+
/** Durable repository gate reduced from acknowledged journal facts. Pending
|
|
244
|
+
* commands deliberately do not change this field optimistically. */
|
|
245
|
+
harvestPaused: boolean
|
|
246
|
+
/** Stable row identity, never a row index. */
|
|
247
|
+
selection?: DashboardSelection
|
|
248
|
+
/** Release courtesy, separate from transient and startup warnings so those
|
|
249
|
+
* notices can never overwrite one another. Store-only frontends project it
|
|
250
|
+
* from their correlated dispatcher run. */
|
|
251
|
+
availableUpgrade?: string
|
|
252
|
+
/** Process-local warnings/errors, in render order. Routine dispatcher
|
|
253
|
+
* notices never enter the interactive model; absence (or an empty array)
|
|
254
|
+
* means the warning chrome is omitted. */
|
|
255
|
+
warningLines?: readonly string[]
|
|
256
|
+
/** Ephemeral blocked-resume field; never derived from or stored in events. */
|
|
257
|
+
resumeInput?: ResumeInputView
|
|
258
|
+
/** Ephemeral destructive-action confirmation; the first `a` writes nothing. */
|
|
259
|
+
abortConfirmation?: AbortConfirmationView
|
|
260
|
+
/** Nested read-only dashboard mode. Omission is the byte-stable list view. */
|
|
261
|
+
view?: DashboardView
|
|
262
|
+
builds: DashboardBuild[]
|
|
263
|
+
/** Present only for an open run or unresolved run-level attention. It is
|
|
264
|
+
* selectable display state, never a synthetic build. */
|
|
265
|
+
harvest?: DashboardHarvest
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* §15.5 sets the reducer's precedence to paused > blocked and documents why:
|
|
270
|
+
* pausing is the operator's explicit instruction, and the escalation is not
|
|
271
|
+
* lost — it stays in `openEscalations`, so resuming reports blocked again. The
|
|
272
|
+
* spec asks for the opposite VISUALLY ("a build with unresolved blockers is
|
|
273
|
+
* treated as blocked even when it is also manually paused"). Both hold at once
|
|
274
|
+
* because the reducer retains both facts: the display overrides, the lifecycle
|
|
275
|
+
* does not. A paused+blocked build still parks on `paused` in `decideNext`,
|
|
276
|
+
* exactly as today.
|
|
277
|
+
*/
|
|
278
|
+
export function effectiveStatus(state: BuildState): EffectiveStatus | BuildState['status'] {
|
|
279
|
+
// Terminal outcomes outrank every pending command and visual override. An
|
|
280
|
+
// acknowledged abort remains in its cleanup display stage, while `done`
|
|
281
|
+
// stays filterable as terminal. Only nonterminal abort intent projects as
|
|
282
|
+
// `aborting`; blocked and pause/resume overrides follow it.
|
|
283
|
+
if (state.status === 'done') return 'done'
|
|
284
|
+
if (state.status === 'aborted') return 'cleaning'
|
|
285
|
+
if (state.pendingCommands.some((command) => command.command === 'abort')) return 'aborting'
|
|
286
|
+
if (
|
|
287
|
+
(state.status === 'paused' || state.status === 'blocked') &&
|
|
288
|
+
state.openEscalations.length > 0
|
|
289
|
+
) {
|
|
290
|
+
return 'blocked'
|
|
291
|
+
}
|
|
292
|
+
if (state.status === 'running') {
|
|
293
|
+
return state.pendingCommands.some((command) => command.command === 'pause')
|
|
294
|
+
? 'pausing'
|
|
295
|
+
: 'running'
|
|
296
|
+
}
|
|
297
|
+
if (state.status === 'paused') {
|
|
298
|
+
return state.pendingCommands.some((command) => command.command === 'resume')
|
|
299
|
+
? 'resuming'
|
|
300
|
+
: 'paused'
|
|
301
|
+
}
|
|
302
|
+
return state.status
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isVisible(status: EffectiveStatus | BuildState['status']): status is EffectiveStatus {
|
|
306
|
+
return (
|
|
307
|
+
status === 'queued' ||
|
|
308
|
+
status === 'running' ||
|
|
309
|
+
status === 'pausing' ||
|
|
310
|
+
status === 'paused' ||
|
|
311
|
+
status === 'resuming' ||
|
|
312
|
+
status === 'blocked' ||
|
|
313
|
+
status === 'aborting' ||
|
|
314
|
+
status === 'cleaning'
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function autoMergeDisplay(state: BuildState): AutoMergeDisplay {
|
|
319
|
+
const { requested, commandSeq, applied } = state.autoMerge
|
|
320
|
+
if (requested) {
|
|
321
|
+
return applied?.enabled === true && applied.commandSeq === commandSeq ? 'enabled' : 'requested'
|
|
322
|
+
}
|
|
323
|
+
if (commandSeq === undefined) return 'off'
|
|
324
|
+
if (applied?.enabled === false && applied.commandSeq === commandSeq) return 'off'
|
|
325
|
+
// Before a PR exists, cancellation clears the durable desired flag and there
|
|
326
|
+
// is no remote state to wait on. Once a PR (or prior enabled fact) exists,
|
|
327
|
+
// keep the pending disable visible until its correlated fact lands.
|
|
328
|
+
if (state.pr === undefined && applied?.enabled !== true) return 'off'
|
|
329
|
+
return 'cancelling'
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* One step, built through one helper so the precedence rule cannot be applied
|
|
334
|
+
* inconsistently: **`current > done > provisional > pending`**. In particular,
|
|
335
|
+
* an active occurrence stays current even when an earlier terminal output for
|
|
336
|
+
* that same occurrence exists. `producedOutput` is display-only and means a
|
|
337
|
+
* terminal event exists in the current round/cycle — never merely a start or
|
|
338
|
+
* an open timing interval.
|
|
339
|
+
*/
|
|
340
|
+
interface StepExtra {
|
|
341
|
+
producedOutput?: boolean
|
|
342
|
+
qualifier?: 'failed' | 'skipped' | 'waiting'
|
|
343
|
+
count?: number
|
|
344
|
+
timing?: StepTiming
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function step(label: string, done: boolean, current: boolean, extra: StepExtra = {}): PipelineStep {
|
|
348
|
+
return {
|
|
349
|
+
label,
|
|
350
|
+
state: current
|
|
351
|
+
? 'current'
|
|
352
|
+
: done
|
|
353
|
+
? 'done'
|
|
354
|
+
: extra.producedOutput === true
|
|
355
|
+
? 'provisional'
|
|
356
|
+
: 'pending',
|
|
357
|
+
...(extra.qualifier !== undefined ? { qualifier: extra.qualifier } : {}),
|
|
358
|
+
...(extra.count !== undefined ? { count: extra.count } : {}),
|
|
359
|
+
...(extra.timing !== undefined ? { timing: extra.timing } : {}),
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One phase occurrence's wall-clock span, keyed by phase. `startSeq` is the
|
|
364
|
+
* seq of the `*.started` event, so callers can scope by the same seq
|
|
365
|
+
* boundaries (`restartSince`/`cycleSince`) the step STATES already use. */
|
|
366
|
+
interface PhaseInterval {
|
|
367
|
+
start: number
|
|
368
|
+
end: number
|
|
369
|
+
startSeq: number
|
|
370
|
+
}
|
|
371
|
+
interface PhaseTiming {
|
|
372
|
+
closed: PhaseInterval[]
|
|
373
|
+
open?: { start: number; startSeq: number }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Walk the raw event log into per-phase `[start, end]` intervals in epoch-ms
|
|
378
|
+
* (`Date.parse(ev.ts)`), the durations the reducer collapses away. Each
|
|
379
|
+
* `*.started` opens an interval for its phase key and its terminal event
|
|
380
|
+
* (`.completed`/`.verdict`) closes it. A second `*.started` while one is still
|
|
381
|
+
* open REPLACES the open start — a §15.6-C cross-sandbox re-run starts the
|
|
382
|
+
* phase afresh, so the crashed attempt contributes nothing. Finalize post-steps
|
|
383
|
+
* (`finalize.step-completed`) have no `.started` and so no interval.
|
|
384
|
+
*/
|
|
385
|
+
function phaseIntervals(events: AbEvent[]): Map<string, PhaseTiming> {
|
|
386
|
+
const timings = new Map<string, PhaseTiming>()
|
|
387
|
+
const get = (key: string): PhaseTiming => {
|
|
388
|
+
let t = timings.get(key)
|
|
389
|
+
if (t === undefined) {
|
|
390
|
+
t = { closed: [] }
|
|
391
|
+
timings.set(key, t)
|
|
392
|
+
}
|
|
393
|
+
return t
|
|
394
|
+
}
|
|
395
|
+
for (const ev of events) {
|
|
396
|
+
const ms = Date.parse(ev.ts)
|
|
397
|
+
const open = (key: string): void => {
|
|
398
|
+
get(key).open = { start: ms, startSeq: ev.seq }
|
|
399
|
+
}
|
|
400
|
+
const close = (key: string): void => {
|
|
401
|
+
const t = get(key)
|
|
402
|
+
if (t.open === undefined) return
|
|
403
|
+
t.closed.push({ start: t.open.start, end: ms, startSeq: t.open.startSeq })
|
|
404
|
+
t.open = undefined
|
|
405
|
+
}
|
|
406
|
+
switch (ev.type) {
|
|
407
|
+
case 'plan.started':
|
|
408
|
+
open('plan')
|
|
409
|
+
break
|
|
410
|
+
case 'plan.completed':
|
|
411
|
+
close('plan')
|
|
412
|
+
break
|
|
413
|
+
case 'plan-review.started':
|
|
414
|
+
open('plan-review')
|
|
415
|
+
break
|
|
416
|
+
case 'plan-review.verdict':
|
|
417
|
+
close('plan-review')
|
|
418
|
+
break
|
|
419
|
+
case 'implement.started':
|
|
420
|
+
open('implement')
|
|
421
|
+
break
|
|
422
|
+
case 'implement.completed':
|
|
423
|
+
close('implement')
|
|
424
|
+
break
|
|
425
|
+
case 'code-review.started':
|
|
426
|
+
open('code-review')
|
|
427
|
+
break
|
|
428
|
+
case 'code-review.verdict':
|
|
429
|
+
close('code-review')
|
|
430
|
+
break
|
|
431
|
+
case 'verify.started':
|
|
432
|
+
open(verifyPhase(ev.payload.step))
|
|
433
|
+
break
|
|
434
|
+
case 'verify.completed':
|
|
435
|
+
close(verifyPhase(ev.payload.step))
|
|
436
|
+
break
|
|
437
|
+
case 'finalize.started':
|
|
438
|
+
open('finalize')
|
|
439
|
+
break
|
|
440
|
+
case 'finalize.completed':
|
|
441
|
+
close('finalize')
|
|
442
|
+
break
|
|
443
|
+
case 'reconcile.started':
|
|
444
|
+
open('reconcile')
|
|
445
|
+
break
|
|
446
|
+
case 'reconcile.completed':
|
|
447
|
+
close('reconcile')
|
|
448
|
+
break
|
|
449
|
+
default:
|
|
450
|
+
break
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return timings
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Sum the in-scope occurrences of one phase into a `StepTiming`, or `undefined`
|
|
458
|
+
* when the phase never ran in scope (the step shows no time, AC 6). Scope is
|
|
459
|
+
* `startSeq > sinceSeq` — the SAME boundary the step's `done`/`current` state
|
|
460
|
+
* uses, so durations restart in lockstep with states (AC 12). The open
|
|
461
|
+
* occurrence stays open (`runningSince`) only when the build is running; a
|
|
462
|
+
* frozen build (`frozenNow` given) closes it at that instant so its timer does
|
|
463
|
+
* not advance (AC 10).
|
|
464
|
+
*/
|
|
465
|
+
function timingFor(
|
|
466
|
+
intervals: Map<string, PhaseTiming>,
|
|
467
|
+
key: string,
|
|
468
|
+
sinceSeq: number,
|
|
469
|
+
frozenNow: number | undefined,
|
|
470
|
+
): StepTiming | undefined {
|
|
471
|
+
const t = intervals.get(key)
|
|
472
|
+
if (t === undefined) return undefined
|
|
473
|
+
let accumulatedMs = 0
|
|
474
|
+
let inScope = false
|
|
475
|
+
for (const iv of t.closed) {
|
|
476
|
+
if (iv.startSeq > sinceSeq) {
|
|
477
|
+
accumulatedMs += iv.end - iv.start
|
|
478
|
+
inScope = true
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
let runningSince: number | undefined
|
|
482
|
+
if (t.open !== undefined && t.open.startSeq > sinceSeq) {
|
|
483
|
+
inScope = true
|
|
484
|
+
if (frozenNow !== undefined) accumulatedMs += Math.max(0, frozenNow - t.open.start)
|
|
485
|
+
else runningSince = t.open.start
|
|
486
|
+
}
|
|
487
|
+
if (!inScope) return undefined
|
|
488
|
+
return { accumulatedMs, ...(runningSince !== undefined ? { runningSince } : {}) }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* `record` + `state` → one dashboard row, or `null` only for terminal builds.
|
|
493
|
+
*/
|
|
494
|
+
export function projectBuild(
|
|
495
|
+
record: BuildRecord,
|
|
496
|
+
state: BuildState,
|
|
497
|
+
config: Config,
|
|
498
|
+
events: AbEvent[],
|
|
499
|
+
streams?: readonly StreamRecord[],
|
|
500
|
+
): DashboardBuild | null {
|
|
501
|
+
const status = effectiveStatus(state)
|
|
502
|
+
if (!isVisible(status)) return null
|
|
503
|
+
const abortProgress =
|
|
504
|
+
status === 'aborting'
|
|
505
|
+
? state.status === 'queued'
|
|
506
|
+
? 'abort requested; waiting for dispatcher acknowledgement'
|
|
507
|
+
: 'abort requested; waiting for running work to stop'
|
|
508
|
+
: status === 'cleaning'
|
|
509
|
+
? 'running work stopped; abort cleanup pending or in progress'
|
|
510
|
+
: undefined
|
|
511
|
+
|
|
512
|
+
if (status === 'queued') {
|
|
513
|
+
const created = events.findLast((event) => event.type === 'build.created')
|
|
514
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
515
|
+
const released = events.findLast((event) => event.type === 'workspace.released')
|
|
516
|
+
const openWorkspace =
|
|
517
|
+
workspace !== undefined && (released === undefined || workspace.seq > released.seq)
|
|
518
|
+
const spec = events.findLast(
|
|
519
|
+
(event) => event.type === 'spec.imported' || event.type === 'spec.authored',
|
|
520
|
+
)
|
|
521
|
+
const commentPosted = events.findLast((event) => event.type === 'dispatch.comment-posted')
|
|
522
|
+
const latestFailure = events.findLast((event) => event.type === 'dispatch.failed')
|
|
523
|
+
const failureSuperseded =
|
|
524
|
+
latestFailure !== undefined &&
|
|
525
|
+
((latestFailure.payload.stage === 'create' &&
|
|
526
|
+
created !== undefined &&
|
|
527
|
+
created.seq > latestFailure.seq) ||
|
|
528
|
+
(latestFailure.payload.stage === 'workspace' &&
|
|
529
|
+
workspace !== undefined &&
|
|
530
|
+
workspace.seq > latestFailure.seq) ||
|
|
531
|
+
(latestFailure.payload.stage === 'spec' &&
|
|
532
|
+
spec !== undefined &&
|
|
533
|
+
spec.seq > latestFailure.seq) ||
|
|
534
|
+
(latestFailure.payload.stage === 'comment' &&
|
|
535
|
+
commentPosted !== undefined &&
|
|
536
|
+
commentPosted.seq > latestFailure.seq))
|
|
537
|
+
const dispatch =
|
|
538
|
+
state.discardRequest !== undefined
|
|
539
|
+
? 'discard requested; cleanup and Ready handback pending'
|
|
540
|
+
: latestFailure !== undefined && !failureSuperseded
|
|
541
|
+
? `dispatch ${latestFailure.payload.stage} failed (attempt ${latestFailure.payload.attempt}): ${latestFailure.payload.error}`
|
|
542
|
+
: created === undefined
|
|
543
|
+
? 'dispatch initialization pending'
|
|
544
|
+
: !openWorkspace
|
|
545
|
+
? 'workspace provisioning pending'
|
|
546
|
+
: spec === undefined
|
|
547
|
+
? 'spec import pending'
|
|
548
|
+
: 'runner attachment pending'
|
|
549
|
+
return {
|
|
550
|
+
slug: record.slug,
|
|
551
|
+
status,
|
|
552
|
+
alsoPaused: false,
|
|
553
|
+
...(record.ticket?.id !== undefined ? { ticketId: record.ticket.id } : {}),
|
|
554
|
+
steps: [],
|
|
555
|
+
dispatch,
|
|
556
|
+
blockers: [],
|
|
557
|
+
autoMerge: autoMergeDisplay(state),
|
|
558
|
+
sessions: projectSessions(events, streams),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (abortProgress !== undefined) {
|
|
563
|
+
return {
|
|
564
|
+
slug: record.slug,
|
|
565
|
+
status,
|
|
566
|
+
alsoPaused: false,
|
|
567
|
+
...(record.ticket?.id !== undefined ? { ticketId: record.ticket.id } : {}),
|
|
568
|
+
steps: [],
|
|
569
|
+
abortProgress,
|
|
570
|
+
blockers: state.openEscalations.map((escalation) => escalation.question),
|
|
571
|
+
...(state.reviewRoundCeilings.plan !== undefined ||
|
|
572
|
+
state.reviewRoundCeilings.code !== undefined
|
|
573
|
+
? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
|
|
574
|
+
: {}),
|
|
575
|
+
autoMerge: autoMergeDisplay(state),
|
|
576
|
+
...(state.pr !== undefined && state.prState !== undefined
|
|
577
|
+
? { pr: { url: state.pr.url, state: state.prState } }
|
|
578
|
+
: {}),
|
|
579
|
+
sessions: projectSessions(events, streams),
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── Boundaries ────────────────────────────────────────────────────────────
|
|
584
|
+
//
|
|
585
|
+
// A revise-spec answer whose `spec.revised` has not landed yet (§6.3,
|
|
586
|
+
// engine.ts:221-227): the engine is parked on `wait{awaiting-spec}` and will
|
|
587
|
+
// restart the build from plan the instant the revision arrives. This window
|
|
588
|
+
// is HUMAN-paced — the operator is off rewriting the spec — so it is exactly
|
|
589
|
+
// the durable state this dashboard exists to render, and rendering
|
|
590
|
+
// `finalize [x] merge [>] waiting` in it would be a wrong answer: every one
|
|
591
|
+
// of those steps is about to re-run. A pending restart therefore raises the
|
|
592
|
+
// boundary the same way the landed one will. (`decideNext` returns `wait`
|
|
593
|
+
// here, naming no phase, so the engine oracle in the tests is blind to this
|
|
594
|
+
// state — it is asserted by hand.)
|
|
595
|
+
const pendingRestart = state.answeredEscalations.some(
|
|
596
|
+
(e) => e.resolution === 'revise-spec' && e.answeredSeq > state.restartSince,
|
|
597
|
+
)
|
|
598
|
+
const restartSince = pendingRestart ? state.lastSeq : state.restartSince
|
|
599
|
+
const cycleSince = Math.max(state.verify.cycleSince, restartSince)
|
|
600
|
+
// Post-steps re-run after a restart too (engine.ts:710-712). Their reducer
|
|
601
|
+
// projection is full-log history, so the same effective boundary handles
|
|
602
|
+
// both a landed revision and the pending human-paced restart window.
|
|
603
|
+
const finalizeSteps = state.finalizeSteps.filter((completion) => completion.seq > restartSince)
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The current phase, scoped to the current spec.
|
|
607
|
+
*
|
|
608
|
+
* `currentPhase` is latest-wins over the FULL log — the same shape as
|
|
609
|
+
* `plan.approved` and `prState`, and stale for the same reason. `start()`
|
|
610
|
+
* sets `currentPhase` and only that phase's OWN terminal event clears it
|
|
611
|
+
* (`reducer.ts`); `escalation.raised` deliberately does not
|
|
612
|
+
* (the phase still needs re-running), and `spec.revised` advances
|
|
613
|
+
* `restartSince` and `cycleSince` but does not clear these. So a phase that
|
|
614
|
+
* was in flight when a restart landed still reads as running.
|
|
615
|
+
*
|
|
616
|
+
* That is the plan's own defect class in the one predicate its rule never
|
|
617
|
+
* quantified over — `current`, not `done` — so it gets the same treatment:
|
|
618
|
+
* across a restart (landed OR pending) nothing is current until a new
|
|
619
|
+
* `*.started` lands. That is exactly what the engine does; it re-runs the
|
|
620
|
+
* loops from plan (engine.ts:466) rather than resuming the interrupted
|
|
621
|
+
* phase, and during a pending restart it runs nothing at all
|
|
622
|
+
* (engine.ts:221-227).
|
|
623
|
+
*
|
|
624
|
+
* `reconcile` is included deliberately. The epilogue is restart-orthogonal
|
|
625
|
+
* in the facts it routes on (engine.ts:161-162) — but rule 3 preempts it
|
|
626
|
+
* while a restart is pending, and once one lands the engine goes to plan and
|
|
627
|
+
* only re-reaches the epilogue after a full rebuild re-opens the PR. A
|
|
628
|
+
* pre-restart `reconcile.started` is not "reconcile is running now".
|
|
629
|
+
*/
|
|
630
|
+
const scoped = (ctx: PhaseContext | undefined): PhaseContext | undefined =>
|
|
631
|
+
ctx !== undefined && ctx.seq > restartSince ? ctx : undefined
|
|
632
|
+
const activePhase = scoped(state.currentPhase)
|
|
633
|
+
|
|
634
|
+
// ── Timing ────────────────────────────────────────────────────────────────
|
|
635
|
+
// Per-occurrence durations from the raw log — the reducer collapses them
|
|
636
|
+
// away, so they are derived here (display-only, at the one call site that
|
|
637
|
+
// already has the log in hand). A running build keeps its open interval live
|
|
638
|
+
// (the renderer ticks it against `now`); a paused build freezes every open
|
|
639
|
+
// interval at its acknowledgement (including while RESUMING), while a
|
|
640
|
+
// blocked build freezes at the latest durable event. Timers therefore do not
|
|
641
|
+
// advance behind either gate (AC 10). Each step scopes by the SAME seq boundary its state uses, so
|
|
642
|
+
// durations and states restart in lockstep (AC 12).
|
|
643
|
+
const intervals = phaseIntervals(events)
|
|
644
|
+
const pausedAt =
|
|
645
|
+
state.status === 'paused'
|
|
646
|
+
? events.findLast((event) => event.type === 'build.paused')?.ts
|
|
647
|
+
: undefined
|
|
648
|
+
const frozenNow =
|
|
649
|
+
state.status === 'running'
|
|
650
|
+
? undefined
|
|
651
|
+
: pausedAt !== undefined
|
|
652
|
+
? Date.parse(pausedAt)
|
|
653
|
+
: state.lastEvent !== undefined
|
|
654
|
+
? Date.parse(state.lastEvent.ts)
|
|
655
|
+
: undefined
|
|
656
|
+
|
|
657
|
+
// ── Shared derivations ────────────────────────────────────────────────────
|
|
658
|
+
// ORDER MATTERS: `cycleFailed` must be defined before `codeDone` reads it.
|
|
659
|
+
const cycle = state.verify.results.filter((r) => r.seq > cycleSince)
|
|
660
|
+
const cycleFailed = cycle.some((r) => r.outcome === 'fail')
|
|
661
|
+
const verifyPassed = (s: string): boolean =>
|
|
662
|
+
cycle.some((r) => r.step === s && r.outcome === 'pass')
|
|
663
|
+
const verifySkipped = (s: string): boolean =>
|
|
664
|
+
cycle.some((r) => r.step === s && r.outcome === 'skipped')
|
|
665
|
+
const verifySatisfied = (s: string): boolean => verifyPassed(s) || verifySkipped(s)
|
|
666
|
+
const verifyDrained = !cycleFailed && config.verify.steps.every(verifySatisfied)
|
|
667
|
+
|
|
668
|
+
// A loop is settled only if its standing approval survived the last restart
|
|
669
|
+
// AND no later producer round reopened it. `approved` alone is a full-log
|
|
670
|
+
// boolean and lies in both cases.
|
|
671
|
+
const loopDone = (
|
|
672
|
+
approval: { seq: number; round: number } | undefined,
|
|
673
|
+
producerRound: number,
|
|
674
|
+
): boolean =>
|
|
675
|
+
approval !== undefined && approval.seq > restartSince && approval.round === producerRound
|
|
676
|
+
|
|
677
|
+
const planDone = loopDone(state.plan.approval, state.plan.round)
|
|
678
|
+
// `&& !cycleFailed`: a verify failure reopens the CODE loop (§15.6-A) the
|
|
679
|
+
// MOMENT it lands — long before `implement.started` moves `implement.round`,
|
|
680
|
+
// and under default policy that start may never come (see below).
|
|
681
|
+
const codeDone = loopDone(state.codeReviewApproval, state.implement.round) && !cycleFailed
|
|
682
|
+
|
|
683
|
+
// A loop row is provisional only when ITS terminal output belongs to the
|
|
684
|
+
// reducer's currently tracked round and landed after the effective restart
|
|
685
|
+
// boundary. Starts and timing intervals are deliberately insufficient: a
|
|
686
|
+
// crashed phase has run, but has produced no output. Matching the round also
|
|
687
|
+
// drops a review verdict as soon as the next producer round starts.
|
|
688
|
+
const planProduced = events.some(
|
|
689
|
+
(ev) =>
|
|
690
|
+
ev.seq > restartSince &&
|
|
691
|
+
ev.type === 'plan.completed' &&
|
|
692
|
+
ev.payload.round === state.plan.round,
|
|
693
|
+
)
|
|
694
|
+
const planReviewProduced = events.some(
|
|
695
|
+
(ev) =>
|
|
696
|
+
ev.seq > restartSince &&
|
|
697
|
+
ev.type === 'plan-review.verdict' &&
|
|
698
|
+
ev.payload.round === state.plan.round,
|
|
699
|
+
)
|
|
700
|
+
const implementProduced = events.some(
|
|
701
|
+
(ev) =>
|
|
702
|
+
ev.seq > restartSince &&
|
|
703
|
+
ev.type === 'implement.completed' &&
|
|
704
|
+
ev.payload.round === state.implement.round,
|
|
705
|
+
)
|
|
706
|
+
const codeReviewProduced = events.some(
|
|
707
|
+
(ev) =>
|
|
708
|
+
ev.seq > restartSince &&
|
|
709
|
+
ev.type === 'code-review.verdict' &&
|
|
710
|
+
ev.payload.round === state.implement.round,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
// Finalize ran for the CURRENT spec — NOT `prState !== undefined`, which a
|
|
714
|
+
// spec restart never resets while the engine re-runs finalize from scratch.
|
|
715
|
+
const finalizeDone = state.finalizeCompletedSeq > restartSince
|
|
716
|
+
const postStepsDrained = config.finalize.steps.every((s) =>
|
|
717
|
+
finalizeSteps.some((f) => f.step === s),
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
const at = (phase: string): boolean => activePhase?.phase === phase
|
|
721
|
+
// Retain first-round counts for detail rendering. The compact renderer still
|
|
722
|
+
// suppresses `/1`, preserving its existing density and output.
|
|
723
|
+
const planCount = state.plan.round > 0 ? state.plan.round : undefined
|
|
724
|
+
const codeCount = state.implement.round > 0 ? state.implement.round : undefined
|
|
725
|
+
|
|
726
|
+
const steps: PipelineStep[] = [
|
|
727
|
+
step('plan', planDone, at('plan'), {
|
|
728
|
+
producedOutput: planProduced,
|
|
729
|
+
count: planCount,
|
|
730
|
+
timing: timingFor(intervals, 'plan', restartSince, frozenNow),
|
|
731
|
+
}),
|
|
732
|
+
step('plan-review', planDone, at('plan-review'), {
|
|
733
|
+
producedOutput: planReviewProduced,
|
|
734
|
+
count: planCount,
|
|
735
|
+
timing: timingFor(intervals, 'plan-review', restartSince, frozenNow),
|
|
736
|
+
}),
|
|
737
|
+
step('implement', codeDone, at('implement'), {
|
|
738
|
+
producedOutput: implementProduced,
|
|
739
|
+
count: codeCount,
|
|
740
|
+
timing: timingFor(intervals, 'implement', restartSince, frozenNow),
|
|
741
|
+
}),
|
|
742
|
+
step('code-review', codeDone, at('code-review'), {
|
|
743
|
+
producedOutput: codeReviewProduced,
|
|
744
|
+
count: codeCount,
|
|
745
|
+
timing: timingFor(intervals, 'code-review', restartSince, frozenNow),
|
|
746
|
+
}),
|
|
747
|
+
]
|
|
748
|
+
|
|
749
|
+
for (const s of config.verify.steps) {
|
|
750
|
+
const phase = verifyPhase(s)
|
|
751
|
+
const current = at(phase)
|
|
752
|
+
const stepResults = cycle.filter((r) => r.step === s)
|
|
753
|
+
// The attempt COUNT comes from `currentPhase.attempt` for the running step
|
|
754
|
+
// — the attempt ACTUALLY running (`verify.started` populates it directly),
|
|
755
|
+
// never `verify.maxAttemptSeen`, which is a full-log high-water and names
|
|
756
|
+
// the previous cycle in the window after a boundary move. A non-running
|
|
757
|
+
// step takes the max attempt among its results IN THE CURRENT CYCLE, so a
|
|
758
|
+
// past cycle's attempt can never leak. Rendered as `/n` only when > 1.
|
|
759
|
+
const maxAttempt =
|
|
760
|
+
current && activePhase?.attempt !== undefined
|
|
761
|
+
? activePhase.attempt
|
|
762
|
+
: stepResults.length > 0
|
|
763
|
+
? Math.max(...stepResults.map((r) => r.attempt))
|
|
764
|
+
: undefined
|
|
765
|
+
const count = maxAttempt
|
|
766
|
+
// `!cycleFailed &&`: §15.6-A re-runs the cycle FROM THE FIRST STEP, so
|
|
767
|
+
// once any step in the cycle has failed, an earlier step's pass in that
|
|
768
|
+
// same cycle is not durably done either. The failing step keeps a `failed`
|
|
769
|
+
// qualifier so the operator does not lose the information; completed rows
|
|
770
|
+
// are provisional because they produced output but will genuinely re-run.
|
|
771
|
+
steps.push(
|
|
772
|
+
step(phase, !cycleFailed && verifySatisfied(s), current, {
|
|
773
|
+
producedOutput: stepResults.length > 0,
|
|
774
|
+
qualifier: stepResults.some((r) => r.outcome === 'fail')
|
|
775
|
+
? 'failed'
|
|
776
|
+
: stepResults.some((r) => r.outcome === 'skipped')
|
|
777
|
+
? 'skipped'
|
|
778
|
+
: undefined,
|
|
779
|
+
count,
|
|
780
|
+
timing: timingFor(intervals, phase, cycleSince, frozenNow),
|
|
781
|
+
}),
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
steps.push(
|
|
786
|
+
step('finalize', finalizeDone, at('finalize'), {
|
|
787
|
+
timing: timingFor(intervals, 'finalize', restartSince, frozenNow),
|
|
788
|
+
}),
|
|
789
|
+
)
|
|
790
|
+
for (const s of config.finalize.steps) {
|
|
791
|
+
// Post-steps have no `.started` event, so they carry no timing.
|
|
792
|
+
const done = finalizeSteps.find((f) => f.step === s)
|
|
793
|
+
steps.push(
|
|
794
|
+
step(s, done !== undefined, false, { qualifier: done?.ok === false ? 'failed' : undefined }),
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Conditional (§15.7): the epilogue is not guaranteed future work, so the
|
|
799
|
+
// row appears only once a conflict activates it. `reconcileAttempts` is
|
|
800
|
+
// full-log — legitimately, per the rule above: the engine routes the
|
|
801
|
+
// epilogue on the same full-log values (engine.ts:402,:416), so a restart
|
|
802
|
+
// genuinely does not re-run reconcile and the display agrees.
|
|
803
|
+
if (state.prState === 'conflicted' || state.reconcileAttempts > 0) {
|
|
804
|
+
steps.push(
|
|
805
|
+
step(
|
|
806
|
+
'reconcile',
|
|
807
|
+
state.reconcileAttempts > 0 && state.prState !== 'conflicted',
|
|
808
|
+
at('reconcile'),
|
|
809
|
+
{
|
|
810
|
+
count: state.reconcileAttempts > 0 ? state.reconcileAttempts : undefined,
|
|
811
|
+
// Full-log scope (sinceSeq 0), matching reconcile's full-log
|
|
812
|
+
// done/current predicate: the epilogue is restart-orthogonal.
|
|
813
|
+
timing: timingFor(intervals, 'reconcile', 0, frozenNow),
|
|
814
|
+
},
|
|
815
|
+
),
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Merge is never `done` for a LISTED build — a merged build is `done` and
|
|
820
|
+
// filtered out above. It is `current` exactly when the engine would return
|
|
821
|
+
// `wait{awaiting-pr}`: the PR is open and every piece of work behind it has
|
|
822
|
+
// drained. `prState === 'open'` alone is not enough — it is set at
|
|
823
|
+
// `finalize.completed` AND again at `reconcile.completed`, both with real
|
|
824
|
+
// work outstanding.
|
|
825
|
+
const mergeCurrent =
|
|
826
|
+
finalizeDone &&
|
|
827
|
+
state.prState === 'open' &&
|
|
828
|
+
verifyDrained &&
|
|
829
|
+
postStepsDrained &&
|
|
830
|
+
activePhase === undefined
|
|
831
|
+
// Merge-ready start = the log's last-event ts: once the build drains the
|
|
832
|
+
// engine parks on `wait{awaiting-pr}` and appends nothing, so `lastEvent.ts`
|
|
833
|
+
// is exactly when it became merge-ready, ticking like a running phase (AC 9).
|
|
834
|
+
// Frozen at that same ts when the build is not running.
|
|
835
|
+
const lastMs = state.lastEvent !== undefined ? Date.parse(state.lastEvent.ts) : 0
|
|
836
|
+
const mergeTiming: StepTiming | undefined = !mergeCurrent
|
|
837
|
+
? undefined
|
|
838
|
+
: frozenNow !== undefined
|
|
839
|
+
? { accumulatedMs: Math.max(0, frozenNow - lastMs) }
|
|
840
|
+
: { accumulatedMs: 0, runningSince: lastMs }
|
|
841
|
+
steps.push(step('merge', false, mergeCurrent, { qualifier: 'waiting', timing: mergeTiming }))
|
|
842
|
+
|
|
843
|
+
const setupError =
|
|
844
|
+
state.infrastructureFailure !== undefined
|
|
845
|
+
? `${state.infrastructureFailure.provider} ${state.infrastructureFailure.operation} failed ` +
|
|
846
|
+
`(attempt ${state.infrastructureFailure.attempt}, ${state.infrastructureFailure.cause}${
|
|
847
|
+
state.infrastructureFailure.cleanupPending ? ', cleanup pending' : ''
|
|
848
|
+
}): ${state.infrastructureFailure.error}`
|
|
849
|
+
: state.setupFailure === undefined
|
|
850
|
+
? undefined
|
|
851
|
+
: `[commands].setup ${JSON.stringify(state.setupFailure.command)} failed ` +
|
|
852
|
+
`(attempt ${state.setupFailure.attempt}, ${
|
|
853
|
+
state.setupFailure.exitStatus === null
|
|
854
|
+
? 'exit status unavailable'
|
|
855
|
+
: `exit status ${state.setupFailure.exitStatus}`
|
|
856
|
+
}): ${state.setupFailure.output || '(no output)'}`
|
|
857
|
+
|
|
858
|
+
const decision = decideNext(events, config)
|
|
859
|
+
const mergeWaitReason =
|
|
860
|
+
decision.kind === 'wait' && decision.reason === 'awaiting-pr'
|
|
861
|
+
? currentAutoMergeDeferral(events, state)
|
|
862
|
+
: undefined
|
|
863
|
+
|
|
864
|
+
return {
|
|
865
|
+
slug: record.slug,
|
|
866
|
+
status,
|
|
867
|
+
alsoPaused: state.status === 'paused' && status === 'blocked',
|
|
868
|
+
...(record.ticket?.id !== undefined ? { ticketId: record.ticket.id } : {}),
|
|
869
|
+
steps,
|
|
870
|
+
...(setupError !== undefined ? { setupError } : {}),
|
|
871
|
+
blockers: [
|
|
872
|
+
...state.openEscalations.map((e) => e.question),
|
|
873
|
+
...(mergeWaitReason !== undefined ? [mergeWaitReason] : []),
|
|
874
|
+
],
|
|
875
|
+
...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
|
|
876
|
+
? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
|
|
877
|
+
: {}),
|
|
878
|
+
autoMerge: autoMergeDisplay(state),
|
|
879
|
+
...(state.pr !== undefined && state.prState !== undefined
|
|
880
|
+
? { pr: { url: state.pr.url, state: state.prState } }
|
|
881
|
+
: {}),
|
|
882
|
+
sessions: projectSessions(events, streams),
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function harvestStepTiming(
|
|
887
|
+
occurrences: Array<{
|
|
888
|
+
step: string
|
|
889
|
+
startedAt: string
|
|
890
|
+
completedAt?: string
|
|
891
|
+
}>,
|
|
892
|
+
name: string,
|
|
893
|
+
frozenAt?: number,
|
|
894
|
+
): StepTiming | undefined {
|
|
895
|
+
const matching = occurrences.filter((occurrence) => occurrence.step === name)
|
|
896
|
+
if (matching.length === 0) return undefined
|
|
897
|
+
let accumulatedMs = 0
|
|
898
|
+
let runningSince: number | undefined
|
|
899
|
+
for (const occurrence of matching) {
|
|
900
|
+
const start = Date.parse(occurrence.startedAt)
|
|
901
|
+
if (occurrence.completedAt !== undefined) {
|
|
902
|
+
accumulatedMs += Math.max(0, Date.parse(occurrence.completedAt) - start)
|
|
903
|
+
} else if (frozenAt !== undefined) {
|
|
904
|
+
accumulatedMs += Math.max(0, frozenAt - start)
|
|
905
|
+
} else {
|
|
906
|
+
runningSince = start
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
accumulatedMs,
|
|
911
|
+
...(runningSince !== undefined ? { runningSince } : {}),
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** A deliberate escalation has no dedicated acknowledgement event. For display
|
|
916
|
+
* only, pair a post-terminal human resume request with the later kernel resume
|
|
917
|
+
* that consumed it. A countermanding pause invalidates the pending request,
|
|
918
|
+
* matching the repository reducer's command semantics. */
|
|
919
|
+
function escalationAttentionDismissed(events: RepositoryEvent[], terminalSeq: number): boolean {
|
|
920
|
+
let pendingHumanResume = false
|
|
921
|
+
for (const event of events) {
|
|
922
|
+
if (event.seq <= terminalSeq) continue
|
|
923
|
+
if (event.type === 'harvest.resume-requested' && event.actor.kind === 'human') {
|
|
924
|
+
pendingHumanResume = true
|
|
925
|
+
continue
|
|
926
|
+
}
|
|
927
|
+
if (event.type === 'harvest.pause-requested') {
|
|
928
|
+
pendingHumanResume = false
|
|
929
|
+
continue
|
|
930
|
+
}
|
|
931
|
+
if (event.type === 'harvest.resumed' && pendingHumanResume) return true
|
|
932
|
+
}
|
|
933
|
+
return false
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function selectDashboardHarvestRun(
|
|
937
|
+
events: RepositoryEvent[],
|
|
938
|
+
state: HarvestState,
|
|
939
|
+
): HarvestRunState | undefined {
|
|
940
|
+
const attention = state.runs.find((run) => {
|
|
941
|
+
if (run.status === 'failed') {
|
|
942
|
+
return run.recoveryExhaustion?.attentionAcknowledgedSeq === undefined
|
|
943
|
+
}
|
|
944
|
+
if (run.status !== 'escalated') return false
|
|
945
|
+
return run.terminalSeq === undefined || !escalationAttentionDismissed(events, run.terminalSeq)
|
|
946
|
+
})
|
|
947
|
+
return attention ?? openHarvestRun(state)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Project one deterministic concrete run. The oldest unresolved failed,
|
|
951
|
+
* exhausted, or escalated attention outranks merely open work; completed and
|
|
952
|
+
* acknowledged terminal history never hides an older actionable run. */
|
|
953
|
+
function projectHarvestRun(
|
|
954
|
+
events: RepositoryEvent[],
|
|
955
|
+
state: HarvestState,
|
|
956
|
+
): DashboardHarvest | undefined {
|
|
957
|
+
const run = selectDashboardHarvestRun(events, state)
|
|
958
|
+
if (run === undefined || run.status === 'completed') return undefined
|
|
959
|
+
const exhaustion = run.recoveryExhaustion
|
|
960
|
+
|
|
961
|
+
const occurrences = run.steps
|
|
962
|
+
const stopped = run.status !== 'running'
|
|
963
|
+
const current = (name: string): boolean => {
|
|
964
|
+
if (stopped || state.paused) return false
|
|
965
|
+
const latest = [...occurrences].reverse().find((occurrence) => occurrence.step === name)
|
|
966
|
+
return latest !== undefined && latest.completedSeq === undefined
|
|
967
|
+
}
|
|
968
|
+
const hasCompleted = (name: string): boolean =>
|
|
969
|
+
occurrences.some(
|
|
970
|
+
(occurrence) => occurrence.step === name && occurrence.completedSeq !== undefined,
|
|
971
|
+
)
|
|
972
|
+
const hasOutcome = (name: string, outcome: string): boolean =>
|
|
973
|
+
occurrences.some((occurrence) => occurrence.step === name && occurrence.outcome === outcome)
|
|
974
|
+
const failedAt = (name: string): boolean => run.status === 'failed' && run.failure?.step === name
|
|
975
|
+
const rounds = Math.max(
|
|
976
|
+
0,
|
|
977
|
+
...run.proposals.map((proposal) => proposal.round),
|
|
978
|
+
...run.reviews.map((review) => review.round),
|
|
979
|
+
...occurrences.map((occurrence) => occurrence.round ?? 0),
|
|
980
|
+
)
|
|
981
|
+
const count = rounds > 1 ? rounds : undefined
|
|
982
|
+
const approved = run.reviews.some((review) => review.verdict === 'approve')
|
|
983
|
+
const synthOutput = run.proposals.length > 0
|
|
984
|
+
const reviewOutput = run.reviews.length > 0
|
|
985
|
+
const failedEvent =
|
|
986
|
+
run.status === 'failed'
|
|
987
|
+
? [...events]
|
|
988
|
+
.reverse()
|
|
989
|
+
.find(
|
|
990
|
+
(event) =>
|
|
991
|
+
event.type === 'harvest.failed' &&
|
|
992
|
+
event.payload.run === run.run &&
|
|
993
|
+
event.payload.willRetry === false,
|
|
994
|
+
)
|
|
995
|
+
: undefined
|
|
996
|
+
// Both durable stop states freeze any open occurrence. A failure normally
|
|
997
|
+
// closes its step immediately afterward, but projecting the boundary itself
|
|
998
|
+
// must never make an errored row look like work is still accumulating time.
|
|
999
|
+
const frozenAt =
|
|
1000
|
+
state.pausedAt !== undefined
|
|
1001
|
+
? Date.parse(state.pausedAt)
|
|
1002
|
+
: failedEvent !== undefined
|
|
1003
|
+
? Date.parse(failedEvent.ts)
|
|
1004
|
+
: undefined
|
|
1005
|
+
const steps: PipelineStep[] = [
|
|
1006
|
+
step('scan', hasOutcome('scan', 'completed'), current('scan'), {
|
|
1007
|
+
qualifier: failedAt('scan') ? 'failed' : undefined,
|
|
1008
|
+
producedOutput: hasCompleted('scan'),
|
|
1009
|
+
timing: harvestStepTiming(occurrences, 'scan', frozenAt),
|
|
1010
|
+
}),
|
|
1011
|
+
step(
|
|
1012
|
+
'synthesize',
|
|
1013
|
+
synthOutput && (approved || stopped) && !failedAt('synthesize'),
|
|
1014
|
+
current('synthesize'),
|
|
1015
|
+
{
|
|
1016
|
+
producedOutput: synthOutput || hasCompleted('synthesize'),
|
|
1017
|
+
qualifier: failedAt('synthesize') ? 'failed' : undefined,
|
|
1018
|
+
count,
|
|
1019
|
+
timing: harvestStepTiming(occurrences, 'synthesize', frozenAt),
|
|
1020
|
+
},
|
|
1021
|
+
),
|
|
1022
|
+
step(
|
|
1023
|
+
'review',
|
|
1024
|
+
reviewOutput && (approved || stopped) && !failedAt('review'),
|
|
1025
|
+
current('review'),
|
|
1026
|
+
{
|
|
1027
|
+
producedOutput: reviewOutput || hasCompleted('review'),
|
|
1028
|
+
qualifier: failedAt('review') ? 'failed' : undefined,
|
|
1029
|
+
count,
|
|
1030
|
+
timing: harvestStepTiming(occurrences, 'review', frozenAt),
|
|
1031
|
+
},
|
|
1032
|
+
),
|
|
1033
|
+
step('file', false, current('file'), {
|
|
1034
|
+
producedOutput: hasCompleted('file'),
|
|
1035
|
+
qualifier: failedAt('file') ? 'failed' : undefined,
|
|
1036
|
+
timing: harvestStepTiming(occurrences, 'file', frozenAt),
|
|
1037
|
+
}),
|
|
1038
|
+
]
|
|
1039
|
+
const recoveryAttempts = run.recoveryRequests.length
|
|
1040
|
+
const recoveryLimit =
|
|
1041
|
+
exhaustion?.limit ?? run.recoveryRequests.at(-1)?.limit ?? DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
|
|
1042
|
+
const stoppedAt = run.failure
|
|
1043
|
+
? `${run.failure.step}${run.failure.round !== undefined ? ` r${run.failure.round}` : ''}`
|
|
1044
|
+
: undefined
|
|
1045
|
+
const detail =
|
|
1046
|
+
run.escalation !== undefined
|
|
1047
|
+
? run.escalation.reason
|
|
1048
|
+
: exhaustion !== undefined
|
|
1049
|
+
? `recovery exhausted - human attention required; stopped at ${exhaustion.step}${
|
|
1050
|
+
exhaustion.round !== undefined ? ` r${exhaustion.round}` : ''
|
|
1051
|
+
}; pending ${exhaustion.releasedObservations.length}`
|
|
1052
|
+
: run.failure !== undefined
|
|
1053
|
+
? `stopped at ${stoppedAt} - automatic recovery ${recoveryAttempts}/${recoveryLimit}; ${run.failure.error}`
|
|
1054
|
+
: recoveryAttempts > 0
|
|
1055
|
+
? `automatic recovery ${recoveryAttempts}/${recoveryLimit} resumed`
|
|
1056
|
+
: undefined
|
|
1057
|
+
const hasPendingResume = state.pendingCommands.some((command) => command.command === 'resume')
|
|
1058
|
+
const action: HarvestRunAction | undefined =
|
|
1059
|
+
state.paused || hasPendingResume
|
|
1060
|
+
? undefined
|
|
1061
|
+
: run.status === 'failed'
|
|
1062
|
+
? exhaustion === undefined
|
|
1063
|
+
? 'resume'
|
|
1064
|
+
: 'acknowledge'
|
|
1065
|
+
: run.status === 'escalated'
|
|
1066
|
+
? 'acknowledge'
|
|
1067
|
+
: undefined
|
|
1068
|
+
return {
|
|
1069
|
+
kind: 'harvest',
|
|
1070
|
+
run: run.run,
|
|
1071
|
+
// A paused gate freezes an open run. Attention statuses remain visible as
|
|
1072
|
+
// FAILED/ESCALATED because the header now carries the gate state.
|
|
1073
|
+
status: state.paused && run.status === 'running' ? 'paused' : run.status,
|
|
1074
|
+
steps,
|
|
1075
|
+
observations: run.observations.length,
|
|
1076
|
+
rounds,
|
|
1077
|
+
...(detail !== undefined ? { detail } : {}),
|
|
1078
|
+
...(action !== undefined ? { action } : {}),
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
interface RepositoryHarvestProjection {
|
|
1083
|
+
harvestPaused: boolean
|
|
1084
|
+
harvest?: DashboardHarvest
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
|
|
1088
|
+
const state = reduceHarvest(events)
|
|
1089
|
+
const harvest = projectHarvestRun(events, state)
|
|
1090
|
+
return {
|
|
1091
|
+
harvestPaused: state.paused,
|
|
1092
|
+
...(harvest !== undefined ? { harvest } : {}),
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export function projectHarvest(events: RepositoryEvent[]): DashboardHarvest | undefined {
|
|
1097
|
+
return projectRepositoryHarvest(events).harvest
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
interface DashboardFrameHeader {
|
|
1101
|
+
repo: string
|
|
1102
|
+
queued: number
|
|
1103
|
+
observationCount: number
|
|
1104
|
+
selection?: DashboardSelection
|
|
1105
|
+
availableUpgrade?: string
|
|
1106
|
+
warningLines?: readonly string[]
|
|
1107
|
+
resumeInput?: ResumeInputView
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Exact dynamic and configured facts needed by the preprojected live path. */
|
|
1111
|
+
export interface DashboardHeader extends DashboardFrameHeader {
|
|
1112
|
+
activeCount: number
|
|
1113
|
+
capacity: number
|
|
1114
|
+
observationLimit: number
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Compose a frame from already projected active rows. Keeping repository
|
|
1119
|
+
* projection here means build rows (including their phase intervals) may be
|
|
1120
|
+
* cached without caching dispatcher settings or Harvest state.
|
|
1121
|
+
*/
|
|
1122
|
+
export function buildDashboardFromProjected(
|
|
1123
|
+
projectedBuilds: readonly DashboardBuild[],
|
|
1124
|
+
header: DashboardHeader,
|
|
1125
|
+
repositoryEvents: RepositoryEvent[] = [],
|
|
1126
|
+
): DashboardModel {
|
|
1127
|
+
const builds = [...projectedBuilds].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
1128
|
+
const harvestProjection = projectRepositoryHarvest(repositoryEvents)
|
|
1129
|
+
const settings = reduceDispatchSettings(repositoryEvents)
|
|
1130
|
+
return {
|
|
1131
|
+
repo: header.repo,
|
|
1132
|
+
queued: header.queued,
|
|
1133
|
+
active: { current: header.activeCount, limit: header.capacity },
|
|
1134
|
+
observations: { current: header.observationCount, limit: header.observationLimit },
|
|
1135
|
+
drained: !settings.intake,
|
|
1136
|
+
repositoryPaused: settings.paused,
|
|
1137
|
+
defaultAutoMerge: settings.defaultAutoMerge,
|
|
1138
|
+
harvestPaused: harvestProjection.harvestPaused,
|
|
1139
|
+
...(header.selection !== undefined ? { selection: header.selection } : {}),
|
|
1140
|
+
...(header.availableUpgrade !== undefined ? { availableUpgrade: header.availableUpgrade } : {}),
|
|
1141
|
+
...(header.warningLines !== undefined && header.warningLines.length > 0
|
|
1142
|
+
? { warningLines: header.warningLines }
|
|
1143
|
+
: {}),
|
|
1144
|
+
...(header.resumeInput !== undefined ? { resumeInput: header.resumeInput } : {}),
|
|
1145
|
+
builds,
|
|
1146
|
+
...(harvestProjection.harvest !== undefined ? { harvest: harvestProjection.harvest } : {}),
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Existing entry-based convenience for uncached callers and projection tests. */
|
|
1151
|
+
export function buildDashboard(
|
|
1152
|
+
entries: { record: BuildRecord; state: BuildState; events: AbEvent[] }[],
|
|
1153
|
+
config: Config,
|
|
1154
|
+
header: DashboardFrameHeader,
|
|
1155
|
+
repositoryEvents: RepositoryEvent[] = [],
|
|
1156
|
+
): DashboardModel {
|
|
1157
|
+
const builds = entries
|
|
1158
|
+
.map(({ record, state, events }) => projectBuild(record, state, config, events))
|
|
1159
|
+
.filter((build): build is DashboardBuild => build !== null)
|
|
1160
|
+
const activeCount = entries.filter(
|
|
1161
|
+
({ state }) => state.status !== 'done' && state.status !== 'aborted',
|
|
1162
|
+
).length
|
|
1163
|
+
return buildDashboardFromProjected(
|
|
1164
|
+
builds,
|
|
1165
|
+
{
|
|
1166
|
+
...header,
|
|
1167
|
+
activeCount,
|
|
1168
|
+
capacity: config.capacity,
|
|
1169
|
+
observationLimit: config.policy.harvestThreshold,
|
|
1170
|
+
},
|
|
1171
|
+
repositoryEvents,
|
|
1172
|
+
)
|
|
1173
|
+
}
|