SLOPSHOPPER

voortgang

A VNX mod: shows the progress of the VNX project the session is in. A pane and a status line with the builders at work, what waits on a decision, and per track…

newpanecommandstatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · voortgang
│ ┃ Voortgang ✕ › fix the failing auth test and add an audit log call │ ┃ Voortgang [ ververs ] │ ┃ geen VNX-project in deze map (geen ⏺ Read(src/auth.ts) │ ┃ .vnx-project-id bove… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /voortgang │ ⎿ voortgang: Voortgang: geen VNX-project in deze map (geen .vnx-pr │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Voortgang
Voortgang [ ververs ] geen VNX-project in deze map (geen .vnx-project-id bove…
README

voortgang

A VNX mod for Claude Code. It shows the progress of the one VNX project the session is in: the builders at work, what waits on a decision, the tracks with how many of their deliverables are done, what is being worked on and what comes next, and the open PRs with the review seats still owed. It needs VNX: the project stores under ~/.vnx-data and the vnx CLI. It ships with a VNX release and lives in this repository under packages/vnx-mods/.

It reads metadata only: ids, counts, statuses, PR numbers, the titles of deliverables, and per builder one title line. It never reads a transcript, a report, a receipt or the body of an open item, so it is safe to load in a session that holds client work.

The labels in the pane are Dutch (ververs, onbeschikbaar, geen store, verouderd).

What it shows

One project per session: the one whose .vnx-project-id lies above the session's working directory. A fleet view over all projects is not part of this mod. It is a separate, aggregated product that comes later.

Which project

The mod walks up from the session's working directory to the first directory that holds .vnx-project-id. The first line of that file, with all whitespace removed, is the project id; it must match ^[a-z][a-z0-9-]{1,31}$ (the rule bin/vnx follows), else the mod says geen geldig project-id in <path>, and the store is <data_root>/<id>. The nearest file decides. It never falls back to another project.

Without such a file the pane says:

geen VNX-project in deze map (geen .vnx-project-id boven /pad/naar/map)

and the status line is empty.

The pane

/voortgang refreshes and opens the pane Voortgang. The pane never opens by itself. The unit of progress is the deliverable: a track row counts deliverables, and names the ones in progress and the ones that come next. PRs are not a unit of a track; an open PR is shown where the work is. The sections of the now view come in this order:

project-a · index 2 min
Nu bezig
  opus · 6 min · <first # line of the instruction> · <track> · PR #2150
    PR #2150 · ci loopt · stoel open: codex_gate
  <title of a deliverable>
    PR #2151 · klaar voor merge
Wacht op een besluit
  1 te promoveren (now) · 2 open uitkomsten
  backlog: 12 voorgesteld op next/later
Tracks
  P1 track-a  ▓▓▓░░░░░  3/12
     bezig:  K2a-3b
     volgt:  K2a-3c · K2b · K3  (+6)
  P1 track-b  ▓▓▓▓▓▓▓▓  8/8
  P2 track-c  geen leveringen gepland
opruimen: 10 stale rijen
  • Header: the project id and the age of the index. verouderd stands once, on the header, when the index or the state is older than 60 minutes or has no timestamp. In the plan view · plan follows.
  • Nu bezig: every builder of live_work (the live and the starting entries), one row each: model of its dispatch-spec.json, its minutes (age_seconds), the first line of its instruction.md that starts with # , and the track_id and pr_id of the spec when it sets them. A spec without model shows model ?; its track and PR still link (the spec's model is optional). A long title gives way to the track and the PR behind it. Nu bezig: niets when there is none and no PR is open.
  • The open PRs sit here, and nowhere else. A PR is on the builder whose spec names its number as pr_id, or whose dispatch opened it (live_work.open_prs). A PR no builder owns keeps a row of its own only when its dispatch id is a deliverable that bezig: names on any track the pane shows, whatever its horizon, under the title of that deliverable. Every other open PR is folded into one row at the end of the section: N open PR's zonder bouwer · M klaar voor merge · K wacht op stoel · J ci rood, each count left out when it is 0 (klaar voor merge in ok, wacht op stoel in warn, ci rood in error, the rest dim). The plan view does not show it. A PR row says PR #N · ci loopt|rood|groen · stoel open: <seats> (the seats of blocked_on), or PR #N · klaar voor merge when nothing is owed and CI is green. A PR the index lists and the readable PR queue misses counts in N only; with an unreadable PR queue the folded row ends in onbeschikbaar: <reason>, and when no PR is known at all (the normal shape when gh fails) Nu bezig carries one row onbeschikbaar: <reason> of its own, never both. A builder list that cannot be read is one onbeschikbaar: <reason> row; the PRs of a readable queue are still drawn after it, all folded because no builder is known.
  • Wacht op een besluit: N te promoveren (now) counts the proposed deliverables on tracks with horizon now; with open uitkomsten after it. Below it one dim row backlog: N voorgesteld op next/later, only when N is above 0. A proposed deliverable on a track whose phase is done (an orphan), on a track without horizon, or on a track the track list does not know, is not counted and not shown. When the tracks or the deliverables cannot be read the row says te promoveren onbeschikbaar: <reason>: without the tracks a deliverable cannot be placed on a horizon.
  • Tracks: every track on the now horizon, plus every track with activity: a builder whose spec names it, an open PR that its pr_ref lists or that the dispatch spec of the PR names, or a deliverable of it with last_activity in the last 7 days. They are ordered by what is going on, in four tiers: a builder whose spec names the track (the track with the most builders first), then an open PR linked to the track, then a deliverable with last_activity in the last 7 days, then no activity (a now track shown only for its horizon). Within a tier by priority (P1, P2, P3, other), then by id.
  • The first row of a track with deliverables is its priority, its id, a bar of 8 cells (▓ for done, ░ for the rest, rounded down) and done/total: the total counts every deliverable of the track, done those whose derived_status is done.
  • bezig: names the deliverables that are ready or in_progress, or that a running builder carries (the builder's dispatch id is the deliverable's id, the part of its deliverable_ref after the colon), and the builders on the track whose dispatch is no deliverable of it (by the builder's title line). A deliverable is named by its title up to the first : or the first 40 cells, whichever is shorter.
  • volgt: names the proposed deliverables in the order vnx deliverable list gives them, as many as fit the width, then (+N) for the rest. The count is never cut: the first title is shortened (with …) to make room for it, and where not even one title fits only (+N) stays. The row is left out when there are none, and so is bezig:.
  • A track without deliverables shows geen leveringen gepland, dim, on its first row. That is a signal: the progress of that track is not measurable. When the deliverables themselves could not be read the second row says leveringen onbeschikbaar: <reason>.
  • When vnx deliverable list fails, a track whose only activity was a recent deliverable cannot be recognised. The section then starts with one row leveringen onleesbaar: <reason>, tracks met alleen recente leveringen ontbreken.
  • opruimen: one dim row at the bottom with the stale and unmeasured counts of live_work, only when one of them is above 0. They appear nowhere else.

Colour. A row is a list of segments and each segment has a tone. ok (the theme's success colour) is the filled part of a bar, a track whose deliverables are all done, ci groen and klaar voor merge. warn is what waits on the operator: the te promoveren (now) count, a proposed deliverable in volgt:, a seat still owed. error is ci rood and every onbeschikbaar. dim is the empty part of a bar, the backlog row, (+N), the count of tracks without horizon. Every row reads correctly without colour; the text carries the meaning and the tone adds to it. Width is measured on the text.

The plan view

/voortgang plan opens the same pane as a plan: the tracks with horizon next, then those with later (each under its own word), each with its bar and its volgt: row, ordered by priority and id. A track whose phase is done is left out. The tracks without a horizon are one dim count, N tracks zonder horizon. /voortgang without an argument opens the now view again; any other argument is answered with the two forms and opens nothing.

Room. The whole pane is for this one project. Sections take rows in the order above. A section that does not fit whole shows the items that fit plus one row +N niet getoond, and never a +N row without at least one item above it. A section of which not even one item fits is not drawn. Every row is cut to the pane's width in display cells: a wide character takes two cells and a combining mark none; ▓ and ░ take one.

The pane draws with Box, Text and Button only, the elements every surface has, so it draws the same in the terminal, the desktop app, VS Code and the mobile app through Remote Control. The ververs button (hotkey r) refreshes.

The status line

After every refresh the mod pins one line under the prompt. It answers the question of the pane at a glance: how far is this project, and does something run.

project-a · track-a ▓▓▓▓▓░░░ 5/8 · track-b ▓▓░░░░░░ 2/8 · 1 bouwer
project-a · track-a: geen plan · track-b ▓▓▓▓▓▓░░ 6/8 · 2 bouwers
project-a · geen tracks in beweging
  • The tracks are the first two of the pane's Tracks order: builders first, then open PRs, then recent deliverables, then priority, then id. A track with deliverables shows the same 8-cell bar as the pane and X/Y (done of all). A track without deliverables shows : geen plan, one whose deliverables could not be read shows : ?.
  • A track id longer than 28 display cells is cut with ….
  • 1 bouwer or N bouwers stands only when at least one builder runs (live_work.counts.live + live_work.counts.starting; stale and unmeasured entries are not builders). It is left out at 0 and when the live work could not be read.
  • verouderd is added when the index or the state is older than 60 minutes. onleesbaar is added when a part could not be read.
  • When no track is shown the line says geen tracks in beweging. When the tracks could not be read it says tracks onbekend, never that nothing moves.
  • The PR count, te promoveren and uitkomsten are not on the line; they stay in the pane.
  • Without a project the line is empty.

Headless

Where no surface places the pane, as in claude --print "/voortgang", the command answers with the status line, then one line per track the pane would show:

project-a · track-a: geen plan · track-b ▓▓▓▓▓▓░░ 6/8 · 1 bouwer
P1 track-a · geen leveringen gepland
P1 track-b · 6 van 8 klaar

Absence is loud

A read that failed is never shown as zero or as an empty list.

SituationWhat the pane shows
No .vnx-project-id above the working directorythe message above, an empty status line
The file holds no usable project id.vnx-project-id in <dir> bevat geen geldige project-id
The store directory is missing<id> · geen store on the header, nothing else
The id is not one directory name (letters, digits, ., _ and -, starting with a letter or digit)ongeldige project-id, and nothing is read for it
A file is missing, unreadable or not JSONthat part: onbeschikbaar: <reason>
A track or deliverable entry that vnx lists is not an objectthat part: onbeschikbaar: vnx gaf geen object op plaats <n>
The store has no runtime_coordination.dbtracks and deliverables: onbeschikbaar: runtime_coordination.db ontbreekt; vnx is not run
A refresh rejectsthe status line and the first pane row: verversen mislukt: <reason>; the last good numbers stay below it
A block says available is anything but truethat part: onbeschikbaar: <the block's own reason>, else available: <value>
vnx exits non-zero, times out, cannot start or prints no JSONtracks and deliverables: onbeschikbaar: vnx exit 1 (or vnx time-out, vnx startfout: ..., vnx gaf ongeldige JSON)
A field of a builder cannot be readonbeschikbaar: <reason> for that field of that builder, and onleesbaar in the status line

The age always shows (index 3 min). A stale index can say there are no builders while one runs, which is why it is marked.

Install

The mod is part of a VNX release: a release copies packages/ into ~/.vnx-system/versions/<version>/, and ~/.vnx-system/current points at the installed one. Install it from there on each Claude account you use:

claude plugin marketplace add ~/.vnx-system/current/packages/vnx-mods
claude plugin install voortgang@vnx-mods

For a second account, the same with its config directory in front:

CLAUDE_CONFIG_DIR=~/.claude-salesminds claude plugin marketplace add ~/.vnx-system/current/packages/vnx-mods
CLAUDE_CONFIG_DIR=~/.claude-salesminds claude plugin install voortgang@vnx-mods

The marketplace is a folder, so an update of VNX that rewrites current reaches the mod after /reload-plugins.

For development from a checkout of this repository:

claude --plugin-dir packages/vnx-mods/voortgang

It needs the vnx binary (VNX 1.7.2 or newer) and the project stores under the data root.

Options

Set them in /config, where each one is a row, or in settings under pluginConfigs.voortgang.options.

OptionDefaultWhat it does
data_root~/.vnx-dataThe directory that holds one store per project. A leading ~ is the HOME of the session.
vnx_bin~/.vnx-system/current/bin/vnxThe vnx executable that lists tracks and deliverables.
refresh_seconds120How often the status line and the pane refresh. 15 seconds at least.

The option exclude of 0.2 is gone: there is no fleet to leave projects out of. When HOME is unknown and a path starts with ~, the mod reads no store and says HOME onbekend, ~ niet op te lossen.

What it reads

The session's working directory comes from the start of the session. Then:

  1. Per directory from the working directory up to /, whether <dir>/.vnx-project-id exists; the first one found is read, and only its first line is kept.

With the project p, the store S = <data_root>/<p>/state and the data directory D = <data_root>/<p>:

  1. Whether S exists.
  2. S/t0_index.json: timestamp, live_work (available, reason, counts, live[].dispatch_id, live[].age_seconds, open_prs[].number, open_prs[].dispatch_id) and open_outcomes (available, reason, total).
  3. S/t0_state.json: generated_at and pr_queue (available, reason, and per open PR number, ci_status, gates_passed, blocked_on).
  4. Whether S/runtime_coordination.db exists. vnx creates that database when a store has none, so the two commands below are run only for a store that has it.
  5. vnx objective list --json --project-id p --state-dir S, run in S with a 10 second timeout: track_id, priority, horizon, phase, pr_ref.
  6. vnx deliverable list --json --project-id p --state-dir S, run the same way: track, derived_status, last_activity, and per deliverable its deliverable_ref and its title.
  7. Per builder, a dispatch d in live_work.live or live_work.starting whose id is one directory name: D/dispatches/pending/d/dispatch-spec.json, fields model, track_id and pr_id only.
  8. Per builder, D/dispatches/pending/d/instruction.md: the first line that starts with # , without the # , control characters turned into spaces. The engine's read returns a whole file, so the mod drops everything but that line at once and keeps none of it. It looks in the first 4096 characters only: a file whose first # line lies beyond them, or is cut by them, shows onbeschikbaar: geen titelregel in de eerste 4096 tekens. A file without such a line shows onbeschikbaar: geen titelregel.
  9. Per open PR in live_work.open_prs whose dispatch id is one directory name: the track_id of D/dispatches/pending/d/dispatch-spec.json. A dispatch that has left pending/ has no spec; the PR then belongs to the tracks whose pr_ref lists it, and nothing is shown as missing.

The age_seconds of each builder comes from its own live_work entry. stale and unmeasured entries are not builders: their spec and instruction are never read.

--state-dir is required: without it, run from a directory outside a VNX repository, objective list fails with sqlite3.OperationalError: unable to open database file.

What it never does

  • It lists no directory. It reads no other file than the ones above: no other project's store, no transcript, report, receipt, event stream, tool name, open-item body, outcome reason or track title, and no other line of an instruction.md than its first # line.
  • It runs no other command: no gh, no git, no shell, never build_t0_state.py or anything else that writes state.
  • It writes nothing: no file and no $.store. For a store without runtime_coordination.db it does not even run vnx, which would create that database. It keeps its model in the session's $.state and pins its status line, nothing more.
  • It does not open the pane unasked.
  • It never shows another project than the one of the session.

Limits

  • A pane with fewer rows than the sections need draws the sections that fit, in order. With very few rows a section that does not fit one item is left out without a marker.
  • The numbers are as fresh as the last refresh and as the index itself. The mod never rebuilds an index. The PR queue is as good as the fabric's gh call: when that fails, the pane shows the reason.
  • An open PR shows in Nu bezig only. Activity of an open PR is read from pr_ref and from the spec of the PR's dispatch while it is in pending/. A PR whose dispatch has left pending/ and that no pr_ref lists links to no track.
  • The tests run on Claude Code with claude plugin test. The pane was also rendered through the test kit against a real store; it was not checked on a live screen.
  • The mod is not a stand-alone tool: it reads VNX data only, so it is not published to a public marketplace.

Development

claude plugin validate packages/vnx-mods
claude plugin validate packages/vnx-mods/voortgang
claude plugin test packages/vnx-mods/voortgang
bash packages/vnx-mods/voortgang/tests/no-operator-names.sh
  • hooks/register.tsx: the hooks module (session.start, command.run, ui.render, the timer).
  • hooks/collect.ts: the collector. Pure functions over parsed JSON, and one function that reads a project through a narrow Io that $ provides in a session and an object in memory provides in a test.
  • hooks/view.ts: the pane sections and rows, cut to the pane's size.
  • types/index.d.ts: the PluginState contract for the five $.state values (model, isRefreshing, failure, cwd, view).
  • tests/: the engine-driven pane, command, timer and boundary tests, and the collector's tests. tests/no-operator-names.sh greps the package for the operator's own project names, because the engine's test runner cannot read files from disk. Fixtures and examples use project-a.

Type-check it with the tsconfig.json from the header of the claude-code.d.ts the engine writes, kept outside this folder, with include naming that file and this folder's hooks, types and tests. Without a loaded mod, load the plugin-authoring skill (it names the types/claude-code.d.ts of the running build) and write a tsconfig.json outside this folder: strict and noUncheckedIndexedAccess on, noEmit, skipLibCheck, types: [], module: esnext, moduleResolution: bundler, lib and target es2023, jsx: react with jsxFactory: h and jsxFragmentFactory: Fragment, and include naming that file plus this folder's hooks, types and tests. Run npx --yes -p typescript@5.6.3 tsc -p tsconfig.json from the folder holding that file; exit 0 is the bar. The engine writes .claude-plugin/types/ when it hot-reloads the folder; that folder carries its own .gitignore and is never committed.

Source 5 files
hooks/register.tsx 178 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Model, Tone } from '../types'
5
6import { collectModel, commandText, configOf, messageOf, statusLine } from './collect'
7import type { Config, Io } from './collect'
8import { paneRows } from './view'
9
10const PANE = 'voortgang'
11const BUTTON_COLUMNS = 13
12
13const model = atom({ plugin: 'voortgang', key: 'model' } as const, null)
14const isRefreshing = atom({ plugin: 'voortgang', key: 'isRefreshing' } as const, false)
15const failure = atom({ plugin: 'voortgang', key: 'failure' } as const, null)
16// The working directory of the session, learned at its start: the project is the one above it.
17const cwd = atom({ plugin: 'voortgang', key: 'cwd' } as const, null)
18// The view the pane draws: what is going on now, or the plan. `/voortgang plan` sets it, `/voortgang` sets it back.
19const paneView = atom({ plugin: 'voortgang', key: 'view' } as const, 'now')
20
21// Joins a refresh asked while one runs (the timer, the button, the command). Never drawn from:
22// a reload starts it over at null, which only means the next ask starts a fresh refresh.
23let running: Promise<Model> | null = null
24
25function ioOf($: EngineInterface): Io {
26  return {
27    list: async path => (await $.fs.list(path)).map(entry => entry.name),
28    exists: path => $.fs.exists(path),
29    read: path => $.fs.read(path),
30    run: (argv, init) => $.process.run(argv, init),
31  }
32}
33
34async function collectInto($: EngineInterface, config: Config): Promise<Model> {
35  await update($, isRefreshing, () => true)
36
37  try {
38    const home = await $.env.get('HOME')
39    const now = await $.clock.now()
40    const next = await collectModel(ioOf($), config, home, await read($, cwd), now)
41    await update($, model, () => next)
42    await update($, failure, () => null)
43    $.ui.status(statusLine(next))
44
45    return next
46  } finally {
47    await update($, isRefreshing, () => false)
48    running = null
49  }
50}
51
52function refresh($: EngineInterface, config: Config): Promise<Model> {
53  if (running === null) {
54    running = collectInto($, config)
55  }
56
57  return running
58}
59
60/** A refresh that rejected is shown in the status line and in the pane; the model of the last good refresh stays. */
61async function showFailure($: EngineInterface, error: unknown): Promise<void> {
62  const reason = messageOf(error)
63
64  await update($, failure, () => reason)
65  $.ui.status(`voortgang · verversen mislukt: ${reason}`)
66}
67
68/** A refresh nobody awaits: the timer, the button and the start of a session. */
69async function refreshShown($: EngineInterface, config: Config): Promise<void> {
70  try {
71    await refresh($, config)
72  } catch (error) {
73    await showFailure($, error)
74  }
75}
76
77const toneOf = (tone: Tone) => {
78  if (tone === 'head') {
79    return { bold: true }
80  }
81
82  if (tone === 'dim') {
83    return { dimColor: true }
84  }
85
86  if (tone === 'ok') {
87    return { color: 'success' }
88  }
89
90  if (tone === 'warn') {
91    return { color: 'warning' }
92  }
93
94  if (tone === 'error') {
95    return { color: 'error' }
96  }
97
98  return {}
99}
100
101export const register: Register = (on, options) => {
102  const config = configOf(options)
103
104  on('session.start', async ($, e, next) => {
105    await update($, cwd, () => e.cwd)
106    await $.command.register({
107      name: 'voortgang',
108      description: 'Toont de voortgang van het VNX-project van deze sessie; met het argument plan het plan van next en later',
109    })
110    void refreshShown($, config)
111    $.clock.every(config.refreshSeconds * 1000, () => {
112      void refreshShown($, config)
113    })
114
115    return next(e)
116  })
117
118  on('command.run', { command: 'voortgang' }, async ($, e) => {
119    const argument = e.args.trim()
120
121    if (argument !== '' && argument !== 'plan') {
122      return { text: `Voortgang: onbekend argument '${argument}'. Gebruik /voortgang of /voortgang plan.` }
123    }
124
125    await update($, paneView, () => (argument === 'plan' ? 'plan' : 'now'))
126
127    let latest: Model
128
129    try {
130      latest = await refresh($, config)
131    } catch (error) {
132      await showFailure($, error)
133
134      return { text: `Voortgang: verversen mislukt (${messageOf(error)}), het paneel is niet geopend.` }
135    }
136
137    const opened = await $.ui.open({ id: PANE, title: 'Voortgang' })
138
139    return { text: commandText(latest, opened.isPlaced ? null : opened.reason) }
140  })
141
142  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
143    const { Box, Button, Text } = $.ui.resolve(e)
144    const rows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 24)
145    const view = paneRows(await read($, model), {
146      columns: e.props.bodyColumns,
147      headerColumns: Math.max(1, e.props.bodyColumns - BUTTON_COLUMNS),
148      rows,
149      isRefreshing: await read($, isRefreshing),
150      failure: await read($, failure),
151      view: await read($, paneView),
152    })
153
154    return (
155      <Box flexDirection="column">
156        <Box flexDirection="row" justifyContent="space-between">
157          <Text bold {...toneOf(view.headerTone)}>{view.header}</Text>
158          <Button
159            key="ververs"
160            label="ververs"
161            hotkey="r"
162            onPress={() => {
163              void refreshShown($, config)
164            }}
165          />
166        </Box>
167        {view.lines.map(line => (
168          <Text>
169            {line.segments.filter(part => part.text !== '').map(part => (
170              <Text {...toneOf(part.tone)}>{part.text}</Text>
171            ))}
172          </Text>
173        ))}
174      </Box>
175    )
176  })
177}
178
hooks/collect.ts 1052 lines
1import type { PluginOptions, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type {
4  Builder,
5  DeliverableItem,
6  DeliverableView,
7  IndexView,
8  LiveWork,
9  Model,
10  Outcomes,
11  Part,
12  Pr,
13  PrDispatch,
14  ProjectModel,
15  Running,
16  StateView,
17  TrackDeliverables,
18  TrackInfo,
19  TrackView,
20} from '../types'
21
22import { fit } from './width'
23
24/** The four things the collector needs from the engine; `$` in a session, an object in memory in a test. */
25export type Io = {
26  /** The names of the entries of a directory. */
27  list: (path: string) => Promise<string[]>
28  exists: (path: string) => Promise<boolean>
29  read: (path: string) => Promise<string>
30  run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
31}
32
33export type Config = { dataRoot: string; vnxBin: string; refreshSeconds: number }
34
35export const STALE_MINUTES = 60
36export const RUN_TIMEOUT_MS = 10_000
37export const MIN_REFRESH_SECONDS = 15
38export const INDEX_FILE = 't0_index.json'
39export const STATE_FILE = 't0_state.json'
40export const DB_FILE = 'runtime_coordination.db'
41export const SPEC_FILE = 'dispatch-spec.json'
42export const INSTRUCTION_FILE = 'instruction.md'
43export const PROJECT_ID_FILE = '.vnx-project-id'
44/** A deliverable counts as recent activity of its track when its `last_activity` is at most this old. */
45export const RECENT_DAYS = 7
46/** The cells of a progress bar. */
47export const BAR_CELLS = 8
48/** How much of `instruction.md` is searched for its first `# ` line, in characters. */
49export const INSTRUCTION_BUDGET = 4096
50
51const DEFAULT_DATA_ROOT = '~/.vnx-data'
52const DEFAULT_VNX_BIN = '~/.vnx-system/current/bin/vnx'
53const DEFAULT_REFRESH_SECONDS = 120
54const HOME_UNKNOWN = 'HOME onbekend, ~ niet op te lossen'
55const PROJECT_ID = /^[a-z][a-z0-9-]{1,31}$/
56const DISPATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
57const MESSAGE_CHARS = 80
58
59type Json = Record<string, unknown>
60
61const ok = <T>(value: T): Part<T> => ({ isOk: true, value })
62const fail = <T>(reason: string): Part<T> => ({ isOk: false, reason })
63const andThen = <A, B>(part: Part<A>, next: (value: A) => Part<B>): Part<B> => (part.isOk ? next(part.value) : fail(part.reason))
64const isRecord = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value)
65const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
66const textOr = (value: unknown, fallback: string): string => (typeof value === 'string' && value !== '' ? value : fallback)
67const stringsOf = (value: unknown): string[] | null =>
68  Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : null
69/** A dictionary whose keys come from data: no prototype, so `__proto__` or `constructor` is a key like any other. */
70const dictionaryOf = <T>(entries: Iterable<readonly [string, T]> = []): Record<string, T> => {
71  const dictionary = Object.create(null) as Record<string, T>
72
73  for (const [key, value] of entries) {
74    dictionary[key] = value
75  }
76
77  return dictionary
78}
79
80/** The reason a rejected `exists`, `read`, `list` or `run` is shown with. */
81const unavailableOf = (error: unknown): `onbeschikbaar: ${string}` => `onbeschikbaar: ${messageOf(error)}`
82
83export function messageOf(error: unknown): string {
84  const text = error instanceof Error ? error.message : String(error)
85  const line = text.split('\n')[0] ?? ''
86
87  return line.length > MESSAGE_CHARS ? `${line.slice(0, MESSAGE_CHARS - 1)}…` : line
88}
89
90export function configOf(options: PluginOptions): Config {
91  const pathOf = (value: unknown, fallback: string) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback)
92  const seconds = options.refresh_seconds
93
94  return {
95    dataRoot: pathOf(options.data_root, DEFAULT_DATA_ROOT),
96    vnxBin: pathOf(options.vnx_bin, DEFAULT_VNX_BIN),
97    refreshSeconds: isCount(seconds) ? Math.max(MIN_REFRESH_SECONDS, seconds) : DEFAULT_REFRESH_SECONDS,
98  }
99}
100
101/** The path with a leading `~` resolved from HOME; null when it needs HOME and there is none. */
102export function expandHome(path: string, home: string | undefined): string | null {
103  if (path !== '~' && !path.startsWith('~/')) {
104    return path
105  }
106
107  return home === undefined ? null : `${home.replace(/\/+$/, '')}${path.slice(1)}`
108}
109
110/** A project id names one directory under the data root: no separator, no `.` or `..`. */
111export function isProjectId(id: string): boolean {
112  return PROJECT_ID.test(id)
113}
114
115/** A dispatch id names one directory under `dispatches/pending`: no separator, no `.` or `..`. */
116export function isDispatchId(id: string): boolean {
117  return DISPATCH_ID.test(id)
118}
119
120export function storeOf(root: string, id: string): string {
121  return `${root.replace(/\/+$/, '')}/${id}/state`
122}
123
124export function objectiveArgv(bin: string, id: string, store: string): string[] {
125  return [bin, 'objective', 'list', '--json', '--project-id', id, '--state-dir', store]
126}
127
128export function deliverableArgv(bin: string, id: string, store: string): string[] {
129  return [bin, 'deliverable', 'list', '--json', '--project-id', id, '--state-dir', store]
130}
131
132export function parseJson(text: string): Part<unknown> {
133  try {
134    return ok(JSON.parse(text) as unknown)
135  } catch {
136    return fail('ongeldige JSON')
137  }
138}
139
140/** Whole minutes from an ISO timestamp to `now`; null when there is no readable timestamp. */
141export function ageMinutesOf(stamp: unknown, now: number): number | null {
142  if (typeof stamp !== 'string') {
143    return null
144  }
145
146  // build_t0_state writes microseconds; a date parser need not take more than milliseconds.
147  const at = Date.parse(stamp.replace(/(\.\d{3})\d+/, '$1'))
148
149  return Number.isNaN(at) ? null : Math.max(0, Math.floor((now - at) / 60_000))
150}
151
152/** An index of unknown age cannot show it is fresh, so it counts as stale. */
153export function isStale(ageMinutes: number | null): boolean {
154  return ageMinutes === null || ageMinutes > STALE_MINUTES
155}
156
157export function formatAge(ageMinutes: number | null): string {
158  if (ageMinutes === null) {
159    return 'leeftijd onbekend'
160  }
161
162  if (ageMinutes < 60) {
163    return `${ageMinutes} min`
164  }
165
166  if (ageMinutes < 48 * 60) {
167    return `${Math.floor(ageMinutes / 60)} u`
168  }
169
170  return `${Math.floor(ageMinutes / (24 * 60))} d`
171}
172
173/** A block of the index or the state that says `available: true`, or why not. */
174function blockOf(value: unknown, name: string): Part<Json> {
175  if (!isRecord(value)) {
176    return fail(`blok ${name} ontbreekt`)
177  }
178
179  if (value.available !== true) {
180    return fail(textOr(value.reason, `available: ${String(value.available)}`))
181  }
182
183  return ok(value)
184}
185
186/** The most entries the index producer keeps in `live` (`_INDEX_LIVE_WORK_LIVE_CAP` in `scripts/build_t0_state.py`). */
187export const INDEX_LIVE_CAP = 8
188
189/**
190 * The builders in the index. The producer writes `live` with the live and the starting entries together, capped,
191 * and no `starting` list; a `starting` list is read too when it is there. A list that is absent stands only for a
192 * count of zero.
193 */
194function runningOf(block: Json, counts: Json): Part<Running[]> {
195  const running: Running[] = []
196
197  for (const name of ['live', 'starting'] as const) {
198    const list = block[name]
199
200    if (list === undefined) {
201      if (name === 'live' && (counts.live as number) + (counts.starting as number) > 0) {
202        return fail('live ontbreekt')
203      }
204
205      continue
206    }
207
208    if (!Array.isArray(list)) {
209      return fail(`${name} ontbreekt`)
210    }
211
212    for (const entry of list) {
213      if (!isRecord(entry) || typeof entry.dispatch_id !== 'string' || entry.dispatch_id === '') {
214        return fail(`${name}: bouwer zonder dispatch_id`)
215      }
216
217      running.push({ dispatchId: entry.dispatch_id, ageSeconds: isCount(entry.age_seconds) ? entry.age_seconds : null })
218    }
219  }
220
221  return ok(running)
222}
223
224/**
225 * `live_work.open_prs`: which dispatch opened which PR. An entry without a number says nothing and is left out;
226 * one without a dispatch id (a branch that is not `dispatch/<id>`) still says the PR is open and links no track.
227 */
228function livePrsOf(value: unknown): PrDispatch[] {
229  if (!Array.isArray(value)) {
230    return []
231  }
232
233  return value.flatMap(one =>
234    isRecord(one) && isCount(one.number) ? [{ number: one.number, dispatchId: typeof one.dispatch_id === 'string' && one.dispatch_id !== '' ? one.dispatch_id : null }] : [],
235  )
236}
237
238function liveWorkOf(value: unknown): Part<LiveWork> {
239  const block = blockOf(value, 'live_work')
240
241  if (!block.isOk) {
242    return fail(block.reason)
243  }
244
245  const counts = block.value.counts
246
247  if (!isRecord(counts) || ![counts.live, counts.starting, counts.stale, counts.unmeasured].every(isCount)) {
248    return fail('counts onvolledig')
249  }
250
251  return ok({
252    live: counts.live as number,
253    starting: counts.starting as number,
254    stale: counts.stale as number,
255    unmeasured: counts.unmeasured as number,
256    openPrs: livePrsOf(block.value.open_prs),
257    running: runningOf(block.value, counts),
258  })
259}
260
261function outcomesOf(value: unknown): Part<Outcomes> {
262  const block = blockOf(value, 'open_outcomes')
263
264  if (!block.isOk) {
265    return fail(block.reason)
266  }
267
268  const { total } = block.value
269
270  if (!isCount(total)) {
271    return fail('total ontbreekt')
272  }
273
274  return ok({ total })
275}
276
277export function indexViewOf(json: unknown, now: number): Part<IndexView> {
278  if (!isRecord(json)) {
279    return fail('geen JSON-object')
280  }
281
282  return ok({
283    ageMinutes: ageMinutesOf(json.timestamp, now),
284    liveWork: liveWorkOf(json.live_work),
285    outcomes: outcomesOf(json.open_outcomes),
286  })
287}
288
289function prsOf(value: unknown): Part<Pr[]> {
290  const block = blockOf(value, 'pr_queue')
291
292  if (!block.isOk) {
293    return fail(block.reason)
294  }
295
296  const open = block.value.open_prs
297
298  if (!Array.isArray(open)) {
299    return fail('open_prs ontbreekt')
300  }
301
302  const records = open.filter(isRecord)
303
304  if (records.length !== open.length || !records.every(pr => isCount(pr.number))) {
305    return fail('PR zonder nummer')
306  }
307
308  return ok(
309    records.map(pr => ({
310      number: pr.number as number,
311      title: textOr(pr.title, ''),
312      ci: textOr(pr.ci_status, '?'),
313      merge: textOr(pr.merge_state, '?'),
314      passed: stringsOf(pr.gates_passed),
315      owed: stringsOf(pr.blocked_on),
316    })),
317  )
318}
319
320export function stateViewOf(json: unknown, now: number): Part<StateView> {
321  if (!isRecord(json)) {
322    return fail('geen JSON-object')
323  }
324
325  return ok({ ageMinutes: ageMinutesOf(json.generated_at, now), prs: prsOf(json.pr_queue) })
326}
327
328/** `pr_ref` of a track is one string of PR numbers (`#2139,#2142`); the numbers, in order. */
329export function prRefsOf(value: unknown): number[] {
330  return typeof value === 'string' ? [...value.matchAll(/\d+/g)].map(one => Number(one[0])) : []
331}
332
333export function trackViewOf(json: unknown): Part<TrackView> {
334  if (!Array.isArray(json)) {
335    return fail('vnx gaf geen lijst')
336  }
337
338  const malformed = json.findIndex(one => !isRecord(one))
339
340  if (malformed >= 0) {
341    return fail(`vnx gaf geen object op plaats ${malformed + 1}`)
342  }
343
344  const tracks: TrackInfo[] = (json as Json[]).map(track => ({
345    id: textOr(track.track_id, '?'),
346    priority: textOr(track.priority, '-'),
347    horizon: textOr(track.horizon, '-'),
348    phase: textOr(track.phase, '-'),
349    prRefs: prRefsOf(track.pr_ref),
350  }))
351
352  return ok({ tracks })
353}
354
355/** `pr:dlv-1db7c550e9dc` -> `dlv-1db7c550e9dc`: the dispatch id behind a `deliverable_ref`; the whole ref when it has no prefix. */
356export function deliverableIdOf(ref: unknown): string {
357  const text = typeof ref === 'string' ? ref : ''
358
359  return text.slice(text.indexOf(':') + 1)
360}
361
362/** A deliverable title on one line, control characters turned into spaces; empty when it has none. */
363export const cleanTitleOf = (value: unknown): string => (typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() : '')
364
365export function deliverableViewOf(json: unknown, now: number): Part<DeliverableView> {
366  if (!Array.isArray(json)) {
367    return fail('vnx gaf geen lijst')
368  }
369
370  const view: DeliverableView = { byTrack: dictionaryOf() }
371  const malformed = json.findIndex(one => !isRecord(one))
372
373  if (malformed >= 0) {
374    return fail(`vnx gaf geen object op plaats ${malformed + 1}`)
375  }
376
377  for (const deliverable of json as Json[]) {
378    const track = textOr(deliverable.track, '?')
379    const status = textOr(deliverable.derived_status, 'onbekend')
380    const age = ageMinutesOf(deliverable.last_activity, now)
381    const counts: TrackDeliverables = view.byTrack[track] ?? { items: [], total: 0, done: 0, isRecent: false }
382
383    counts.items.push({ id: deliverableIdOf(deliverable.deliverable_ref), title: cleanTitleOf(deliverable.title), status })
384    counts.total += 1
385    counts.done += status === 'done' ? 1 : 0
386    counts.isRecent = counts.isRecent || (age !== null && age <= RECENT_DAYS * 24 * 60)
387    view.byTrack[track] = counts
388  }
389
390  return ok(view)
391}
392
393async function readText(io: Io, path: string, name: string): Promise<Part<string>> {
394  try {
395    if (!(await io.exists(path))) {
396      return fail(`${name} ontbreekt`)
397    }
398  } catch (error) {
399    return fail(unavailableOf(error))
400  }
401
402  try {
403    return ok(await io.read(path))
404  } catch (error) {
405    return fail(`${name} ${unavailableOf(error)}`)
406  }
407}
408
409const readJson = async (io: Io, path: string, name: string): Promise<Part<unknown>> => andThen(await readText(io, path, name), parseJson)
410
411/** Whether a file exists, or why that could not be told. */
412async function existsPart(io: Io, path: string): Promise<Part<boolean>> {
413  try {
414    return ok(await io.exists(path))
415  } catch (error) {
416    return fail(unavailableOf(error))
417  }
418}
419
420/**
421 * The first line that starts with `# ` in the first `INSTRUCTION_BUDGET` characters,
422 * without the `# `. The engine reads a file whole, so the rest of the text is dropped
423 * here and nothing of it is kept: no other line is looked at or shown.
424 */
425export function titleOf(text: string): Part<string> {
426  const isCut = text.length > INSTRUCTION_BUDGET
427  const lines = (isCut ? text.slice(0, INSTRUCTION_BUDGET) : text).split('\n')
428
429  if (isCut) {
430    // The last line ends at the budget, not at its own end: it may be cut in half.
431    lines.pop()
432  }
433
434  for (const line of lines) {
435    if (line.startsWith('# ')) {
436      const title = line.slice(2).replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
437
438      return title === '' ? fail('lege titelregel') : ok(title)
439    }
440  }
441
442  return fail(isCut ? `geen titelregel in de eerste ${INSTRUCTION_BUDGET} tekens` : 'geen titelregel')
443}
444
445/** A spec field that is a non-empty string, or null when the spec sets none. */
446const optionalTextOf = (value: unknown): string | null => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
447
448/** `model`, `track_id` and `pr_id` of a dispatch spec, each read on its own; null when the spec sets none (`DispatchSpec.model` is optional). */
449export function whoOf(json: unknown): Part<{ model: string | null; trackId: string | null; prId: string | null }> {
450  if (!isRecord(json)) {
451    return fail('spec is geen JSON-object')
452  }
453
454  const { model, track_id: trackId, pr_id: prId } = json
455
456  return ok({ model: optionalTextOf(model), trackId: optionalTextOf(trackId), prId: isCount(prId) ? String(prId) : optionalTextOf(prId) })
457}
458
459/** The minutes of an `age_seconds`, whole. */
460export function minutesOf(ageSeconds: number | null): Part<number> {
461  return ageSeconds === null ? fail('age_seconds ontbreekt') : ok(Math.floor(Math.max(0, ageSeconds) / 60))
462}
463
464/** One builder: its spec, the first title line of its instruction, and its age; each part on its own. */
465async function builderOf(io: Io, dir: string, entry: Running): Promise<Builder> {
466  const minutes = minutesOf(entry.ageSeconds)
467
468  if (!isDispatchId(entry.dispatchId)) {
469    return { dispatchId: entry.dispatchId, who: fail('ongeldige dispatch-id'), task: fail('ongeldige dispatch-id'), minutes }
470  }
471
472  const base = `${dir}/${entry.dispatchId}`
473  const who = andThen(await readJson(io, `${base}/${SPEC_FILE}`, SPEC_FILE), whoOf)
474  const task = andThen(await readText(io, `${base}/${INSTRUCTION_FILE}`, INSTRUCTION_FILE), titleOf)
475
476  return { dispatchId: entry.dispatchId, who, task, minutes }
477}
478
479const dispatchesOf = (root: string, id: string) => `${root.replace(/\/+$/, '')}/${id}/dispatches/pending`
480
481/** The builders of a project, in the order of the index: `live` first, then `starting`. */
482async function buildersOf(io: Io, root: string, id: string, index: Part<IndexView>): Promise<Part<Builder[]>> {
483  if (!index.isOk) {
484    return fail(index.reason)
485  }
486
487  const { liveWork } = index.value
488
489  if (!liveWork.isOk) {
490    return fail(liveWork.reason)
491  }
492
493  const { running } = liveWork.value
494
495  if (!running.isOk) {
496    return fail(running.reason)
497  }
498
499  const dir = dispatchesOf(root, id)
500
501  return ok(await Promise.all(running.value.map(entry => builderOf(io, dir, entry))))
502}
503
504/**
505 * The track that the dispatch of each open PR names in its spec. A spec that is gone (the dispatch left `pending/`)
506 * or has no `track_id` links nothing (the model is not needed to link it): the PR still shows under the tracks whose `pr_ref` lists it.
507 */
508async function prTracksOf(io: Io, root: string, id: string, index: Part<IndexView>): Promise<Record<number, string>> {
509  if (!index.isOk || !index.value.liveWork.isOk) {
510    return {}
511  }
512
513  const dir = dispatchesOf(root, id)
514  const linked: Record<number, string> = {}
515
516  await Promise.all(
517    index.value.liveWork.value.openPrs.map(async ({ number, dispatchId }) => {
518      if (dispatchId === null || !isDispatchId(dispatchId)) {
519        return
520      }
521
522      const who = andThen(await readJson(io, `${dir}/${dispatchId}/${SPEC_FILE}`, SPEC_FILE), whoOf)
523
524      if (who.isOk && who.value.trackId !== null) {
525        linked[number] = who.value.trackId
526      }
527    }),
528  )
529
530  return linked
531}
532
533/** Builders the counts name that the index lists no entry for: its list is capped, the counts are not. */
534function omittedOf(index: Part<IndexView>, builders: Part<Builder[]>): number {
535  if (!index.isOk || !index.value.liveWork.isOk || !builders.isOk) {
536    return 0
537  }
538
539  const { live, starting } = index.value.liveWork.value
540
541  return Math.max(0, live + starting - builders.value.length)
542}
543
544async function runJson(io: Io, argv: string[], store: string): Promise<Part<unknown>> {
545  let result: ProcessRunResult
546
547  try {
548    result = await io.run(argv, { cwd: store, timeoutMs: RUN_TIMEOUT_MS })
549  } catch (error) {
550    const message = messageOf(error)
551
552    return fail(/timed? ?-?out|ETIMEDOUT/i.test(message) ? 'vnx time-out' : `vnx startfout: ${message}`)
553  }
554
555  if (result.exitCode !== 0) {
556    return fail(`vnx exit ${result.exitCode}`)
557  }
558
559  const parsed = parseJson(result.stdout)
560
561  return parsed.isOk ? parsed : fail('vnx gaf ongeldige JSON')
562}
563
564/**
565 * The project: the store directory, its two files and the two vnx lists, each
566 * part read on its own so one that fails leaves the others standing.
567 */
568export async function collectProject(io: Io, id: string, root: string, bin: string, now: number): Promise<ProjectModel> {
569  if (!isProjectId(id)) {
570    return { id, hasStore: false, reason: 'ongeldige project-id' }
571  }
572
573  const store = storeOf(root, id)
574
575  try {
576    if (!(await io.exists(store))) {
577      return { id, hasStore: false, reason: 'geen store' }
578    }
579  } catch (error) {
580    return { id, hasStore: false, reason: unavailableOf(error) }
581  }
582
583  const index = andThen(await readJson(io, `${store}/${INDEX_FILE}`, INDEX_FILE), json => indexViewOf(json, now))
584  const state = andThen(await readJson(io, `${store}/${STATE_FILE}`, STATE_FILE), json => stateViewOf(json, now))
585  const builders = await buildersOf(io, root, id, index)
586  const buildersOmitted = omittedOf(index, builders)
587  const prTracks = await prTracksOf(io, root, id, index)
588
589  // `vnx objective list` and `vnx deliverable list` create the database when the store has none; the mod writes nothing.
590  const hasDb = await existsPart(io, `${store}/${DB_FILE}`)
591
592  if (!hasDb.isOk || !hasDb.value) {
593    const reason = hasDb.isOk ? `${DB_FILE} ontbreekt` : hasDb.reason
594
595    return { id, hasStore: true, index, state, tracks: fail(reason), deliverables: fail(reason), builders, buildersOmitted, prTracks }
596  }
597
598  const tracks = andThen(await runJson(io, objectiveArgv(bin, id, store), store), trackViewOf)
599  const deliverables = andThen(await runJson(io, deliverableArgv(bin, id, store), store), json => deliverableViewOf(json, now))
600
601  return { id, hasStore: true, index, state, tracks, deliverables, builders, buildersOmitted, prTracks }
602}
603
604/** The directories from `cwd` up to `/`, nearest first. */
605export function ancestorsOf(cwd: string): string[] {
606  const parts = cwd.split('/').filter(part => part !== '')
607  const dirs: string[] = []
608
609  for (let depth = parts.length; depth > 0; depth -= 1) {
610    dirs.push(`/${parts.slice(0, depth).join('/')}`)
611  }
612
613  return [...dirs, '/']
614}
615
616/**
617 * The project id of a session: walk up from `cwd` to the first directory that holds `.vnx-project-id`; its first
618 * line, with all whitespace removed, is the id (the rule `bin/vnx` follows). The nearest file decides, as there. Never another project.
619 */
620export async function resolveProject(io: Io, cwd: string): Promise<Part<string>> {
621  for (const dir of ancestorsOf(cwd)) {
622    const file = `${dir === '/' ? '' : dir}/${PROJECT_ID_FILE}`
623    const found = await existsPart(io, file)
624
625    if (!found.isOk) {
626      return fail(found.reason)
627    }
628
629    if (!found.value) {
630      continue
631    }
632
633    const text = await readText(io, file, PROJECT_ID_FILE)
634
635    if (!text.isOk) {
636      return fail(text.reason)
637    }
638
639    const id = (text.value.split('\n')[0] ?? '').replace(/\s+/g, '')
640
641    return isProjectId(id) ? ok(id) : fail(`geen geldig project-id in ${file}`)
642  }
643
644  return fail(`geen VNX-project in deze map (geen ${PROJECT_ID_FILE} boven ${cwd})`)
645}
646
647export async function collectModel(io: Io, config: Config, home: string | undefined, cwd: string | null, now: number): Promise<Model> {
648  if (cwd === null) {
649    return { collectedAt: now, noProject: 'geen VNX-project in deze map (werkmap onbekend)', project: null }
650  }
651
652  const id = await resolveProject(io, cwd)
653
654  if (!id.isOk) {
655    return { collectedAt: now, noProject: id.reason, project: null }
656  }
657
658  const root = expandHome(config.dataRoot, home)
659  const bin = expandHome(config.vnxBin, home)
660
661  if (root === null || bin === null) {
662    return { collectedAt: now, noProject: HOME_UNKNOWN, project: null }
663  }
664
665  return { collectedAt: now, noProject: null, project: await collectProject(io, id.value, root, bin, now) }
666}
667
668export function isUnreadable(project: ProjectModel): boolean {
669  if (!project.hasStore) {
670    return true
671  }
672
673  const { index, state, tracks, deliverables } = project
674
675  return (
676    !index.isOk ||
677    !index.value.liveWork.isOk ||
678    !index.value.outcomes.isOk ||
679    !state.isOk ||
680    !state.value.prs.isOk ||
681    !tracks.isOk ||
682    !deliverables.isOk ||
683    !project.builders.isOk ||
684    project.builders.value.some(builder => !builder.who.isOk || !builder.task.isOk || !builder.minutes.isOk)
685  )
686}
687
688export function isOutdated(project: ProjectModel): boolean {
689  if (!project.hasStore) {
690    return false
691  }
692
693  const { index, state } = project
694
695  return (index.isOk && isStale(index.value.ageMinutes)) || (state.isOk && isStale(state.value.ageMinutes))
696}
697
698/** The number of builders: the counts of the live work, not the capped list. Null when it could not be read. */
699export function builderCountOf(project: ProjectModel): number | null {
700  return project.hasStore && project.builders.isOk ? project.builders.value.length + project.buildersOmitted : null
701}
702
703/**
704 * What is known of the open PRs. `numbers` holds every PR a readable source lists: the PR queue of the state, and
705 * `live_work.open_prs` of the index. The index list is capped and is empty when `gh` fails, so it only ever proves a
706 * PR open. `isComplete` is true when the PR queue could be read: only then does a PR missing from `numbers` count as closed.
707 * `reason` says why the PR queue could not be read; empty when it could.
708 */
709export function openNumbersOf(project: ProjectModel): { numbers: Set<number>; isComplete: boolean; reason: string } {
710  const numbers = new Set<number>()
711
712  if (!project.hasStore) {
713    return { numbers, isComplete: false, reason: project.reason }
714  }
715
716  const { index, state } = project
717
718  if (index.isOk && index.value.liveWork.isOk) {
719    index.value.liveWork.value.openPrs.forEach(pr => numbers.add(pr.number))
720  }
721
722  if (!state.isOk) {
723    return { numbers, isComplete: false, reason: state.reason }
724  }
725
726  if (!state.value.prs.isOk) {
727    return { numbers, isComplete: false, reason: state.value.prs.reason }
728  }
729
730  state.value.prs.value.forEach(pr => numbers.add(pr.number))
731
732  return { numbers, isComplete: true, reason: '' }
733}
734
735/** One track the pane shows: the track, its measured progress, and what is going on around it. */
736export type ShownTrack = {
737  track: TrackInfo
738  /** Null when the track has no deliverables, or when the deliverables could not be read (`unreadable`). */
739  progress: TrackDeliverables | null
740  /** Why the deliverables could not be read; null when they could. */
741  unreadable: string | null
742  builders: number
743  /** The PRs of `pr_ref`: `isOpen` when a source lists them, false when the PR queue was read and does not, else null. Used for the order and for showing the track, never drawn. */
744  prs: { number: number; isOpen: boolean | null }[]
745  hasActivity: boolean
746  /** Short titles of what is being worked on: `ready` and `in_progress` deliverables, deliverables with a live dispatch, builders of the track. */
747  busy: string[]
748  /** Short titles of the `proposed` deliverables, in the order the list gives them. */
749  upcoming: string[]
750}
751
752/** The cells a short title keeps at most. */
753export const SHORT_TITLE_CELLS = 40
754
755/** The title up to the first `:` or the first 40 cells, whichever is shorter. */
756export function shortTitleOf(title: string): string {
757  const colon = title.indexOf(':')
758  const head = (colon > 0 ? title.slice(0, colon) : title).trim()
759
760  return fit(head, SHORT_TITLE_CELLS)
761}
762
763const PRIORITY_ORDER = ['P1', 'P2', 'P3']
764
765const rankOf = (priority: string): number => {
766  const at = PRIORITY_ORDER.indexOf(priority)
767
768  return at < 0 ? PRIORITY_ORDER.length : at
769}
770
771const byPriorityThenId = (a: TrackInfo, b: TrackInfo): number =>
772  rankOf(a.priority) !== rankOf(b.priority) ? rankOf(a.priority) - rankOf(b.priority) : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
773
774/** How alive a track is: 0 a builder works on it, 1 an open PR is linked, 2 a deliverable moved in the last 7 days, 3 nothing. */
775const tierOf = (one: ShownTrack): number => (one.builders > 0 ? 0 : one.prs.some(pr => pr.isOpen === true) ? 1 : one.progress?.isRecent ? 2 : 3)
776
777const BUSY_STATUSES = ['ready', 'in_progress']
778
779const isBusyItem = (item: DeliverableItem, running: ReadonlyMap<string, Builder>): boolean => BUSY_STATUSES.includes(item.status) || (item.status !== 'done' && running.has(item.id))
780
781/** The ids of the deliverables that `bezig:` names, on every track the pane shows: `ready`, `in_progress`, or carried by a running builder. */
782export function busyDeliverableIdsOf(project: ProjectModel): Set<string> {
783  const ids = new Set<string>()
784
785  if (!project.hasStore || !project.deliverables.isOk) {
786    return ids
787  }
788
789  const running = new Map<string, Builder>((project.builders.isOk ? project.builders.value : []).map(builder => [builder.dispatchId, builder]))
790
791  for (const one of shownTracksOf(project) ?? []) {
792    one.progress?.items.filter(item => isBusyItem(item, running)).forEach(item => ids.add(item.id))
793  }
794
795  return ids
796}
797
798/** What is being worked on in a track, and what is proposed next, as short titles. */
799function workOf(project: Extract<ProjectModel, { hasStore: true }>, track: TrackInfo, progress: TrackDeliverables | null): { busy: string[]; upcoming: string[] } {
800  const running = new Map<string, Builder>()
801  const trackBuilders: Builder[] = []
802
803  if (project.builders.isOk) {
804    for (const builder of project.builders.value) {
805      running.set(builder.dispatchId, builder)
806
807      if (builder.who.isOk && builder.who.value.trackId === track.id) {
808        trackBuilders.push(builder)
809      }
810    }
811  }
812
813  const items = progress?.items ?? []
814  const titleOf = (item: DeliverableItem) => shortTitleOf(item.title === '' ? cleanTitleOf(item.id) : item.title)
815  const isBusy = (item: DeliverableItem) => isBusyItem(item, running)
816  const busy = items.filter(isBusy).map(titleOf)
817  const covered = new Set(items.map(item => item.id))
818  const builderTitle = (builder: Builder) => shortTitleOf(builder.task.isOk ? builder.task.value : builder.dispatchId)
819
820  // A builder on the track whose dispatch is no deliverable of it still counts as work in progress.
821  busy.push(...trackBuilders.filter(builder => !covered.has(builder.dispatchId)).map(builderTitle))
822
823  return { busy, upcoming: items.filter(item => item.status === 'proposed' && !isBusy(item)).map(titleOf) }
824}
825
826function shownOf(project: Extract<ProjectModel, { hasStore: true }>, track: TrackInfo, open: ReturnType<typeof openNumbersOf>, builderTracks: string[]): ShownTrack {
827  const progress = project.deliverables.isOk ? (project.deliverables.value.byTrack[track.id] ?? null) : null
828  const builders = builderTracks.filter(id => id === track.id).length
829  // An open PR whose dispatch spec names the track belongs to it even when `pr_ref` does not list it yet.
830  const named = Object.entries(project.prTracks).flatMap(([number, id]) => (id === track.id && open.numbers.has(Number(number)) ? [Number(number)] : []))
831  const numbers = [...new Set([...track.prRefs, ...named])]
832  const prs = numbers.map(number => ({ number, isOpen: open.numbers.has(number) ? true : open.isComplete ? false : null }))
833  const hasActivity = builders > 0 || prs.some(pr => pr.isOpen === true) || (progress?.isRecent ?? false)
834
835  return { track, progress, unreadable: project.deliverables.isOk ? null : project.deliverables.reason, builders, prs, hasActivity, ...workOf(project, track, progress) }
836}
837
838const builderTracksOf = (project: Extract<ProjectModel, { hasStore: true }>): string[] =>
839  project.builders.isOk ? project.builders.value.flatMap(builder => (builder.who.isOk && builder.who.value.trackId !== null ? [builder.who.value.trackId] : [])) : []
840
841/**
842 * The tracks the pane shows: every `now` track, and every track with activity (a builder whose spec names it, an
843 * open PR that its `pr_ref` lists or whose dispatch spec names it, a deliverable with recent `last_activity`).
844 * Ordered by activity tier (a builder, an open PR, a recent deliverable, nothing), within the builder tier by builder
845 * count, then by priority, then by id. Null when the tracks could not be read.
846 */
847export function shownTracksOf(project: ProjectModel): ShownTrack[] | null {
848  if (!project.hasStore || !project.tracks.isOk) {
849    return null
850  }
851
852  const open = openNumbersOf(project)
853  const builderTracks = builderTracksOf(project)
854  const shown: ShownTrack[] = []
855
856  for (const track of project.tracks.value.tracks) {
857    const one = shownOf(project, track, open, builderTracks)
858    // A failed read never drops a track: while a PR of it is neither listed nor known closed, the track stays.
859    const isUnknown = one.prs.some(pr => pr.isOpen === null)
860
861    if (track.horizon === 'now' || one.hasActivity || isUnknown) {
862      shown.push(one)
863    }
864  }
865
866  return shown.sort((a, b) => {
867    if (tierOf(a) !== tierOf(b)) {
868      return tierOf(a) - tierOf(b)
869    }
870
871    // Within the builder tier the track with the most builders comes first.
872    if (a.builders !== b.builders) {
873      return b.builders - a.builders
874    }
875
876    return byPriorityThenId(a.track, b.track)
877  })
878}
879
880/** The plan: the tracks on `next` and on `later` whose phase is not `done`, by priority then id, and how many tracks have no horizon. Null when the tracks could not be read. */
881export function planOf(project: ProjectModel): { next: ShownTrack[]; later: ShownTrack[]; withoutHorizon: number } | null {
882  if (!project.hasStore || !project.tracks.isOk) {
883    return null
884  }
885
886  const open = openNumbersOf(project)
887  const builderTracks = builderTracksOf(project)
888  const all = project.tracks.value.tracks
889  const listOf = (horizon: string) =>
890    all
891      .filter(track => track.horizon === horizon && track.phase !== 'done')
892      .sort(byPriorityThenId)
893      .map(track => shownOf(project, track, open, builderTracks))
894
895  return { next: listOf('next'), later: listOf('later'), withoutHorizon: all.filter(track => track.phase !== 'done' && track.horizon !== 'now' && track.horizon !== 'next' && track.horizon !== 'later').length }
896}
897
898/**
899 * What waits on the operator's promote: the `proposed` deliverables on tracks with horizon `now`, and the backlog of
900 * those on `next` and `later`. A `proposed` deliverable on a track whose phase is `done` is an orphan and is not counted,
901 * nor is one on a track the list does not know. Null with the reason when the tracks or the deliverables could not be read.
902 */
903export function promotionOf(project: ProjectModel): Part<{ now: number; backlog: number }> {
904  if (!project.hasStore) {
905    return fail(project.reason)
906  }
907
908  if (!project.deliverables.isOk) {
909    return fail(project.deliverables.reason)
910  }
911
912  if (!project.tracks.isOk) {
913    return fail(project.tracks.reason)
914  }
915
916  let now = 0
917  let backlog = 0
918
919  for (const track of project.tracks.value.tracks) {
920    const proposed = (project.deliverables.value.byTrack[track.id]?.items ?? []).filter(item => item.status === 'proposed').length
921
922    if (track.phase === 'done') {
923      continue
924    }
925
926    if (track.horizon === 'now') {
927      now += proposed
928    } else if (track.horizon === 'next' || track.horizon === 'later') {
929      backlog += proposed
930    }
931  }
932
933  return ok({ now, backlog })
934}
935
936/** `X van Y klaar` for a track with deliverables, `geen leveringen gepland` for one without. */
937export function progressText(one: ShownTrack): string {
938  if (one.unreadable !== null) {
939    return `leveringen onbeschikbaar: ${one.unreadable}`
940  }
941
942  return one.progress === null ? 'geen leveringen gepland' : `${one.progress.done} van ${one.progress.total} klaar`
943}
944
945/** The filled and the empty cells of the bar: the filled count rounds down. */
946export function barCellsOf(progress: TrackDeliverables): { filled: number; empty: number } {
947  const filled = Math.floor((BAR_CELLS * progress.done) / progress.total)
948
949  return { filled, empty: BAR_CELLS - filled }
950}
951
952/** The bar of `BAR_CELLS` cells: `▓` for done, `░` for the rest, rounded down. */
953export function barOf(progress: TrackDeliverables): string {
954  const { filled, empty } = barCellsOf(progress)
955
956  return '▓'.repeat(filled) + '░'.repeat(empty)
957}
958
959/** The tracks the status line names, at most. */
960export const STATUS_TRACKS = 2
961/** The longest track id the status line shows, in display cells. */
962export const STATUS_TRACK_CELLS = 28
963
964/** One track in the status line: its bar and `X/Y`, `: geen plan` without deliverables, `: ?` when they could not be read. */
965function statusTrackOf(one: ShownTrack): string {
966  const id = fit(one.track.id, STATUS_TRACK_CELLS)
967
968  if (one.unreadable !== null) {
969    return `${id}: ?`
970  }
971
972  return one.progress === null ? `${id}: geen plan` : `${id} ${barOf(one.progress)} ${one.progress.done}/${one.progress.total}`
973}
974
975/**
976 * The one line under the prompt: how far the first two tracks of the pane are, and whether builders run. Empty when
977 * the session is in no VNX project.
978 */
979export function statusLine(model: Model): string {
980  const { project } = model
981
982  if (project === null) {
983    return ''
984  }
985
986  const tracks = shownTracksOf(project)
987  const builders = builderCountOf(project)
988  const parts: string[] = [project.id]
989
990  if (tracks === null) {
991    parts.push('tracks onbekend')
992  } else if (tracks.length === 0) {
993    parts.push('geen tracks in beweging')
994  } else {
995    parts.push(...tracks.slice(0, STATUS_TRACKS).map(statusTrackOf))
996  }
997
998  if (builders !== null && builders > 0) {
999    parts.push(`${builders} ${builders === 1 ? 'bouwer' : 'bouwers'}`)
1000  }
1001
1002  if (isOutdated(project)) {
1003    parts.push('verouderd')
1004  }
1005
1006  if (isUnreadable(project)) {
1007    parts.push('onleesbaar')
1008  }
1009
1010  return parts.join(' · ')
1011}
1012
1013/**
1014 * The note under `Tracks` when `vnx deliverable list` failed: a track whose only activity is a recent deliverable
1015 * cannot be recognised then. Null when the deliverables could be read.
1016 */
1017export function deliverablesNotice(project: ProjectModel): string | null {
1018  return project.hasStore && !project.deliverables.isOk ? `leveringen onleesbaar: ${project.deliverables.reason}, tracks met alleen recente leveringen ontbreken` : null
1019}
1020
1021/** The summary of `/voortgang` where no surface draws the pane: the status line, then one line per shown track. */
1022export function summaryLines(model: Model): string[] {
1023  if (model.project === null) {
1024    return [model.noProject ?? '']
1025  }
1026
1027  const tracks = shownTracksOf(model.project)
1028
1029  if (tracks === null) {
1030    const reason = model.project.hasStore && !model.project.tracks.isOk ? model.project.tracks.reason : model.project.hasStore ? '' : model.project.reason
1031
1032    return [statusLine(model), `tracks onbeschikbaar: ${reason}`]
1033  }
1034
1035  const notice = deliverablesNotice(model.project)
1036
1037  return [statusLine(model), ...(notice === null ? [] : [notice]), ...tracks.map(one => `${one.track.priority} ${one.track.id} · ${progressText(one)}`)]
1038}
1039
1040/**
1041 * What `/voortgang` answers: one short line when the pane is placed; where it
1042 * is not (no surface draws it, as in a headless run) the summary lines, so the
1043 * person still sees the progress.
1044 */
1045export function commandText(model: Model, unplacedReason: string | null): string {
1046  if (unplacedReason === null) {
1047    return model.project === null ? `Voortgang: ${model.noProject ?? ''}` : `Voortgang geopend: ${statusLine(model)}`
1048  }
1049
1050  return summaryLines(model).join('\n')
1051}
1052
hooks/view.ts 415 lines
1import type { Builder, Model, PaneView, Pr, ProjectModel, Row, Segment, Tone } from '../types'
2
3import { barCellsOf, busyDeliverableIdsOf, cleanTitleOf, deliverablesNotice, formatAge, INDEX_LIVE_CAP, isOutdated, planOf, promotionOf, shownTracksOf } from './collect'
4import type { ShownTrack } from './collect'
5import { cellsOf, fit } from './width'
6
7export { cellsOf, fit }
8
9export type PaneSize = { columns: number; headerColumns: number; rows: number; isRefreshing: boolean; failure?: string | null; view?: PaneView }
10
11type Stored = Extract<ProjectModel, { hasStore: true }>
12
13/** The column under which the second row of every track starts: after `  P1 `, whatever the priority is. */
14const TRACK_INDENT = 5
15/** The columns a title keeps at least, before it is cut for the track and the PR behind it. */
16const MIN_TITLE_COLUMNS = 8
17
18const seg = (text: string, tone: Tone = 'normal'): Segment => ({ text, tone })
19const rowOf = (...segments: Segment[]): Row => ({ segments })
20const row = (text: string, tone: Tone = 'normal'): Row => rowOf(seg(text, tone))
21
22/** The text of a row, without its tones. */
23export const textOf = (line: Row): string => line.segments.map(one => one.text).join('')
24
25/** The row cut to `columns` display cells with an ellipsis; width is measured on the text, whatever the tones. */
26export function fitRow(line: Row, columns: number): Row {
27  const full = textOf(line)
28  const fitted = fit(full, columns)
29
30  if (fitted === full) {
31    return line
32  }
33
34  let left = [...fitted].length - 1
35
36  if (left <= 0) {
37    return rowOf(seg('…', line.segments[0]?.tone))
38  }
39
40  const segments: Segment[] = []
41
42  for (const one of line.segments) {
43    const chars = [...one.text]
44
45    if (chars.length >= left) {
46      segments.push(seg(`${chars.slice(0, left).join('')}…`, one.tone))
47      break
48    }
49
50    segments.push(one)
51    left -= chars.length
52  }
53
54  return rowOf(...segments)
55}
56
57const seatsOf = (seats: string[] | null) => (seats === null ? 'onbekend' : seats.length === 0 ? 'geen' : seats.join(', '))
58const pluralOf = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
59
60/** A section of the pane: a title row, then items of one or more rows each. */
61type Section = {
62  title: string
63  /** What the title row says after a colon when the section has no item. */
64  empty: string
65  items: Row[][]
66  /** Items the data names that could not be drawn at all (the index lists at most `INDEX_LIVE_CAP` builders). */
67  omitted: number
68}
69
70const sectionOf = (title: string, empty: string, items: Row[][], omitted = 0): Section => ({ title, empty, items, omitted })
71const failedSection = (title: string, reason: string): Section => sectionOf(title, '', [[row(`  onbeschikbaar: ${reason}`, 'error')]])
72
73/** `PR #2150` from the `pr_id` of a spec, which is a number or a string such as `#2150`. */
74const prLabelOf = (prId: string): string => (/^#?\d+$/.test(prId) ? `PR #${prId.replace('#', '')}` : `PR ${prId}`)
75
76function builderRow(builder: Builder, columns: number): Row {
77  const failures = [builder.who, builder.task, builder.minutes].some(part => !part.isOk)
78  const unavailableOf = (reason: string) => `onbeschikbaar: ${reason}`
79  const model = builder.who.isOk ? (builder.who.value.model ?? 'model ?') : unavailableOf(builder.who.reason)
80  const minutes = builder.minutes.isOk ? `${builder.minutes.value} min` : unavailableOf(builder.minutes.reason)
81  const links = builder.who.isOk
82    ? [builder.who.value.trackId, builder.who.value.prId === null ? null : prLabelOf(builder.who.value.prId)].filter((one): one is string => one !== null)
83    : []
84  const before = `  ${model} · ${minutes} · `
85  const after = links.length === 0 ? '' : ` · ${links.join(' · ')}`
86  const task = builder.task.isOk ? builder.task.value : unavailableOf(builder.task.reason)
87  // The title gives way to the track and the PR behind it: those say what the builder belongs to.
88  const room = columns - cellsOf(before) - cellsOf(after)
89  const title = room >= MIN_TITLE_COLUMNS ? fit(task, room) : task
90
91  return row(`${before}${title}${after}`, failures ? 'error' : 'normal')
92}
93
94/** An open PR: its number, what the PR queue knows of it (null when only the index lists it), and the dispatch behind it. */
95type OpenPr = { number: number; detail: Pr | null; dispatchId: string | null }
96
97/** Every open PR a source lists: the PR queue, then the ones only the index lists. */
98function openPrsToShow(project: Stored): { prs: OpenPr[]; reason: string | null } {
99  const { index, state } = project
100  const fromIndex = index.isOk && index.value.liveWork.isOk ? index.value.liveWork.value.openPrs : []
101  const dispatchOf = (number: number) => fromIndex.find(pr => pr.number === number)?.dispatchId ?? null
102  const queue = state.isOk && state.value.prs.isOk ? state.value.prs.value : []
103  const reason = !state.isOk ? state.reason : !state.value.prs.isOk ? state.value.prs.reason : null
104  const prs: OpenPr[] = queue.map(detail => ({ number: detail.number, detail, dispatchId: dispatchOf(detail.number) }))
105
106  for (const pr of fromIndex) {
107    if (!prs.some(one => one.number === pr.number)) {
108      prs.push({ number: pr.number, detail: null, dispatchId: pr.dispatchId })
109    }
110  }
111
112  return { prs, reason }
113}
114
115const ciTone = (ci: string): Tone => (ci === 'pass' ? 'ok' : ci === 'fail' ? 'error' : 'normal')
116const ciOf = (ci: string): string => (ci === 'pass' ? 'groen' : ci === 'fail' ? 'rood' : ci === 'pending' ? 'loopt' : ci)
117
118/** `PR #N · ci loopt|rood|groen · stoel open: <seats>`, or `PR #N · klaar voor merge` when nothing is owed and CI is green. */
119function prRow(pr: OpenPr, indent: string, reason: string | null): Row {
120  const label = seg(`${indent}PR #${pr.number}`)
121  const dot = seg(' · ', 'dim')
122
123  if (pr.detail === null) {
124    return reason === null ? rowOf(label, dot, seg('niet in de PR-wachtrij', 'warn')) : rowOf(label, dot, seg(`onbeschikbaar: ${reason}`, 'error'))
125  }
126
127  const { ci, owed } = pr.detail
128
129  if (owed !== null && owed.length === 0 && ci === 'pass') {
130    return rowOf(label, dot, seg('klaar voor merge', 'ok'))
131  }
132
133  const checks = [label, dot, seg(`ci ${ciOf(ci)}`, ciTone(ci))]
134
135  return owed !== null && owed.length === 0 ? rowOf(...checks) : rowOf(...checks, dot, seg(`stoel open: ${seatsOf(owed)}`, 'warn'))
136}
137
138const isReady = (pr: OpenPr): boolean => pr.detail !== null && pr.detail.owed !== null && pr.detail.owed.length === 0 && pr.detail.ci === 'pass'
139const isWaiting = (pr: OpenPr): boolean => pr.detail !== null && (pr.detail.owed === null || pr.detail.owed.length > 0)
140const isRed = (pr: OpenPr): boolean => pr.detail !== null && pr.detail.ci === 'fail'
141
142/** `N open PR's zonder bouwer · M klaar voor merge · K wacht op stoel · J ci rood`, each count left out at 0, then the reason when the PR queue cannot be read. */
143function foldedRow(prs: OpenPr[], reason: string | null): Row {
144  const counts: [number, string, Tone][] = [
145    [prs.filter(isReady).length, 'klaar voor merge', 'ok'],
146    [prs.filter(isWaiting).length, 'wacht op stoel', 'warn'],
147    [prs.filter(isRed).length, 'ci rood', 'error'],
148  ]
149  const dot = seg(' · ', 'dim')
150  const parts = counts.filter(([count]) => count > 0).flatMap(([count, text, tone]) => [dot, seg(`${count} ${text}`, tone)])
151
152  const unavailable = reason === null ? [] : [dot, seg(`onbeschikbaar: ${reason}`, 'error')]
153
154  return rowOf(seg(`  ${prs.length} open PR's zonder bouwer`, 'dim'), ...parts, ...unavailable)
155}
156
157/** The builders at work, each with the PR it belongs to; then the open PRs that belong to a deliverable `bezig:` names. The rest fold into one row. */
158function nowSectionOf(project: Stored, columns: number): Section {
159  const { prs, reason } = openPrsToShow(project)
160  const owned = new Set<number>()
161  // A builder list that cannot be read is its own row; the PRs of a readable queue are still drawn, all folded as no builder is known.
162  const items: Row[][] = project.builders.isOk ? [] : [[row(`  onbeschikbaar: ${project.builders.reason}`, 'error')]]
163  const builders = project.builders.isOk ? project.builders.value : []
164
165  items.push(...builders.map(builder => {
166    const prId = builder.who.isOk && builder.who.value.prId !== null ? (/\d+/.exec(builder.who.value.prId)?.[0] ?? null) : null
167    const mine = prs.filter(pr => (prId !== null && pr.number === Number(prId)) || pr.dispatchId === builder.dispatchId)
168
169    mine.forEach(pr => owned.add(pr.number))
170
171    return [builderRow(builder, columns), ...mine.map(pr => prRow(pr, '    ', reason))]
172  }))
173  const deliverables = project.deliverables.isOk ? Object.values(project.deliverables.value.byTrack).flatMap(one => one.items) : []
174
175  const busyIds = project.builders.isOk ? busyDeliverableIdsOf(project) : new Set<string>()
176  const folded: OpenPr[] = []
177
178  for (const pr of prs.filter(one => !owned.has(one.number))) {
179    const deliverable = pr.dispatchId === null || !busyIds.has(pr.dispatchId) ? undefined : deliverables.find(one => one.id === pr.dispatchId)
180
181    if (deliverable === undefined) {
182      folded.push(pr)
183      continue
184    }
185
186    const label = deliverable.title !== '' ? fit(deliverable.title, MIN_TITLE_COLUMNS * 4) : cleanTitleOf(deliverable.id)
187
188    items.push([row(`  ${label}`), prRow(pr, '    ', reason)])
189  }
190
191  if (folded.length > 0) {
192    items.push([foldedRow(folded, reason)])
193  }
194
195  // With no PR known at all no row can carry the reason of an unreadable PR queue, so it gets its own.
196  if (reason !== null && prs.length === 0) {
197    items.push([row(`  onbeschikbaar: ${reason}`, 'error')])
198  }
199
200  return sectionOf('Nu bezig', 'niets', items, project.buildersOmitted)
201}
202
203function waitSectionOf(project: Stored): Section {
204  const { index } = project
205  const outcomes = index.isOk ? index.value.outcomes : index
206  const promotion = promotionOf(project)
207  const parts: Segment[] = []
208
209  if (promotion.isOk) {
210    parts.push(seg(`${promotion.value.now} te promoveren (now)`, promotion.value.now > 0 ? 'warn' : 'normal'))
211  } else {
212    parts.push(seg(`te promoveren onbeschikbaar: ${promotion.reason}`, 'error'))
213  }
214
215  if (outcomes.isOk) {
216    parts.push(seg(`${pluralOf(outcomes.value.total, 'open uitkomst', 'open uitkomsten')}`))
217  } else {
218    parts.push(seg(`uitkomsten onbeschikbaar: ${outcomes.reason}`, 'error'))
219  }
220
221  const backlog = promotion.isOk ? promotion.value.backlog : 0
222  const isNothing = promotion.isOk && outcomes.isOk && promotion.value.now === 0 && outcomes.value.total === 0 && backlog === 0
223  const [first = seg(''), ...others] = parts
224  const main = rowOf(seg('  '), first, ...others.flatMap(other => [seg(' · '), other]))
225  const lines = [main, ...(backlog > 0 ? [row(`  backlog: ${backlog} voorgesteld op next/later`, 'dim')] : [])]
226
227  return sectionOf('Wacht op een besluit', 'niets', isNothing ? [] : [lines])
228}
229
230/** The bar in two tones: the filled cells `ok`, the empty cells `dim`. */
231const barSegments = (one: NonNullable<ShownTrack['progress']>): Segment[] => {
232  const { filled, empty } = barCellsOf(one)
233
234  return [seg('▓'.repeat(filled), 'ok'), seg('░'.repeat(empty), 'dim')]
235}
236
237const BUSY_LABEL = 'bezig:  '
238const NEXT_LABEL = 'volgt:  '
239
240/** `volgt:` titles as many as fit the width, then `(+N)` for the rest. The count is never cut: the first title gives way to it. */
241function upcomingRow(titles: string[], columns: number): Row {
242  const prefix = ' '.repeat(TRACK_INDENT) + NEXT_LABEL
243  const room = columns - cellsOf(prefix)
244  const countOf = (rest: number) => (rest > 0 ? `  (+${rest})` : '')
245  let shown = titles.length
246
247  while (shown > 1 && cellsOf(titles.slice(0, shown).join(' · ')) + cellsOf(countOf(titles.length - shown)) > room) {
248    shown -= 1
249  }
250
251  const rest = titles.length - shown
252  const count = countOf(rest)
253  const shownTitles = titles.slice(0, shown)
254  const titleRoom = room - cellsOf(count)
255
256  if (shown === 1) {
257    const only = titleRoom >= 1 ? fit(titles[0] ?? '', titleRoom) : ''
258    const dim = only === '' ? count.trim() : count
259
260    return rowOf(seg(prefix), ...(only === '' ? [] : [seg(only, 'warn')]), ...(rest > 0 ? [seg(dim, 'dim')] : []))
261  }
262
263  const list = shownTitles.flatMap((title, at) => (at === 0 ? [seg(title, 'warn')] : [seg(' · ', 'dim'), seg(title, 'warn')]))
264
265  return rowOf(seg(prefix), ...list, ...(rest > 0 ? [seg(count, 'dim')] : []))
266}
267
268function trackItem(one: ShownTrack, columns: number, isNow: boolean): Row[] {
269  const head = `  ${one.track.priority} ${one.track.id}`
270  const indent = ' '.repeat(TRACK_INDENT)
271  const busy = isNow && one.busy.length > 0 ? [rowOf(seg(`${indent}${BUSY_LABEL}`), seg(one.busy.join(' · ')))] : []
272  const upcoming = one.upcoming.length > 0 ? [upcomingRow(one.upcoming, columns)] : []
273
274  if (one.unreadable !== null) {
275    return [row(head), row(`${indent}leveringen onbeschikbaar: ${one.unreadable}`, 'error'), ...busy]
276  }
277
278  if (one.progress === null) {
279    return [rowOf(seg(head), seg('  geen leveringen gepland', 'dim')), ...busy]
280  }
281
282  const isAllDone = one.progress.done === one.progress.total
283  const tone: Tone = isAllDone ? 'ok' : 'normal'
284
285  return [rowOf(seg(head, tone), seg('  '), ...barSegments(one.progress), seg(`  ${one.progress.done}/${one.progress.total}`, tone)), ...busy, ...upcoming]
286}
287
288function tracksSectionOf(project: Stored, columns: number): Section {
289  const tracks = shownTracksOf(project)
290
291  if (tracks === null) {
292    return failedSection('Tracks', project.tracks.isOk ? '' : project.tracks.reason)
293  }
294
295  const notice = deliverablesNotice(project)
296
297  return sectionOf('Tracks', 'geen', [...(notice === null ? [] : [[row(`  ${notice}`, 'error')]]), ...tracks.map(one => trackItem(one, columns, true))])
298}
299
300/** The plan sections: the tracks on `next`, the tracks on `later`, and one dim count of the tracks without a horizon. */
301function planSectionsOf(project: Stored, columns: number): { sections: Section[]; footer: Row | null } {
302  const plan = planOf(project)
303
304  if (plan === null) {
305    return { sections: [failedSection('Plan', project.tracks.isOk ? '' : project.tracks.reason)], footer: null }
306  }
307
308  const notice = deliverablesNotice(project)
309  const noticeItems = notice === null ? [] : [[row(`  ${notice}`, 'error')]]
310  const footer = plan.withoutHorizon > 0 ? row(`${pluralOf(plan.withoutHorizon, 'track', 'tracks')} zonder horizon`, 'dim') : null
311
312  return {
313    sections: [
314      sectionOf('next', 'geen', [...noticeItems, ...plan.next.map(one => trackItem(one, columns, false))]),
315      sectionOf('later', 'geen', plan.later.map(one => trackItem(one, columns, false))),
316    ],
317    footer,
318  }
319}
320
321/** The rows of a section in `free` rows: whole, or as many whole items as fit plus one row for the rest. Nothing when not even one item fits. */
322function placeSection(section: Section, free: number): Row[] {
323  const { title, items } = section
324
325  if (items.length === 0) {
326    const text = section.omitted > 0 ? `${section.omitted} niet getoond (index noemt ze niet)` : section.empty
327
328    return free >= 1 ? [row(`${title}: ${text}`, 'head')] : []
329  }
330
331  const cost = (list: Row[][]) => list.reduce((sum, item) => sum + item.length, 0)
332  const rest = (hidden: number) => row(`  +${hidden} niet getoond${hidden === section.omitted ? ` (index toont er max. ${INDEX_LIVE_CAP})` : ''}`, 'dim')
333
334  if (1 + cost(items) + (section.omitted > 0 ? 1 : 0) <= free) {
335    return [row(title, 'head'), ...items.flat(), ...(section.omitted > 0 ? [rest(section.omitted)] : [])]
336  }
337
338  let shown = items.length
339
340  while (shown > 0 && 1 + cost(items.slice(0, shown)) + 1 > free) {
341    shown -= 1
342  }
343
344  return shown === 0 ? [] : [row(title, 'head'), ...items.slice(0, shown).flat(), rest(items.length - shown + section.omitted)]
345}
346
347/** The one dim row at the bottom: what the live work counts as `stale` or `unmeasured`. Nothing when both are zero. */
348function cleanupRow(project: Stored): Row | null {
349  if (!project.index.isOk || !project.index.value.liveWork.isOk) {
350    return null
351  }
352
353  const { stale, unmeasured } = project.index.value.liveWork.value
354  const parts = [stale > 0 ? pluralOf(stale, 'stale rij', 'stale rijen') : null, unmeasured > 0 ? `${unmeasured} ongemeten` : null].filter((part): part is string => part !== null)
355
356  return parts.length === 0 ? null : row(`opruimen: ${parts.join(' · ')}`, 'dim')
357}
358
359/** The header of the pane: the project and the age of its index; `verouderd` once, when the index or the state is old. */
360function headerOf(project: ProjectModel): { text: string; tone: Tone } {
361  if (!project.hasStore) {
362    return { text: `${project.id} · ${project.reason}`, tone: 'error' }
363  }
364
365  const age = project.index.isOk ? formatAge(project.index.value.ageMinutes) : 'onbeschikbaar'
366  const isOld = isOutdated(project)
367
368  return { text: `${project.id} · index ${age}${isOld ? ' · verouderd' : ''}`, tone: isOld ? 'warn' : 'head' }
369}
370
371/**
372 * The sections in the order that takes the room. The now view: Nu bezig, Wacht op een besluit, Tracks, opruimen.
373 * The plan view: next, later, the count without horizon. Each section gets what is left of `budget` rows.
374 */
375function projectRows(project: Stored, budget: number, columns: number, view: PaneView): Row[] {
376  const plan = view === 'plan' ? planSectionsOf(project, columns) : null
377  const sections = plan === null ? [nowSectionOf(project, columns), waitSectionOf(project), tracksSectionOf(project, columns)] : plan.sections
378  const rows: Row[] = []
379
380  for (const section of sections) {
381    rows.push(...placeSection(section, budget - rows.length))
382  }
383
384  const last = plan === null ? cleanupRow(project) : plan.footer
385
386  return last !== null && budget - rows.length >= 1 ? [...rows, last] : rows
387}
388
389/**
390 * The pane, drawn from the model alone: the header (project and index age), then the sections of that one
391 * project, every row cut to the pane's width and every section cut to the rows that are left.
392 */
393export function paneRows(model: Model | null, size: PaneSize): { header: string; headerTone: Tone; lines: Row[] } {
394  const busy = size.isRefreshing ? ' · verversen…' : ''
395  const failed = size.failure === undefined || size.failure === null ? [] : [row(`verversen mislukt: ${size.failure}`, 'error')]
396  const cut = (lines: Row[]) => lines.map(line => fitRow(line, size.columns))
397
398  if (model === null) {
399    return { header: fit(`Voortgang${busy}`, size.headerColumns), headerTone: 'head', lines: cut([...failed, row('nog niet ververst', 'dim')]) }
400  }
401
402  const { project } = model
403
404  if (project === null) {
405    return { header: fit(`Voortgang${busy}`, size.headerColumns), headerTone: 'head', lines: cut([...failed, row(model.noProject ?? '', 'dim')]) }
406  }
407
408  const header = headerOf(project)
409  // The header row and the failure row come out of the pane's rows before the sections share what is left.
410  const budget = Math.max(0, size.rows - 1 - failed.length)
411  const lines = project.hasStore ? projectRows(project, budget, size.columns, size.view ?? 'now') : []
412
413  return { header: fit(`${header.text}${busy}${size.view === 'plan' ? ' · plan' : ''}`, size.headerColumns), headerTone: header.tone, lines: cut([...failed, ...lines]) }
414}
415
hooks/width.ts 113 lines
1// Display width of text in terminal cells; shared by the pane and the status line.
2
3/** Code point ranges a terminal draws two cells wide: East Asian wide and fullwidth forms, and emoji. */
4const WIDE: readonly (readonly [number, number])[] = [
5  [0x1100, 0x115f],
6  [0x231a, 0x231b],
7  [0x23e9, 0x23ec],
8  [0x25fd, 0x25fe],
9  [0x2614, 0x2615],
10  [0x2648, 0x2653],
11  [0x2693, 0x2693],
12  [0x26a1, 0x26a1],
13  [0x26bd, 0x26be],
14  [0x26c4, 0x26c5],
15  [0x26d4, 0x26d4],
16  [0x26ea, 0x26ea],
17  [0x26f2, 0x26f3],
18  [0x26f5, 0x26f5],
19  [0x26fa, 0x26fa],
20  [0x26fd, 0x26fd],
21  [0x2705, 0x2705],
22  [0x270a, 0x270b],
23  [0x2728, 0x2728],
24  [0x274c, 0x274c],
25  [0x2753, 0x2755],
26  [0x2795, 0x2797],
27  [0x2b1b, 0x2b1c],
28  [0x2b50, 0x2b50],
29  [0x2b55, 0x2b55],
30  [0x2e80, 0x303e],
31  [0x3041, 0x33ff],
32  [0x3400, 0x4dbf],
33  [0x4e00, 0x9fff],
34  [0xa000, 0xa4cf],
35  [0xac00, 0xd7a3],
36  [0xf900, 0xfaff],
37  [0xfe30, 0xfe4f],
38  [0xff00, 0xff60],
39  [0xffe0, 0xffe6],
40  [0x1f300, 0x1f64f],
41  [0x1f680, 0x1f6ff],
42  [0x1f900, 0x1faff],
43  [0x20000, 0x3fffd],
44]
45const MARK = /^\p{M}$/u
46const ZERO_WIDTH_JOINER = 0x200d
47const VARIATION_SELECTOR_EMOJI = 0xfe0f
48
49const isExtender = (char: string, code: number): boolean =>
50  MARK.test(char) || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x1f3fb && code <= 0x1f3ff) || code === ZERO_WIDTH_JOINER
51
52function cellsOfCode(char: string, code: number): number {
53  if (code < 0x20 || (code >= 0x7f && code < 0xa0) || (code >= 0x200b && code <= 0x200f) || code === 0x2060 || code === 0xfeff || MARK.test(char)) {
54    return 0
55  }
56
57  return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
58}
59
60type Cluster = { text: string; cells: number }
61
62/** The text as what a terminal draws in one place: a character with the marks, selectors and joined characters after it. */
63function clustersOf(text: string): Cluster[] {
64  const clusters: Cluster[] = []
65  let isJoined = false
66
67  for (const char of text) {
68    const code = char.codePointAt(0) ?? 0
69    const last = clusters[clusters.length - 1]
70
71    if (last !== undefined && (isJoined || isExtender(char, code))) {
72      last.text += char
73      last.cells = code === VARIATION_SELECTOR_EMOJI ? Math.max(last.cells, 2) : last.cells
74      isJoined = code === ZERO_WIDTH_JOINER
75    } else {
76      clusters.push({ text: char, cells: cellsOfCode(char, code) })
77      isJoined = false
78    }
79  }
80
81  return clusters
82}
83
84/** The width of the text in display cells: wide characters take two, combining marks none. */
85export function cellsOf(text: string): number {
86  return clustersOf(text).reduce((sum, cluster) => sum + cluster.cells, 0)
87}
88
89/** The text cut to `columns` display cells with an ellipsis, so no character is split and none sticks out of the pane. */
90export function fit(text: string, columns: number): string {
91  if (cellsOf(text) <= columns) {
92    return text
93  }
94
95  if (columns <= 1) {
96    return '…'
97  }
98
99  let cells = 0
100  let kept = ''
101
102  for (const cluster of clustersOf(text)) {
103    if (cells + cluster.cells > columns - 1) {
104      break
105    }
106
107    cells += cluster.cells
108    kept += cluster.text
109  }
110
111  return `${kept}…`
112}
113
types/index.d.ts 153 lines
1/**
2 * One part of a project's progress: read and parsed, or unavailable with the
3 * reason. An unavailable part is never shown as zero or as an empty list.
4 */
5export type Part<T> = { isOk: true; value: T } | { isOk: false; reason: string }
6
7/** One entry of `live_work.open_prs`: an open PR and the dispatch that opened it; null when the branch is not `dispatch/<id>`. */
8export type PrDispatch = { number: number; dispatchId: string | null }
9
10/** One entry of `live_work.live` or `live_work.starting`: a dispatch that runs, and for how long. */
11export type Running = {
12  dispatchId: string
13  /** `age_seconds`; null when the entry carries none. */
14  ageSeconds: number | null
15}
16
17/** `live_work` of `t0_index.json`: the counts, and the dispatch ids that run. */
18export type LiveWork = {
19  live: number
20  starting: number
21  stale: number
22  unmeasured: number
23  /** `live_work.open_prs`: which dispatch belongs to which open PR. Positive evidence only: the producer caps the list and keeps it empty when `gh` fails. */
24  openPrs: PrDispatch[]
25  /** The `live` and `starting` entries, the builders; `stale` and `unmeasured` entries are not builders. */
26  running: Part<Running[]>
27}
28
29/** One builder: a dispatch in `live` or `starting`, as three parts that each stand or fall on their own. */
30export type Builder = {
31  dispatchId: string
32  /** `model`, `track_id` and `pr_id` of the dispatch's `dispatch-spec.json`; each is null when the spec sets none. */
33  who: Part<{ model: string | null; trackId: string | null; prId: string | null }>
34  /** The first `# ` line of the dispatch's `instruction.md`. */
35  task: Part<string>
36  /** Whole minutes of the entry's `age_seconds`. */
37  minutes: Part<number>
38}
39
40/** `open_outcomes` of `t0_index.json`: what still waits on a decision. */
41export type Outcomes = {
42  total: number
43}
44
45/** A pane row: its text, already cut to the pane's width, and how it is drawn. */
46export type Tone = 'normal' | 'head' | 'dim' | 'ok' | 'warn' | 'error'
47
48/** A stretch of a pane row with its own tone. The text carries the meaning; the tone only adds to it. */
49export type Segment = { text: string; tone: Tone }
50
51/** A pane row: segments, already cut to the pane's width. */
52export type Row = { segments: Segment[] }
53
54/** Which view the pane draws: what is going on now, or the plan (the tracks on `next` and `later`). */
55export type PaneView = 'now' | 'plan'
56
57/** `t0_index.json`: its age, and its two blocks the mod shows. */
58export type IndexView = {
59  /** Minutes between the index's `timestamp` and the refresh; null when it has none. */
60  ageMinutes: number | null
61  liveWork: Part<LiveWork>
62  outcomes: Part<Outcomes>
63}
64
65/** One open PR of `pr_queue`. A list that was missing in the file is null, never empty. */
66export type Pr = {
67  number: number
68  title: string
69  ci: string
70  merge: string
71  /** `gates_passed`: the seats that passed. */
72  passed: string[] | null
73  /** `blocked_on`: the seats still owed. */
74  owed: string[] | null
75}
76
77/** `t0_state.json`: its age, and the PR queue. */
78export type StateView = {
79  /** Minutes between the state's `generated_at` and the refresh; null when it has none. */
80  ageMinutes: number | null
81  prs: Part<Pr[]>
82}
83
84/** One track of `vnx objective list`, on any horizon. */
85export type TrackInfo = {
86  id: string
87  /** `P1`, `P2`, `P3`, or `-` when the track has none or another scale. */
88  priority: string
89  horizon: string
90  /** `phase`; `done` marks a finished track whose leftovers are orphans. */
91  phase: string
92  /** The PR numbers of `pr_ref`, in the order it lists them. */
93  prRefs: number[]
94}
95
96/** `vnx objective list`: every track; the pane picks the ones to show. */
97export type TrackView = { tracks: TrackInfo[] }
98
99/** One deliverable of `vnx deliverable list`: the id behind its `deliverable_ref`, its title and its derived status. */
100export type DeliverableItem = { id: string; title: string; status: string }
101
102/** The deliverables of one track. */
103export type TrackDeliverables = {
104  /** In the order the list gives them. */
105  items: DeliverableItem[]
106  total: number
107  /** Those with `derived_status` `done`. */
108  done: number
109  /** Whether one has a `last_activity` in the last seven days. */
110  isRecent: boolean
111}
112
113/** `vnx deliverable list`: per track the deliverables, counted and listed. */
114export type DeliverableView = {
115  byTrack: Record<string, TrackDeliverables>
116}
117
118/**
119 * The session's own project, as the last refresh read it. Without a store nothing of it was
120 * read: its store directory is missing (`geen store`), the check for it rejected
121 * (`onbeschikbaar: <reason>`), or its id would name a path outside the data root (`ongeldige project-id`).
122 */
123export type ProjectModel =
124  | { id: string; hasStore: false; reason: 'geen store' | 'ongeldige project-id' | `onbeschikbaar: ${string}` }
125  | {
126      id: string
127      hasStore: true
128      index: Part<IndexView>
129      state: Part<StateView>
130      tracks: Part<TrackView>
131      deliverables: Part<DeliverableView>
132      builders: Part<Builder[]>
133      /** `counts.live + counts.starting` minus the entries the index lists: its list is capped, the counts are not. */
134      buildersOmitted: number
135      /** The `track_id` that the dispatch of an open PR names in its spec, per PR number; only the ones that could be read. */
136      prTracks: Record<number, string>
137    }
138
139/** What the pane and the status line draw from. */
140export type Model = {
141  /** Milliseconds since the epoch at which the refresh started. */
142  collectedAt: number
143  /** Why no project was read: no `.vnx-project-id` above the cwd, or a path that could not be resolved. Null when there is one. */
144  noProject: string | null
145  project: ProjectModel | null
146}
147
148declare module 'claude-code' {
149  interface PluginState {
150    voortgang: { model: Model | null; isRefreshing: boolean; failure: string | null; cwd: string | null; view: PaneView }
151  }
152}
153