@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,1045 @@
|
|
|
1
|
+
import { configSchema, type Config } from '../config/schema'
|
|
2
|
+
import { humanActor } from '../events/envelope'
|
|
3
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
4
|
+
import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
|
|
5
|
+
import type { BuildState } from '../kernel/reducer'
|
|
6
|
+
import { scanUnclaimedObservations } from '../processes/harvest'
|
|
7
|
+
import {
|
|
8
|
+
controlHarvestRun,
|
|
9
|
+
OperatorControlError,
|
|
10
|
+
toggleHarvestGate,
|
|
11
|
+
toggleRepositorySetting,
|
|
12
|
+
} from '../operator/control'
|
|
13
|
+
import type { BuildStore, Clock } from '../store/types'
|
|
14
|
+
import { systemClock } from '../store/types'
|
|
15
|
+
import { BuildControlError, buildControlUser, controlBuild } from './build-control'
|
|
16
|
+
import { bulkControlReport, bulkControlRepository } from './bulk-control'
|
|
17
|
+
import { deleteBefore, insertText, moveCursor, type ComposerMotion } from './dashboard/composer'
|
|
18
|
+
import { dashboardBuildControl } from './dashboard/actions'
|
|
19
|
+
import {
|
|
20
|
+
buildDashboardFromProjected,
|
|
21
|
+
type DashboardBuild,
|
|
22
|
+
type DashboardModel,
|
|
23
|
+
type DashboardSelection,
|
|
24
|
+
type DashboardView,
|
|
25
|
+
} from './dashboard/model'
|
|
26
|
+
import { DashboardBuildPollCache } from './dashboard/poll'
|
|
27
|
+
import {
|
|
28
|
+
dashboardContentWidth,
|
|
29
|
+
detailScrollLimit,
|
|
30
|
+
moveDetailScroll,
|
|
31
|
+
moveTranscriptScroll,
|
|
32
|
+
renderDashboard,
|
|
33
|
+
revealDetailFocus,
|
|
34
|
+
type DashboardRendererResolver,
|
|
35
|
+
} from './dashboard/render'
|
|
36
|
+
import { dashboardSelections, moveSelection, reconcileSelection } from './dashboard/selection'
|
|
37
|
+
import { parseTranscript } from './dashboard/transcript'
|
|
38
|
+
import { applySessionFeedUpdate, SessionStreamFeed } from './dashboard/session-feed'
|
|
39
|
+
import { moveSessionScroll, sessionScrollLimit } from './dashboard/render'
|
|
40
|
+
import { LiveRegion, paintableRows } from './dashboard/live'
|
|
41
|
+
import { createKeyboardProtocol } from './keyboard'
|
|
42
|
+
import type { TerminalInput, TerminalInputEvent, TerminalOut } from './terminal'
|
|
43
|
+
import {
|
|
44
|
+
superviseDispatchChild,
|
|
45
|
+
type DispatchChildHandle,
|
|
46
|
+
type DispatchChildOptions,
|
|
47
|
+
} from './dispatch-process'
|
|
48
|
+
|
|
49
|
+
const POLL_MS = 500
|
|
50
|
+
const PAINT_MS = 250
|
|
51
|
+
const ABORTABLE_STATUSES = new Set(['queued', 'running', 'paused', 'blocked'])
|
|
52
|
+
|
|
53
|
+
function validAbortConfirmation(state: BuildState | undefined): boolean {
|
|
54
|
+
return (
|
|
55
|
+
state !== undefined &&
|
|
56
|
+
ABORTABLE_STATUSES.has(state.status) &&
|
|
57
|
+
!state.pendingCommands.some((command) => command.command === 'abort')
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isStaleAbortControlError(error: unknown): error is BuildControlError {
|
|
62
|
+
return (
|
|
63
|
+
error instanceof BuildControlError &&
|
|
64
|
+
['not-found', 'wrong-repository', 'inactive', 'no-longer-active', 'abort-pending'].includes(
|
|
65
|
+
error.code,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
|
|
71
|
+
return (
|
|
72
|
+
event.type.startsWith('harvest.') ||
|
|
73
|
+
event.type === 'dispatcher.intake-set' ||
|
|
74
|
+
event.type === 'dispatcher.pause-set' ||
|
|
75
|
+
event.type === 'dispatcher.auto-merge-default-set'
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ResumePrompt {
|
|
80
|
+
slug: string
|
|
81
|
+
escalationIds: string[]
|
|
82
|
+
value: string
|
|
83
|
+
cursor: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DispatchFrontendOptions {
|
|
87
|
+
/** Repository identity (§12): the normalized origin, or the checkout path
|
|
88
|
+
* when there is no origin. Every Store key uses this. */
|
|
89
|
+
repo: string
|
|
90
|
+
/** The physical main checkout — the supervised kernel's process cwd. */
|
|
91
|
+
checkout?: string
|
|
92
|
+
storeRef: string
|
|
93
|
+
store: BuildStore
|
|
94
|
+
env: Record<string, string | undefined>
|
|
95
|
+
terminal: TerminalOut
|
|
96
|
+
input: TerminalInput
|
|
97
|
+
signal?: AbortSignal
|
|
98
|
+
once: boolean
|
|
99
|
+
intervalMs?: number
|
|
100
|
+
intake?: boolean
|
|
101
|
+
defaultAutoMerge?: boolean
|
|
102
|
+
clock?: Clock
|
|
103
|
+
resolveDashboardRenderer?: DashboardRendererResolver
|
|
104
|
+
launchChild?: (input: {
|
|
105
|
+
store: BuildStore
|
|
106
|
+
repo: string
|
|
107
|
+
cwd?: string
|
|
108
|
+
run: string
|
|
109
|
+
env: Record<string, string | undefined>
|
|
110
|
+
options: DispatchChildOptions
|
|
111
|
+
}) => DispatchChildHandle
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Terminal controller with a deliberately narrow wiring surface: terminal,
|
|
115
|
+
* BuildStore, immutable repository identity, clock, and child supervision. */
|
|
116
|
+
export class DispatchFrontend {
|
|
117
|
+
private readonly runId = crypto.randomUUID()
|
|
118
|
+
private readonly clock: Clock
|
|
119
|
+
private readonly region: LiveRegion
|
|
120
|
+
private readonly keyboard
|
|
121
|
+
private child: DispatchChildHandle | undefined
|
|
122
|
+
private repositorySeq = 0
|
|
123
|
+
private repositoryEvents: RepositoryEvent[] = []
|
|
124
|
+
private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
|
|
125
|
+
private eventRefreshTail: Promise<void> = Promise.resolve()
|
|
126
|
+
private cache: DashboardBuildPollCache | undefined
|
|
127
|
+
private config: Config | undefined
|
|
128
|
+
private configRef: string | undefined
|
|
129
|
+
private configRevision = 0
|
|
130
|
+
/** Display-only pressure is sampled by the Store-only frontend. Undefined
|
|
131
|
+
* means no factual sample has succeeded yet; failures retain the last value. */
|
|
132
|
+
private observationCount: number | undefined
|
|
133
|
+
private model: DashboardModel | undefined
|
|
134
|
+
private selection: DashboardSelection | undefined = { kind: 'global' }
|
|
135
|
+
private view: DashboardView | undefined
|
|
136
|
+
/** The session view's live-read cycle, created with the view it serves and
|
|
137
|
+
* discarded on Escape. All read semantics live in the shared feed module —
|
|
138
|
+
* the same instance contract the DispatchLoop path uses. */
|
|
139
|
+
private sessionFeed: SessionStreamFeed | undefined
|
|
140
|
+
private resumePrompt: ResumePrompt | undefined
|
|
141
|
+
private abortConfirmation: { slug: string } | undefined
|
|
142
|
+
private accepting = false
|
|
143
|
+
private cleanupInput: (() => void) | undefined
|
|
144
|
+
private pollTimer: ReturnType<typeof setInterval> | undefined
|
|
145
|
+
private paintTimer: ReturnType<typeof setInterval> | undefined
|
|
146
|
+
private polling = false
|
|
147
|
+
private pollInFlight: Promise<void> | undefined
|
|
148
|
+
private actionTail: Promise<void> = Promise.resolve()
|
|
149
|
+
private presentationRestored = false
|
|
150
|
+
private operatorStopRequested = false
|
|
151
|
+
|
|
152
|
+
constructor(private readonly opts: DispatchFrontendOptions) {
|
|
153
|
+
this.clock = opts.clock ?? systemClock
|
|
154
|
+
this.keyboard = createKeyboardProtocol(
|
|
155
|
+
(chunk) => opts.terminal.write(chunk),
|
|
156
|
+
opts.terminal.modes,
|
|
157
|
+
)
|
|
158
|
+
this.region = new LiveRegion(opts.terminal, this.keyboard)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Advance the repository cursor exactly once even when polling overlaps an
|
|
162
|
+
* operator action. High-frequency dispatcher status is folded from deltas;
|
|
163
|
+
* only the low-volume settings/Harvest facts needed by the dashboard model
|
|
164
|
+
* are retained for their existing replay reducers. */
|
|
165
|
+
private async events(): Promise<RepositoryEvent[]> {
|
|
166
|
+
const refresh = this.eventRefreshTail.then(async () => {
|
|
167
|
+
const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
|
|
168
|
+
if (delta.length === 0) return
|
|
169
|
+
this.repositorySeq = delta.at(-1)!.seq
|
|
170
|
+
this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
|
|
171
|
+
this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
|
|
172
|
+
})
|
|
173
|
+
this.eventRefreshTail = refresh.catch(() => {})
|
|
174
|
+
await refresh
|
|
175
|
+
return this.repositoryEvents
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async report(message: string, level: 'info' | 'warning' = 'info'): Promise<void> {
|
|
179
|
+
await this.opts.store.appendRepo(this.opts.repo, {
|
|
180
|
+
actor: humanActor(buildControlUser(this.opts.env)),
|
|
181
|
+
type: 'dispatcher.operator-reported',
|
|
182
|
+
payload: { run: this.runId, level, message },
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private queue(operation: () => Promise<void>): void {
|
|
187
|
+
const result = this.actionTail.then(operation)
|
|
188
|
+
this.actionTail = result.then(
|
|
189
|
+
() => undefined,
|
|
190
|
+
async (error) => {
|
|
191
|
+
try {
|
|
192
|
+
await this.report(
|
|
193
|
+
`dashboard action failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
194
|
+
'warning',
|
|
195
|
+
)
|
|
196
|
+
await this.renderOnce()
|
|
197
|
+
} catch {
|
|
198
|
+
// The next poll remains the recovery boundary.
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private selectedSlug(): string | undefined {
|
|
205
|
+
return this.view?.slug ?? (this.selection?.kind === 'build' ? this.selection.slug : undefined)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private selectedBuild(): DashboardBuild | undefined {
|
|
209
|
+
const slug = this.selectedSlug()
|
|
210
|
+
return this.model?.builds.find((build) => build.slug === slug)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private syncControls(): void {
|
|
214
|
+
if (this.model === undefined) return
|
|
215
|
+
const {
|
|
216
|
+
selection: _selection,
|
|
217
|
+
resumeInput: _resume,
|
|
218
|
+
abortConfirmation: _abort,
|
|
219
|
+
view: _view,
|
|
220
|
+
...base
|
|
221
|
+
} = this.model
|
|
222
|
+
let next: DashboardModel = {
|
|
223
|
+
...base,
|
|
224
|
+
...(this.selection !== undefined ? { selection: this.selection } : {}),
|
|
225
|
+
...(this.resumePrompt !== undefined
|
|
226
|
+
? {
|
|
227
|
+
resumeInput: {
|
|
228
|
+
slug: this.resumePrompt.slug,
|
|
229
|
+
value: this.resumePrompt.value,
|
|
230
|
+
cursor: this.resumePrompt.cursor,
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
: {}),
|
|
234
|
+
...(this.abortConfirmation !== undefined
|
|
235
|
+
? { abortConfirmation: this.abortConfirmation }
|
|
236
|
+
: {}),
|
|
237
|
+
...(this.view !== undefined ? { view: this.view } : {}),
|
|
238
|
+
}
|
|
239
|
+
if (this.view?.kind === 'detail') {
|
|
240
|
+
this.view = {
|
|
241
|
+
...this.view,
|
|
242
|
+
scroll: Math.max(
|
|
243
|
+
0,
|
|
244
|
+
Math.min(
|
|
245
|
+
this.view.scroll,
|
|
246
|
+
detailScrollLimit(
|
|
247
|
+
next,
|
|
248
|
+
dashboardContentWidth(this.opts.terminal.columns),
|
|
249
|
+
paintableRows(this.opts.terminal.rows),
|
|
250
|
+
),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
next = { ...next, view: this.view }
|
|
255
|
+
}
|
|
256
|
+
this.model = next
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private paint(): void {
|
|
260
|
+
if (this.model === undefined) return
|
|
261
|
+
const renderer = this.opts.resolveDashboardRenderer?.() ?? renderDashboard
|
|
262
|
+
this.region.update(
|
|
263
|
+
renderer(this.model, {
|
|
264
|
+
color: true,
|
|
265
|
+
width: this.opts.terminal.columns,
|
|
266
|
+
height: paintableRows(this.opts.terminal.rows),
|
|
267
|
+
now: this.clock().getTime(),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async loadConfig(kind: string, rev: number): Promise<Config> {
|
|
273
|
+
const key = `${kind}@${rev}`
|
|
274
|
+
if (this.config !== undefined && this.configRef === key) return this.config
|
|
275
|
+
const artifact = await this.opts.store.getRepoArtifact(this.opts.repo, kind, rev)
|
|
276
|
+
if (artifact === null) throw new Error(`effective config ${key} is not retrievable`)
|
|
277
|
+
const parsed = configSchema.parse(JSON.parse(new TextDecoder().decode(artifact.content)))
|
|
278
|
+
this.config = parsed
|
|
279
|
+
this.configRef = key
|
|
280
|
+
this.configRevision += 1
|
|
281
|
+
return parsed
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async renderOnce(): Promise<void> {
|
|
285
|
+
let repositoryEvents = await this.events()
|
|
286
|
+
let status = this.dispatchStatus
|
|
287
|
+
const ref = status.effectiveConfig
|
|
288
|
+
// No filesystem fallback: the first frame waits for the child's durable,
|
|
289
|
+
// run-correlated effective snapshot.
|
|
290
|
+
if (ref === undefined) {
|
|
291
|
+
if (status.health === 'failed' && status.warningNotice !== undefined) {
|
|
292
|
+
this.region.update([status.warningNotice])
|
|
293
|
+
}
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
const config = await this.loadConfig(ref.kind, ref.rev)
|
|
297
|
+
try {
|
|
298
|
+
const scan = await scanUnclaimedObservations(this.opts.store, this.opts.repo)
|
|
299
|
+
this.observationCount = scan.observations.length
|
|
300
|
+
} catch {
|
|
301
|
+
// Before the first successful sample there is no factual zero to place in
|
|
302
|
+
// a complete frame. Leave the prior frame untouched and retry on the next
|
|
303
|
+
// ordinary poll, without manufacturing a repository notice.
|
|
304
|
+
if (this.observationCount === undefined) return
|
|
305
|
+
}
|
|
306
|
+
this.cache ??= new DashboardBuildPollCache(this.opts.store, this.opts.repo, config)
|
|
307
|
+
const snapshot = await this.cache.refresh(config, this.configRevision)
|
|
308
|
+
if (!this.cache.isCurrent(snapshot)) return
|
|
309
|
+
|
|
310
|
+
if (this.resumePrompt !== undefined) {
|
|
311
|
+
const state = snapshot.states.get(this.resumePrompt.slug)
|
|
312
|
+
const open = new Set(state?.openEscalations.map((item) => item.id) ?? [])
|
|
313
|
+
const remaining = this.resumePrompt.escalationIds.filter((id) => open.has(id))
|
|
314
|
+
if (remaining.length === 0) this.resumePrompt = undefined
|
|
315
|
+
else this.resumePrompt = { ...this.resumePrompt, escalationIds: remaining }
|
|
316
|
+
}
|
|
317
|
+
if (this.abortConfirmation !== undefined) {
|
|
318
|
+
const slug = this.abortConfirmation.slug
|
|
319
|
+
if (!validAbortConfirmation(snapshot.states.get(slug))) {
|
|
320
|
+
await this.dismissStaleAbortConfirmation(slug)
|
|
321
|
+
repositoryEvents = await this.events()
|
|
322
|
+
status = this.dispatchStatus
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const previous = this.model === undefined ? [] : dashboardSelections(this.model)
|
|
327
|
+
const warningLines = [
|
|
328
|
+
...status.roleWarnings,
|
|
329
|
+
...(status.warningNotice !== undefined ? [status.warningNotice] : []),
|
|
330
|
+
]
|
|
331
|
+
const projected = buildDashboardFromProjected(
|
|
332
|
+
snapshot.builds,
|
|
333
|
+
{
|
|
334
|
+
repo: this.opts.repo,
|
|
335
|
+
queued: status.queued ?? 0,
|
|
336
|
+
activeCount: [...snapshot.states.values()].filter(
|
|
337
|
+
(state) => state.status !== 'done' && state.status !== 'aborted',
|
|
338
|
+
).length,
|
|
339
|
+
capacity: config.capacity,
|
|
340
|
+
observationCount: this.observationCount,
|
|
341
|
+
observationLimit: config.policy.harvestThreshold,
|
|
342
|
+
...(status.availableUpgrade !== undefined
|
|
343
|
+
? { availableUpgrade: status.availableUpgrade }
|
|
344
|
+
: {}),
|
|
345
|
+
...(warningLines.length > 0 ? { warningLines } : {}),
|
|
346
|
+
},
|
|
347
|
+
repositoryEvents,
|
|
348
|
+
)
|
|
349
|
+
this.selection = reconcileSelection(previous, dashboardSelections(projected), this.selection)
|
|
350
|
+
if (
|
|
351
|
+
this.view !== undefined &&
|
|
352
|
+
!projected.builds.some((build) => build.slug === this.view!.slug)
|
|
353
|
+
) {
|
|
354
|
+
this.view = undefined
|
|
355
|
+
}
|
|
356
|
+
this.model = projected
|
|
357
|
+
this.syncControls()
|
|
358
|
+
this.paint()
|
|
359
|
+
// The session view's live-read cycle rides the poll tick: one immediate
|
|
360
|
+
// read per interval, applied through the action queue under the fence.
|
|
361
|
+
this.pollSessionFeed()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private moveVertical(delta: number): void {
|
|
365
|
+
if (this.view?.kind === 'session') {
|
|
366
|
+
const view = this.view
|
|
367
|
+
const upgrade = this.model?.availableUpgrade !== undefined
|
|
368
|
+
const limit = sessionScrollLimit(
|
|
369
|
+
view,
|
|
370
|
+
this.opts.terminal.columns,
|
|
371
|
+
paintableRows(this.opts.terminal.rows),
|
|
372
|
+
upgrade,
|
|
373
|
+
)
|
|
374
|
+
const current = view.follow ? limit : view.scroll
|
|
375
|
+
const scroll = moveSessionScroll(
|
|
376
|
+
view,
|
|
377
|
+
this.opts.terminal.columns,
|
|
378
|
+
paintableRows(this.opts.terminal.rows),
|
|
379
|
+
current,
|
|
380
|
+
delta,
|
|
381
|
+
upgrade,
|
|
382
|
+
)
|
|
383
|
+
let follow = view.follow
|
|
384
|
+
if (delta < 0 && follow && scroll < limit) follow = false
|
|
385
|
+
if (delta > 0 && !follow && scroll >= limit) follow = true
|
|
386
|
+
this.view = { ...view, scroll, follow }
|
|
387
|
+
this.syncControls()
|
|
388
|
+
this.paint()
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
if (this.view?.kind === 'transcript') {
|
|
392
|
+
this.view = {
|
|
393
|
+
...this.view,
|
|
394
|
+
scroll: moveTranscriptScroll(
|
|
395
|
+
this.view.transcript,
|
|
396
|
+
this.opts.terminal.columns,
|
|
397
|
+
paintableRows(this.opts.terminal.rows),
|
|
398
|
+
this.view.scroll,
|
|
399
|
+
delta,
|
|
400
|
+
this.model?.availableUpgrade !== undefined,
|
|
401
|
+
),
|
|
402
|
+
}
|
|
403
|
+
} else if (this.view?.kind === 'detail' && this.model !== undefined) {
|
|
404
|
+
this.view = {
|
|
405
|
+
...this.view,
|
|
406
|
+
scroll: moveDetailScroll(
|
|
407
|
+
this.model,
|
|
408
|
+
dashboardContentWidth(this.opts.terminal.columns),
|
|
409
|
+
paintableRows(this.opts.terminal.rows),
|
|
410
|
+
this.view.scroll,
|
|
411
|
+
delta,
|
|
412
|
+
),
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
const rows =
|
|
416
|
+
this.model === undefined ? [{ kind: 'global' } as const] : dashboardSelections(this.model)
|
|
417
|
+
this.selection = moveSelection(rows, this.selection, delta)
|
|
418
|
+
}
|
|
419
|
+
this.syncControls()
|
|
420
|
+
this.paint()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private moveSession(delta: number): void {
|
|
424
|
+
if (this.view?.kind !== 'detail') return
|
|
425
|
+
const build = this.model?.builds.find((item) => item.slug === this.view!.slug)
|
|
426
|
+
const sessions = build?.sessions ?? []
|
|
427
|
+
if (sessions.length === 0) return
|
|
428
|
+
const current = sessions.findIndex((session) => session.id === this.view!.sessionId)
|
|
429
|
+
const sessionId =
|
|
430
|
+
sessions[Math.max(0, Math.min(sessions.length - 1, (current < 0 ? 0 : current) + delta))]!.id
|
|
431
|
+
const candidate = { ...this.view, sessionId }
|
|
432
|
+
this.view = {
|
|
433
|
+
...candidate,
|
|
434
|
+
scroll: revealDetailFocus(
|
|
435
|
+
{ ...this.model!, view: candidate },
|
|
436
|
+
dashboardContentWidth(this.opts.terminal.columns),
|
|
437
|
+
paintableRows(this.opts.terminal.rows),
|
|
438
|
+
'session',
|
|
439
|
+
candidate.scroll,
|
|
440
|
+
),
|
|
441
|
+
}
|
|
442
|
+
this.syncControls()
|
|
443
|
+
this.paint()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private async openSelected(): Promise<void> {
|
|
447
|
+
if (this.view === undefined) {
|
|
448
|
+
const build = this.selectedBuild()
|
|
449
|
+
if (build === undefined) return
|
|
450
|
+
this.view = {
|
|
451
|
+
kind: 'detail',
|
|
452
|
+
slug: build.slug,
|
|
453
|
+
scroll: 0,
|
|
454
|
+
...(build.sessions?.[0] !== undefined ? { sessionId: build.sessions[0].id } : {}),
|
|
455
|
+
}
|
|
456
|
+
this.syncControls()
|
|
457
|
+
this.paint()
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (this.view.kind !== 'detail') return
|
|
461
|
+
const captured = this.view
|
|
462
|
+
const session = this.selectedBuild()?.sessions?.find((item) => item.id === captured.sessionId)
|
|
463
|
+
if (session === undefined) return
|
|
464
|
+
// The stream branch outranks every transcript outcome: a session with a
|
|
465
|
+
// stream — open or closed — opens the read-only live view. Sessions
|
|
466
|
+
// without a stream keep the frontend's existing behavior byte-for-byte
|
|
467
|
+
// (the silent open-session return, the reclaimed message, the transcript).
|
|
468
|
+
if (session.stream !== undefined) {
|
|
469
|
+
this.view = {
|
|
470
|
+
kind: 'session',
|
|
471
|
+
slug: captured.slug,
|
|
472
|
+
sessionId: session.id,
|
|
473
|
+
stream: session.stream,
|
|
474
|
+
status: session.streamStatus,
|
|
475
|
+
source: { kind: 'parts', parts: [], lastSeq: 0 },
|
|
476
|
+
follow: true,
|
|
477
|
+
scroll: 0,
|
|
478
|
+
}
|
|
479
|
+
this.sessionFeed = new SessionStreamFeed(this.opts.store, captured.slug)
|
|
480
|
+
this.syncControls()
|
|
481
|
+
this.paint()
|
|
482
|
+
// Kick the first read immediately rather than waiting one interval.
|
|
483
|
+
this.pollSessionFeed()
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
if (session.status === 'reclaimed') {
|
|
487
|
+
const { message: _message, messageWhileSessionOpen: _fence, ...stable } = captured
|
|
488
|
+
const candidate = {
|
|
489
|
+
...stable,
|
|
490
|
+
message: 'This session was reclaimed by a recovering runner; transcript unavailable.',
|
|
491
|
+
}
|
|
492
|
+
this.view = {
|
|
493
|
+
...candidate,
|
|
494
|
+
scroll: revealDetailFocus(
|
|
495
|
+
{ ...this.model!, view: candidate },
|
|
496
|
+
dashboardContentWidth(this.opts.terminal.columns),
|
|
497
|
+
paintableRows(this.opts.terminal.rows),
|
|
498
|
+
'message',
|
|
499
|
+
candidate.scroll,
|
|
500
|
+
),
|
|
501
|
+
}
|
|
502
|
+
this.syncControls()
|
|
503
|
+
this.paint()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
if (session.status === 'open' || session.transcript === undefined) return
|
|
507
|
+
const artifact = await this.opts.store.getArtifact(
|
|
508
|
+
captured.slug,
|
|
509
|
+
session.transcript.kind,
|
|
510
|
+
session.transcript.rev,
|
|
511
|
+
)
|
|
512
|
+
if (
|
|
513
|
+
artifact === null ||
|
|
514
|
+
this.view?.kind !== 'detail' ||
|
|
515
|
+
this.view.slug !== captured.slug ||
|
|
516
|
+
this.view.sessionId !== captured.sessionId
|
|
517
|
+
) {
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
this.view = {
|
|
521
|
+
kind: 'transcript',
|
|
522
|
+
slug: captured.slug,
|
|
523
|
+
sessionId: session.id,
|
|
524
|
+
transcript: parseTranscript(new TextDecoder().decode(artifact.content)),
|
|
525
|
+
scroll: 0,
|
|
526
|
+
}
|
|
527
|
+
this.syncControls()
|
|
528
|
+
this.paint()
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Fire-and-forget live-read cycle for an open session view (or one whose
|
|
532
|
+
* last read failed). The apply runs through the action queue under the
|
|
533
|
+
* feed's identity+cursor fence — the same pattern the DispatchLoop path
|
|
534
|
+
* uses, from the same shared feed module. */
|
|
535
|
+
private pollSessionFeed(): void {
|
|
536
|
+
const view = this.view
|
|
537
|
+
if (view?.kind !== 'session') return
|
|
538
|
+
if (view.status !== 'open' && view.error === undefined) {
|
|
539
|
+
// A closed view is final once its content is in hand; an unloaded one
|
|
540
|
+
// (a closed session just opened, empty chunk log so far) still needs
|
|
541
|
+
// its read — which is where the artifact fallback happens too.
|
|
542
|
+
if (view.source.kind !== 'parts' || view.source.lastSeq !== 0) return
|
|
543
|
+
}
|
|
544
|
+
const feed = this.sessionFeed
|
|
545
|
+
if (feed === undefined) return
|
|
546
|
+
void feed
|
|
547
|
+
.poll(view)
|
|
548
|
+
.then((update) => {
|
|
549
|
+
if (update === undefined) return
|
|
550
|
+
this.queue(async () => {
|
|
551
|
+
const next = applySessionFeedUpdate(
|
|
552
|
+
this.view?.kind === 'session' ? this.view : undefined,
|
|
553
|
+
update,
|
|
554
|
+
)
|
|
555
|
+
if (next === undefined) return
|
|
556
|
+
const terminal = this.opts.terminal
|
|
557
|
+
const limit = sessionScrollLimit(
|
|
558
|
+
next,
|
|
559
|
+
terminal.columns,
|
|
560
|
+
paintableRows(terminal.rows),
|
|
561
|
+
this.model?.availableUpgrade !== undefined,
|
|
562
|
+
)
|
|
563
|
+
this.view = {
|
|
564
|
+
...next,
|
|
565
|
+
scroll: next.follow ? limit : Math.min(next.scroll, limit),
|
|
566
|
+
}
|
|
567
|
+
this.syncControls()
|
|
568
|
+
this.paint()
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
.catch(() => {
|
|
572
|
+
// The feed contains its own failures; the next poll retries.
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private leaveView(): void {
|
|
577
|
+
if (this.view?.kind === 'session') {
|
|
578
|
+
this.sessionFeed = undefined
|
|
579
|
+
this.view = {
|
|
580
|
+
kind: 'detail',
|
|
581
|
+
slug: this.view.slug,
|
|
582
|
+
sessionId: this.view.sessionId,
|
|
583
|
+
scroll: 0,
|
|
584
|
+
}
|
|
585
|
+
} else if (this.view?.kind === 'transcript') {
|
|
586
|
+
this.view = {
|
|
587
|
+
kind: 'detail',
|
|
588
|
+
slug: this.view.slug,
|
|
589
|
+
sessionId: this.view.sessionId,
|
|
590
|
+
scroll: 0,
|
|
591
|
+
}
|
|
592
|
+
} else if (this.view?.kind === 'detail') this.view = undefined
|
|
593
|
+
else return
|
|
594
|
+
this.syncControls()
|
|
595
|
+
this.paint()
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async dismissStaleAbortConfirmation(slug: string): Promise<void> {
|
|
599
|
+
if (this.abortConfirmation?.slug === slug) this.abortConfirmation = undefined
|
|
600
|
+
await this.report(
|
|
601
|
+
`build ${slug}: abort confirmation dismissed because the build state changed`,
|
|
602
|
+
'warning',
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async confirmAbort(slug: string): Promise<void> {
|
|
607
|
+
await this.events()
|
|
608
|
+
const ref = this.dispatchStatus.effectiveConfig
|
|
609
|
+
if (ref === undefined) throw new Error('effective config is not available')
|
|
610
|
+
const config = await this.loadConfig(ref.kind, ref.rev)
|
|
611
|
+
this.cache ??= new DashboardBuildPollCache(this.opts.store, this.opts.repo, config)
|
|
612
|
+
let snapshot = await this.cache.refresh(config, this.configRevision)
|
|
613
|
+
while (!this.cache.isCurrent(snapshot)) {
|
|
614
|
+
snapshot = await this.cache.refresh(config, this.configRevision)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!validAbortConfirmation(snapshot.states.get(slug))) {
|
|
618
|
+
await this.dismissStaleAbortConfirmation(slug)
|
|
619
|
+
await this.renderOnce()
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
await this.buildAction('abort', slug)
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (!isStaleAbortControlError(error)) throw error
|
|
627
|
+
await this.dismissStaleAbortConfirmation(slug)
|
|
628
|
+
await this.renderOnce()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async buildAction(
|
|
633
|
+
kind: 'pause' | 'resume' | 'discard' | 'toggle-auto-merge' | 'abort',
|
|
634
|
+
slug: string,
|
|
635
|
+
): Promise<void> {
|
|
636
|
+
const result = await controlBuild({
|
|
637
|
+
store: this.opts.store,
|
|
638
|
+
repo: this.opts.repo,
|
|
639
|
+
slug,
|
|
640
|
+
env: this.opts.env,
|
|
641
|
+
action:
|
|
642
|
+
kind === 'pause'
|
|
643
|
+
? { kind: 'dashboard-pause' }
|
|
644
|
+
: kind === 'resume'
|
|
645
|
+
? { kind: 'dashboard-resume' }
|
|
646
|
+
: { kind },
|
|
647
|
+
})
|
|
648
|
+
if (result.kind === 'answer-required') {
|
|
649
|
+
this.resumePrompt = { slug, escalationIds: result.escalationIds, value: '', cursor: 0 }
|
|
650
|
+
this.syncControls()
|
|
651
|
+
this.paint()
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
const message =
|
|
655
|
+
result.kind !== 'command'
|
|
656
|
+
? `build ${slug}: action recorded`
|
|
657
|
+
: result.command === 'pause'
|
|
658
|
+
? `build ${slug}: pause requested`
|
|
659
|
+
: result.command === 'resume'
|
|
660
|
+
? kind === 'pause'
|
|
661
|
+
? `build ${slug}: pending pause cancelled`
|
|
662
|
+
: `build ${slug}: resume requested`
|
|
663
|
+
: result.command === 'discard'
|
|
664
|
+
? `build ${slug}: discard requested`
|
|
665
|
+
: result.command === 'abort'
|
|
666
|
+
? `build ${slug}: abort requested`
|
|
667
|
+
: result.command === 'auto-merge-on'
|
|
668
|
+
? `build ${slug}: auto-merge requested`
|
|
669
|
+
: `build ${slug}: auto-merge cancelled`
|
|
670
|
+
await this.report(message)
|
|
671
|
+
await this.renderOnce()
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async toggleIntake(): Promise<void> {
|
|
675
|
+
const event = await toggleRepositorySetting({
|
|
676
|
+
store: this.opts.store,
|
|
677
|
+
repo: this.opts.repo,
|
|
678
|
+
user: buildControlUser(this.opts.env),
|
|
679
|
+
setting: 'intake',
|
|
680
|
+
})
|
|
681
|
+
await this.report(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
|
|
682
|
+
await this.renderOnce()
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private async toggleDefaultAutoMerge(): Promise<void> {
|
|
686
|
+
const event = await toggleRepositorySetting({
|
|
687
|
+
store: this.opts.store,
|
|
688
|
+
repo: this.opts.repo,
|
|
689
|
+
user: buildControlUser(this.opts.env),
|
|
690
|
+
setting: 'auto-merge-default',
|
|
691
|
+
})
|
|
692
|
+
await this.report(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
|
|
693
|
+
await this.renderOnce()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private async bulk(direction: 'pause' | 'resume'): Promise<void> {
|
|
697
|
+
const summary = await bulkControlRepository({
|
|
698
|
+
store: this.opts.store,
|
|
699
|
+
repo: this.opts.repo,
|
|
700
|
+
env: this.opts.env,
|
|
701
|
+
direction,
|
|
702
|
+
})
|
|
703
|
+
await this.report(bulkControlReport(summary))
|
|
704
|
+
await this.renderOnce()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private async toggleHarvest(): Promise<void> {
|
|
708
|
+
const result = await toggleHarvestGate({
|
|
709
|
+
store: this.opts.store,
|
|
710
|
+
repo: this.opts.repo,
|
|
711
|
+
user: buildControlUser(this.opts.env),
|
|
712
|
+
})
|
|
713
|
+
await this.report(`harvest gate: ${result.command} requested`)
|
|
714
|
+
await this.renderOnce()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private async harvestRun(expectedRun: string | undefined): Promise<void> {
|
|
718
|
+
if (expectedRun === undefined) {
|
|
719
|
+
await this.report('harvest run action ignored: selected run is no longer active')
|
|
720
|
+
await this.renderOnce()
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
const result = await controlHarvestRun({
|
|
725
|
+
store: this.opts.store,
|
|
726
|
+
repo: this.opts.repo,
|
|
727
|
+
user: buildControlUser(this.opts.env),
|
|
728
|
+
run: expectedRun,
|
|
729
|
+
})
|
|
730
|
+
await this.report(`harvest: ${result.action} requested`)
|
|
731
|
+
} catch (error) {
|
|
732
|
+
await this.report(
|
|
733
|
+
error instanceof OperatorControlError && error.code === 'harvest-unavailable'
|
|
734
|
+
? 'harvest run action ignored: selected run is no longer active'
|
|
735
|
+
: error instanceof Error
|
|
736
|
+
? error.message
|
|
737
|
+
: String(error),
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
await this.renderOnce()
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private async submitResume(prompt: ResumePrompt): Promise<void> {
|
|
744
|
+
const result = await controlBuild({
|
|
745
|
+
store: this.opts.store,
|
|
746
|
+
repo: this.opts.repo,
|
|
747
|
+
slug: prompt.slug,
|
|
748
|
+
env: this.opts.env,
|
|
749
|
+
action: { kind: 'answer', text: prompt.value, escalationIds: prompt.escalationIds },
|
|
750
|
+
})
|
|
751
|
+
this.resumePrompt = undefined
|
|
752
|
+
await this.report(
|
|
753
|
+
`build ${prompt.slug}: blocked resume requested${
|
|
754
|
+
result.kind === 'answered' && result.resolution === 'guidance' ? ' with guidance' : ''
|
|
755
|
+
}`,
|
|
756
|
+
)
|
|
757
|
+
await this.renderOnce()
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private editResume(input: TerminalInputEvent): void {
|
|
761
|
+
const prompt = this.resumePrompt
|
|
762
|
+
if (prompt === undefined) return
|
|
763
|
+
const update = (value: string, cursor: number): void => {
|
|
764
|
+
this.resumePrompt = { ...prompt, value, cursor }
|
|
765
|
+
this.syncControls()
|
|
766
|
+
this.paint()
|
|
767
|
+
}
|
|
768
|
+
if (input.type === 'text' || input.type === 'paste') {
|
|
769
|
+
const next = insertText(prompt.value, prompt.cursor, input.text)
|
|
770
|
+
update(next.value, next.cursor)
|
|
771
|
+
} else if (input.type === 'newline') {
|
|
772
|
+
const next = insertText(prompt.value, prompt.cursor, '\n')
|
|
773
|
+
update(next.value, next.cursor)
|
|
774
|
+
} else if (input.type === 'backspace') {
|
|
775
|
+
const next = deleteBefore(prompt.value, prompt.cursor)
|
|
776
|
+
update(next.value, next.cursor)
|
|
777
|
+
} else if (['left', 'right', 'up', 'down', 'home', 'end'].includes(input.type)) {
|
|
778
|
+
update(prompt.value, moveCursor(prompt.value, prompt.cursor, input.type as ComposerMotion))
|
|
779
|
+
} else if (input.type === 'escape') {
|
|
780
|
+
this.resumePrompt = undefined
|
|
781
|
+
this.syncControls()
|
|
782
|
+
this.paint()
|
|
783
|
+
} else if (input.type === 'enter') this.queue(() => this.submitResume(prompt))
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private onInput(input: TerminalInputEvent): void {
|
|
787
|
+
if (!this.accepting) return
|
|
788
|
+
if (input.type === 'interrupt') {
|
|
789
|
+
this.requestOperatorStop()
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
if (this.resumePrompt !== undefined) {
|
|
793
|
+
this.editResume(input)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
// The session view is strictly read-only: before the abort-confirmation,
|
|
797
|
+
// Enter, and text branches can be reached, every key except Up/Down
|
|
798
|
+
// (scroll) and Escape (back) returns without queueing any action. No key
|
|
799
|
+
// handled in the session view can append an event, and the abort
|
|
800
|
+
// confirmation cannot be opened from it.
|
|
801
|
+
if (this.view?.kind === 'session') {
|
|
802
|
+
if (input.type === 'up' || input.type === 'down') {
|
|
803
|
+
this.moveVertical(input.type === 'up' ? -1 : 1)
|
|
804
|
+
} else if (input.type === 'escape') {
|
|
805
|
+
this.leaveView()
|
|
806
|
+
}
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
const enter = input.type === 'enter' || input.type === 'newline'
|
|
810
|
+
if (this.abortConfirmation !== undefined) {
|
|
811
|
+
if (enter) {
|
|
812
|
+
const slug = this.abortConfirmation.slug
|
|
813
|
+
this.abortConfirmation = undefined
|
|
814
|
+
this.queue(() => this.confirmAbort(slug))
|
|
815
|
+
} else if (input.type === 'escape') {
|
|
816
|
+
this.abortConfirmation = undefined
|
|
817
|
+
this.syncControls()
|
|
818
|
+
this.paint()
|
|
819
|
+
}
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
if (input.type === 'up' || input.type === 'down') {
|
|
823
|
+
this.moveVertical(input.type === 'up' ? -1 : 1)
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
if (input.type === 'left' || input.type === 'right') {
|
|
827
|
+
this.moveSession(input.type === 'left' ? -1 : 1)
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
if (enter) {
|
|
831
|
+
// List/detail identity is captured synchronously before any Store read;
|
|
832
|
+
// a later selection move cannot retarget this navigation.
|
|
833
|
+
void this.openSelected().catch((error) =>
|
|
834
|
+
this.report(
|
|
835
|
+
`dashboard open action failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
836
|
+
'warning',
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
if (input.type === 'escape') {
|
|
842
|
+
this.leaveView()
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
if (input.type !== 'text') return
|
|
846
|
+
switch (input.text.toLowerCase()) {
|
|
847
|
+
case 'i':
|
|
848
|
+
if (this.selection?.kind === 'global' && this.view === undefined)
|
|
849
|
+
this.queue(() => this.toggleIntake())
|
|
850
|
+
break
|
|
851
|
+
case 'm': {
|
|
852
|
+
const slug = this.selectedSlug()
|
|
853
|
+
if (this.view === undefined && this.selection?.kind === 'global') {
|
|
854
|
+
this.queue(() => this.toggleDefaultAutoMerge())
|
|
855
|
+
} else if (slug !== undefined) {
|
|
856
|
+
this.queue(() => this.buildAction('toggle-auto-merge', slug))
|
|
857
|
+
}
|
|
858
|
+
break
|
|
859
|
+
}
|
|
860
|
+
case 'p': {
|
|
861
|
+
const build = this.selectedBuild()
|
|
862
|
+
if (this.selection?.kind === 'global' && this.view === undefined)
|
|
863
|
+
this.queue(() => this.bulk('pause'))
|
|
864
|
+
else if (this.selection?.kind === 'harvest' && this.view === undefined) {
|
|
865
|
+
const run = this.model?.harvest?.run
|
|
866
|
+
this.queue(() => this.harvestRun(run))
|
|
867
|
+
} else if (dashboardBuildControl(build?.status ?? 'queued')?.key === 'p') {
|
|
868
|
+
this.queue(() => this.buildAction('pause', build!.slug))
|
|
869
|
+
}
|
|
870
|
+
break
|
|
871
|
+
}
|
|
872
|
+
case 'r': {
|
|
873
|
+
const build = this.selectedBuild()
|
|
874
|
+
if (this.selection?.kind === 'global' && this.view === undefined)
|
|
875
|
+
this.queue(() => this.bulk('resume'))
|
|
876
|
+
else if (dashboardBuildControl(build?.status ?? 'queued')?.key === 'r')
|
|
877
|
+
this.queue(() => this.buildAction('resume', build!.slug))
|
|
878
|
+
break
|
|
879
|
+
}
|
|
880
|
+
case 'd': {
|
|
881
|
+
const build = this.selectedBuild()
|
|
882
|
+
if (build?.status === 'queued') this.queue(() => this.buildAction('discard', build.slug))
|
|
883
|
+
break
|
|
884
|
+
}
|
|
885
|
+
case 'a':
|
|
886
|
+
// The session-view gate above already returns for a session view; the
|
|
887
|
+
// transcript guard predates it. Together they keep the abort
|
|
888
|
+
// confirmation unreachable from any read-only view.
|
|
889
|
+
if (this.selectedBuild() !== undefined && this.view?.kind !== 'transcript') {
|
|
890
|
+
this.abortConfirmation = { slug: this.selectedBuild()!.slug }
|
|
891
|
+
this.syncControls()
|
|
892
|
+
this.paint()
|
|
893
|
+
}
|
|
894
|
+
break
|
|
895
|
+
case 'h':
|
|
896
|
+
if (this.selection?.kind === 'global' && this.view === undefined)
|
|
897
|
+
this.queue(() => this.toggleHarvest())
|
|
898
|
+
break
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private startPresentation(): void {
|
|
903
|
+
this.accepting = true
|
|
904
|
+
this.cleanupInput = this.opts.input.start((input) => this.onInput(input), {
|
|
905
|
+
onListening: () => this.keyboard.query(),
|
|
906
|
+
onKeyboardFlags: (flags) => this.keyboard.reported(flags),
|
|
907
|
+
onDeviceAttributes: () => this.keyboard.deviceAttributes(),
|
|
908
|
+
})
|
|
909
|
+
const poll = (): void => {
|
|
910
|
+
if (this.polling) return
|
|
911
|
+
this.polling = true
|
|
912
|
+
const current = this.renderOnce()
|
|
913
|
+
.catch(async (error) => {
|
|
914
|
+
try {
|
|
915
|
+
await this.report(
|
|
916
|
+
`dashboard render failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
917
|
+
'warning',
|
|
918
|
+
)
|
|
919
|
+
} catch {
|
|
920
|
+
// retry on the next poll
|
|
921
|
+
}
|
|
922
|
+
})
|
|
923
|
+
.finally(() => {
|
|
924
|
+
if (this.pollInFlight === current) this.pollInFlight = undefined
|
|
925
|
+
this.polling = false
|
|
926
|
+
})
|
|
927
|
+
this.pollInFlight = current
|
|
928
|
+
}
|
|
929
|
+
this.pollTimer = setInterval(poll, POLL_MS)
|
|
930
|
+
this.paintTimer = setInterval(() => this.paint(), PAINT_MS)
|
|
931
|
+
this.pollTimer.unref?.()
|
|
932
|
+
this.paintTimer.unref?.()
|
|
933
|
+
poll()
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private requestOperatorStop(): void {
|
|
937
|
+
this.operatorStopRequested = true
|
|
938
|
+
this.restorePresentationForStop()
|
|
939
|
+
void this.child?.stop()
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Stop terminal ownership synchronously before awaiting a kernel that may
|
|
943
|
+
* still be finishing an unsafe ticket-claim tick. Normal/abnormal child exits
|
|
944
|
+
* retain the final-frame path below; operator interruption prioritizes shell
|
|
945
|
+
* restoration without force-killing that tick. */
|
|
946
|
+
private restorePresentationForStop(): void {
|
|
947
|
+
if (this.presentationRestored) return
|
|
948
|
+
this.presentationRestored = true
|
|
949
|
+
this.accepting = false
|
|
950
|
+
try {
|
|
951
|
+
this.cleanupInput?.()
|
|
952
|
+
} finally {
|
|
953
|
+
this.cleanupInput = undefined
|
|
954
|
+
if (this.pollTimer !== undefined) clearInterval(this.pollTimer)
|
|
955
|
+
if (this.paintTimer !== undefined) clearInterval(this.paintTimer)
|
|
956
|
+
this.pollTimer = undefined
|
|
957
|
+
this.paintTimer = undefined
|
|
958
|
+
this.region.finish()
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private async finishPresentation(): Promise<void> {
|
|
963
|
+
if (!this.presentationRestored) {
|
|
964
|
+
this.accepting = false
|
|
965
|
+
try {
|
|
966
|
+
this.cleanupInput?.()
|
|
967
|
+
} finally {
|
|
968
|
+
this.cleanupInput = undefined
|
|
969
|
+
}
|
|
970
|
+
if (this.pollTimer !== undefined) clearInterval(this.pollTimer)
|
|
971
|
+
if (this.paintTimer !== undefined) clearInterval(this.paintTimer)
|
|
972
|
+
this.pollTimer = undefined
|
|
973
|
+
this.paintTimer = undefined
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
await this.pollInFlight
|
|
977
|
+
await this.actionTail
|
|
978
|
+
await this.renderOnce()
|
|
979
|
+
} catch {
|
|
980
|
+
// final frame is best-effort
|
|
981
|
+
} finally {
|
|
982
|
+
this.region.finish()
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async run(): Promise<void> {
|
|
987
|
+
await this.opts.store.ensureRepo(this.opts.repo)
|
|
988
|
+
const actor = humanActor(buildControlUser(this.opts.env))
|
|
989
|
+
if (this.opts.intake !== undefined) {
|
|
990
|
+
await this.opts.store.appendRepo(this.opts.repo, {
|
|
991
|
+
actor,
|
|
992
|
+
type: 'dispatcher.intake-set',
|
|
993
|
+
payload: { enabled: this.opts.intake },
|
|
994
|
+
})
|
|
995
|
+
}
|
|
996
|
+
if (this.opts.defaultAutoMerge !== undefined) {
|
|
997
|
+
await this.opts.store.appendRepo(this.opts.repo, {
|
|
998
|
+
actor,
|
|
999
|
+
type: 'dispatcher.auto-merge-default-set',
|
|
1000
|
+
payload: { enabled: this.opts.defaultAutoMerge },
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const options: DispatchChildOptions = {
|
|
1005
|
+
targetRepo: this.opts.checkout ?? this.opts.repo,
|
|
1006
|
+
storeRef: this.opts.storeRef,
|
|
1007
|
+
run: this.runId,
|
|
1008
|
+
once: this.opts.once,
|
|
1009
|
+
...(this.opts.intervalMs !== undefined ? { intervalMs: this.opts.intervalMs } : {}),
|
|
1010
|
+
}
|
|
1011
|
+
this.child = (this.opts.launchChild ?? ((input) => superviseDispatchChild(input)))({
|
|
1012
|
+
store: this.opts.store,
|
|
1013
|
+
repo: this.opts.repo,
|
|
1014
|
+
cwd: this.opts.checkout,
|
|
1015
|
+
run: this.runId,
|
|
1016
|
+
env: this.opts.env,
|
|
1017
|
+
options,
|
|
1018
|
+
})
|
|
1019
|
+
const stop = (): void => this.requestOperatorStop()
|
|
1020
|
+
this.opts.signal?.addEventListener('abort', stop, { once: true })
|
|
1021
|
+
try {
|
|
1022
|
+
if (!this.opts.once) this.startPresentation()
|
|
1023
|
+
if (this.opts.signal?.aborted) this.requestOperatorStop()
|
|
1024
|
+
const result = await this.child.completed
|
|
1025
|
+
if (
|
|
1026
|
+
!(result.outcome === 'normal' && result.exitCode === 0) &&
|
|
1027
|
+
!(result.outcome === 'forced' && this.operatorStopRequested)
|
|
1028
|
+
) {
|
|
1029
|
+
const processDetails = [
|
|
1030
|
+
`exit code ${result.exitCode}`,
|
|
1031
|
+
...(result.signal !== undefined ? [`signal ${result.signal}`] : []),
|
|
1032
|
+
].join(', ')
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
result.error !== undefined
|
|
1035
|
+
? `dispatcher kernel failed: ${result.error} (${processDetails})`
|
|
1036
|
+
: `dispatcher kernel ${result.outcome} (${processDetails})`,
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
} finally {
|
|
1040
|
+
this.opts.signal?.removeEventListener('abort', stop)
|
|
1041
|
+
await this.child.stop()
|
|
1042
|
+
await this.finishPresentation()
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|