@nutteen/conductor-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +383 -0
- package/bin/conductor-ui.js +17 -0
- package/dist/main/index.js +1608 -0
- package/dist/preload/index.cjs +70 -0
- package/dist/renderer/assets/index-Chkena_6.css +975 -0
- package/dist/renderer/assets/index-DJ8N8Wk7.js +14218 -0
- package/dist/renderer/index.html +22 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Conductor Control Room
|
|
2
|
+
|
|
3
|
+
A desktop viewer for Conductor: per ticket, **the graph**, **live state on that graph**, and
|
|
4
|
+
**the audit log**.
|
|
5
|
+
|
|
6
|
+
`conductor control-tower` prints a one-shot table and `conductor logs -f` prints raw JSONL.
|
|
7
|
+
This is the same data, drawn.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm run build --workspace apps/conductor-ui
|
|
11
|
+
npm run dev --workspace apps/conductor-ui # electron-vite dev server
|
|
12
|
+
|
|
13
|
+
# Options (all optional)
|
|
14
|
+
electron apps/conductor-ui \
|
|
15
|
+
--config conductor-runner/flows.yaml \ # which flows to start/stop; remembered between launches
|
|
16
|
+
--runtime-dir ~/.conductor \ # defaults to CONDUCTOR_RUNTIME_DIR, then ~/.conductor
|
|
17
|
+
--ticket IST-128 \ # open straight to a ticket instead of the config view
|
|
18
|
+
--tab graph|state|audit|config \ # which panel that deep link lands on
|
|
19
|
+
--capture shot.png # render, save a PNG, exit
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## The CLI does not depend on this
|
|
23
|
+
|
|
24
|
+
The app **reads** files the daemon writes, and **starts and stops the daemon by running the
|
|
25
|
+
same CLI you would run yourself** — `conductor start <flow>` / `conductor stop <flow>`, as a
|
|
26
|
+
detached subprocess. It never reaches *into* a running daemon: no socket, no control file, no
|
|
27
|
+
new flag on `run`. The only signal it sends is the `SIGTERM` that `conductor stop` already
|
|
28
|
+
sends, and a daemon started from the app keeps running after you quit it.
|
|
29
|
+
|
|
30
|
+
That distinction is the whole of it. Spawning `conductor start` is exactly what a terminal
|
|
31
|
+
does and the daemon cannot tell the difference; a socket or a signal protocol would be a new
|
|
32
|
+
process contract, and *that* is what these guarantees protect.
|
|
33
|
+
|
|
34
|
+
| Guarantee | How it is held |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `apps/conductor` gains no runtime deps | still `js-yaml` + `liquidjs`; `src/lib.ts` is re-export only |
|
|
37
|
+
| The published CLI package is unchanged in shape | `files: ["dist/src", "README.md"]` and `bin` untouched |
|
|
38
|
+
| No IPC *into* a running daemon | no socket, no control file, no hooks, no new flags on `run`; `lib.ts` exports no lifecycle symbol and `tests/lib.test.ts` fails the build if one appears |
|
|
39
|
+
| Every UI action has a CLI equivalent | Start/Stop/Restart shell out to `conductor start\|stop`; the app adds no capability the terminal lacks — notably no `SIGKILL` escalation |
|
|
40
|
+
| The app never writes to the runtime dir | records and pid files are still written by the CLI; the app's own remembered config path lives in Electron `userData` |
|
|
41
|
+
| The daemon outlives the app | it is spawned detached and `unref`'d, so `tail.ts`'s "stopping the app stops a reader, nothing else" stays literally true |
|
|
42
|
+
| The CLI's own output is unchanged | `buildRunSnapshotsFromLogLines` and `control-tower` still emit one row per `(ticket, attempt)`; nothing in the CLI's runtime path imports `replay.ts` |
|
|
43
|
+
| Deleting this workspace breaks nothing | nothing in `apps/conductor` imports it |
|
|
44
|
+
| It works with **no daemon running** | it reads historical JSONL; liveness is `isProcessRunning`, never a requirement |
|
|
45
|
+
|
|
46
|
+
`scripts/check.sh architecture` machine-checks the read/write split: `src/main/sources.ts` may
|
|
47
|
+
not import `child_process`, signal a pid or write a file — all of that lives in
|
|
48
|
+
`src/main/daemon.ts`, which is also why `sources.ts` can keep its header rule literally true.
|
|
49
|
+
|
|
50
|
+
`scripts/check.sh conductor` is deliberately a separate scope from
|
|
51
|
+
`scripts/check.sh conductor-ui`, so the first keeps passing with this directory deleted.
|
|
52
|
+
|
|
53
|
+
**Where the app and the CLI differ, precisely.** Both read the same log through the same
|
|
54
|
+
library, and every number still comes from the CLI's own functions. What differs is grouping:
|
|
55
|
+
`conductor control-tower` prints one row per `(ticket, attempt)`, whereas the sidebar here is
|
|
56
|
+
**ticket-level** — one row, badged `N runs` — and the detail panels are **dispatch-level**,
|
|
57
|
+
with earlier dispatches behind the run switcher. That is a presentation choice on top of
|
|
58
|
+
shared data, not a second parser.
|
|
59
|
+
|
|
60
|
+
The one real risk was the `exports` map: `apps/conductor/package.json` had no `main` or
|
|
61
|
+
`exports`, so deep paths resolved freely, and adding `exports` *restricts* resolution. It
|
|
62
|
+
therefore ships a `"./dist/src/*"` wildcard alongside `"."` and `"./lib"`, and
|
|
63
|
+
`apps/conductor/tests/lib.test.ts` asserts that.
|
|
64
|
+
|
|
65
|
+
## Starting and stopping a flow
|
|
66
|
+
|
|
67
|
+
Start, Stop and Restart sit in the topbar. Each is a shell-out:
|
|
68
|
+
|
|
69
|
+
| Button | What it runs |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `Start…` | `conductor --config <flows.yaml> start <flow>` |
|
|
72
|
+
| `Stop` | `conductor --config <flows.yaml> stop <flow>` |
|
|
73
|
+
| `Restart…` | `stop`, then wait for the pid to be gone, then `start` |
|
|
74
|
+
|
|
75
|
+
**Start and Restart confirm first, and the preflight runs inside the dialog.** Starting a flow
|
|
76
|
+
is not free: it sweeps workspaces, then autonomously dispatches tickets, spends tokens and
|
|
77
|
+
opens pull requests. The dialog names the resolved workspace root, how many directories are
|
|
78
|
+
under it, and the owner-scoped rule the sweep uses — Conductor reads each directory's
|
|
79
|
+
`.conductor-owner` marker and only removes ones owned by that flow or unmarked. (It is
|
|
80
|
+
*Symphony's* cleanup that is unscoped; that is what `flows.yaml`'s longest comment is about.)
|
|
81
|
+
It does not say which directories will go: that needs a tracker query, and opening a dialog
|
|
82
|
+
should not make one.
|
|
83
|
+
|
|
84
|
+
The checks are split by who can actually see the answer. **The CLI owns config and graph
|
|
85
|
+
correctness** — `preflightFlows` in `apps/conductor` is the same function `conductor start`
|
|
86
|
+
gates its spawn on, so a second copy cannot drift from it. **The app owns the host
|
|
87
|
+
environment**, which the CLI cannot see because it inherits a terminal's.
|
|
88
|
+
|
|
89
|
+
The session probe is `ist auth whoami`, not `ist auth status`. `status` is a **local** check:
|
|
90
|
+
it reads the token file and reports `expired` from its own `expiresAt`, so a token the server
|
|
91
|
+
has since rejected still passes it. Observed on a real machine: `status` printed
|
|
92
|
+
`"authenticated": true, "expired": false` while `whoami` returned *"Your session has
|
|
93
|
+
expired"* — and `startOrchestrator` calls `whoami` and refuses to start without it. A
|
|
94
|
+
preflight that passes where the daemon fails is worse than no preflight.
|
|
95
|
+
|
|
96
|
+
### The host environment is the whole risk
|
|
97
|
+
|
|
98
|
+
A GUI-launched Electron app never sourced `~/.zprofile`. Its `PATH` is the bare
|
|
99
|
+
`/usr/bin:/bin:/usr/sbin:/sbin` macOS hands a double-clicked app, and none of `ist`, `claude`,
|
|
100
|
+
`git`, `gh` or `uv` is on it. That matters more than it sounds, because **an `ist auth` failure
|
|
101
|
+
is not fatal in the daemon**: `orchestrator.ts` logs `Dispatch skipped: ist auth check failed`
|
|
102
|
+
and keeps polling forever, doing nothing. A daemon spawned with a stripped PATH looks perfectly
|
|
103
|
+
healthy and accomplishes nothing at all.
|
|
104
|
+
|
|
105
|
+
So `host-env.ts` asks the login shell for its PATH once — `$SHELL -lc` with the value wrapped
|
|
106
|
+
in sentinel markers, because login shells emit MOTDs, nvm banners and `direnv` chatter, and
|
|
107
|
+
`-lc` rather than `-ilc` because interactive mode can block on a prompt. PATH only, not the
|
|
108
|
+
whole environment: every version manager works by putting real directories on PATH, and
|
|
109
|
+
importing a stale login env over Electron's own is a far larger surface than the problem.
|
|
110
|
+
Every failure path — no `$SHELL`, non-zero exit, timeout, no markers — falls back to the
|
|
111
|
+
inherited PATH and says so in the dialog; none of them throws.
|
|
112
|
+
|
|
113
|
+
The same capture answers the other hazard for free. `process.execPath` inside Electron is the
|
|
114
|
+
**Electron binary**, so spawning the CLI with it would fork Electron rather than node; the
|
|
115
|
+
`node` on the login PATH is both simpler and more faithful than `ELECTRON_RUN_AS_NODE=1`,
|
|
116
|
+
which is kept only as the fallback. When a real `node` is used, every `ELECTRON_*` key is
|
|
117
|
+
stripped from the daemon's environment — it lives for days and spreads its env into every
|
|
118
|
+
`claude`, `git`, `gh` and `npm` it spawns, and a stray `ELECTRON_RUN_AS_NODE=1` reaching a
|
|
119
|
+
subprocess that happens to be an Electron app makes it start in node mode and silently do
|
|
120
|
+
nothing.
|
|
121
|
+
|
|
122
|
+
### Why the dot is never driven by `stopped_at`
|
|
123
|
+
|
|
124
|
+
`conductor stop` sends one `SIGTERM`, writes `stopped_at` and removes the pid file
|
|
125
|
+
**synchronously and unconditionally** — while the daemon's `shutdown()` aborts in-flight graphs
|
|
126
|
+
and *awaits* them, which can take minutes. So a record that says stopped is routinely a record
|
|
127
|
+
whose process is still alive and still holding a workspace.
|
|
128
|
+
|
|
129
|
+
Liveness is therefore `isProcessRunning(pid)` and nothing else. `stopped_at` is used for one
|
|
130
|
+
thing: telling a clean stop apart from a crash, which is the **`stale`** state — a pid, no
|
|
131
|
+
`stopped_at`, and no process. The topbar used to hide that behind "no daemon running".
|
|
132
|
+
|
|
133
|
+
`renderer/lifecycle.ts` holds the rule as a pure function of `(flow, pending, now, draining)`,
|
|
134
|
+
so it unit-tests with a fake clock. Transitional states are overlays with a ~15 s deadline that
|
|
135
|
+
clear the moment a `FlowInfo` disagrees; the deadline is what stops a spinner spinning forever
|
|
136
|
+
when a spawn dies before writing a record, and when it lapses it says where to look — the
|
|
137
|
+
daemon detaches and logs to JSONL, so its own startup failure never reaches the spawn's stderr.
|
|
138
|
+
|
|
139
|
+
**A restart that times out starts nothing.** This is the subtlest trap in the feature: calling
|
|
140
|
+
`start` while the old daemon is still alive makes `startManaged` print `IST already running
|
|
141
|
+
(PID …)` and exit **0**, so a naive restart reports success having done nothing but SIGTERM a
|
|
142
|
+
daemon. Instead the flow holds a distinct **draining** state — a `SIGTERM` has been sent to a
|
|
143
|
+
pid that is still alive — which a plain Stop also enters, and which clears itself the moment
|
|
144
|
+
that pid is gone.
|
|
145
|
+
|
|
146
|
+
**Draining offers the log and nothing else, and that is a safety property.** `runForeground`
|
|
147
|
+
registers `process.once("SIGTERM", …)`, so after the first signal the handler is removed and
|
|
148
|
+
Node restores default termination: a **second** `conductor stop` during a drain does not hurry
|
|
149
|
+
the daemon, it hard-kills it, orphaning an agent mid-`git push`. So Stop and Restart are both
|
|
150
|
+
off the screen while draining, and the button opens the log — which is where the wait explains
|
|
151
|
+
itself (`Shutdown: waiting for N in-flight run(s) to complete`). There is no `SIGKILL`
|
|
152
|
+
escalation anywhere; if force-stop is ever wanted it belongs in the CLI as
|
|
153
|
+
`conductor stop --force`, where it can shut down cleanly rather than from outside.
|
|
154
|
+
|
|
155
|
+
`pid: 0` gets its own guard, because `isProcessRunning(0)` returns **true** —
|
|
156
|
+
`process.kill(0, sig)` signals the entire process group, which from this app includes the app
|
|
157
|
+
itself. Every liveness question goes through `isLivePid`, which requires a positive integer
|
|
158
|
+
first.
|
|
159
|
+
|
|
160
|
+
## Editing `flows.yaml`
|
|
161
|
+
|
|
162
|
+
The Config tab shows every declared flow, what it declares and what that resolves to, and can
|
|
163
|
+
edit it. **Edits are line-level splices with a diff preview and an explicit confirmation, never
|
|
164
|
+
`yaml.dump`.**
|
|
165
|
+
|
|
166
|
+
Nothing in this repo writes YAML — every use is `yaml.load` — and `conductor-runner/flows.yaml`
|
|
167
|
+
is 18 lines of which **10 are comments**, one of them the reason a Symphony restart does not
|
|
168
|
+
`rm -rf` Conductor's working trees. A `load` → mutate → `dump` round trip deletes all ten on
|
|
169
|
+
the first save.
|
|
170
|
+
|
|
171
|
+
So `flows-file.ts` is pure string → string: it parses the file into line spans, plans splices,
|
|
172
|
+
and copies **every line the plan does not name by reference**. "Untouched bytes are identical"
|
|
173
|
+
holds by construction. The diff is derived from the same splices that perform the write, which
|
|
174
|
+
makes a preview that differs from the write structurally impossible — which is why it is
|
|
175
|
+
hand-rolled rather than a re-diff of two texts.
|
|
176
|
+
|
|
177
|
+
Comments are attributed by walking *up* from a field while the line is a comment at the same
|
|
178
|
+
indent, stopping at a blank line. That is what binds the Symphony warning to `workspace_root`:
|
|
179
|
+
removing that field removes its comment, visibly, in the diff, and editing a different field
|
|
180
|
+
leaves it alone.
|
|
181
|
+
|
|
182
|
+
It **refuses** rather than guesses, naming the offending lines, for block sequences, block
|
|
183
|
+
scalars, multi-document files, mixed line endings, and especially anchors / aliases / merge
|
|
184
|
+
keys — with a merge key the effective config is not the lines you can see, so a line edit would
|
|
185
|
+
be a *silent* lie rather than a visible one.
|
|
186
|
+
|
|
187
|
+
`flows-config.ts` supplies the filesystem half. Validation writes the candidate to
|
|
188
|
+
`<configPath>.preview.<pid>` — a **sibling**, not `tmpdir()`, because `loadManagedConfig`
|
|
189
|
+
resolves `./workflows/IST.md` against the config file's own directory and a copy elsewhere
|
|
190
|
+
reports a bogus "workflow not found". The write is tmp + rename with a `.bak`, preserving the
|
|
191
|
+
file mode, then re-reads and asserts it still loads, restoring from the backup if not. Preview
|
|
192
|
+
and commit are two round trips, so the commit token pins the *before* text and is re-checked
|
|
193
|
+
against disk: an edit made in vim in between is refused, not clobbered.
|
|
194
|
+
|
|
195
|
+
**One thing the checker cannot see.** `flowCollisions` compares flows *within one config file*.
|
|
196
|
+
Symphony's `workspace_root` lives in `symphony-runner/`, which this loader never reads — so the
|
|
197
|
+
editor validates Conductor-vs-Conductor collisions and is blind to the Conductor-vs-Symphony
|
|
198
|
+
one. That comment in `flows.yaml` is not made redundant by this panel.
|
|
199
|
+
|
|
200
|
+
`runtime_dir` is deliberately read-only here: changing it orphans every record, log, pid file
|
|
201
|
+
and checkpoint under the old directory, with no migration.
|
|
202
|
+
|
|
203
|
+
## Why the JSONL is the source, not the checkpoint
|
|
204
|
+
|
|
205
|
+
**Live graph state is deleted the moment a run ends.** `~/.conductor/graph/<flow>/<TICKET>.json`
|
|
206
|
+
is the only place holding `cursor`, `state`, `node_history` and `traversals`, and
|
|
207
|
+
`orchestrator.ts` removes it at `__end__` — keeping it would make the next dispatch resume
|
|
208
|
+
into a completed run.
|
|
209
|
+
|
|
210
|
+
So a per-ticket view built on the checkpoint alone is **blank for every finished ticket** and
|
|
211
|
+
only works mid-run. It looks fine right up until the run ends.
|
|
212
|
+
|
|
213
|
+
The log is the durable record. `node_started`, `node_completed`, `node_failed` and
|
|
214
|
+
`edge_taken` are enough to reconstruct the walk, which is what
|
|
215
|
+
`@nutteen/conductor/lib`'s `replayWalks` does — using the same bookkeeping rules
|
|
216
|
+
`graph/executor.ts` uses live.
|
|
217
|
+
|
|
218
|
+
They do *not* produce identical numbers, and the difference matters: a **checkpoint**
|
|
219
|
+
belongs to a ticket and its `node_history` spans every resume, while a **replayed run**
|
|
220
|
+
covers one dispatch. The checkpoint is also live state, so it is attached to the ticket's
|
|
221
|
+
most recent run only — hanging it off an older one would draw a run's graph with another
|
|
222
|
+
run's values on it.
|
|
223
|
+
|
|
224
|
+
That also collapses two of the four asks into one pipeline: **the audit log and the graph
|
|
225
|
+
state are the same events, rendered differently.**
|
|
226
|
+
|
|
227
|
+
The checkpoint is still read, for one thing the log does not have: **state values**.
|
|
228
|
+
`node_completed.writes` is a list of key *names*, so the log can say the reviewer set
|
|
229
|
+
`review_verdict` and can never say what it set it to. The State panel says so rather than
|
|
230
|
+
rendering a blank that reads as "unset".
|
|
231
|
+
|
|
232
|
+
### What a "run" is
|
|
233
|
+
|
|
234
|
+
**One `graph run starting` line — one per `dispatchIssue` — begins exactly one graph walk.**
|
|
235
|
+
Not one attempt. `state.retry_attempts` lives in memory in the daemon and is rebuilt on every
|
|
236
|
+
start, so the counter resets on restart and is not a run identity: in the real IST log,
|
|
237
|
+
IST-116 was **dispatched 15 times under 3 distinct attempt numbers**, 10 of those dispatches
|
|
238
|
+
sharing attempt 1 — and the numbers are not even monotonic in time, since attempt 1 was still
|
|
239
|
+
logging at 02:41 after attempts 2 and 3 ended at 02:35 and 02:28.
|
|
240
|
+
|
|
241
|
+
Two things followed from keying on attempt, and both are fixed here: "the highest attempt is
|
|
242
|
+
the current run" returned a run that had finished *before* two others, and ten dispatches
|
|
243
|
+
concatenated into one walk produced a history with 24 node entries but only 16 edges — with
|
|
244
|
+
`checks -> checks -> checks` runs that no edge ever connected.
|
|
245
|
+
|
|
246
|
+
| | |
|
|
247
|
+
|---|---|
|
|
248
|
+
| Detection | `run_started: true`, or `started_at` as the structural fallback. Never `phase` prose. |
|
|
249
|
+
| Run id | `` `${identifier}#${startedAtIso}` `` — stable across re-reads, because the log window slides and a positional index would not. |
|
|
250
|
+
| Resumed dispatch | Its own run, *labelled* `resumed_at`, so each run's `node_history` lines up with its own edges. |
|
|
251
|
+
| Window opened mid-run | Kept as `partial: true` under `` `${identifier}#~${firstEventTs}` ``, rather than dropped. |
|
|
252
|
+
|
|
253
|
+
Ordinals — "run 12 of 15" — are display only.
|
|
254
|
+
|
|
255
|
+
### `run_event`
|
|
256
|
+
|
|
257
|
+
Every graph event line now carries `run_event: "<kind>"`, so a reader recovers the event kind
|
|
258
|
+
by reading a field instead of pattern-matching `phase` prose. Logs written before it are read
|
|
259
|
+
by `inferRunEventKind`, which keys off field shape — with one exact-match exclusion list,
|
|
260
|
+
because several whole-run lifecycle lines carry `nodes_visited` exactly as
|
|
261
|
+
`worker_exited_normal` does, and field shape alone reports a budget halt as a clean exit.
|
|
262
|
+
|
|
263
|
+
## Two modes, one renderer
|
|
264
|
+
|
|
265
|
+
`GraphView` is a pure function of `GraphSpec` plus an optional overlay, so ticket state stays
|
|
266
|
+
out of the component and it is testable against a fixture with no daemon and no runtime data.
|
|
267
|
+
|
|
268
|
+
**Config mode** (`overlay === null`) — a workflow's declared graph, no ticket. Shows what is
|
|
269
|
+
otherwise only readable as YAML: node kinds, which `agents:` profile *and model* each node
|
|
270
|
+
binds to, edge predicates, loop budgets, back edges, `on_error` behaviour, and
|
|
271
|
+
`on_enter.tracker_status` transitions. Works with no daemon and no ticket ever dispatched.
|
|
272
|
+
|
|
273
|
+
It also **surfaces validation inline**. A workflow that fails to load is a rendered
|
|
274
|
+
diagnostic, not a crash — and where the message names a node or an edge, it is drawn there.
|
|
275
|
+
This is the view that would have made `unknown builtin 'record_validation'` obvious
|
|
276
|
+
immediately, instead of leaving a daemon walking an older graph for three hours behind one
|
|
277
|
+
warning line.
|
|
278
|
+
|
|
279
|
+
**Ticket mode** — the same drawing, restyled: current node, visited path, and loop pressure
|
|
280
|
+
(traversals against each edge's `max_traversals`).
|
|
281
|
+
|
|
282
|
+
Layout is computed once per `GraphSpec` and only CSS classes change as events stream in.
|
|
283
|
+
Not mermaid: it re-renders the whole SVG from a text source on every change, which flickers
|
|
284
|
+
and drops pan position on a live graph. Not dagre either — the graph is small and strictly
|
|
285
|
+
layered apart from its back edges, which want to be drawn as arcs rather than reversed, and
|
|
286
|
+
keeping the dependency tree small matters for something that must never become load-bearing.
|
|
287
|
+
|
|
288
|
+
### Reading the drawing
|
|
289
|
+
|
|
290
|
+
**It runs left to right.** Layers advance along x; nodes sharing a layer stack along y.
|
|
291
|
+
Top-down, IST's nine-stage pipeline was a 460×1448 ribbon inside a ~1130px panel — 40% of
|
|
292
|
+
the width used, vertical scrolling, and `__end__` off-screen. Sideways it is ~2850×392,
|
|
293
|
+
because the widest layer is two nodes (`check_plan`/`land`, `plan`/`merge_pr`). The layering
|
|
294
|
+
passes are unchanged; only the axis the result lands on is.
|
|
295
|
+
|
|
296
|
+
**An edge into `__end__` may be drawn as a stub** — a short drop out of the bottom of its
|
|
297
|
+
source ending in a bar, rather than a line across the whole picture. That happens when the
|
|
298
|
+
source *also* has a way to carry on, which is every `on_error` edge plus `__start__ -> __end__`
|
|
299
|
+
(the ticket is already handled) and `checks -> __end__` (the gate gave up): seven of IST's
|
|
300
|
+
edges, and most of its former crossing clutter. `handoff -> __end__` and `merge_pr -> __end__`
|
|
301
|
+
stay real lines, because ending is all those nodes have left to do — both pipelines still
|
|
302
|
+
visibly terminate somewhere. A stub carries the same label, the same tooltip and the same
|
|
303
|
+
`id`/`from`/`to`, so `traversals` still keys on it and a failed run reads *better* than it
|
|
304
|
+
did: the taken edge is next to the node that failed. Stubs are excluded from the layering
|
|
305
|
+
pass exactly as back edges are, so a failure edge cannot stretch the layer of a sink it no
|
|
306
|
+
longer reaches.
|
|
307
|
+
|
|
308
|
+
**Anything that would cut across the row is routed under it**: back-edge loops, and forward
|
|
309
|
+
edges spanning more than two layers (`merge_pr -> __end__`). Each gets its own depth lane,
|
|
310
|
+
and they label themselves on a row below the deepest of them.
|
|
311
|
+
|
|
312
|
+
**The gap between two columns is label space.** A forward edge's predicate is anchored in
|
|
313
|
+
the corridor immediately right of its source — the one place with no node boxes in it — and
|
|
314
|
+
the corridor is widened to fit the widest label anchored there. Predicates truncate at 40
|
|
315
|
+
characters on the canvas and are complete in the `<title>`.
|
|
316
|
+
|
|
317
|
+
**It has its own contrast rules.** The shared palette — mirrored from `apps/web` so
|
|
318
|
+
the two read as one product — draws borders at about 2:1 against their background,
|
|
319
|
+
which is right where text carries the meaning and a loud rule would only shout. A
|
|
320
|
+
node-link diagram is the one place in this app where the boxes and the lines *are*
|
|
321
|
+
the content, and at those values a node box measured 1.05:1 against the canvas and an
|
|
322
|
+
edge 1.79:1. The `--graph-*` variables in `styles.css` are the exception: 3.5:1 and
|
|
323
|
+
5.3:1 in dark, 5.2:1 and 8.6:1 in light, against a canvas dropped to `--color-shell`.
|
|
324
|
+
`tests/theme.test.ts` parses the stylesheet and asserts the ratios, because the
|
|
325
|
+
failure mode here is a number being wrong, not anything throwing.
|
|
326
|
+
|
|
327
|
+
**It fits the panel, with a floor.** The canvas scales down to 75% and then the panel
|
|
328
|
+
scrolls horizontally, because scaling a nine-stage pipeline into ~1130px puts node text at
|
|
329
|
+
about 5px. Selecting a ticket scrolls its current node to the middle, which is the "where is
|
|
330
|
+
my run" question the panel exists to answer.
|
|
331
|
+
|
|
332
|
+
## Dark and light
|
|
333
|
+
|
|
334
|
+
Dark by default, light by `data-theme="light"` on `<html>` — the same mechanism and
|
|
335
|
+
the same token values as `apps/web`, minus its plumbing, which round-trips the choice
|
|
336
|
+
to the tracker API against an authenticated user. With no explicit choice the viewer
|
|
337
|
+
follows the OS, which is the fix for the original complaint: it used to force dark on
|
|
338
|
+
a light desktop.
|
|
339
|
+
|
|
340
|
+
An explicit choice is persisted **by main**, in `preferences.json` under
|
|
341
|
+
`app.getPath("userData")`. Not `localStorage`, which is the obvious answer and does
|
|
342
|
+
not work: the renderer is loaded with `loadFile`, so it is a `file://` document, and
|
|
343
|
+
Chromium gives one of those an opaque storage bucket — writing succeeds, reading back
|
|
344
|
+
in the same window succeeds, and the value is gone on the next launch. Verified by
|
|
345
|
+
launching the app twice, not assumed.
|
|
346
|
+
|
|
347
|
+
It comes back to the renderer through the preload's `additionalArguments` as
|
|
348
|
+
`window.conductor.storedTheme`, a plain value rather than a promise, because it has
|
|
349
|
+
to be known *before* the first paint; an awaited answer is one frame of the other
|
|
350
|
+
theme on every launch. `BrowserWindow`'s `backgroundColor` is chosen from the same
|
|
351
|
+
value, since that is painted before any HTML exists at all.
|
|
352
|
+
|
|
353
|
+
## Architecture
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
Electron main (Node) Electron renderer (React, sandboxed)
|
|
357
|
+
──────────────────── ────────────────────────────────────
|
|
358
|
+
imports @nutteen/conductor/lib Tickets ← RunSnapshot[]
|
|
359
|
+
buildControlTowerReport() ─── IPC ──► Graph ← SerializableGraphSpec + overlay
|
|
360
|
+
loadWorkflow() → GraphSpec State ← checkpoint.state / replayed writes
|
|
361
|
+
readGraphCheckpoint() Audit ← RunEventRecord[]
|
|
362
|
+
replayWalks()
|
|
363
|
+
tail on IST.jsonl ───── IPC push ──────► (live updates)
|
|
364
|
+
watchWorkflow() ─────── IPC push ──────► (redraw on save)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
No HTTP server and no SSE: in Electron the main process is already Node, so it tails the
|
|
368
|
+
JSONL and pushes over IPC. The renderer runs with `contextIsolation` and no Node, which is
|
|
369
|
+
also why it cannot read `~/.conductor` itself — everything it shows crosses the typed surface
|
|
370
|
+
in `src/shared/contract.ts`.
|
|
371
|
+
|
|
372
|
+
Everything crossing that boundary must survive `structuredClone`: no `Map`, no `Set`, no
|
|
373
|
+
compiled predicate. `EdgeSpec.when_expr` is stripped by `serializableGraphSpec`; the `when`
|
|
374
|
+
source text is what gets rendered.
|
|
375
|
+
|
|
376
|
+
Every rendered string passes through `redactSensitive` in the main process first.
|
|
377
|
+
|
|
378
|
+
## Known limitation
|
|
379
|
+
|
|
380
|
+
The graph drawn is the workflow **as it is now**, with a historical walk overlaid. A ticket
|
|
381
|
+
that ran under an earlier revision can therefore reference nodes the current graph no longer
|
|
382
|
+
declares — they simply do not appear. The audit log still shows them, because it is the raw
|
|
383
|
+
event stream.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-undef */
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const electron = require("electron");
|
|
10
|
+
const appDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
|
|
12
|
+
const child = spawn(String(electron), [appDir, ...process.argv.slice(2)], {
|
|
13
|
+
stdio: "inherit",
|
|
14
|
+
windowsHide: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
child.on("close", (code) => process.exit(code ?? 0));
|