@joshuafolkken/kit 1.1379.0 → 1.1383.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/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
- package/.claude/skills/workflow-commands/retrospective.md +1 -2
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-YSDAH73S.js → chunk-2X2ART5G.js} +2 -2
- package/dist/hooks/{chunk-RW4XMF47.js → chunk-4MGHBTOM.js} +125 -232
- package/dist/hooks/{chunk-TG2AB3W6.js → chunk-6AC4C2IO.js} +45 -55
- package/dist/hooks/{chunk-HGA4EG5S.js → chunk-6MAW7BCM.js} +12 -12
- package/dist/hooks/{chunk-QARPP36N.js → chunk-7VSZ2N4B.js} +3 -3
- package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-KC6LUZZ2.js} +1 -1
- package/dist/hooks/{chunk-4RWRP3FF.js → chunk-KCF2YE7O.js} +5 -5
- package/dist/hooks/{chunk-E7LZL5AN.js → chunk-LC56R53A.js} +3 -3
- package/dist/hooks/{chunk-546X4PX3.js → chunk-LOJTBU5V.js} +3 -3
- package/dist/hooks/{chunk-7ZM464OW.js → chunk-RYP3TSUJ.js} +3 -3
- package/dist/hooks/{chunk-TORRSNDJ.js → chunk-TT742SIO.js} +2 -2
- package/dist/hooks/{chunk-NBEAGPS4.js → chunk-Y5R47QHU.js} +1 -1
- package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-YKUITFT7.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +199 -200
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +19 -15
- package/dist/josh.js +1 -1
- package/package.json +1 -1
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/scripts/backlog/backlog-budget-cli.ts +2 -20
- package/scripts/backlog/backlog-budget.ts +72 -109
- package/scripts/backlog/backlog-drive-cli.ts +10 -3
- package/scripts/backlog/backlog-drive.ts +5 -2
- package/scripts/backlog/backlog-offer-cli.ts +1 -15
- package/scripts/epic/epic-candidate-confirm.ts +1 -1
- package/scripts/epic/epic-cross-repo.ts +4 -4
- package/scripts/epic/epic-next.ts +1 -1
- package/scripts/hooks/stop-guard.ts +20 -9
- package/scripts/josh/josh-commands-ai.ts +1 -1
- package/scripts/lane/lane-cli.ts +1 -3
- package/scripts/run/board/run-board-header.ts +107 -84
- package/scripts/run/board/run-board-labels.ts +18 -17
- package/scripts/run/board/run-board-machine.ts +21 -8
- package/scripts/run/board/run-board-render.ts +1 -5
- package/scripts/run/board/run-board-span.ts +0 -1
- package/scripts/run/board/run-board-status.ts +0 -17
- package/scripts/run/board/run-board-tick.ts +1 -0
- package/scripts/run/carry/run-carry-cli.ts +8 -62
- package/scripts/run/carry/run-carry-ended.ts +1 -1
- package/scripts/run/carry/run-carry.ts +22 -68
- package/scripts/run/cut/run-cut.ts +4 -4
- package/scripts/run/event/run-event-render.ts +0 -1
- package/scripts/run/event/run-event-scope.ts +4 -8
- package/scripts/run/event/run-event-stream-emit.ts +0 -25
- package/scripts/run/event/run-event-stream.ts +23 -31
- package/scripts/run/merge/run-merge-collect.ts +1 -1
- package/scripts/run/merge/run-merge-steps.ts +1 -1
- package/scripts/run/progress/run-progress-read.ts +1 -1
- package/scripts/run/run-headless.ts +2 -2
- package/scripts/run/run-status-cli.ts +1 -5
- package/scripts/run/run-step.ts +2 -9
- package/scripts/run/run-stop-notify.ts +1 -1
- package/scripts/run/run-stranded-detect.ts +3 -3
- package/scripts/run/run-stranded.ts +25 -17
- package/scripts/run/wake/run-wake-cli.ts +7 -9
- package/scripts/run/wake/run-wake-describe.ts +3 -5
- package/scripts/run/wake/run-wake-driver.ts +3 -3
- package/scripts/run/wake/run-wake-loop.ts +4 -21
- package/scripts/run/wake/run-wake-restart.ts +1 -1
- package/scripts/run/wake/run-wake.ts +6 -7
- package/scripts/backlog/backlog-idle.ts +0 -88
|
@@ -80,7 +80,7 @@ const NOT_FOUND_STATUS = 404
|
|
|
80
80
|
//
|
|
81
81
|
// **`absent` is not `unreadable`.** A repository with no
|
|
82
82
|
// manifest, or one whose manifest says `private`, publishes nothing — so a closed blocker there has
|
|
83
|
-
// no release anyone could wait for, and waiting means waiting until the run's
|
|
83
|
+
// no release anyone could wait for, and waiting means waiting until the run's idle wait runs out
|
|
84
84
|
// with nothing an operator can edit to clear it. A read that merely failed says none of that.
|
|
85
85
|
//
|
|
86
86
|
// **It is read from the repository rather than from the registry, and that is the whole design.** The
|
|
@@ -123,7 +123,7 @@ type WorkspaceCheck = 'workspace' | 'single' | 'unreadable'
|
|
|
123
123
|
//
|
|
124
124
|
// **The file's existence proves nothing.** `josh sync` distributes it to every consumer regardless of
|
|
125
125
|
// layout — it carries `allowBuilds`, `overrides` and the like — so probing for its presence would read
|
|
126
|
-
// every private first-party repository as a workspace and wait the
|
|
126
|
+
// every private first-party repository as a workspace and wait the whole idle wait on each. What
|
|
127
127
|
// makes a repository a workspace is a non-empty `packages`
|
|
128
128
|
// key, so that is what is read.
|
|
129
129
|
function declares_packages(yaml: string): boolean {
|
|
@@ -271,7 +271,7 @@ function read_default_branch_version(repo: string): string | undefined {
|
|
|
271
271
|
// would start a dependent before its blocker's release existed. What is asked is only whether the
|
|
272
272
|
// repository *is* a workspace — the members are not enumerated and nothing looks for which of them
|
|
273
273
|
// publishes, so a workspace whose members are all private waits when it need not. That is the safe
|
|
274
|
-
// direction: waiting ends
|
|
274
|
+
// direction: waiting ends when the run's idle wait runs out, resolving early does not end at all.
|
|
275
275
|
function publishes_nothing(repo: string): boolean {
|
|
276
276
|
const answer = read_manifest(repo)
|
|
277
277
|
|
|
@@ -285,7 +285,7 @@ function publishes_nothing(repo: string): boolean {
|
|
|
285
285
|
//
|
|
286
286
|
// **"Ships nothing" is asked first, and it is a different question.** Waiting is right only where
|
|
287
287
|
// something could still arrive; a closed blocker in a repository that publishes no package has no
|
|
288
|
-
// release for `is_published` to ever find, so it would wait until the run's
|
|
288
|
+
// release for `is_published` to ever find, so it would wait until the run's idle wait runs out
|
|
289
289
|
// with nothing an operator could edit to clear it. It is answered from that repository's own manifest and never from
|
|
290
290
|
// the registry, for the reason `ManifestAnswer` records: a registry 404 also means "this token may
|
|
291
291
|
// not see it", and resolving on one would start a consumer child before its blocker's release
|
|
@@ -194,7 +194,7 @@ function refuse(reason: string): number {
|
|
|
194
194
|
|
|
195
195
|
// The verdict as it applies to *this* repository. `run` never reaches a caller here: it means some
|
|
196
196
|
// other repository has work, which for this session is something to wait on rather than a state its
|
|
197
|
-
// loop has a branch for. The
|
|
197
|
+
// loop has a branch for. The idle wait is what bounds that wait.
|
|
198
198
|
function repo_verdict(verdict: LaneOffer['verdict']): LaneOffer['verdict'] {
|
|
199
199
|
return verdict === 'run' ? 'wait' : verdict
|
|
200
200
|
}
|
|
@@ -126,20 +126,22 @@ async function outcome_of(raw_payload: string): Promise<StopOutcome> {
|
|
|
126
126
|
|
|
127
127
|
// A live carry record is in place — `carried` alone reads as a run. Both report checks below are about
|
|
128
128
|
// a carried run: the strand judge reads this very record and returns on anything but `carried`, and a
|
|
129
|
-
// stall needs a backlog driver, which begins one before its plan.
|
|
130
|
-
// record a crashed run left behind is no run either
|
|
131
|
-
//
|
|
129
|
+
// stall needs a backlog driver, which begins one before its plan. An `unreadable`
|
|
130
|
+
// record a crashed run left behind is no run either.
|
|
131
|
+
// The answer is whether that run's owner is still live, and `undefined` where there is no run at all.
|
|
132
132
|
// A failed git read — outside a repository, `git rev-parse` exits non-zero — reads as no run, since this
|
|
133
133
|
// gate sits ahead of the checks' own swallowing and must not throw into the stop decision either.
|
|
134
|
-
async function
|
|
134
|
+
async function carried_owner_live(): Promise<boolean | undefined> {
|
|
135
135
|
try {
|
|
136
136
|
const directory = await run_carry.repository_directory()
|
|
137
137
|
|
|
138
|
-
if (directory === undefined) return
|
|
138
|
+
if (directory === undefined) return undefined
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
const read = run_carry.read_carry(run_carry.carry_path(directory))
|
|
141
|
+
|
|
142
|
+
return read.kind === 'carried' ? run_carry.is_owner_live(read.carry) : undefined
|
|
141
143
|
} catch {
|
|
142
|
-
return
|
|
144
|
+
return undefined
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
@@ -149,10 +151,19 @@ async function has_run_record(): Promise<boolean> {
|
|
|
149
151
|
// — the budget handed off, the owner dead, no supervisor watching. Both only report and swallow their
|
|
150
152
|
// own failures, so neither can change the stop decision. An ordinary stop with no run record skips
|
|
151
153
|
// both, so it pays one directory read rather than their reads and a stale stream's backlog probe.
|
|
154
|
+
//
|
|
155
|
+
// **A stall is a live driver not dispatching, so a record whose owner is gone is not asked.** A record
|
|
156
|
+
// stands until a person carries or discards it, so a crashed run's would otherwise re-arm the stall's
|
|
157
|
+
// `git worktree list` and network backlog probe on every stop in the repository, for a run nobody
|
|
158
|
+
// drives. A gone driver is the strand check's question — handed off or not (`run-stranded.ts` →
|
|
159
|
+
// `had_driver`) — and that one reads local files only.
|
|
152
160
|
async function run_report_checks(): Promise<void> {
|
|
153
|
-
|
|
161
|
+
const is_owner_live = await carried_owner_live()
|
|
162
|
+
|
|
163
|
+
if (is_owner_live === undefined) return
|
|
164
|
+
|
|
165
|
+
if (is_owner_live) await backlog_stalled_detect.run_stall_check(backlog_ready.DEFAULT_PORTS)
|
|
154
166
|
|
|
155
|
-
await backlog_stalled_detect.run_stall_check(backlog_ready.DEFAULT_PORTS)
|
|
156
167
|
await run_stranded_detect.run_stranded_check()
|
|
157
168
|
}
|
|
158
169
|
|
|
@@ -214,7 +214,7 @@ const AI_COMMANDS: Record<string, CommandEntry> = {
|
|
|
214
214
|
// credentials, the same reason `run:wake` carries these flags.
|
|
215
215
|
tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
|
|
216
216
|
description:
|
|
217
|
-
'Report whether the run is stranded —
|
|
217
|
+
'Report whether the run is stranded — owner gone, no successor holding the budget, and no supervisor watching',
|
|
218
218
|
category: 'AI tools',
|
|
219
219
|
reference: ['', 'automation', ['processes', 'notifications']],
|
|
220
220
|
},
|
package/scripts/lane/lane-cli.ts
CHANGED
|
@@ -62,9 +62,7 @@ async function guard_cut_session(): Promise<string | undefined> {
|
|
|
62
62
|
|
|
63
63
|
const read = run_carry.read_carry(run_carry.carry_path(directory))
|
|
64
64
|
|
|
65
|
-
if (
|
|
66
|
-
return undefined
|
|
67
|
-
}
|
|
65
|
+
if (read.kind !== 'carried' || read.carry.is_handed_off !== true) return undefined
|
|
68
66
|
|
|
69
67
|
return run_carry.count_refused_message(read.carry)
|
|
70
68
|
}
|
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
import { styleText } from 'node:util'
|
|
2
2
|
import { backlog_budget } from '#scripts/backlog/backlog-budget'
|
|
3
|
-
import {
|
|
3
|
+
import { run_board_fit } from './run-board-fit'
|
|
4
4
|
import { run_board_labels } from './run-board-labels'
|
|
5
5
|
import { run_board_layout, type BoardLayout, type BoardRow } from './run-board-layout'
|
|
6
6
|
import { run_board_machine, type MachineGauges } from './run-board-machine'
|
|
7
|
+
import { run_board_render_slot } from './run-board-render-slot'
|
|
7
8
|
import type { RunActivity } from './run-board-status'
|
|
8
9
|
import type { LaneUsages } from './run-board-usage'
|
|
9
10
|
|
|
10
|
-
// The top of `run:board`: whether the run is moving, how long it has run, how
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
// The top of `run:board`: whether the run is moving, how long it has run, how fresh its last event is,
|
|
12
|
+
// and how far through the plan it is. Every time comes from a record (the carry's start, the stream's
|
|
13
|
+
// events); nothing here guesses one. Lines of symbols rather than sentences: the `⏰` that moves every
|
|
14
|
+
// second is the proof the board is live, so no `updated` line is drawn. **Always two lines, in three
|
|
15
|
+
// columns**: the run's mark and name over its repository's, the run's age over the `💓`, then the
|
|
16
|
+
// progress over the machine the run is on — so the first line reads as how far the run is and the
|
|
17
|
+
// second as what it runs on, a slow run apart from a stuck one. Both are written the same while the
|
|
18
|
+
// run works and while it waits, so a wait is read off the mark and the `💓` rather than off a line
|
|
19
|
+
// that changes shape. A value not read yet is drawn `-`, never `0`, so a header still loading reads
|
|
20
|
+
// as unknown rather than as an empty run.
|
|
21
|
+
|
|
22
|
+
const { HEADER_ICONS, STATE_ICONS, WORDS, bar_of, minute_of, span_of } = run_board_labels
|
|
23
|
+
const { cell_of } = run_board_render_slot
|
|
22
24
|
const UNKNOWN = '-'
|
|
23
|
-
const
|
|
25
|
+
const BLANK = ' '
|
|
26
|
+
const REPO_SEPARATOR = '/'
|
|
27
|
+
const { GAP, PERCENT_WIDTH } = run_board_machine
|
|
24
28
|
// A running run whose newest event is older than this is drawn as stale, so a hang stands out; past
|
|
25
29
|
// twice that it is drawn as alarming.
|
|
26
30
|
const STALE_MINUTES = 15
|
|
27
31
|
const STALE_MS = STALE_MINUTES * backlog_budget.MS_PER_MINUTE
|
|
28
32
|
const ALARM_FACTOR = 2
|
|
29
33
|
const ALARM_MS = STALE_MS * ALARM_FACTOR
|
|
30
|
-
const INDENT = ' '
|
|
31
34
|
const RUN_NAME = 'backlogrun'
|
|
32
35
|
|
|
33
36
|
// Who reads the frame: a person on a screen, or a chat a session answers a progress question in, which
|
|
@@ -56,6 +59,8 @@ interface BoardHeader {
|
|
|
56
59
|
// terminal and they draw their still icons.
|
|
57
60
|
spinner: string | undefined
|
|
58
61
|
form: BoardForm
|
|
62
|
+
// The repository the plan was read for, as `owner/repo`; `undefined` before a plan is read.
|
|
63
|
+
repo: string | undefined
|
|
59
64
|
// An issue reference as the board draws it — `3450` or `owner/repo#12` — made a link where the
|
|
60
65
|
// terminal opens one.
|
|
61
66
|
link: (reference: string) => string
|
|
@@ -103,9 +108,8 @@ function quiet_mark(layout: BoardLayout | undefined): RunMark {
|
|
|
103
108
|
// The run's state, each state overriding the ones below it.
|
|
104
109
|
function mark_of(header: BoardHeader, running: number): RunMark {
|
|
105
110
|
if (header.activity.is_stopped) return 'stopped'
|
|
106
|
-
if (running > 0) return 'running'
|
|
107
111
|
|
|
108
|
-
return
|
|
112
|
+
return running > 0 ? 'running' : quiet_mark(header.layout)
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
// A running run turns the spinner in place of ▶, both one column wide; every other mark stands still.
|
|
@@ -116,25 +120,61 @@ function run_part(mark: RunMark, spinner: string | undefined): string {
|
|
|
116
120
|
return styleText(color, `${shown} ${RUN_NAME}`)
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
// The repository's own name — `kit` of `owner/kit` — under the run's, past the mark's columns, so a
|
|
124
|
+
// board open beside another repository's is told apart. Blank before a plan names one.
|
|
125
|
+
function repo_part(header: BoardHeader, mark: RunMark): string {
|
|
126
|
+
const name = header.repo?.split(REPO_SEPARATOR).at(-1) ?? ''
|
|
127
|
+
const indent = run_board_fit.width_of(RUN_MARKS[mark].glyph) + BLANK.length
|
|
128
|
+
|
|
129
|
+
return `${BLANK.repeat(indent)}${name}`
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
function aged(part: string, age_ms: number): string {
|
|
120
133
|
if (age_ms > ALARM_MS) return styleText('red', part)
|
|
121
134
|
|
|
122
135
|
return age_ms > STALE_MS ? styleText('yellow', part) : part
|
|
123
136
|
}
|
|
124
137
|
|
|
125
|
-
// How long ago the stream last moved
|
|
126
|
-
//
|
|
127
|
-
function
|
|
138
|
+
// How long ago the stream last moved. An ended run has no heartbeat to keep: one counting on would
|
|
139
|
+
// read as a hang.
|
|
140
|
+
function age_of(header: BoardHeader): number | undefined {
|
|
128
141
|
const last = header.activity.last_event_ms
|
|
129
142
|
|
|
130
143
|
if (last === undefined || header.ended_ms !== undefined) return undefined
|
|
131
144
|
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
return header.now_ms - last
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The minute an ended run ended, standing where its heartbeat did. Blank while the run has none to draw.
|
|
149
|
+
function ended_part(header: BoardHeader): string {
|
|
150
|
+
const { ended_ms } = header
|
|
151
|
+
|
|
152
|
+
return ended_ms === undefined ? '' : `${HEADER_ICONS.ended} ${minute_of(ended_ms)}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The `💓` under the run's age, in the `⏰`'s own format with its digits right-aligned to `width` —
|
|
156
|
+
// only a running run can be stale, so only it is colored. Only an ended run draws anything else there:
|
|
157
|
+
// the minute it ended.
|
|
158
|
+
function beat_part(header: BoardHeader, mark: RunMark, width: number): string {
|
|
159
|
+
const age = age_of(header)
|
|
160
|
+
|
|
161
|
+
if (age === undefined) return ended_part(header)
|
|
162
|
+
|
|
163
|
+
const part = `${HEADER_ICONS.heartbeat} ${span_of(age).padStart(width)}`
|
|
134
164
|
|
|
135
165
|
return mark === 'running' ? aged(part, age) : part
|
|
136
166
|
}
|
|
137
167
|
|
|
168
|
+
// A run's age over the `💓`, or over the minute an ended run ended: the shorter of the two lengths is
|
|
169
|
+
// right-aligned to the longer, so the digits stand in one column when only one has passed ten hours.
|
|
170
|
+
function time_parts(header: BoardHeader, mark: RunMark): Array<string> {
|
|
171
|
+
const elapsed = span_of((header.ended_ms ?? header.now_ms) - header.started_ms)
|
|
172
|
+
const age = age_of(header)
|
|
173
|
+
const width = Math.max(elapsed.length, age === undefined ? 0 : span_of(age).length)
|
|
174
|
+
|
|
175
|
+
return [`${HEADER_ICONS.elapsed} ${elapsed.padStart(width)}`, beat_part(header, mark, width)]
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
// A plan fetch is said only when it failed — the board otherwise keeps quiet about a routine read.
|
|
139
179
|
function plan_warning(header: BoardHeader): string | undefined {
|
|
140
180
|
const failed = header.plan_failed_ms
|
|
@@ -152,23 +192,6 @@ function loading_part(header: BoardHeader): string | undefined {
|
|
|
152
192
|
return is_plan_loading && spinner !== undefined ? `${HEADER_ICONS.loading} ${spinner}` : undefined
|
|
153
193
|
}
|
|
154
194
|
|
|
155
|
-
// A running run's age and time left before the cut-off, or an ended run's frozen duration and the
|
|
156
|
-
// minute it ended — an ended run has no cut-off left.
|
|
157
|
-
function time_parts(header: BoardHeader): Array<string> {
|
|
158
|
-
const { now_ms, started_ms, ended_ms } = header
|
|
159
|
-
|
|
160
|
-
if (ended_ms !== undefined) {
|
|
161
|
-
return [
|
|
162
|
-
`⏱ ${elapsed_of(ended_ms - started_ms)}`,
|
|
163
|
-
`${HEADER_ICONS.ended} ${minute_of(ended_ms)}`,
|
|
164
|
-
]
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const cutoff = started_ms + backlog_budget.WHOLE_RUN_BUDGET_MS
|
|
168
|
-
|
|
169
|
-
return [`⏱ ${elapsed_of(now_ms - started_ms)}`, `⌛ ${left_of(cutoff - now_ms)}`]
|
|
170
|
-
}
|
|
171
|
-
|
|
172
195
|
function layout_of(header: BoardHeader): BoardLayout {
|
|
173
196
|
return header.layout ?? run_board_layout.EMPTY_LAYOUT
|
|
174
197
|
}
|
|
@@ -186,14 +209,25 @@ function plus_of(counts: BoardCounts, header: BoardHeader): string {
|
|
|
186
209
|
return added > 0 ? ` (+${String(added)})` : ''
|
|
187
210
|
}
|
|
188
211
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
212
|
+
// The plan's total as the tally draws it, unknown until the plan is read.
|
|
213
|
+
function total_of(counts: BoardCounts, header: BoardHeader): string {
|
|
214
|
+
return is_plan_read(header) ? String(counts.total) : UNKNOWN
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The width the tally and the machine's first figure are both right-aligned to — the wider of a full
|
|
218
|
+
// tally and a full percent — so the progress bar and the gauge under it start in one column, and
|
|
219
|
+
// neither moves as a count or a percent gains a digit.
|
|
220
|
+
function figure_width(total: string): number {
|
|
221
|
+
return Math.max(PERCENT_WIDTH, `${total}/${total}`.length)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Icon, figure, then bar, as the machine gauges. Arrivals since the board first looked follow the
|
|
225
|
+
// bar, where they cannot move it.
|
|
226
|
+
function tally_part(counts: BoardCounts, header: BoardHeader): string {
|
|
193
227
|
const { settled } = counts
|
|
194
|
-
const total =
|
|
195
|
-
const tally = `${String(settled)}/${total}`.padStart(
|
|
196
|
-
const bar =
|
|
228
|
+
const total = total_of(counts, header)
|
|
229
|
+
const tally = `${String(settled)}/${total}`.padStart(figure_width(total))
|
|
230
|
+
const bar = is_plan_read(header) ? bar_of(settled, counts.total) : bar_of(0, 0)
|
|
197
231
|
|
|
198
232
|
return `${STATE_ICONS.merged} ${tally} ${bar}`
|
|
199
233
|
}
|
|
@@ -202,47 +236,20 @@ function progress_parts(counts: BoardCounts, header: BoardHeader): Array<string>
|
|
|
202
236
|
const is_read = is_plan_read(header)
|
|
203
237
|
|
|
204
238
|
return [
|
|
205
|
-
`${tally_part(counts,
|
|
239
|
+
`${tally_part(counts, header)}${is_read ? plus_of(counts, header) : ''}`,
|
|
206
240
|
`${STATE_ICONS.running} ${String(counts.running)}`,
|
|
207
241
|
`${STATE_ICONS.waiting} ${is_read ? String(counts.remaining) : UNKNOWN}`,
|
|
208
242
|
`${STATE_ICONS.parked} ${String(counts.parked)}`,
|
|
209
243
|
]
|
|
210
244
|
}
|
|
211
245
|
|
|
212
|
-
// The run's
|
|
213
|
-
function
|
|
214
|
-
const
|
|
215
|
-
const parts = [
|
|
216
|
-
run_part(mark, header.spinner),
|
|
217
|
-
...progress_parts(counts, header),
|
|
218
|
-
heartbeat_part(header, mark),
|
|
219
|
-
loading_part(header),
|
|
220
|
-
plan_warning(header),
|
|
221
|
-
]
|
|
246
|
+
// The run's progress, then what its plan read is doing.
|
|
247
|
+
function progress_part(counts: BoardCounts, header: BoardHeader): string {
|
|
248
|
+
const parts = [...progress_parts(counts, header), loading_part(header), plan_warning(header)]
|
|
222
249
|
|
|
223
250
|
return parts.filter((part) => part !== undefined).join(GAP)
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
function idle_line(idle: IdleWindow, now_ms: number): string {
|
|
227
|
-
const ending = idle.bound === 'idle' ? WORDS.idle_end_idle : WORDS.idle_end_run
|
|
228
|
-
const left = `${elapsed_of(idle.until_ms - now_ms)} ${WORDS.idle_left}`
|
|
229
|
-
const until = `${WORDS.idle_until} ${clock_of(idle.until_ms)} (${left}) → ${ending}`
|
|
230
|
-
const next_check = `${WORDS.next_check} ${clock_of(backlog_idle.next_check_ms(idle))}`
|
|
231
|
-
|
|
232
|
-
return `${INDENT}${RUN_MARKS.idle.glyph} ${until} · ${next_check}`
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// The wait, under the header's blank line, drawn only while it is what the run is doing: no child
|
|
236
|
-
// running and the run not ended. The children running are the run's own, read
|
|
237
|
-
// locally, so a board still waiting on GitHub never draws a running run as waiting.
|
|
238
|
-
function idle_lines(header: BoardHeader): Array<string> {
|
|
239
|
-
const { idle, is_stopped } = header.activity
|
|
240
|
-
|
|
241
|
-
if (is_stopped || idle === undefined || counts_of(layout_of(header)).running > 0) return []
|
|
242
|
-
|
|
243
|
-
return ['', idle_line(idle, header.now_ms)]
|
|
244
|
-
}
|
|
245
|
-
|
|
246
253
|
// The gauges the machine line draws: every one on a screen, the memory alone in a chat.
|
|
247
254
|
function machine_of(header: BoardHeader): MachineGauges | undefined {
|
|
248
255
|
const { machine } = header
|
|
@@ -257,19 +264,35 @@ const MACHINE_UNKNOWN = [HEADER_ICONS.cpu, HEADER_ICONS.memory, HEADER_ICONS.swa
|
|
|
257
264
|
.map((icon) => `${icon} ${UNKNOWN}`)
|
|
258
265
|
.join(GAP)
|
|
259
266
|
|
|
260
|
-
// The
|
|
261
|
-
function
|
|
262
|
-
const
|
|
267
|
+
// The machine the run is on, its first figure as wide as the tally over it.
|
|
268
|
+
function machine_part(counts: BoardCounts, header: BoardHeader): string {
|
|
269
|
+
const width = figure_width(total_of(counts, header))
|
|
263
270
|
|
|
264
|
-
return
|
|
271
|
+
return run_board_machine.line_of(machine_of(header), width) ?? MACHINE_UNKNOWN
|
|
265
272
|
}
|
|
266
273
|
|
|
267
|
-
//
|
|
274
|
+
// Both lines' cells of one column, the narrower padded to the wider as the terminal counts them.
|
|
275
|
+
function column_of(cells: ReadonlyArray<string>): Array<string> {
|
|
276
|
+
const width = Math.max(...cells.map((cell) => run_board_fit.width_of(cell)))
|
|
277
|
+
|
|
278
|
+
return cells.map((cell) => cell_of(cell, width))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The run's name, age and progress, then its repository, heartbeat and machine under them, in every
|
|
282
|
+
// state the board can be in.
|
|
268
283
|
function header_lines(header: BoardHeader): Array<string> {
|
|
269
|
-
|
|
284
|
+
const counts = counts_of(layout_of(header))
|
|
285
|
+
const mark = mark_of(header, counts.running)
|
|
286
|
+
const columns = [
|
|
287
|
+
column_of([run_part(mark, header.spinner), repo_part(header, mark)]),
|
|
288
|
+
column_of(time_parts(header, mark)),
|
|
289
|
+
[progress_part(counts, header), machine_part(counts, header)],
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
return [0, 1].map((line) => columns.map((column) => column[line]).join(GAP))
|
|
270
293
|
}
|
|
271
294
|
|
|
272
|
-
const run_board_header = { counts_of, header_lines,
|
|
295
|
+
const run_board_header = { counts_of, header_lines, is_plan_read }
|
|
273
296
|
|
|
274
297
|
export { run_board_header }
|
|
275
298
|
export type { BoardCounts, BoardForm, BoardHeader }
|
|
@@ -6,16 +6,16 @@ import type { BoardNote } from './run-board-notes'
|
|
|
6
6
|
import type { Phase } from './run-board-phase'
|
|
7
7
|
import type { ItemState } from './run-board-status'
|
|
8
8
|
|
|
9
|
-
// The words and the clock `run:board` draws with. A moment on the board is a local `HH:MM
|
|
10
|
-
// how long
|
|
11
|
-
//
|
|
9
|
+
// The words and the clock `run:board` draws with. A moment on the board is a local clock, `HH:MM`
|
|
10
|
+
// where a person reads it to the minute; how long a row or the heartbeat has run is a short `MM:SS`,
|
|
11
|
+
// and how long the run has run or its wait has left is `H:MM:SS` — two separators are a length and one
|
|
12
|
+
// is a time of day, so the header's time column never reads a length as a moment.
|
|
12
13
|
|
|
13
14
|
const CLOCK_WIDTH = 2
|
|
14
15
|
const CLOCK_PAD = '0'
|
|
15
16
|
const MS_PER_SECOND = 1000
|
|
16
17
|
const SECONDS_PER_MINUTE = 60
|
|
17
18
|
const MINUTES_PER_HOUR = 60
|
|
18
|
-
const MS_PER_MINUTE = MS_PER_SECOND * SECONDS_PER_MINUTE
|
|
19
19
|
// Every gauge is this wide — the plan, the machine and a row's phase.
|
|
20
20
|
const BAR_WIDTH = 10
|
|
21
21
|
// A centered square done and a thin line left: the shapes alone tell the two apart where no color is
|
|
@@ -58,11 +58,6 @@ const WORDS = {
|
|
|
58
58
|
followup: 'followup',
|
|
59
59
|
report: 'report',
|
|
60
60
|
failed: 'failed',
|
|
61
|
-
idle_until: 'wait ends',
|
|
62
|
-
idle_left: 'left',
|
|
63
|
-
idle_end_idle: 'ends and reports if nothing new',
|
|
64
|
-
idle_end_run: 'ends at the whole-run cut-off and reports',
|
|
65
|
-
next_check: 'next check',
|
|
66
61
|
notes: 'findings and decisions',
|
|
67
62
|
more: 'more',
|
|
68
63
|
filed: 'filed',
|
|
@@ -168,13 +163,16 @@ const KIND_WORDS: Readonly<Record<FiledKind, keyof Words>> = {
|
|
|
168
163
|
}
|
|
169
164
|
|
|
170
165
|
// The header's gauges and marks. ⚡ rather than 🔥: a fire beside a gauge drawn green reads as an
|
|
171
|
-
// alarm.
|
|
166
|
+
// alarm. ⏰ rather than ⏱, a text symbol some terminals draw one column wide: under it the 💓's digits
|
|
167
|
+
// would stand a column off.
|
|
172
168
|
const HEADER_ICONS = {
|
|
173
169
|
cpu: '⚡',
|
|
174
170
|
memory: '🧠',
|
|
175
171
|
swap: '💾',
|
|
176
172
|
ended: '🔚',
|
|
177
173
|
loading: '⏳',
|
|
174
|
+
elapsed: '⏰',
|
|
175
|
+
heartbeat: '💓',
|
|
178
176
|
} as const
|
|
179
177
|
|
|
180
178
|
// **The machine gauges' three colors, decided here alone**: Activity Monitor's green, yellow and red
|
|
@@ -207,8 +205,8 @@ function minute_of(ms: number): string {
|
|
|
207
205
|
return [date.getHours(), date.getMinutes()].map((value) => two_digits(value)).join(':')
|
|
208
206
|
}
|
|
209
207
|
|
|
210
|
-
// How long
|
|
211
|
-
//
|
|
208
|
+
// How long a row's child has run, as `MM:SS` whose minutes never carry into hours (`61:05`), so every
|
|
209
|
+
// row reads one short column; a negative span reads as zero.
|
|
212
210
|
function elapsed_of(ms: number): string {
|
|
213
211
|
const seconds = Math.floor(Math.max(0, ms) / MS_PER_SECOND)
|
|
214
212
|
const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
|
|
@@ -216,12 +214,15 @@ function elapsed_of(ms: number): string {
|
|
|
216
214
|
return `${two_digits(minutes)}:${two_digits(seconds % SECONDS_PER_MINUTE)}`
|
|
217
215
|
}
|
|
218
216
|
|
|
219
|
-
// How long
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
// How long the run has run or its stream has been silent, as `H:MM:SS` with the hours not padded; a negative
|
|
218
|
+
// span reads as zero. Always three fields, so a run under an hour (`0:03:25`) never reads as a time.
|
|
219
|
+
function span_of(ms: number): string {
|
|
220
|
+
const seconds = Math.floor(Math.max(0, ms) / MS_PER_SECOND)
|
|
221
|
+
const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
|
|
222
222
|
const hours = Math.floor(minutes / MINUTES_PER_HOUR)
|
|
223
|
+
const tail = [minutes % MINUTES_PER_HOUR, seconds % SECONDS_PER_MINUTE]
|
|
223
224
|
|
|
224
|
-
return
|
|
225
|
+
return [String(hours), ...tail.map((value) => two_digits(value))].join(':')
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
function is_shade(color: Paint): color is Shade {
|
|
@@ -289,9 +290,9 @@ const run_board_labels = {
|
|
|
289
290
|
bar_of,
|
|
290
291
|
clock_of,
|
|
291
292
|
elapsed_of,
|
|
292
|
-
left_of,
|
|
293
293
|
minute_of,
|
|
294
294
|
painted,
|
|
295
|
+
span_of,
|
|
295
296
|
spinner_of,
|
|
296
297
|
}
|
|
297
298
|
|
|
@@ -157,28 +157,40 @@ function alert_of(spec: GaugeSpec, value: number, pressure: number | undefined):
|
|
|
157
157
|
return verdict ?? threshold_alert(value, spec)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// One gauge's reading: its value, the width its figure is right-aligned to where that is wider than
|
|
161
|
+
// the figure's own, and the kernel's pressure verdict where the gauge takes one.
|
|
162
|
+
interface Reading {
|
|
163
|
+
value: number | undefined
|
|
164
|
+
width: number
|
|
165
|
+
pressure?: number | undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
160
168
|
// Icon, figure, then bar: the figure is what a person reads, and its fixed
|
|
161
169
|
// width keeps every bar starting in one column. The bar stops full past `spec.full`; the figure still
|
|
162
170
|
// says how far past, and takes the warning colors only, so a quiet machine's figures stay the terminal's
|
|
163
171
|
// own.
|
|
164
|
-
function gauge(spec: GaugeSpec,
|
|
172
|
+
function gauge(spec: GaugeSpec, reading: Reading): string | undefined {
|
|
173
|
+
const { value, width } = reading
|
|
174
|
+
|
|
165
175
|
if (value === undefined) return undefined
|
|
166
176
|
|
|
167
|
-
const alert = alert_of(spec, value, pressure)
|
|
177
|
+
const alert = alert_of(spec, value, reading.pressure)
|
|
168
178
|
const warning = alert === 'normal' ? undefined : GAUGE_SHADES[alert]
|
|
169
179
|
const bar = bar_of(value, spec.full, undefined, warning ?? spec.normal)
|
|
170
180
|
|
|
171
|
-
return `${spec.icon} ${painted(warning, spec.format(value))} ${bar}`
|
|
181
|
+
return `${spec.icon} ${painted(warning, spec.format(value).padStart(width))} ${bar}`
|
|
172
182
|
}
|
|
173
183
|
|
|
174
|
-
// `undefined` when not one gauge could be read, so the header draws no line at all.
|
|
175
|
-
|
|
184
|
+
// `undefined` when not one gauge could be read, so the header draws no line at all. `width` is the
|
|
185
|
+
// header's figure column: a figure narrower than it is right-aligned to it, so the first gauge's bar
|
|
186
|
+
// starts under the progress bar whichever gauge that is.
|
|
187
|
+
function line_of(gauges: MachineGauges | undefined, width = 0): string | undefined {
|
|
176
188
|
if (gauges === undefined) return undefined
|
|
177
189
|
|
|
178
190
|
const parts = [
|
|
179
|
-
gauge(SPECS.cpu, gauges.cpu_percent),
|
|
180
|
-
gauge(SPECS.memory, gauges.memory_percent, gauges.memory_pressure),
|
|
181
|
-
gauge(SPECS.swap, gauges.swap_mb_per_s),
|
|
191
|
+
gauge(SPECS.cpu, { value: gauges.cpu_percent, width }),
|
|
192
|
+
gauge(SPECS.memory, { value: gauges.memory_percent, width, pressure: gauges.memory_pressure }),
|
|
193
|
+
gauge(SPECS.swap, { value: gauges.swap_mb_per_s, width }),
|
|
182
194
|
].filter((part) => part !== undefined)
|
|
183
195
|
|
|
184
196
|
return parts.length === 0 ? undefined : parts.join(GAP)
|
|
@@ -188,6 +200,7 @@ const run_board_machine = {
|
|
|
188
200
|
CPU_YELLOW,
|
|
189
201
|
GAP,
|
|
190
202
|
MEMORY_YELLOW,
|
|
203
|
+
PERCENT_WIDTH,
|
|
191
204
|
gauges_of,
|
|
192
205
|
line_of,
|
|
193
206
|
percent_text,
|
|
@@ -373,11 +373,7 @@ function bounded(parts: FrameParts, bounds: FrameBounds): Array<string> {
|
|
|
373
373
|
|
|
374
374
|
function render(view: BoardView): Array<string> {
|
|
375
375
|
const { header, notes } = view
|
|
376
|
-
const above = [
|
|
377
|
-
...run_board_header.header_lines(header),
|
|
378
|
-
...resume_lines(view.resume),
|
|
379
|
-
...run_board_header.idle_lines(header),
|
|
380
|
-
]
|
|
376
|
+
const above = [...run_board_header.header_lines(header), ...resume_lines(view.resume)]
|
|
381
377
|
const parts = {
|
|
382
378
|
above,
|
|
383
379
|
plan: plan_sections(header, view.size),
|
|
@@ -28,7 +28,6 @@ function ended_span(ended: EndedRun | undefined): BoardSpan | undefined {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// The running run wins over the ended one, so a run that begins replaces the finished board at once.
|
|
31
|
-
// An expired carry is still the running run's budget spent, not a run that ended, so it draws nothing.
|
|
32
31
|
function span_of(read: CarryRead, ended: EndedRun | undefined): BoardSpan | undefined {
|
|
33
32
|
if (read.kind !== 'carried') return read.kind === 'none' ? ended_span(ended) : undefined
|
|
34
33
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { backlog_idle, type IdleWindow } from '#scripts/backlog/backlog-idle'
|
|
2
1
|
import {
|
|
3
2
|
has_label_name,
|
|
4
3
|
IN_PROGRESS_LABEL,
|
|
@@ -30,8 +29,6 @@ interface ItemStatus {
|
|
|
30
29
|
// The run as a whole, read off the same stream.
|
|
31
30
|
interface RunActivity {
|
|
32
31
|
last_event_ms: number | undefined
|
|
33
|
-
// The idle watch's window, when the newest one opened after the newest launch.
|
|
34
|
-
idle: IdleWindow | undefined
|
|
35
32
|
// The run's last position is a `stop` that names no child — the run itself ended.
|
|
36
33
|
is_stopped: boolean
|
|
37
34
|
}
|
|
@@ -243,19 +240,6 @@ function settle_closed(
|
|
|
243
240
|
return new Map([...statuses].map(([issue, status]) => [issue, settled(issue, status, read)]))
|
|
244
241
|
}
|
|
245
242
|
|
|
246
|
-
function newest_of(events: ReadonlyArray<RunEvent>, kind: string): RunEvent | undefined {
|
|
247
|
-
return events.findLast((event) => event.kind === kind)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function idle_of(events: ReadonlyArray<RunEvent>): IdleWindow | undefined {
|
|
251
|
-
const idle = newest_of(events, KIND.IDLE)
|
|
252
|
-
const launch = newest_of(events, KIND.CHILD_LAUNCH)
|
|
253
|
-
|
|
254
|
-
if (idle === undefined || (launch !== undefined && launch.pos > idle.pos)) return undefined
|
|
255
|
-
|
|
256
|
-
return backlog_idle.parse(idle.text)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
243
|
function is_run_stop(event: RunEvent | undefined): boolean {
|
|
260
244
|
return event?.kind === KIND.STOP && run_event_scope.issue_named(event) === undefined
|
|
261
245
|
}
|
|
@@ -268,7 +252,6 @@ function activity_of(events: ReadonlyArray<RunEvent>, is_ended = false): RunActi
|
|
|
268
252
|
|
|
269
253
|
return {
|
|
270
254
|
last_event_ms: last === undefined ? undefined : moment(last),
|
|
271
|
-
idle: idle_of(events),
|
|
272
255
|
is_stopped: is_ended || is_run_stop(position),
|
|
273
256
|
}
|
|
274
257
|
}
|
|
@@ -135,6 +135,7 @@ function header_of(
|
|
|
135
135
|
machine: state.gauges,
|
|
136
136
|
usages: state.usages,
|
|
137
137
|
form: redraw.ports.form,
|
|
138
|
+
repo: state.plan?.context.repo,
|
|
138
139
|
spinner: redraw.ports.is_tty ? SPOT : undefined,
|
|
139
140
|
link: run_board_link.linker(state.plan?.context.repo, redraw.ports.link),
|
|
140
141
|
}
|