@vzn/vx 0.0.0 → 0.0.12
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 +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Stable-key derivation, shared by remote-prefetch and the local
|
|
2
|
+
// short-circuit. Both need to identify the tasks whose cache key is
|
|
3
|
+
// provably independent of any upstream task's OUTPUTS — those are the
|
|
4
|
+
// only tasks safe to probe / restore ahead of the schedule. Factored
|
|
5
|
+
// out so the two callers can never drift on the stability gate.
|
|
6
|
+
//
|
|
7
|
+
// A task's key is "stable" only if its inputs can't be altered by an
|
|
8
|
+
// upstream's outputs (a `cache.inputs.files` glob that could match a
|
|
9
|
+
// sibling's declared output has a PRELIMINARY key until that upstream
|
|
10
|
+
// runs — probing it would target the wrong artifact). Unstable tasks
|
|
11
|
+
// fall back to lazy read-through in execute-task, which is always
|
|
12
|
+
// correct. The check is conservative: when unsure, treat as unstable.
|
|
13
|
+
|
|
14
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
15
|
+
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
|
+
import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
|
|
17
|
+
|
|
18
|
+
export interface DeriveStableKeysArgs {
|
|
19
|
+
nodes: Map<string, TaskNode>
|
|
20
|
+
cache: CacheLayer
|
|
21
|
+
workspaceRoot: string
|
|
22
|
+
workspaceFingerprint: string
|
|
23
|
+
forwardArgs?: readonly string[] | undefined
|
|
24
|
+
nestedDirsByProject: Map<string, string[]>
|
|
25
|
+
gitFilesCache: GitFilesCache
|
|
26
|
+
hashCache: HashCache
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StableKey {
|
|
30
|
+
hash: string
|
|
31
|
+
node: TaskNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Walk the graph in topological order, deriving every task's key the
|
|
36
|
+
* SAME way execute-task does (folding upstream keys via the synthetic
|
|
37
|
+
* {node, hash} outcomes filterUpstreamHashes / computeGroupHash read)
|
|
38
|
+
* and classifying each as stable / unstable. Returns the stable,
|
|
39
|
+
* cache-enabled, non-group tasks paired with their keys.
|
|
40
|
+
*
|
|
41
|
+
* Reuses the run's `hashCache`, so execute-task's later computeTaskHash
|
|
42
|
+
* for the same node hits the memo (package.json bytes + task config) —
|
|
43
|
+
* the key bytes are derived here, the heavy per-project hashing is
|
|
44
|
+
* shared. No cache layer is touched: keys only.
|
|
45
|
+
*/
|
|
46
|
+
export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
|
|
47
|
+
const order = topoOrder(args.nodes)
|
|
48
|
+
const keyById = new Map<string, string>()
|
|
49
|
+
const unstableById = new Set<string>()
|
|
50
|
+
const stableKeys: StableKey[] = []
|
|
51
|
+
|
|
52
|
+
for (const id of order) {
|
|
53
|
+
const node = args.nodes.get(id)!
|
|
54
|
+
const upstream = synthUpstream(node, args.nodes, keyById)
|
|
55
|
+
|
|
56
|
+
if (isGroupTask(node)) {
|
|
57
|
+
// Groups have no exec/cache; they only fold upstream keys so
|
|
58
|
+
// dependents that filter inputs.tasks through the group still
|
|
59
|
+
// cascade. They inherit instability from any unstable member.
|
|
60
|
+
keyById.set(id, computeGroupHash(upstream))
|
|
61
|
+
if (node.deps.some((d) => unstableById.has(d))) unstableById.add(id)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hash = await computeTaskHash({
|
|
66
|
+
node,
|
|
67
|
+
upstream,
|
|
68
|
+
workspaceRoot: args.workspaceRoot,
|
|
69
|
+
workspaceFingerprint: args.workspaceFingerprint,
|
|
70
|
+
cache: args.cache,
|
|
71
|
+
forwardArgs: args.forwardArgs,
|
|
72
|
+
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
73
|
+
gitFilesCache: args.gitFilesCache,
|
|
74
|
+
hashCache: args.hashCache,
|
|
75
|
+
})
|
|
76
|
+
keyById.set(id, hash)
|
|
77
|
+
|
|
78
|
+
const unstable =
|
|
79
|
+
node.deps.some((d) => unstableById.has(d)) || dependsOnSiblingOutputs(node, args.nodes)
|
|
80
|
+
if (unstable) unstableById.add(id)
|
|
81
|
+
|
|
82
|
+
const cacheEnabled = node.config.cache !== undefined
|
|
83
|
+
if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
|
|
84
|
+
}
|
|
85
|
+
return stableKeys
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Synthetic upstream outcomes for key derivation. Only `node` + `hash`
|
|
90
|
+
* are read by filterUpstreamHashes / computeGroupHash; the rest of
|
|
91
|
+
* TaskOutcome is irrelevant here, so we cast a minimal shape.
|
|
92
|
+
*/
|
|
93
|
+
export function synthUpstream(
|
|
94
|
+
node: TaskNode,
|
|
95
|
+
nodes: Map<string, TaskNode>,
|
|
96
|
+
keyById: Map<string, string>,
|
|
97
|
+
): TaskOutcome[] {
|
|
98
|
+
const out: TaskOutcome[] = []
|
|
99
|
+
for (const dep of node.deps) {
|
|
100
|
+
const depNode = nodes.get(dep)
|
|
101
|
+
if (!depNode) continue
|
|
102
|
+
out.push({
|
|
103
|
+
node: depNode,
|
|
104
|
+
status: 'success',
|
|
105
|
+
exitCode: 0,
|
|
106
|
+
durationMs: 0,
|
|
107
|
+
hash: keyById.get(dep),
|
|
108
|
+
} as TaskOutcome)
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Conservative stability check: does any UPSTREAM task whose outputs
|
|
115
|
+
* could land where this task reads its inputs make this task's key
|
|
116
|
+
* preliminary?
|
|
117
|
+
*
|
|
118
|
+
* - Same-project upstream with declared `cache.outputs.files`: its
|
|
119
|
+
* outputs land inside this project's dir, which this task's input
|
|
120
|
+
* globs (default `**` / anything project-relative) can match —
|
|
121
|
+
* the upstream must run before this key is final. Project
|
|
122
|
+
* boundaries are hard, so only SAME-project outputs can reach a
|
|
123
|
+
* project-relative input.
|
|
124
|
+
* - Any upstream with declared `cache.outputs.workspaceFiles` when
|
|
125
|
+
* this task reads `cache.inputs.workspaceFiles`: root-anchored
|
|
126
|
+
* outputs ignore boundaries and can land anywhere the root-anchored
|
|
127
|
+
* inputs read.
|
|
128
|
+
*
|
|
129
|
+
* Either case → unstable → skip prefetch (lazy read-through in
|
|
130
|
+
* execute-task stays correct). When in doubt, unstable.
|
|
131
|
+
*/
|
|
132
|
+
export function dependsOnSiblingOutputs(node: TaskNode, nodes: Map<string, TaskNode>): boolean {
|
|
133
|
+
const inputs = node.config.cache?.inputs
|
|
134
|
+
// A cache-disabled task has no key to prefetch anyway; treat as
|
|
135
|
+
// unstable-irrelevant (caller filters on cacheEnabled).
|
|
136
|
+
if (node.config.cache === undefined) return false
|
|
137
|
+
const readsWorkspaceFiles = (inputs?.workspaceFiles?.length ?? 0) > 0
|
|
138
|
+
|
|
139
|
+
for (const dep of node.deps) {
|
|
140
|
+
const depNode = nodes.get(dep)
|
|
141
|
+
if (!depNode) continue
|
|
142
|
+
const depOutputs = depNode.config.cache?.outputs
|
|
143
|
+
if (depOutputs === undefined) continue
|
|
144
|
+
const sameProject = depNode.projectName === node.projectName
|
|
145
|
+
if (sameProject && (depOutputs.files?.length ?? 0) > 0) return true
|
|
146
|
+
if (readsWorkspaceFiles && (depOutputs.workspaceFiles?.length ?? 0) > 0) return true
|
|
147
|
+
}
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function topoOrder(nodes: Map<string, TaskNode>): string[] {
|
|
152
|
+
const indegree = new Map<string, number>()
|
|
153
|
+
const dependents = new Map<string, string[]>()
|
|
154
|
+
for (const node of nodes.values()) {
|
|
155
|
+
indegree.set(node.id, node.deps.length)
|
|
156
|
+
for (const dep of node.deps) {
|
|
157
|
+
const list = dependents.get(dep)
|
|
158
|
+
if (list) list.push(node.id)
|
|
159
|
+
else dependents.set(dep, [node.id])
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Kahn's algorithm with a head pointer instead of `queue.shift()` — a shift
|
|
163
|
+
// reindexes the whole array (O(N) each), making the walk O(N²) on a big
|
|
164
|
+
// graph. `out` doubles as the queue (every dequeued id is already in topo
|
|
165
|
+
// order); we only ever append and advance `head`. Same pattern as
|
|
166
|
+
// computeReverseDepCount / package-graph.
|
|
167
|
+
const out: string[] = []
|
|
168
|
+
for (const [id, deg] of indegree) if (deg === 0) out.push(id)
|
|
169
|
+
for (let head = 0; head < out.length; head++) {
|
|
170
|
+
for (const d of dependents.get(out[head]!) ?? []) {
|
|
171
|
+
const rem = (indegree.get(d) ?? 0) - 1
|
|
172
|
+
indegree.set(d, rem)
|
|
173
|
+
if (rem === 0) out.push(d)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Dynamic status display for interactive runs. NOT a TUI: a
|
|
2
|
+
// fixed-height worker region redrawn in place with clear-line /
|
|
3
|
+
// cursor-up — no alternate screen.
|
|
4
|
+
// The writer is the serialization point for ALL logger stdout: every
|
|
5
|
+
// ordinary write erases the status display first, writes its content,
|
|
6
|
+
// then redraws, so the display can never interleave with task output.
|
|
7
|
+
|
|
8
|
+
import { paint, type ColorSupport } from './colors.js'
|
|
9
|
+
import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
|
|
10
|
+
|
|
11
|
+
const NO_COLOR: ColorSupport = { enabled: false }
|
|
12
|
+
// Same palette as framed-output.ts so glyphs and stats agree.
|
|
13
|
+
const ACCENT = '#06b6d4'
|
|
14
|
+
const ERROR = '#ef4444'
|
|
15
|
+
|
|
16
|
+
/** Erase the current line and return the cursor to column 0. */
|
|
17
|
+
const CLEAR = '\x1b[2K\r'
|
|
18
|
+
|
|
19
|
+
export interface StatusStream {
|
|
20
|
+
write(chunk: string): unknown
|
|
21
|
+
isTTY?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OutputWriter {
|
|
25
|
+
/** True only when the stream is a TTY and the run isn't CI. */
|
|
26
|
+
readonly enabled: boolean
|
|
27
|
+
/** Serialized content write: erase status → content → redraw. */
|
|
28
|
+
write(chunk: string): void
|
|
29
|
+
/**
|
|
30
|
+
* Replace the status line's content. Redraws are throttled to
|
|
31
|
+
* `minRedrawMs` unless `force` (task start/finish) is set.
|
|
32
|
+
*/
|
|
33
|
+
setStatus(line: string, opts?: { force?: boolean }): void
|
|
34
|
+
/**
|
|
35
|
+
* Replace the multi-line status region. Same throttle contract as
|
|
36
|
+
* `setStatus`; the two share one display slot.
|
|
37
|
+
*/
|
|
38
|
+
setRegion(lines: readonly string[], opts?: { force?: boolean }): void
|
|
39
|
+
/** Permanently remove the status display; later set* is a no-op. */
|
|
40
|
+
clearStatus(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OutputWriterOptions {
|
|
44
|
+
/** Master switch on top of the TTY check (false in CI). */
|
|
45
|
+
enabled?: boolean
|
|
46
|
+
/** Minimum interval between unforced redraws. */
|
|
47
|
+
minRedrawMs?: number
|
|
48
|
+
/**
|
|
49
|
+
* Floor between FORCED redraws. Task events force a redraw each; a
|
|
50
|
+
* 3,270-task warm run produced 6,540 forced redraws ≈ 6.7 MB of
|
|
51
|
+
* ANSI to the terminal. A forced set inside the floor marks the
|
|
52
|
+
* content dirty and ONE trailing draw lands the final state when
|
|
53
|
+
* the floor expires. 0 disables (tests asserting synchronously).
|
|
54
|
+
*/
|
|
55
|
+
forceFloorMs?: number
|
|
56
|
+
/** Injectable clock for tests. */
|
|
57
|
+
now?: () => number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createOutputWriter(
|
|
61
|
+
stream: StatusStream,
|
|
62
|
+
opts: OutputWriterOptions = {},
|
|
63
|
+
): OutputWriter {
|
|
64
|
+
const enabled = (opts.enabled ?? true) && stream.isTTY === true
|
|
65
|
+
const minRedrawMs = opts.minRedrawMs ?? 100
|
|
66
|
+
const forceFloorMs = opts.forceFloorMs ?? 30
|
|
67
|
+
const now = opts.now ?? Date.now
|
|
68
|
+
|
|
69
|
+
let current: readonly string[] | null = null
|
|
70
|
+
let shown = false
|
|
71
|
+
let shownHeight = 0
|
|
72
|
+
let dead = false
|
|
73
|
+
let lastDraw = -Infinity
|
|
74
|
+
// Trailing draw scheduled when a forced set lands inside the floor.
|
|
75
|
+
let trailing: ReturnType<typeof setTimeout> | null = null
|
|
76
|
+
// Streamed task output (focused mode) can end mid-line; redrawing
|
|
77
|
+
// the status display would wipe the partial line, so we hold off
|
|
78
|
+
// until a write restores column 0.
|
|
79
|
+
let atLineStart = true
|
|
80
|
+
|
|
81
|
+
const cancelTrailing = (): void => {
|
|
82
|
+
if (trailing !== null) {
|
|
83
|
+
clearTimeout(trailing)
|
|
84
|
+
trailing = null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Erase sequence for whatever is currently shown. Single line keeps
|
|
89
|
+
// the exact legacy bytes (ESC[2K\r); a taller region moves to its
|
|
90
|
+
// top line and clears to end of screen.
|
|
91
|
+
const eraseSeq = (): string => (shownHeight > 1 ? `\r\x1b[${shownHeight - 1}A\x1b[J` : CLEAR)
|
|
92
|
+
|
|
93
|
+
const draw = (): void => {
|
|
94
|
+
// Whatever was pending is now painted — the trailing draw would
|
|
95
|
+
// only repeat these bytes.
|
|
96
|
+
cancelTrailing()
|
|
97
|
+
const erase = shown ? eraseSeq() : CLEAR
|
|
98
|
+
stream.write(erase + current!.join('\n'))
|
|
99
|
+
shown = true
|
|
100
|
+
shownHeight = current!.length
|
|
101
|
+
lastDraw = now()
|
|
102
|
+
}
|
|
103
|
+
const set = (lines: readonly string[], o: { force?: boolean }): void => {
|
|
104
|
+
if (!enabled || dead) return
|
|
105
|
+
current = lines
|
|
106
|
+
if (!atLineStart) return
|
|
107
|
+
const since = now() - lastDraw
|
|
108
|
+
if (o.force) {
|
|
109
|
+
if (since >= forceFloorMs) {
|
|
110
|
+
draw()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
// Coalesce the burst: `current` is already the latest state, so
|
|
114
|
+
// one trailing draw at floor expiry lands it. unref — a stray
|
|
115
|
+
// timer must never hold the process open.
|
|
116
|
+
if (trailing === null) {
|
|
117
|
+
trailing = setTimeout(() => {
|
|
118
|
+
trailing = null
|
|
119
|
+
if (dead || current === null || !atLineStart) return
|
|
120
|
+
draw()
|
|
121
|
+
}, forceFloorMs - since)
|
|
122
|
+
trailing.unref?.()
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (since >= minRedrawMs) draw()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
enabled,
|
|
131
|
+
write(chunk) {
|
|
132
|
+
if (!enabled) {
|
|
133
|
+
stream.write(chunk)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (shown) {
|
|
137
|
+
stream.write(eraseSeq())
|
|
138
|
+
shown = false
|
|
139
|
+
shownHeight = 0
|
|
140
|
+
}
|
|
141
|
+
stream.write(chunk)
|
|
142
|
+
if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
|
|
143
|
+
if (!dead && current !== null && atLineStart) draw()
|
|
144
|
+
},
|
|
145
|
+
setStatus(line, o = {}) {
|
|
146
|
+
set([line], o)
|
|
147
|
+
},
|
|
148
|
+
setRegion(lines, o = {}) {
|
|
149
|
+
set(lines, o)
|
|
150
|
+
},
|
|
151
|
+
clearStatus() {
|
|
152
|
+
if (!enabled || dead) return
|
|
153
|
+
dead = true
|
|
154
|
+
current = null
|
|
155
|
+
cancelTrailing()
|
|
156
|
+
if (shown) {
|
|
157
|
+
stream.write(eraseSeq())
|
|
158
|
+
shown = false
|
|
159
|
+
shownHeight = 0
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const IDLE = '#6b7280'
|
|
166
|
+
|
|
167
|
+
export interface WorkerSlot {
|
|
168
|
+
id: string
|
|
169
|
+
startedMs: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface StatusRegionState {
|
|
173
|
+
/**
|
|
174
|
+
* Ready persistent tasks (dev servers …) whose children keep
|
|
175
|
+
* running after their outcome lands. Pinned until runEnd — the
|
|
176
|
+
* orchestrator SIGTERMs them when the graph finishes, so runEnd is
|
|
177
|
+
* the honest end of their life.
|
|
178
|
+
*/
|
|
179
|
+
pinnedPersistent: readonly string[]
|
|
180
|
+
/** Fixed-size slot array; null = idle worker. Index = display row. */
|
|
181
|
+
slots: readonly (WorkerSlot | null)[]
|
|
182
|
+
/** Running tasks beyond the displayed slots. */
|
|
183
|
+
overflow: number
|
|
184
|
+
/** Clock value used for per-slot elapsed (injectable for tests). */
|
|
185
|
+
nowMs: number
|
|
186
|
+
/**
|
|
187
|
+
* The live summary section (built by the logger via
|
|
188
|
+
* formatSummarySection) — the SAME section the final summary
|
|
189
|
+
* prints, filling in as the run progresses. Rendered verbatim
|
|
190
|
+
* below the worker rows.
|
|
191
|
+
*/
|
|
192
|
+
summaryLines: readonly string[]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Identity-colored id, full (never truncated) — name is the last column. */
|
|
196
|
+
function paintPinnedId(id: string, colors: ColorSupport): string {
|
|
197
|
+
const sep = id.indexOf('#')
|
|
198
|
+
if (sep < 0) return id
|
|
199
|
+
return paintIdParts(id.slice(0, sep), id.slice(0, sep), id.slice(sep + 1), colors)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Permanent one-liner logged the moment a task fails (owner design:
|
|
204
|
+
* "log the failure and continue, full frames at the end"). On the grid:
|
|
205
|
+
* red ◼ glyph + exec time + `failed` + `miss` + id. The exit code and
|
|
206
|
+
* output live in the framed block that replays at runEnd, not the line.
|
|
207
|
+
*/
|
|
208
|
+
export function formatFailureLine(
|
|
209
|
+
id: string,
|
|
210
|
+
durationMs: number | null,
|
|
211
|
+
colors: ColorSupport = NO_COLOR,
|
|
212
|
+
): string {
|
|
213
|
+
return formatTaskRow(
|
|
214
|
+
paint(ERROR, '◼\uFE0E', colors),
|
|
215
|
+
durationMs,
|
|
216
|
+
'failed',
|
|
217
|
+
ERROR,
|
|
218
|
+
'miss',
|
|
219
|
+
'',
|
|
220
|
+
paintPinnedId(id, colors),
|
|
221
|
+
colors,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Worker region: pinned ready persistent tasks, one row per worker
|
|
227
|
+
* slot — a task stays in its slot for its whole life so names never
|
|
228
|
+
* jump — then the live summary section. Every row sits on the shared
|
|
229
|
+
* column grid (see formatTaskRow), so nothing shifts with id length:
|
|
230
|
+
* layout shift IS the bug this display exists to fix. Height varies
|
|
231
|
+
* only when pins arrive.
|
|
232
|
+
*/
|
|
233
|
+
export function formatStatusRegion(
|
|
234
|
+
s: StatusRegionState,
|
|
235
|
+
colors: ColorSupport = NO_COLOR,
|
|
236
|
+
): string[] {
|
|
237
|
+
const dim = (t: string) => paint('', t, colors, { dim: true })
|
|
238
|
+
// Leading blank separates the live running region from the completed
|
|
239
|
+
// task list scrolling above it (owner: "separate running from list").
|
|
240
|
+
const lines: string[] = ['']
|
|
241
|
+
// Persistent (dev-server) rows: ▸ glyph, no elapsed, `running`, no
|
|
242
|
+
// cache state (it's still alive).
|
|
243
|
+
for (const id of s.pinnedPersistent) {
|
|
244
|
+
lines.push(
|
|
245
|
+
formatTaskRow(
|
|
246
|
+
paint(ACCENT, '▸', colors),
|
|
247
|
+
null,
|
|
248
|
+
'running',
|
|
249
|
+
ACCENT,
|
|
250
|
+
'',
|
|
251
|
+
'',
|
|
252
|
+
paintPinnedId(id, colors),
|
|
253
|
+
colors,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Worker rows: NO glyph — the live elapsed time leads (no spinner;
|
|
259
|
+
// the ticking time IS the motion), then `running`, full id (never
|
|
260
|
+
// truncated — name is the last column). Idle rows hold their slot's
|
|
261
|
+
// place, dim, aligned under the status column.
|
|
262
|
+
for (const slot of s.slots) {
|
|
263
|
+
if (slot === null) {
|
|
264
|
+
lines.push(`${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
lines.push(
|
|
268
|
+
formatTaskRow(
|
|
269
|
+
' ',
|
|
270
|
+
Math.max(0, s.nowMs - slot.startedMs),
|
|
271
|
+
'running',
|
|
272
|
+
ACCENT,
|
|
273
|
+
'',
|
|
274
|
+
'',
|
|
275
|
+
paintPinnedId(slot.id, colors),
|
|
276
|
+
colors,
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
if (s.overflow > 0) {
|
|
281
|
+
lines.push(dim(`\u2026 +${s.overflow} more running`))
|
|
282
|
+
}
|
|
283
|
+
// The live summary section — identical shape to the final printout,
|
|
284
|
+
// so the region visually BECOMES the summary when the run ends.
|
|
285
|
+
lines.push(...s.summaryLines)
|
|
286
|
+
return lines
|
|
287
|
+
}
|