@renzrollon/interlock 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/hooks/mod.mjs +245 -74
- package/lib/meter-time.mjs +43 -0
- package/lib/meter-timeline.mjs +161 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.1",
|
|
5
5
|
"description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Carl Rollon",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows Keep a Changelog 1.1.0, and
|
|
6
6
|
this project adheres to Semantic Versioning 2.0.0.
|
|
7
7
|
|
|
8
|
+
## 1.4.1 — 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The ship meter pane reads as a timeline.** In 1.4.0 the `agents` section was a flat list in first-seen order: relay pings, the planner and lane workers were interleaved, nothing said when anything happened, and lanes that ran in parallel looked like lanes that ran in turn.
|
|
13
|
+
- **The `timeline` section replaces it.** There is one line per CLI step as it crossed, such as `run-batch · wave 2 · batch 1/2 · 3 in parallel` or `verify · wave 2 · skipped: <reason>`. The relay agent that carried the step is folded into that line: `cli <id>`, its served model, its request count and its duration.
|
|
14
|
+
- **Each step's agents** are indented beneath it in the order they started, with name, id, model, turn word and a dim requests-and-tokens line. A spawn no agent has picked up yet reads `waiting`. A relay whose line printed no step, such as `interlock limits`, stands at its own time, and agents nothing names are listed last under `unmatched`.
|
|
15
|
+
- **Every timed line has a local `HH:MM:SS` gutter**, blank where the clock gave no reading, never a guessed time.
|
|
16
|
+
- **The header** reads `started <date> <time> · last activity <time>`, replacing `last activity <ISO>`.
|
|
17
|
+
- **Durations** read `41.2s`, `2m 14s` or `1h 03m`, replacing `134.2s`.
|
|
18
|
+
- **Wave cards group their lanes by batch**, under a line with the batch's dispatch time and `N in parallel` or `1 lane`. The lanes are indented beneath it and no longer repeat the batch cell. A lane's note adds its duration once its turn ended.
|
|
19
|
+
- **Each section's body is indented** under its heading.
|
|
20
|
+
- **Relay token figures:** a relay's token breakdown is no longer drawn. The trajectory's `agent-result` line still carries it.
|
|
21
|
+
- **New modules:** the words and the order come from two new Node-free modules, `lib/meter-time.mjs` and `lib/meter-timeline.mjs`, pinned by `test/spine/meter-time.test.mjs` and `test/spine/meter-timeline.test.mjs`.
|
|
22
|
+
- **Layout probe:** a probe on 2.1.295 showed the gutter keeps a hanging indent when a line wraps, inside a card too. The pane is as tall as the engine seats it, and a long run scrolls.
|
|
23
|
+
- **Unchanged:** `interlock waves --format board` prints exactly what it printed before.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **A run resumed across the 1.4.0 upgrade does not re-run its lanes.** The 1.4.0 entry said it would, because the runtime caches an agent by its label. The runtime keys an agent on its prompt and its options together, the label among them, but a lane's label is the title its step carried, and on a resume the completed relays hand back the steps they first printed. So every lane the run had already dispatched is spawned under its old title and returns its saved result. `docs/04` now says the replay cache keys on the prompt and the options, not on the prompt text alone, and says why an upgrade does not rename the lanes a resume replays.
|
|
28
|
+
- **`docs/07` no longer says the board names a lane exactly as its spawn.** Since 1.4.0 the spawn is shown as `task 1.1 · …`, while the board, the briefing file, the worktree and the trajectory line name the lane by its label, `1.1`.
|
|
29
|
+
|
|
8
30
|
## 1.4.0 — 2026-10-09
|
|
9
31
|
|
|
10
32
|
### Changed
|
package/hooks/mod.mjs
CHANGED
|
@@ -79,6 +79,17 @@
|
|
|
79
79
|
// is a theme key from `lib/meter-palette.mjs` and names a meaning, never a
|
|
80
80
|
// threshold: the engine's own figures carry none.
|
|
81
81
|
//
|
|
82
|
+
// THE STEP TIMELINE. The agents list's place is a timeline: one line per step
|
|
83
|
+
// as it crossed, the agent whose Bash call printed it folded into that line,
|
|
84
|
+
// the agents a step spawned one indent beneath it in the order they started,
|
|
85
|
+
// relays no step names at their own time, and the agents nothing names last.
|
|
86
|
+
// Every timed line carries the local `HH:MM:SS` of a reading the module took
|
|
87
|
+
// itself (the launch's, a step's stamp, an agent's first request), blank
|
|
88
|
+
// where the clock gave none; durations are humanised. The order and the
|
|
89
|
+
// step's words are `lib/meter-timeline.mjs`'s, the time words
|
|
90
|
+
// `lib/meter-time.mjs`'s. Within a wave card the lanes sit beneath their
|
|
91
|
+
// batch's sub-header, timed by when that batch was first dispatched.
|
|
92
|
+
//
|
|
82
93
|
// THE SESSION START (show-preflight-and-interrupted-runs-at-session-start
|
|
83
94
|
// design D5-D8). Outside a run the module draws two things, both from the
|
|
84
95
|
// report `hooks/preflight.mjs` leaves at `.claude/ship/preflight.json`: the
|
|
@@ -113,6 +124,8 @@ import { decideLaunch, emptyRecord, isAcceptedLaunch, isShipLaunch, withLaunch,
|
|
|
113
124
|
import { LIMITS } from '../lib/limits.mjs'
|
|
114
125
|
import { PALETTE, stateProps, turnProps } from '../lib/meter-palette.mjs'
|
|
115
126
|
import { TICK_MS, quietMs, quietWord } from '../lib/meter-quiet.mjs'
|
|
127
|
+
import { clockText, durationText, stampText } from '../lib/meter-time.mjs'
|
|
128
|
+
import { BATCH_ACTIONS, batchText, laneCountText, planWaveCount, stepText, timeline } from '../lib/meter-timeline.mjs'
|
|
116
129
|
import { guardDenialLine, guardOf, guardReason, launchGuardLine } from '../lib/meter-refusals.mjs'
|
|
117
130
|
import {
|
|
118
131
|
adoptPreflight,
|
|
@@ -155,8 +168,6 @@ const UNPLACED_TOAST = '/interlock-meter opens the ship meter'
|
|
|
155
168
|
const NO_LANES_LINE = 'no lanes dispatched yet'
|
|
156
169
|
const UNRELAYED_LINE = 'plan structure not relayed by this CLI'
|
|
157
170
|
const BOARD_FAILED_LINE = 'wave board could not be drawn'
|
|
158
|
-
// The actions that dispatch a batch of lanes, and so place the board's cursor on one.
|
|
159
|
-
const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
|
|
160
171
|
// The session start's two panes, each opened by the command of its name (design D6).
|
|
161
172
|
const PREFLIGHT_PANE = 'interlock-preflight'
|
|
162
173
|
const HANDOFF_PANE = 'interlock-handoff'
|
|
@@ -186,6 +197,12 @@ const PATH_BEFORE = /[\s\/'"=]/
|
|
|
186
197
|
const PATH_AFTER = /[\w.+-]/
|
|
187
198
|
/** The props a name is drawn with: a lane label, a spawn's title, a joined agent's name. */
|
|
188
199
|
const NAME_PROPS = { bold: true, color: PALETTE.identity }
|
|
200
|
+
const DIM = { dimColor: true }
|
|
201
|
+
// The timeline's gutter: `HH:MM:SS` and two spaces, and how far a section body
|
|
202
|
+
// and a child line sit in from what holds them (layout probe P3, Claude Code 2.1.295).
|
|
203
|
+
const GUTTER_COLUMNS = 10
|
|
204
|
+
const INDENT = 2
|
|
205
|
+
const NO_STEP_LINE = 'no step has crossed yet'
|
|
189
206
|
/** The props the run header and each section heading are drawn with. */
|
|
190
207
|
const HEADING_PROPS = { bold: true, color: PALETTE.accent }
|
|
191
208
|
const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
|
|
@@ -230,6 +247,13 @@ function freshRun() {
|
|
|
230
247
|
// activity (null until the clock answers), the word it makes, and the one
|
|
231
248
|
// interval that recomputes it, known by its handle and its generation.
|
|
232
249
|
lastActivityAt: null,
|
|
250
|
+
// The reading the last stamp took (null when the clock failed), and the
|
|
251
|
+
// launch's own: the timeline's times, never guessed.
|
|
252
|
+
stampedAt: null,
|
|
253
|
+
launchedAt: null,
|
|
254
|
+
// Every step applied, in crossing order, and the time each batch was first dispatched, by `<waveIndex>:<batchIndex>`.
|
|
255
|
+
steps: [],
|
|
256
|
+
batchAt: new Map(),
|
|
233
257
|
quietWord: null,
|
|
234
258
|
tick: null,
|
|
235
259
|
generation: 0,
|
|
@@ -308,7 +332,8 @@ function position() {
|
|
|
308
332
|
if (run.change) parts.push(run.change)
|
|
309
333
|
if (run.action) parts.push(run.action)
|
|
310
334
|
if (run.wave !== undefined && run.wave !== null) parts.push(`wave ${run.wave}`)
|
|
311
|
-
|
|
335
|
+
const batch = batchText(run.batchIndex, run.batchCount)
|
|
336
|
+
if (batch) parts.push(batch)
|
|
312
337
|
return parts.join(' · ')
|
|
313
338
|
}
|
|
314
339
|
|
|
@@ -376,6 +401,7 @@ async function stamp($) {
|
|
|
376
401
|
const held = run
|
|
377
402
|
const now = await clockNow($)
|
|
378
403
|
if (held !== run) return false
|
|
404
|
+
run.stampedAt = now
|
|
379
405
|
if (now !== null) run.lastActivityAt = run.lastActivityAt === null ? now : Math.max(run.lastActivityAt, now)
|
|
380
406
|
return setWord(null)
|
|
381
407
|
}
|
|
@@ -813,13 +839,24 @@ function agentOf(id) {
|
|
|
813
839
|
// The two words after `interlock` in the first driver line it ran while no row named it.
|
|
814
840
|
relay: null,
|
|
815
841
|
// Whether its turn end has been spoken: once per agent per run (speak-lane-turn-ends design D1).
|
|
816
|
-
endToasted: false
|
|
842
|
+
endToasted: false,
|
|
843
|
+
// The reading at its first request, taken once (null when the clock failed then), and at its turn end.
|
|
844
|
+
stepped: false,
|
|
845
|
+
firstAt: null,
|
|
846
|
+
endAt: null
|
|
817
847
|
}
|
|
818
848
|
run.agents.set(id, a)
|
|
819
849
|
}
|
|
820
850
|
return a
|
|
821
851
|
}
|
|
822
852
|
|
|
853
|
+
/** The agent's first request, timed once by the stamp just taken: a later reading never moves it. */
|
|
854
|
+
function firstRequest(a) {
|
|
855
|
+
if (a.stepped) return
|
|
856
|
+
a.stepped = true
|
|
857
|
+
a.firstAt = run.stampedAt
|
|
858
|
+
}
|
|
859
|
+
|
|
823
860
|
/** Field by field: a field one request omitted leaves that figure unknown (`null`), never a guessed zero (design D5). */
|
|
824
861
|
function addUsage(a, usage) {
|
|
825
862
|
a.requests++
|
|
@@ -905,7 +942,34 @@ function takeBoard($, record) {
|
|
|
905
942
|
if (record.action === 'halt') run.halt = { reason: str(record.reason) || (run.halt ? run.halt.reason : null) }
|
|
906
943
|
}
|
|
907
944
|
|
|
908
|
-
|
|
945
|
+
/**
|
|
946
|
+
* One step as the timeline draws it, its fields the record's own: the time the
|
|
947
|
+
* stamp read, its position, a skip or halt reason, the size of a plan summary
|
|
948
|
+
* it carried, the agent whose Bash call printed it, and the spawns it named.
|
|
949
|
+
*/
|
|
950
|
+
function noteStep(record, relayId, spawns) {
|
|
951
|
+
const step = {
|
|
952
|
+
n: run.steps.length,
|
|
953
|
+
at: run.stampedAt,
|
|
954
|
+
action: record.action,
|
|
955
|
+
wave: record.wave,
|
|
956
|
+
waveIndex: record.waveIndex,
|
|
957
|
+
batchIndex: record.batchIndex,
|
|
958
|
+
batchCount: record.batchCount,
|
|
959
|
+
skipped: record.skipped,
|
|
960
|
+
reason: record.reason,
|
|
961
|
+
planWaves: isPlanSummary(record.plan) ? planWaveCount(record.plan) : null,
|
|
962
|
+
relayId: str(relayId),
|
|
963
|
+
spawns
|
|
964
|
+
}
|
|
965
|
+
run.steps.push(step)
|
|
966
|
+
if (BATCH_ACTIONS.has(record.action) && isInt(record.waveIndex) && isInt(record.batchIndex)) {
|
|
967
|
+
const at = `${record.waveIndex}:${record.batchIndex}`
|
|
968
|
+
if (!run.batchAt.has(at)) run.batchAt.set(at, step.at)
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function applyRecord($, record, relayId) {
|
|
909
973
|
const held = run
|
|
910
974
|
await stamp($)
|
|
911
975
|
if (held !== run) return
|
|
@@ -915,8 +979,10 @@ async function applyRecord($, record) {
|
|
|
915
979
|
if (record.wave !== undefined) run.wave = record.wave
|
|
916
980
|
run.batchIndex = record.batchIndex
|
|
917
981
|
run.batchCount = record.batchCount
|
|
982
|
+
const spawns = []
|
|
918
983
|
for (const s of Array.isArray(record.spawns) ? record.spawns : []) {
|
|
919
984
|
if (!s || typeof s !== 'object' || !str(s.label)) continue
|
|
985
|
+
spawns.push({ label: s.label, title: str(s.title) || s.label, sha: str(s.promptSha256) })
|
|
920
986
|
run.rows.set(s.label, {
|
|
921
987
|
label: s.label,
|
|
922
988
|
title: str(s.title) || s.label,
|
|
@@ -930,6 +996,7 @@ async function applyRecord($, record) {
|
|
|
930
996
|
batch: BATCH_ACTIONS.has(record.action)
|
|
931
997
|
})
|
|
932
998
|
}
|
|
999
|
+
noteStep(record, relayId, spawns)
|
|
933
1000
|
takeBoard($, record)
|
|
934
1001
|
for (const banner of Array.isArray(record.banners) ? record.banners : []) {
|
|
935
1002
|
if (typeof banner !== 'string' || !banner) continue
|
|
@@ -1106,39 +1173,118 @@ function sessionLines(usage, failure) {
|
|
|
1106
1173
|
}
|
|
1107
1174
|
|
|
1108
1175
|
/**
|
|
1109
|
-
* `
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1176
|
+
* `started <local date and time> · last activity <local time>` (the start left
|
|
1177
|
+
* out when the launch read no time, `last activity unknown` when no stamp did),
|
|
1178
|
+
* then the quiet word while a live run is quiet, as the line and the word
|
|
1179
|
+
* apart. The word is recomputed from the clock at each draw, so a pane opened
|
|
1180
|
+
* after the host stopped the interval still shows it (design D2, D5).
|
|
1113
1181
|
*/
|
|
1114
1182
|
async function lastActivityText($) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1183
|
+
const started = run.launchedAt === null ? '' : `started ${stampText(run.launchedAt)} · `
|
|
1184
|
+
if (run.lastActivityAt === null) return { line: `${started}last activity unknown`, word: null }
|
|
1117
1185
|
const word = run.phase === 'live' ? quietWord(quietMs(run.lastActivityAt, await clockNow($))) : null
|
|
1118
|
-
return { line:
|
|
1186
|
+
return { line: `${started}last activity ${clockText(run.lastActivityAt)}`, word }
|
|
1119
1187
|
}
|
|
1120
1188
|
|
|
1189
|
+
/** An agent id as a line shows it: its first eight characters. */
|
|
1190
|
+
const id8 = id => String(id).slice(0, 8)
|
|
1191
|
+
|
|
1192
|
+
/** `1 request`, `N requests`. */
|
|
1193
|
+
const requestsText = a => (a.requests === 1 ? '1 request' : `${a.requests} requests`)
|
|
1194
|
+
|
|
1195
|
+
/** An agent's turn: `running`, else the host's word and, once reported, how long the turn took. */
|
|
1196
|
+
const turnText = a =>
|
|
1197
|
+
a.reason ? `${a.reason}${a.durationMs !== null ? ` ${durationText(a.durationMs)}` : ''}` : 'running'
|
|
1198
|
+
|
|
1199
|
+
/** Whether the host ended the agent's turn without an answer. */
|
|
1200
|
+
const abnormal = a => Boolean(a.reason) && a.reason !== 'answer'
|
|
1201
|
+
|
|
1121
1202
|
/**
|
|
1122
|
-
* An agent's first line: its name, its id and models dim,
|
|
1123
|
-
* A joined agent is named in the identity colour, a relay
|
|
1124
|
-
* agent dim and italic. The turn word is the host's, coloured
|
|
1125
|
-
* a relay's running or answered turn stays dim, and only its
|
|
1203
|
+
* An agent's first line, one wrapping text: its name, its id and models dim,
|
|
1204
|
+
* then its turn word. A joined agent is named in the identity colour, a relay
|
|
1205
|
+
* dim, an unmatched agent dim and italic. The turn word is the host's, coloured
|
|
1206
|
+
* by what it means; a relay's running or answered turn stays dim, and only its
|
|
1207
|
+
* abnormal end is coloured.
|
|
1126
1208
|
*/
|
|
1127
|
-
function agentLine(Text, a,
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1130
|
-
const nameProps = row ? NAME_PROPS : a.relay ? { dimColor: true } : { dimColor: true, italic: true }
|
|
1131
|
-
const quiet = !row && a.relay && (!a.reason || a.reason === 'answer')
|
|
1209
|
+
function agentLine(Text, a, name, kind) {
|
|
1210
|
+
const nameProps = kind === 'joined' ? NAME_PROPS : kind === 'relay' ? DIM : { dimColor: true, italic: true }
|
|
1211
|
+
const quiet = kind === 'relay' && !abnormal(a)
|
|
1132
1212
|
return h(
|
|
1133
1213
|
Text,
|
|
1134
|
-
|
|
1135
|
-
h(Text, nameProps, name
|
|
1136
|
-
h(Text,
|
|
1214
|
+
{ wrap: 'wrap' },
|
|
1215
|
+
h(Text, nameProps, name),
|
|
1216
|
+
h(Text, DIM, ` · ${id8(a.id)} · ${a.models.join(', ') || 'model not reported'}`),
|
|
1137
1217
|
' · ',
|
|
1138
|
-
h(Text, quiet ?
|
|
1218
|
+
h(Text, quiet ? DIM : turnProps(a.reason), turnText(a))
|
|
1139
1219
|
)
|
|
1140
1220
|
}
|
|
1141
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* One timed line (layout probe P3): the local time in a fixed dim gutter,
|
|
1224
|
+
* blank when no time was read, then the content in a column that wraps beside
|
|
1225
|
+
* it, `depth` steps in. Each content line is one Text with its coloured words
|
|
1226
|
+
* nested inside it, never a row of sibling Texts.
|
|
1227
|
+
*/
|
|
1228
|
+
function gutterRow(Box, Text, key, at, depth, ...content) {
|
|
1229
|
+
return h(
|
|
1230
|
+
Box,
|
|
1231
|
+
{ key, flexDirection: 'row' },
|
|
1232
|
+
h(Box, { width: GUTTER_COLUMNS, flexShrink: 0 }, h(Text, DIM, clockText(at))),
|
|
1233
|
+
h(Box, { flexGrow: 1, flexShrink: 1, flexDirection: 'column', paddingLeft: depth * INDENT }, ...content)
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* A step's line: its own words, then the agent whose Bash call printed it,
|
|
1239
|
+
* dim, with its served models, its requests and, once its turn ended, how long
|
|
1240
|
+
* it took; the relay's turn word only when the host ended it without an answer.
|
|
1241
|
+
*/
|
|
1242
|
+
function stepLine(Text, step) {
|
|
1243
|
+
const spans = [stepText(step)]
|
|
1244
|
+
if (step.relayId) {
|
|
1245
|
+
const a = run.agents.get(step.relayId)
|
|
1246
|
+
const figures = a ? ` · ${a.models.join(', ') || 'model not reported'} · ${requestsText(a)}` : ''
|
|
1247
|
+
spans.push(h(Text, DIM, ` · cli ${id8(step.relayId)}${figures}`))
|
|
1248
|
+
if (a && abnormal(a)) spans.push(' · ', h(Text, turnProps(a.reason), turnText(a)))
|
|
1249
|
+
else if (a && a.durationMs !== null) spans.push(h(Text, DIM, ` · ${durationText(a.durationMs)}`))
|
|
1250
|
+
}
|
|
1251
|
+
return h(Text, { wrap: 'wrap' }, ...spans)
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The timeline section: `lib/meter-timeline.mjs`'s entries, each a gutter
|
|
1256
|
+
* line. An agent a step spawned and an agent no step names keep the requests
|
|
1257
|
+
* and tokens line beneath their own; a relay's figures ride its step's line or
|
|
1258
|
+
* its own one line, and its token breakdown is the trajectory's.
|
|
1259
|
+
*/
|
|
1260
|
+
function drawTimeline($, e) {
|
|
1261
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1262
|
+
const entries = timeline({
|
|
1263
|
+
steps: run.steps,
|
|
1264
|
+
agents: [...run.agents.values()],
|
|
1265
|
+
launch: { at: run.launchedAt, name: run.workflowName }
|
|
1266
|
+
})
|
|
1267
|
+
const figures = a => h(Text, { dimColor: true, wrap: 'wrap' }, `${requestsText(a)} · ${tokensText(a)}`)
|
|
1268
|
+
const out = []
|
|
1269
|
+
for (const entry of entries) {
|
|
1270
|
+
const row = (...content) => gutterRow(Box, Text, entry.key, entry.at, entry.depth, ...content)
|
|
1271
|
+
if (entry.kind === 'launch') out.push(row(h(Text, { wrap: 'wrap' }, `launch · ${entry.name || 'interlock:ship'}`)))
|
|
1272
|
+
else if (entry.kind === 'step') out.push(row(stepLine(Text, entry.step)))
|
|
1273
|
+
else if (entry.kind === 'spawn' && entry.agent) {
|
|
1274
|
+
out.push(row(agentLine(Text, entry.agent, entry.spawn.title, 'joined'), figures(entry.agent)))
|
|
1275
|
+
} else if (entry.kind === 'spawn') {
|
|
1276
|
+
out.push(row(h(Text, { wrap: 'wrap' }, h(Text, NAME_PROPS, entry.spawn.title), ' · ', h(Text, DIM, 'waiting'))))
|
|
1277
|
+
} else if (entry.kind === 'relay') out.push(row(agentLine(Text, entry.agent, `cli · ${entry.agent.relay}`, 'relay')))
|
|
1278
|
+
else if (entry.kind === 'unmatched-head') out.push(h(Box, { key: entry.key }, h(Text, DIM, 'unmatched')))
|
|
1279
|
+
else if (entry.kind === 'unmatched') out.push(row(agentLine(Text, entry.agent, UNMATCHED, 'unmatched'), figures(entry.agent)))
|
|
1280
|
+
}
|
|
1281
|
+
if (entries.some(entry => entry.kind === 'unmatched')) {
|
|
1282
|
+
out.push(h(Box, { key: 'agents-unmatched', paddingLeft: INDENT }, h(Text, { dimColor: true, wrap: 'wrap' }, UNMATCHED_LINE)))
|
|
1283
|
+
}
|
|
1284
|
+
if (!run.steps.length) out.push(h(Box, { key: 'timeline-no-step' }, h(Text, DIM, NO_STEP_LINE)))
|
|
1285
|
+
return out
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1142
1288
|
/** One spawn as a flat row, as the section drew every spawn before the board: its title in the identity colour. */
|
|
1143
1289
|
function flatRow($, e, r) {
|
|
1144
1290
|
const { Box, Text } = $.ui.resolve(e)
|
|
@@ -1180,7 +1326,7 @@ function laneNotes() {
|
|
|
1180
1326
|
for (const r of run.rows.values()) {
|
|
1181
1327
|
const a = agentForSha(r.sha)
|
|
1182
1328
|
const served = a && a.models.length ? a.models.join(', ') : '?'
|
|
1183
|
-
notes[r.label] = `served ${served}${a ? ` · ${a
|
|
1329
|
+
notes[r.label] = `served ${served}${a ? ` · ${turnText(a)}` : ''}`
|
|
1184
1330
|
}
|
|
1185
1331
|
return notes
|
|
1186
1332
|
}
|
|
@@ -1193,11 +1339,13 @@ function boardKey(key) {
|
|
|
1193
1339
|
return `${kind === 'wave' ? 'wave-block' : `wave-${kind}`}-${rest.join('-')}`
|
|
1194
1340
|
}
|
|
1195
1341
|
|
|
1196
|
-
/**
|
|
1342
|
+
/**
|
|
1343
|
+
* The width a card and the renderer share: the section body (the pane's body
|
|
1344
|
+
* less the body's indent beneath its heading), capped at the published default.
|
|
1345
|
+
*/
|
|
1197
1346
|
function boardColumns(e) {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
: LIMITS.waveBoardDefaultColumns
|
|
1347
|
+
const pane = e.props && isInt(e.props.bodyColumns) && e.props.bodyColumns > 0 ? e.props.bodyColumns : null
|
|
1348
|
+
const body = pane === null ? LIMITS.waveBoardDefaultColumns : Math.max(1, pane - INDENT)
|
|
1201
1349
|
return body < LIMITS.waveBoardMinColumns ? body : Math.min(body, LIMITS.waveBoardDefaultColumns)
|
|
1202
1350
|
}
|
|
1203
1351
|
|
|
@@ -1226,14 +1374,14 @@ function idSpans(Text, parts) {
|
|
|
1226
1374
|
}
|
|
1227
1375
|
|
|
1228
1376
|
/**
|
|
1229
|
-
* A lane's
|
|
1230
|
-
*
|
|
1377
|
+
* A lane's label, model, tier and effort cells, space-joined as the board
|
|
1378
|
+
* prints them, the label a span in the identity colour. The batch cell is the
|
|
1379
|
+
* sub-header the lane sits under, so the lane does not repeat it.
|
|
1231
1380
|
*/
|
|
1232
1381
|
function headSpans(Text, parts) {
|
|
1233
|
-
const before = [parts.batch].filter(Boolean).join(' ')
|
|
1234
1382
|
const after = [parts.model, parts.tier, parts.effort].filter(Boolean).join(' ')
|
|
1235
|
-
if (!parts.label) return [
|
|
1236
|
-
return [
|
|
1383
|
+
if (!parts.label) return [after]
|
|
1384
|
+
return [h(Text, NAME_PROPS, parts.label), ...(after ? [` ${after}`] : [])]
|
|
1237
1385
|
}
|
|
1238
1386
|
|
|
1239
1387
|
/** Whether a lane the board drew holds the cursor: its own state word, or a task's, is `current`. */
|
|
@@ -1242,10 +1390,11 @@ const isCurrentLane = parts =>
|
|
|
1242
1390
|
(parts.state === 'current' || (Array.isArray(parts.tasks) && parts.tasks.some(task => task && task.word === 'current')))
|
|
1243
1391
|
|
|
1244
1392
|
/**
|
|
1245
|
-
* A lane in its card
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1393
|
+
* A lane in its card, one step in beneath its batch's sub-header with a blank
|
|
1394
|
+
* gutter: the fixed cells as one Text, the gist wrapping beneath, then the
|
|
1395
|
+
* host's note, dim. The coloured words are spans nested in that one Text,
|
|
1396
|
+
* because a row of separate elements shrinks each into its own column and
|
|
1397
|
+
* breaks words apart once the line is wider than the card.
|
|
1249
1398
|
*/
|
|
1250
1399
|
function drawLane($, e, row) {
|
|
1251
1400
|
const { Box, Text } = $.ui.resolve(e)
|
|
@@ -1261,9 +1410,35 @@ function drawLane($, e, row) {
|
|
|
1261
1410
|
const kids = [
|
|
1262
1411
|
h(Text, { wrap: 'wrap' }, ...line),
|
|
1263
1412
|
parts.gist ? h(Box, { key: `${key}-gist` }, h(Text, { wrap: 'wrap' }, parts.gist)) : null,
|
|
1264
|
-
parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true }, parts.note)) : null
|
|
1413
|
+
parts.note ? h(Box, { key: `${key}-note` }, h(Text, { dimColor: true, wrap: 'wrap' }, parts.note)) : null
|
|
1265
1414
|
].filter(Boolean)
|
|
1266
|
-
return
|
|
1415
|
+
return gutterRow(Box, Text, key, null, 1, ...kids)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* A card's lanes, grouped by the batch the board printed for each: before each
|
|
1420
|
+
* group a gutter line with the time that batch was first dispatched and how
|
|
1421
|
+
* many lanes it runs side by side. It carries no state word: the cursor card's
|
|
1422
|
+
* border already marks where the run is.
|
|
1423
|
+
*/
|
|
1424
|
+
function drawBatches($, e, index, lanes) {
|
|
1425
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
1426
|
+
const groups = new Map()
|
|
1427
|
+
for (const row of lanes) {
|
|
1428
|
+
const batch = row.parts && typeof row.parts.batch === 'string' ? row.parts.batch : null
|
|
1429
|
+
if (!groups.has(batch)) groups.set(batch, [])
|
|
1430
|
+
groups.get(batch).push(row)
|
|
1431
|
+
}
|
|
1432
|
+
const out = []
|
|
1433
|
+
for (const [batch, rows] of groups) {
|
|
1434
|
+
const k = batch && /^b\d+$/.test(batch) ? batch.slice(1) : null
|
|
1435
|
+
if (k !== null) {
|
|
1436
|
+
const at = run.batchAt.has(`${index}:${k}`) ? run.batchAt.get(`${index}:${k}`) : null
|
|
1437
|
+
out.push(gutterRow(Box, Text, `wave-batch-${index}-${k}`, at, 0, h(Text, { wrap: 'wrap' }, `${batch} · ${laneCountText(rows.length)}`)))
|
|
1438
|
+
}
|
|
1439
|
+
out.push(...rows.map(row => drawLane($, e, row)))
|
|
1440
|
+
}
|
|
1441
|
+
return out
|
|
1267
1442
|
}
|
|
1268
1443
|
|
|
1269
1444
|
function drawBoardCards($, e, board, width) {
|
|
@@ -1272,10 +1447,12 @@ function drawBoardCards($, e, board, width) {
|
|
|
1272
1447
|
const out = []
|
|
1273
1448
|
let index = null
|
|
1274
1449
|
let kids = null
|
|
1450
|
+
let lanes = null
|
|
1275
1451
|
// Whether the card being filled holds a lane at the cursor: its border is then the current colour.
|
|
1276
1452
|
let current = false
|
|
1277
1453
|
const flush = () => {
|
|
1278
1454
|
if (index === null) return
|
|
1455
|
+
kids.push(...drawBatches($, e, index, lanes))
|
|
1279
1456
|
out.push(
|
|
1280
1457
|
h(
|
|
1281
1458
|
Box,
|
|
@@ -1294,6 +1471,7 @@ function drawBoardCards($, e, board, width) {
|
|
|
1294
1471
|
)
|
|
1295
1472
|
index = null
|
|
1296
1473
|
kids = null
|
|
1474
|
+
lanes = null
|
|
1297
1475
|
current = false
|
|
1298
1476
|
}
|
|
1299
1477
|
for (const row of board) {
|
|
@@ -1302,14 +1480,14 @@ function drawBoardCards($, e, board, width) {
|
|
|
1302
1480
|
flush()
|
|
1303
1481
|
index = row.key.slice('wave:'.length)
|
|
1304
1482
|
kids = [h(Box, { key: boardKey(row.key) }, h(Text, { bold: true }, waveTitleOf(row)))]
|
|
1483
|
+
lanes = []
|
|
1305
1484
|
continue
|
|
1306
1485
|
}
|
|
1307
1486
|
if (row.key.startsWith('lane:')) {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
kids.push(lane)
|
|
1487
|
+
if (lanes) {
|
|
1488
|
+
lanes.push(row)
|
|
1311
1489
|
if (isCurrentLane(row.parts)) current = true
|
|
1312
|
-
} else out.push(
|
|
1490
|
+
} else out.push(drawLane($, e, row))
|
|
1313
1491
|
continue
|
|
1314
1492
|
}
|
|
1315
1493
|
flush()
|
|
@@ -1358,6 +1536,8 @@ async function drawPane($, e) {
|
|
|
1358
1536
|
const { Box, Text } = $.ui.resolve(e)
|
|
1359
1537
|
const keyed = (key, text, props) => h(Box, { key }, h(Text, props || null, text))
|
|
1360
1538
|
const heading = text => h(Box, { marginTop: 1 }, h(Text, HEADING_PROPS, text))
|
|
1539
|
+
// Each section's body sits one indent in beneath its heading.
|
|
1540
|
+
const body = (key, ...kids) => h(Box, { key, flexDirection: 'column', paddingLeft: INDENT }, ...kids)
|
|
1361
1541
|
// The launch-guard line, in the alarm colour only while its ruling refuses the next launch.
|
|
1362
1542
|
const guardLine = async () => {
|
|
1363
1543
|
const guard = await launchGuardText($)
|
|
@@ -1382,23 +1562,10 @@ async function drawPane($, e) {
|
|
|
1382
1562
|
out.push(h(Text, { dimColor: true }, RECORD_LINE))
|
|
1383
1563
|
|
|
1384
1564
|
out.push(heading('waves'))
|
|
1385
|
-
out.push(...drawWaves($, e))
|
|
1565
|
+
out.push(body('waves-body', ...drawWaves($, e)))
|
|
1386
1566
|
|
|
1387
|
-
out.push(heading('
|
|
1388
|
-
|
|
1389
|
-
for (const a of run.agents.values()) {
|
|
1390
|
-
const n = a.requests === 1 ? '1 request' : `${a.requests} requests`
|
|
1391
|
-
const done = a.reason ? `${a.reason}${a.durationMs !== null ? ` ${(a.durationMs / 1000).toFixed(1)}s` : ''}` : 'running'
|
|
1392
|
-
out.push(
|
|
1393
|
-
h(
|
|
1394
|
-
Box,
|
|
1395
|
-
{ key: `agent-${a.id}`, flexDirection: 'column' },
|
|
1396
|
-
h(Box, null, agentLine(Text, a, done)),
|
|
1397
|
-
h(Box, null, h(Text, { dimColor: true }, `${n} · ${tokensText(a)}`))
|
|
1398
|
-
)
|
|
1399
|
-
)
|
|
1400
|
-
}
|
|
1401
|
-
if ([...run.agents.values()].some(a => !agentName(a))) out.push(keyed('agents-unmatched', UNMATCHED_LINE, { dimColor: true }))
|
|
1567
|
+
out.push(heading('timeline'))
|
|
1568
|
+
out.push(body('timeline-body', ...drawTimeline($, e)))
|
|
1402
1569
|
|
|
1403
1570
|
// One plain read feeds the session lines and the plan windows; a breakdown
|
|
1404
1571
|
// is never asked for, because only the plain call is free (design D4).
|
|
@@ -1412,36 +1579,35 @@ async function drawPane($, e) {
|
|
|
1412
1579
|
}
|
|
1413
1580
|
const [contextLine, costLine] = sessionLines(usage, failure)
|
|
1414
1581
|
out.push(heading('session'))
|
|
1415
|
-
out.push(keyed('session-context', contextLine))
|
|
1416
|
-
out.push(keyed('session-cost', costLine))
|
|
1582
|
+
out.push(body('session-body', keyed('session-context', contextLine), keyed('session-cost', costLine)))
|
|
1417
1583
|
|
|
1418
1584
|
out.push(heading('plan windows'))
|
|
1419
1585
|
const windows = usage && Array.isArray(usage.rateLimits) ? usage.rateLimits : []
|
|
1420
|
-
|
|
1421
|
-
|
|
1586
|
+
const planLines = []
|
|
1587
|
+
if (failure !== null) planLines.push(h(Text, { dimColor: true }, `plan windows unavailable: ${failure}`))
|
|
1588
|
+
else if (!windows.length) planLines.push(h(Text, { dimColor: true }, 'none reported (off a subscription the session has none)'))
|
|
1422
1589
|
for (const w of windows) {
|
|
1423
1590
|
if (!w || typeof w !== 'object') continue
|
|
1424
1591
|
const kind = String(w.kind)
|
|
1425
|
-
|
|
1592
|
+
planLines.push(keyed(`plan-window-${kind}`, `${kind} ${w.percentUsed}% used${w.resetsAt ? ` · resets ${w.resetsAt}` : ''}`))
|
|
1426
1593
|
}
|
|
1594
|
+
out.push(body('plan-windows-body', ...planLines))
|
|
1427
1595
|
|
|
1428
1596
|
out.push(heading('banners'))
|
|
1429
1597
|
out.push(
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
{ key: 'banners', flexDirection: 'column' },
|
|
1598
|
+
body(
|
|
1599
|
+
'banners',
|
|
1433
1600
|
...(run.banners.length ? run.banners.map(b => h(Text, { color: PALETTE.warn }, b)) : [h(Text, { dimColor: true }, 'none so far')])
|
|
1434
1601
|
)
|
|
1435
1602
|
)
|
|
1436
1603
|
|
|
1437
1604
|
out.push(heading('refusals'))
|
|
1438
1605
|
const denials = guardDenialLine(run.guardDenials)
|
|
1439
|
-
|
|
1440
|
-
out.push(await guardLine())
|
|
1606
|
+
out.push(body('refusals-body', ...(denials ? [keyed('guard-denials', denials, { color: PALETTE.alarm })] : []), await guardLine()))
|
|
1441
1607
|
|
|
1442
1608
|
if (run.summary) {
|
|
1443
1609
|
out.push(heading('close summary'))
|
|
1444
|
-
out.push(
|
|
1610
|
+
out.push(body('summary', h(Text, null, run.summary)))
|
|
1445
1611
|
}
|
|
1446
1612
|
return h(Box, { flexDirection: 'column' }, ...out)
|
|
1447
1613
|
}
|
|
@@ -1456,7 +1622,7 @@ export function register(on) {
|
|
|
1456
1622
|
try {
|
|
1457
1623
|
await $.command.register({
|
|
1458
1624
|
name: PANE,
|
|
1459
|
-
description: 'Open the Interlock ship meter: the live run, its waves,
|
|
1625
|
+
description: 'Open the Interlock ship meter: the live run, its waves, step timeline, plan windows and banners',
|
|
1460
1626
|
immediate: true
|
|
1461
1627
|
})
|
|
1462
1628
|
} catch (err) {
|
|
@@ -1573,6 +1739,7 @@ export function register(on) {
|
|
|
1573
1739
|
run.workflowName = str(r.result.workflowName)
|
|
1574
1740
|
run.transcriptDir = str(r.result.transcriptDir)
|
|
1575
1741
|
run.lastActivityAt = now
|
|
1742
|
+
run.launchedAt = now
|
|
1576
1743
|
$.ui.invalidate('ui.render')
|
|
1577
1744
|
const placed = await openPane($)
|
|
1578
1745
|
if (!placed || placed.isPlaced !== true) $.ui.toast(UNPLACED_TOAST)
|
|
@@ -1608,7 +1775,7 @@ export function register(on) {
|
|
|
1608
1775
|
try {
|
|
1609
1776
|
const { record, problem } = readRecord(r.text)
|
|
1610
1777
|
if (!record) $.ui.log(`interlock meter: ${command.slice(0, 80)}: not a step record (${problem})`, { to: 'debug' })
|
|
1611
|
-
else if (held === run) await applyRecord($, record)
|
|
1778
|
+
else if (held === run) await applyRecord($, record, str(e.agentId))
|
|
1612
1779
|
else if (run.phase === 'idle' && !run.idleStepNamed) nameIdleStep($)
|
|
1613
1780
|
} catch (err) {
|
|
1614
1781
|
$.ui.log(`interlock meter: step read failed: ${(err && err.message) || err}`, { to: 'debug' })
|
|
@@ -1642,7 +1809,9 @@ export function register(on) {
|
|
|
1642
1809
|
on('turn.step', async function* ($, e, next) {
|
|
1643
1810
|
if (!session.interactive || run.phase !== 'live' || !e.agentId) return yield* next(e)
|
|
1644
1811
|
// A run agent's request starting, and its answer arriving, are each activity (design D1).
|
|
1812
|
+
const held = run
|
|
1645
1813
|
if (await stamp($)) redrawWord($)
|
|
1814
|
+
if (held === run) firstRequest(agentOf(e.agentId))
|
|
1646
1815
|
const result = yield* next(e)
|
|
1647
1816
|
if (await stamp($)) redrawWord($)
|
|
1648
1817
|
try {
|
|
@@ -1670,7 +1839,9 @@ export function register(on) {
|
|
|
1670
1839
|
$.ui.log(`interlock meter: the turn-end toast for ${a.id} was refused: ${messageOf(err)}`, { to: 'debug' })
|
|
1671
1840
|
}
|
|
1672
1841
|
}
|
|
1842
|
+
const held = run
|
|
1673
1843
|
if (await stamp($)) redrawWord($)
|
|
1844
|
+
if (held === run) a.endAt = run.stampedAt
|
|
1674
1845
|
$.ui.invalidate('ui.render')
|
|
1675
1846
|
}
|
|
1676
1847
|
return next(e)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The ship meter's time words: the clock reading a line was stamped with, and
|
|
2
|
+
// how long a turn took, as a person reads them.
|
|
3
|
+
//
|
|
4
|
+
// Each reading is the engine's own (`$.clock.now()` in the hooks module), so a
|
|
5
|
+
// time is either a real reading or absent: absent draws as nothing, never as a
|
|
6
|
+
// guessed time. The wall-clock words are the local time of the machine the
|
|
7
|
+
// engine runs on, from `Date`'s own local fields. The duration bands are unit
|
|
8
|
+
// conversions (seconds, minutes, hours), not thresholds: no band is a verdict
|
|
9
|
+
// on the figure it formats.
|
|
10
|
+
//
|
|
11
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
12
|
+
// imports this file.
|
|
13
|
+
|
|
14
|
+
const isTime = ms => typeof ms === 'number' && Number.isFinite(ms)
|
|
15
|
+
const pad = n => String(n).padStart(2, '0')
|
|
16
|
+
|
|
17
|
+
/** The local wall-clock time of a reading, `HH:MM:SS`; `''` for no reading. */
|
|
18
|
+
export function clockText(ms) {
|
|
19
|
+
if (!isTime(ms)) return ''
|
|
20
|
+
const d = new Date(ms)
|
|
21
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The local date and time of a reading, `YYYY-MM-DD HH:MM:SS`; `''` for no reading. */
|
|
25
|
+
export function stampText(ms) {
|
|
26
|
+
if (!isTime(ms)) return ''
|
|
27
|
+
const d = new Date(ms)
|
|
28
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clockText(ms)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A duration in milliseconds as a person reads it: under a minute, seconds to
|
|
33
|
+
* one decimal, cut down (`41.2s`, `0.8s`, never `60.0s`); under an hour, whole
|
|
34
|
+
* minutes and seconds (`2m 14s`); else hours and minutes (`1h 03m`). `''` for
|
|
35
|
+
* no figure, a negative one or one that is not a finite number.
|
|
36
|
+
*/
|
|
37
|
+
export function durationText(ms) {
|
|
38
|
+
if (!isTime(ms) || ms < 0) return ''
|
|
39
|
+
if (ms < 60_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`
|
|
40
|
+
const seconds = Math.floor(ms / 1000)
|
|
41
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`
|
|
42
|
+
return `${Math.floor(seconds / 3600)}h ${pad(Math.floor((seconds % 3600) / 60))}m`
|
|
43
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The ship meter's step timeline: what each step the CLI printed says, and the
|
|
2
|
+
// order the pane lays the steps, the agents they spawned and the agents no
|
|
3
|
+
// step names in.
|
|
4
|
+
//
|
|
5
|
+
// A step's words are its own fields, verbatim: the action, its position, how
|
|
6
|
+
// many lanes a batch dispatched, a skipped verification's reason, a halt's
|
|
7
|
+
// reason and the plan size a step relayed. No word here is a verdict, a cause
|
|
8
|
+
// or a state the step did not carry.
|
|
9
|
+
//
|
|
10
|
+
// The order is pure data over plain inputs, so it is pinned under Node
|
|
11
|
+
// (`test/spine/meter-timeline.test.mjs`) and the hooks module only draws it:
|
|
12
|
+
//
|
|
13
|
+
// - The top level is the launch, every step and every relay agent no step
|
|
14
|
+
// names, laid by time. An entry with a time sorts by it, ties keeping their
|
|
15
|
+
// input order (the launch, then the steps as they crossed, then the relays as
|
|
16
|
+
// first seen); an entry with no time follows every timed one, in input order.
|
|
17
|
+
// - Beneath a step, at depth 1, the agents it spawned: a spawn is joined to an
|
|
18
|
+
// agent by its briefing hash, one agent per spawn in step order (an agent
|
|
19
|
+
// beyond the spawns that carry its hash stays with the last of them). The
|
|
20
|
+
// joined spawns are laid by their agent's first request time, by the same
|
|
21
|
+
// rule; the spawns no agent has joined yet follow, `waiting`, in the step's
|
|
22
|
+
// spawn order. A step's relay is drawn on the step's own line and nowhere
|
|
23
|
+
// else.
|
|
24
|
+
// - Last, the agents nothing names, under one head, in first-seen order.
|
|
25
|
+
//
|
|
26
|
+
// Pure and Node-free: the hooks module runs in the engine with no Node, and
|
|
27
|
+
// imports this file.
|
|
28
|
+
|
|
29
|
+
/** The actions that dispatch a batch of lanes. */
|
|
30
|
+
export const BATCH_ACTIONS = new Set(['run-batch', 'test-wave'])
|
|
31
|
+
|
|
32
|
+
const isInt = v => Number.isInteger(v)
|
|
33
|
+
const isTime = v => typeof v === 'number' && Number.isFinite(v)
|
|
34
|
+
const str = v => (typeof v === 'string' && v ? v : null)
|
|
35
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
36
|
+
|
|
37
|
+
/** `batch <j>/<k>`, one-based, as the status line says it; `null` when the step carries no position. */
|
|
38
|
+
export function batchText(batchIndex, batchCount) {
|
|
39
|
+
return isInt(batchIndex) && isInt(batchCount) ? `batch ${batchIndex + 1}/${batchCount}` : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many lanes run side by side: `N in parallel`, `1 lane`, or `null` for none. */
|
|
43
|
+
export function laneCountText(n) {
|
|
44
|
+
if (!isInt(n) || n < 1) return null
|
|
45
|
+
return n === 1 ? '1 lane' : `${n} in parallel`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The waves a plan summary holds, its test wave counted; `null` for anything that is not a summary. */
|
|
49
|
+
export function planWaveCount(plan) {
|
|
50
|
+
if (plan === null || typeof plan !== 'object' || !Array.isArray(plan.waves)) return null
|
|
51
|
+
return plan.waves.length + (plan.testWave ? 1 : 0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A step's words, from its own fields only. */
|
|
55
|
+
export function stepText(step) {
|
|
56
|
+
const s = step && typeof step === 'object' ? step : {}
|
|
57
|
+
const parts = [String(s.action)]
|
|
58
|
+
if (s.wave !== undefined && s.wave !== null) parts.push(`wave ${s.wave}`)
|
|
59
|
+
const batch = batchText(s.batchIndex, s.batchCount)
|
|
60
|
+
if (batch) parts.push(batch)
|
|
61
|
+
if (BATCH_ACTIONS.has(s.action)) {
|
|
62
|
+
const lanes = laneCountText(Array.isArray(s.spawns) ? s.spawns.length : 0)
|
|
63
|
+
if (lanes) parts.push(lanes)
|
|
64
|
+
}
|
|
65
|
+
if (s.action === 'verify' && s.skipped === true) parts.push(str(s.reason) ? `skipped: ${s.reason}` : 'skipped')
|
|
66
|
+
if (s.action === 'halt' && str(s.reason)) parts.push(s.reason)
|
|
67
|
+
if (isInt(s.planWaves)) parts.push(`plan: ${plural(s.planWaves, 'wave')}`)
|
|
68
|
+
return parts.join(' · ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The entries with a time laid by it, ties in input order; then the entries with none, in input order. */
|
|
72
|
+
function byTime(entries) {
|
|
73
|
+
const timed = entries.map((entry, i) => ({ entry, i })).filter(({ entry }) => isTime(entry.at))
|
|
74
|
+
timed.sort((a, b) => a.entry.at - b.entry.at || a.i - b.i)
|
|
75
|
+
return [...timed.map(({ entry }) => entry), ...entries.filter(entry => !isTime(entry.at))]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The timeline as ordered display entries, each `{ key, kind, at, depth, ... }`:
|
|
80
|
+
* `launch` (`name`), `step` (`step`), `spawn` (`step`, `spawn`, `agent` or
|
|
81
|
+
* `null` while it waits), `relay` (`agent`, a relay agent no step names),
|
|
82
|
+
* `unmatched-head`, and `unmatched` (`agent`). An agent's entry is keyed
|
|
83
|
+
* `agent-<id>`, a waiting spawn's `spawn-<n>-<label>`, a step's `step-<n>`.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* steps?: {n: number, at: number|null, action: string, wave?: unknown, waveIndex?: unknown, batchIndex?: unknown,
|
|
87
|
+
* batchCount?: unknown, skipped?: unknown, reason?: unknown, planWaves?: number|null, relayId?: string|null,
|
|
88
|
+
* spawns: {label: string, title: string, sha: string|null}[]}[],
|
|
89
|
+
* agents?: {id: string, sha?: string|null, relay?: string|null, firstAt?: number|null}[],
|
|
90
|
+
* launch?: {at: number|null, name?: string|null}|null
|
|
91
|
+
* }} input
|
|
92
|
+
* @returns {object[]}
|
|
93
|
+
*/
|
|
94
|
+
export function timeline({ steps = [], agents = [], launch = null } = {}) {
|
|
95
|
+
const relays = new Set(steps.map(step => str(step.relayId)).filter(Boolean))
|
|
96
|
+
|
|
97
|
+
// Each spawn carrying a hash, in step order, and the agents carrying it, first seen first.
|
|
98
|
+
const slots = new Map()
|
|
99
|
+
const spawnsOf = step => (Array.isArray(step.spawns) ? step.spawns : [])
|
|
100
|
+
steps.forEach((step, i) =>
|
|
101
|
+
spawnsOf(step).forEach((spawn, j) => {
|
|
102
|
+
const sha = str(spawn.sha)
|
|
103
|
+
if (!sha) return
|
|
104
|
+
if (!slots.has(sha)) slots.set(sha, [])
|
|
105
|
+
slots.get(sha).push({ pos: `${i}:${j}`, agents: [] })
|
|
106
|
+
})
|
|
107
|
+
)
|
|
108
|
+
const placed = new Set()
|
|
109
|
+
const counts = new Map()
|
|
110
|
+
for (const agent of agents) {
|
|
111
|
+
const sha = str(agent.sha)
|
|
112
|
+
if (!sha || relays.has(agent.id) || !slots.has(sha)) continue
|
|
113
|
+
const held = slots.get(sha)
|
|
114
|
+
const at = counts.get(sha) || 0
|
|
115
|
+
held[Math.min(at, held.length - 1)].agents.push(agent)
|
|
116
|
+
counts.set(sha, at + 1)
|
|
117
|
+
placed.add(agent.id)
|
|
118
|
+
}
|
|
119
|
+
const joinedBy = new Map()
|
|
120
|
+
for (const held of slots.values()) for (const slot of held) joinedBy.set(slot.pos, slot.agents)
|
|
121
|
+
|
|
122
|
+
const top = []
|
|
123
|
+
if (launch && typeof launch === 'object') {
|
|
124
|
+
top.push({ key: 'launch', kind: 'launch', at: isTime(launch.at) ? launch.at : null, depth: 0, name: str(launch.name) })
|
|
125
|
+
}
|
|
126
|
+
for (const step of steps) top.push({ key: `step-${step.n}`, kind: 'step', at: isTime(step.at) ? step.at : null, depth: 0, step })
|
|
127
|
+
const orphans = agents.filter(agent => str(agent.relay) && !relays.has(agent.id) && !placed.has(agent.id))
|
|
128
|
+
for (const agent of orphans) {
|
|
129
|
+
top.push({ key: `agent-${agent.id}`, kind: 'relay', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 0, agent })
|
|
130
|
+
placed.add(agent.id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const out = []
|
|
134
|
+
for (const entry of byTime(top)) {
|
|
135
|
+
out.push(entry)
|
|
136
|
+
if (entry.kind !== 'step') continue
|
|
137
|
+
const i = steps.indexOf(entry.step)
|
|
138
|
+
const joined = []
|
|
139
|
+
const waiting = []
|
|
140
|
+
for (const [j, spawn] of spawnsOf(entry.step).entries()) {
|
|
141
|
+
const held = joinedBy.get(`${i}:${j}`) || []
|
|
142
|
+
if (!held.length) {
|
|
143
|
+
waiting.push({ key: `spawn-${entry.step.n}-${spawn.label}`, kind: 'spawn', at: null, depth: 1, step: entry.step, spawn, agent: null })
|
|
144
|
+
}
|
|
145
|
+
for (const agent of held) {
|
|
146
|
+
const at = isTime(agent.firstAt) ? agent.firstAt : null
|
|
147
|
+
joined.push({ key: `agent-${agent.id}`, kind: 'spawn', at, depth: 1, step: entry.step, spawn, agent })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out.push(...byTime(joined), ...waiting)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const unmatched = agents.filter(agent => !relays.has(agent.id) && !placed.has(agent.id))
|
|
154
|
+
if (unmatched.length) {
|
|
155
|
+
out.push({ key: 'agents-unmatched-head', kind: 'unmatched-head', at: null, depth: 0 })
|
|
156
|
+
for (const agent of unmatched) {
|
|
157
|
+
out.push({ key: `agent-${agent.id}`, kind: 'unmatched', at: isTime(agent.firstAt) ? agent.firstAt : null, depth: 1, agent })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out
|
|
161
|
+
}
|