@defrex/autobuild 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +204 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/skills/tickets/SKILL.md +57 -5
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facts a dashboard row reads from a build's event array, as an
|
|
3
|
+
* incremental reducer: the queued-row dispatch text, the session brackets, the
|
|
4
|
+
* per-phase wall-clock intervals, the pause timestamp, and the seqs of the four
|
|
5
|
+
* loop outputs. `projectBuildFromFacts` renders a row from these plus the
|
|
6
|
+
* reduced `BuildState`, so a row derives from a stored accumulator and a delta
|
|
7
|
+
* read with no event array, and `projectBuild(…, events, …)` is the same
|
|
8
|
+
* projection over `dashboardFactsReducer.reduce(events)` — one implementation,
|
|
9
|
+
* so the snapshot and replay paths agree by construction.
|
|
10
|
+
*
|
|
11
|
+
* The accumulator is plain JSON (see `kernel/incremental.ts`).
|
|
12
|
+
*/
|
|
13
|
+
import type { AbEvent } from '../../events/catalog'
|
|
14
|
+
import { defineReducer } from '../../kernel/incremental'
|
|
15
|
+
import { verifyPhase } from '../../ontology'
|
|
16
|
+
|
|
17
|
+
export const DASHBOARD_FACTS_REDUCER_VERSION = 1
|
|
18
|
+
|
|
19
|
+
/** One phase occurrence's wall-clock span, keyed by phase. `startSeq` is the
|
|
20
|
+
* seq of the `*.started` event, so callers can scope by the same seq
|
|
21
|
+
* boundaries (`restartSince`/`cycleSince`) the step STATES already use. */
|
|
22
|
+
export interface PhaseInterval {
|
|
23
|
+
start: number
|
|
24
|
+
end: number
|
|
25
|
+
startSeq: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PhaseTiming {
|
|
29
|
+
closed: PhaseInterval[]
|
|
30
|
+
open?: { start: number; startSeq: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DispatchFailureFact {
|
|
34
|
+
seq: number
|
|
35
|
+
stage: string
|
|
36
|
+
attempt: number
|
|
37
|
+
error: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Max seq per round of one loop output event. */
|
|
41
|
+
export type RoundSeqs = Record<string, number>
|
|
42
|
+
|
|
43
|
+
type SessionEvent = Extract<AbEvent, { type: 'session.started' | 'session.ended' }>
|
|
44
|
+
|
|
45
|
+
export interface DashboardFacts {
|
|
46
|
+
/** Seq of the latest event of each queued-row kind (`undefined` until seen). */
|
|
47
|
+
created?: number
|
|
48
|
+
workspaceProvisioned?: number
|
|
49
|
+
workspaceReleased?: number
|
|
50
|
+
specLanded?: number
|
|
51
|
+
commentPosted?: number
|
|
52
|
+
latestFailure?: DispatchFailureFact
|
|
53
|
+
/** The session brackets, in log order; `projectSessions` pairs them. */
|
|
54
|
+
sessionEvents: SessionEvent[]
|
|
55
|
+
intervals: Record<string, PhaseTiming>
|
|
56
|
+
/** Timestamp of the latest `build.paused`. */
|
|
57
|
+
pausedAt?: string
|
|
58
|
+
produced: {
|
|
59
|
+
planCompleted: RoundSeqs
|
|
60
|
+
planReviewVerdict: RoundSeqs
|
|
61
|
+
implementCompleted: RoundSeqs
|
|
62
|
+
codeReviewVerdict: RoundSeqs
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a loop output of `round` landed after `since` (the restart boundary). */
|
|
67
|
+
export function producedAfter(seqs: RoundSeqs, round: number, since: number): boolean {
|
|
68
|
+
const seq = seqs[String(round)]
|
|
69
|
+
return seq !== undefined && seq > since
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function raise(seqs: RoundSeqs, round: number, seq: number): void {
|
|
73
|
+
const key = String(round)
|
|
74
|
+
const current = seqs[key]
|
|
75
|
+
seqs[key] = current === undefined ? seq : Math.max(current, seq)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function foldInterval(intervals: Record<string, PhaseTiming>, ev: AbEvent): void {
|
|
79
|
+
const ms = Date.parse(ev.ts)
|
|
80
|
+
const get = (key: string): PhaseTiming => {
|
|
81
|
+
let t = intervals[key]
|
|
82
|
+
if (t === undefined) {
|
|
83
|
+
t = { closed: [] }
|
|
84
|
+
intervals[key] = t
|
|
85
|
+
}
|
|
86
|
+
return t
|
|
87
|
+
}
|
|
88
|
+
const open = (key: string): void => {
|
|
89
|
+
get(key).open = { start: ms, startSeq: ev.seq }
|
|
90
|
+
}
|
|
91
|
+
const close = (key: string): void => {
|
|
92
|
+
const t = get(key)
|
|
93
|
+
if (t.open === undefined) return
|
|
94
|
+
t.closed.push({ start: t.open.start, end: ms, startSeq: t.open.startSeq })
|
|
95
|
+
delete t.open
|
|
96
|
+
}
|
|
97
|
+
// Each `*.started` opens an interval for its phase key and its terminal event
|
|
98
|
+
// (`.completed`/`.verdict`) closes it. A second `*.started` while one is still
|
|
99
|
+
// open REPLACES the open start — a §15.6-C cross-sandbox re-run starts the
|
|
100
|
+
// phase afresh, so the crashed attempt contributes nothing. Finalize
|
|
101
|
+
// post-steps (`finalize.step-completed`) have no `.started` and no interval.
|
|
102
|
+
switch (ev.type) {
|
|
103
|
+
case 'plan.started':
|
|
104
|
+
open('plan')
|
|
105
|
+
break
|
|
106
|
+
case 'plan.completed':
|
|
107
|
+
close('plan')
|
|
108
|
+
break
|
|
109
|
+
case 'plan-review.started':
|
|
110
|
+
open('plan-review')
|
|
111
|
+
break
|
|
112
|
+
case 'plan-review.verdict':
|
|
113
|
+
close('plan-review')
|
|
114
|
+
break
|
|
115
|
+
case 'implement.started':
|
|
116
|
+
open('implement')
|
|
117
|
+
break
|
|
118
|
+
case 'implement.completed':
|
|
119
|
+
close('implement')
|
|
120
|
+
break
|
|
121
|
+
case 'code-review.started':
|
|
122
|
+
open('code-review')
|
|
123
|
+
break
|
|
124
|
+
case 'code-review.verdict':
|
|
125
|
+
close('code-review')
|
|
126
|
+
break
|
|
127
|
+
case 'verify.started':
|
|
128
|
+
open(verifyPhase(ev.payload.step))
|
|
129
|
+
break
|
|
130
|
+
case 'verify.completed':
|
|
131
|
+
close(verifyPhase(ev.payload.step))
|
|
132
|
+
break
|
|
133
|
+
case 'finalize.started':
|
|
134
|
+
open('finalize')
|
|
135
|
+
break
|
|
136
|
+
case 'finalize.completed':
|
|
137
|
+
close('finalize')
|
|
138
|
+
break
|
|
139
|
+
case 'reconcile.started':
|
|
140
|
+
open('reconcile')
|
|
141
|
+
break
|
|
142
|
+
case 'reconcile.completed':
|
|
143
|
+
close('reconcile')
|
|
144
|
+
break
|
|
145
|
+
default:
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const dashboardFactsReducer = defineReducer<DashboardFacts, AbEvent, DashboardFacts>({
|
|
151
|
+
version: DASHBOARD_FACTS_REDUCER_VERSION,
|
|
152
|
+
initial: () => ({
|
|
153
|
+
sessionEvents: [],
|
|
154
|
+
intervals: {},
|
|
155
|
+
produced: {
|
|
156
|
+
planCompleted: {},
|
|
157
|
+
planReviewVerdict: {},
|
|
158
|
+
implementCompleted: {},
|
|
159
|
+
codeReviewVerdict: {},
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
fold(acc, events) {
|
|
163
|
+
for (const event of events) {
|
|
164
|
+
foldInterval(acc.intervals, event)
|
|
165
|
+
switch (event.type) {
|
|
166
|
+
case 'build.created':
|
|
167
|
+
acc.created = event.seq
|
|
168
|
+
break
|
|
169
|
+
case 'workspace.provisioned':
|
|
170
|
+
acc.workspaceProvisioned = event.seq
|
|
171
|
+
break
|
|
172
|
+
case 'workspace.released':
|
|
173
|
+
acc.workspaceReleased = event.seq
|
|
174
|
+
break
|
|
175
|
+
case 'spec.imported':
|
|
176
|
+
case 'spec.authored':
|
|
177
|
+
acc.specLanded = event.seq
|
|
178
|
+
break
|
|
179
|
+
case 'dispatch.comment-posted':
|
|
180
|
+
acc.commentPosted = event.seq
|
|
181
|
+
break
|
|
182
|
+
case 'dispatch.failed':
|
|
183
|
+
acc.latestFailure = {
|
|
184
|
+
seq: event.seq,
|
|
185
|
+
stage: event.payload.stage,
|
|
186
|
+
attempt: event.payload.attempt,
|
|
187
|
+
error: event.payload.error,
|
|
188
|
+
}
|
|
189
|
+
break
|
|
190
|
+
case 'session.started':
|
|
191
|
+
case 'session.ended':
|
|
192
|
+
acc.sessionEvents.push(event)
|
|
193
|
+
break
|
|
194
|
+
case 'build.paused':
|
|
195
|
+
acc.pausedAt = event.ts
|
|
196
|
+
break
|
|
197
|
+
case 'plan.completed':
|
|
198
|
+
raise(acc.produced.planCompleted, event.payload.round, event.seq)
|
|
199
|
+
break
|
|
200
|
+
case 'plan-review.verdict':
|
|
201
|
+
raise(acc.produced.planReviewVerdict, event.payload.round, event.seq)
|
|
202
|
+
break
|
|
203
|
+
case 'implement.completed':
|
|
204
|
+
raise(acc.produced.implementCompleted, event.payload.round, event.seq)
|
|
205
|
+
break
|
|
206
|
+
case 'code-review.verdict':
|
|
207
|
+
raise(acc.produced.codeReviewVerdict, event.payload.round, event.seq)
|
|
208
|
+
break
|
|
209
|
+
default:
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
finish: (acc) => structuredClone(acc),
|
|
215
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { graphemes } from './cells'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic parsing of a rendered dashboard frame.
|
|
5
|
+
*
|
|
6
|
+
* The dashboard deliberately emits a tiny terminal vocabulary: SGR reset,
|
|
7
|
+
* bold/dim and six named foreground colours, plus OSC 8 hyperlinks. This
|
|
8
|
+
* parser accepts exactly that vocabulary and nothing else. Unknown control
|
|
9
|
+
* traffic is an error rather than evidence that merely looks plausible after
|
|
10
|
+
* bytes were dropped. Every rendition of a frame — the PNG evidence, the
|
|
11
|
+
* website's HTML — derives from these same exact cells.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ESC = '\x1b'
|
|
15
|
+
const BEL = '\x07'
|
|
16
|
+
|
|
17
|
+
export type Foreground = 'foreground' | 'red' | 'green' | 'yellow' | 'blue' | 'cyan'
|
|
18
|
+
|
|
19
|
+
export interface FrameStyle {
|
|
20
|
+
foreground: Foreground
|
|
21
|
+
bold: boolean
|
|
22
|
+
dim: boolean
|
|
23
|
+
href?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FrameRun {
|
|
27
|
+
/** Zero-based terminal cell column the run starts at. */
|
|
28
|
+
column: number
|
|
29
|
+
/** Terminal cells the run occupies. */
|
|
30
|
+
cells: number
|
|
31
|
+
text: string
|
|
32
|
+
style: FrameStyle
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParsedFrameLine {
|
|
36
|
+
/** The line with every escape removed. */
|
|
37
|
+
text: string
|
|
38
|
+
/** Terminal cells the whole line occupies. */
|
|
39
|
+
cells: number
|
|
40
|
+
runs: FrameRun[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cloneStyle(style: FrameStyle): FrameStyle {
|
|
44
|
+
return {
|
|
45
|
+
foreground: style.foreground,
|
|
46
|
+
bold: style.bold,
|
|
47
|
+
dim: style.dim,
|
|
48
|
+
...(style.href !== undefined ? { href: style.href } : {}),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sameStyle(left: FrameStyle, right: FrameStyle): boolean {
|
|
53
|
+
return (
|
|
54
|
+
left.foreground === right.foreground &&
|
|
55
|
+
left.bold === right.bold &&
|
|
56
|
+
left.dim === right.dim &&
|
|
57
|
+
left.href === right.href
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function applySgr(style: FrameStyle, raw: string, line: number): void {
|
|
62
|
+
const codes = raw === '' ? [0] : raw.split(';').map((part) => Number(part))
|
|
63
|
+
if (codes.some((code) => !Number.isInteger(code))) {
|
|
64
|
+
throw new Error(`dashboard frame line ${line}: malformed SGR sequence ESC[${raw}m`)
|
|
65
|
+
}
|
|
66
|
+
for (const code of codes) {
|
|
67
|
+
switch (code) {
|
|
68
|
+
case 0:
|
|
69
|
+
style.foreground = 'foreground'
|
|
70
|
+
style.bold = false
|
|
71
|
+
style.dim = false
|
|
72
|
+
break
|
|
73
|
+
case 1:
|
|
74
|
+
style.bold = true
|
|
75
|
+
break
|
|
76
|
+
case 2:
|
|
77
|
+
style.dim = true
|
|
78
|
+
break
|
|
79
|
+
case 22:
|
|
80
|
+
style.bold = false
|
|
81
|
+
style.dim = false
|
|
82
|
+
break
|
|
83
|
+
case 31:
|
|
84
|
+
style.foreground = 'red'
|
|
85
|
+
break
|
|
86
|
+
case 32:
|
|
87
|
+
style.foreground = 'green'
|
|
88
|
+
break
|
|
89
|
+
case 33:
|
|
90
|
+
style.foreground = 'yellow'
|
|
91
|
+
break
|
|
92
|
+
case 34:
|
|
93
|
+
style.foreground = 'blue'
|
|
94
|
+
break
|
|
95
|
+
case 36:
|
|
96
|
+
style.foreground = 'cyan'
|
|
97
|
+
break
|
|
98
|
+
case 39:
|
|
99
|
+
style.foreground = 'foreground'
|
|
100
|
+
break
|
|
101
|
+
default:
|
|
102
|
+
throw new Error(`dashboard frame line ${line}: unsupported SGR code ${code} in ESC[${raw}m`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Parse one rendered line into styled runs pinned to their terminal cells.
|
|
108
|
+
* `lineNumber` is one-based and only names the line in errors. */
|
|
109
|
+
export function parseFrameLine(value: string, lineNumber: number): ParsedFrameLine {
|
|
110
|
+
const style: FrameStyle = {
|
|
111
|
+
foreground: 'foreground',
|
|
112
|
+
bold: false,
|
|
113
|
+
dim: false,
|
|
114
|
+
}
|
|
115
|
+
const runs: FrameRun[] = []
|
|
116
|
+
let text = ''
|
|
117
|
+
let cells = 0
|
|
118
|
+
|
|
119
|
+
const append = (cluster: string, width: number): void => {
|
|
120
|
+
const previous = runs.at(-1)
|
|
121
|
+
// ASCII runs may coalesce. Unicode clusters stay independently pinned to
|
|
122
|
+
// their terminal cell columns so font fallback/advance cannot shift the
|
|
123
|
+
// run that follows a wide glyph.
|
|
124
|
+
if (
|
|
125
|
+
previous !== undefined &&
|
|
126
|
+
previous.column + previous.cells === cells &&
|
|
127
|
+
/^[\x20-\x7e]*$/.test(previous.text) &&
|
|
128
|
+
/^[\x20-\x7e]$/.test(cluster) &&
|
|
129
|
+
sameStyle(previous.style, style)
|
|
130
|
+
) {
|
|
131
|
+
previous.text += cluster
|
|
132
|
+
previous.cells += width
|
|
133
|
+
} else {
|
|
134
|
+
runs.push({ column: cells, cells: width, text: cluster, style: cloneStyle(style) })
|
|
135
|
+
}
|
|
136
|
+
text += cluster
|
|
137
|
+
cells += width
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let index = 0; index < value.length; ) {
|
|
141
|
+
if (value[index] === ESC) {
|
|
142
|
+
const family = value[index + 1]
|
|
143
|
+
if (family === '[') {
|
|
144
|
+
const rest = value.slice(index + 2)
|
|
145
|
+
const match = /^([0-9;]*)m/.exec(rest)
|
|
146
|
+
if (match === null) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`dashboard frame line ${lineNumber}: unsupported or unterminated CSI sequence`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
applySgr(style, match[1]!, lineNumber)
|
|
152
|
+
index += 2 + match[0].length
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (family === ']') {
|
|
156
|
+
const end = value.indexOf(BEL, index + 2)
|
|
157
|
+
if (end === -1) {
|
|
158
|
+
throw new Error(`dashboard frame line ${lineNumber}: unterminated OSC sequence`)
|
|
159
|
+
}
|
|
160
|
+
const payload = value.slice(index + 2, end)
|
|
161
|
+
const match = /^8;;(.*)$/.exec(payload)
|
|
162
|
+
if (match === null) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`dashboard frame line ${lineNumber}: unsupported OSC sequence ${JSON.stringify(payload)}`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
const href = match[1]!
|
|
168
|
+
style.href = href === '' ? undefined : href
|
|
169
|
+
index = end + 1
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
`dashboard frame line ${lineNumber}: unsupported escape family ${JSON.stringify(family ?? '')}`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const nextEscape = value.indexOf(ESC, index)
|
|
178
|
+
const stop = nextEscape === -1 ? value.length : nextEscape
|
|
179
|
+
const plain = value.slice(index, stop)
|
|
180
|
+
for (const character of plain) {
|
|
181
|
+
const code = character.codePointAt(0)!
|
|
182
|
+
if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`dashboard frame line ${lineNumber}: unsupported control U+${code
|
|
185
|
+
.toString(16)
|
|
186
|
+
.toUpperCase()
|
|
187
|
+
.padStart(4, '0')}`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const cluster of graphemes(plain)) append(cluster.text, cluster.width)
|
|
192
|
+
index = stop
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (style.href !== undefined) {
|
|
196
|
+
throw new Error(`dashboard frame line ${lineNumber}: OSC 8 hyperlink was not closed`)
|
|
197
|
+
}
|
|
198
|
+
if (style.foreground !== 'foreground' || style.bold || style.dim) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`dashboard frame line ${lineNumber}: SGR style was not reset before end of line`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return { text, cells, runs }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Parse a whole frame, holding every line to the declared terminal width. */
|
|
207
|
+
export function parseFrame(lines: readonly string[], columns: number): ParsedFrameLine[] {
|
|
208
|
+
if (!Number.isInteger(columns) || columns <= 0) {
|
|
209
|
+
throw new Error(`dashboard frame columns must be a positive integer, got ${columns}`)
|
|
210
|
+
}
|
|
211
|
+
if (lines.length === 0) {
|
|
212
|
+
throw new Error('dashboard frame is empty')
|
|
213
|
+
}
|
|
214
|
+
const parsed = lines.map((line, index) => parseFrameLine(line, index + 1))
|
|
215
|
+
for (const [index, line] of parsed.entries()) {
|
|
216
|
+
if (line.cells > columns) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`dashboard frame line ${index + 1} is ${line.cells} cells wide, exceeding declared terminal width ${columns}`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return parsed
|
|
223
|
+
}
|