@elaraai/e3-ui-cli 1.0.79 → 1.0.81

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.
Files changed (119) hide show
  1. package/README.md +8 -6
  2. package/dist/app/assets/{engine-DAHqaY4g-B9nTMQVT.js → engine-D7VXHt7P-TStdXyXV.js} +1 -1
  3. package/dist/app/assets/{index-CIIzwdrV.js → index-DUOL-ROM.js} +113 -115
  4. package/dist/app/index.html +1 -1
  5. package/dist/cli.js +2 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/tui.d.ts +15 -3
  8. package/dist/commands/tui.d.ts.map +1 -1
  9. package/dist/commands/tui.js +14 -4
  10. package/dist/commands/tui.js.map +1 -1
  11. package/dist/e3-server.d.ts +6 -1
  12. package/dist/e3-server.d.ts.map +1 -1
  13. package/dist/e3-server.js +6 -1
  14. package/dist/e3-server.js.map +1 -1
  15. package/dist/load-source.d.ts +13 -11
  16. package/dist/load-source.d.ts.map +1 -1
  17. package/dist/load-source.js +9 -10
  18. package/dist/load-source.js.map +1 -1
  19. package/dist/tui/api.d.ts +17 -1
  20. package/dist/tui/api.d.ts.map +1 -1
  21. package/dist/tui/api.js +6 -1
  22. package/dist/tui/api.js.map +1 -1
  23. package/dist/tui/app.d.ts.map +1 -1
  24. package/dist/tui/app.js +19 -1
  25. package/dist/tui/app.js.map +1 -1
  26. package/dist/tui/controller.d.ts +3 -1
  27. package/dist/tui/controller.d.ts.map +1 -1
  28. package/dist/tui/controller.js +48 -20
  29. package/dist/tui/controller.js.map +1 -1
  30. package/dist/tui/data/dataflow.d.ts +1 -2
  31. package/dist/tui/data/dataflow.d.ts.map +1 -1
  32. package/dist/tui/data/dataflow.js +1 -3
  33. package/dist/tui/data/dataflow.js.map +1 -1
  34. package/dist/tui/data/dataset.d.ts +24 -5
  35. package/dist/tui/data/dataset.d.ts.map +1 -1
  36. package/dist/tui/data/dataset.js +100 -25
  37. package/dist/tui/data/dataset.js.map +1 -1
  38. package/dist/tui/data/feeds.d.ts +3 -0
  39. package/dist/tui/data/feeds.d.ts.map +1 -1
  40. package/dist/tui/data/feeds.js +60 -7
  41. package/dist/tui/data/feeds.js.map +1 -1
  42. package/dist/tui/data/records.d.ts +63 -0
  43. package/dist/tui/data/records.d.ts.map +1 -0
  44. package/dist/tui/data/records.js +144 -0
  45. package/dist/tui/data/records.js.map +1 -0
  46. package/dist/tui/input/commands.d.ts +14 -11
  47. package/dist/tui/input/commands.d.ts.map +1 -1
  48. package/dist/tui/input/commands.js +20 -15
  49. package/dist/tui/input/commands.js.map +1 -1
  50. package/dist/tui/input/completion.d.ts +10 -4
  51. package/dist/tui/input/completion.d.ts.map +1 -1
  52. package/dist/tui/input/completion.js +6 -0
  53. package/dist/tui/input/completion.js.map +1 -1
  54. package/dist/tui/model/catalogue.d.ts.map +1 -1
  55. package/dist/tui/model/catalogue.js +39 -3
  56. package/dist/tui/model/catalogue.js.map +1 -1
  57. package/dist/tui/model/help.d.ts.map +1 -1
  58. package/dist/tui/model/help.js +44 -4
  59. package/dist/tui/model/help.js.map +1 -1
  60. package/dist/tui/model/status.d.ts +55 -3
  61. package/dist/tui/model/status.d.ts.map +1 -1
  62. package/dist/tui/model/status.js +126 -4
  63. package/dist/tui/model/status.js.map +1 -1
  64. package/dist/tui/model/tree.d.ts +36 -1
  65. package/dist/tui/model/tree.d.ts.map +1 -1
  66. package/dist/tui/model/tree.js +58 -8
  67. package/dist/tui/model/tree.js.map +1 -1
  68. package/dist/tui/render/glyphs.d.ts +2 -0
  69. package/dist/tui/render/glyphs.d.ts.map +1 -1
  70. package/dist/tui/render/glyphs.js +2 -0
  71. package/dist/tui/render/glyphs.js.map +1 -1
  72. package/dist/tui/render/layout.d.ts +1 -1
  73. package/dist/tui/render/layout.d.ts.map +1 -1
  74. package/dist/tui/render/layout.js +50 -5
  75. package/dist/tui/render/layout.js.map +1 -1
  76. package/dist/tui/session.d.ts.map +1 -1
  77. package/dist/tui/session.js +7 -0
  78. package/dist/tui/session.js.map +1 -1
  79. package/dist/tui/state/actions.d.ts +70 -5
  80. package/dist/tui/state/actions.d.ts.map +1 -1
  81. package/dist/tui/state/actions.js +10 -1
  82. package/dist/tui/state/actions.js.map +1 -1
  83. package/dist/tui/state/persist.d.ts +1 -1
  84. package/dist/tui/state/persist.d.ts.map +1 -1
  85. package/dist/tui/state/reducer.d.ts.map +1 -1
  86. package/dist/tui/state/reducer.js +24 -2
  87. package/dist/tui/state/reducer.js.map +1 -1
  88. package/dist/tui/ui/App.js +1 -1
  89. package/dist/tui/ui/App.js.map +1 -1
  90. package/dist/tui/ui/shell/chrome.d.ts +6 -1
  91. package/dist/tui/ui/shell/chrome.d.ts.map +1 -1
  92. package/dist/tui/ui/shell/chrome.js +26 -23
  93. package/dist/tui/ui/shell/chrome.js.map +1 -1
  94. package/dist/tui/ui/views/all.d.ts +1 -0
  95. package/dist/tui/ui/views/all.d.ts.map +1 -1
  96. package/dist/tui/ui/views/all.js +1 -0
  97. package/dist/tui/ui/views/all.js.map +1 -1
  98. package/dist/tui/ui/views/dashboard.d.ts +75 -18
  99. package/dist/tui/ui/views/dashboard.d.ts.map +1 -1
  100. package/dist/tui/ui/views/dashboard.js +265 -40
  101. package/dist/tui/ui/views/dashboard.js.map +1 -1
  102. package/dist/tui/ui/views/help.js +1 -1
  103. package/dist/tui/ui/views/record.d.ts +38 -0
  104. package/dist/tui/ui/views/record.d.ts.map +1 -0
  105. package/dist/tui/ui/views/record.js +298 -0
  106. package/dist/tui/ui/views/record.js.map +1 -0
  107. package/dist/tui/ui/widgets/reads.d.ts +7 -7
  108. package/dist/tui/ui/widgets/reads.d.ts.map +1 -1
  109. package/dist/tui/ui/widgets/reads.js +9 -21
  110. package/dist/tui/ui/widgets/reads.js.map +1 -1
  111. package/dist/tui/ui/widgets/runs.d.ts +2 -2
  112. package/dist/tui/ui/widgets/runs.d.ts.map +1 -1
  113. package/dist/tui/ui/widgets/runs.js +3 -2
  114. package/dist/tui/ui/widgets/runs.js.map +1 -1
  115. package/dist/tui/ui/widgets/tree.d.ts +11 -6
  116. package/dist/tui/ui/widgets/tree.d.ts.map +1 -1
  117. package/dist/tui/ui/widgets/tree.js +28 -17
  118. package/dist/tui/ui/widgets/tree.js.map +1 -1
  119. package/package.json +14 -14
@@ -3,12 +3,13 @@
3
3
  * Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
4
4
  */
5
5
  import { breakpoint, columnPlan, scrollIntoView } from '../../render/layout.js';
6
- import { agoShort, formatDuration, formatSize, hashShort, padEnd, padStart, timeAgo } from '../../render/text.js';
6
+ import { agoShort, formatDuration, formatInt, formatSize, hashShort, padEnd, padStart, timeAgo } from '../../render/text.js';
7
7
  import { layoutOf, registerListModel } from '../../model/index.js';
8
8
  import { datasetEntries } from '../../model/catalogue.js';
9
- import { datasetStatusCell, eventCell, executionDuration, executionStatusCell, statusText, taskStatusCell } from '../../model/status.js';
9
+ import { datasetStatusCell, eventCell, executionDuration, executionStatusCell, intakeCell, recordDeployCell, splitPlace, statusText, taskStatusCell, unitPlace, waitCell, } from '../../model/status.js';
10
10
  import { registerViewHooks } from '../../controller.js';
11
11
  import { isRunLive, lockHolderText } from '../../data/dataflow.js';
12
+ import { recordEntries } from '../../data/records.js';
12
13
  import { b, blank, d, lineWidth, lrLine, t } from '../lines.js';
13
14
  import { centredBlock, sectionLine, tableLine, tablePlan, withScrollbar } from '../shell/widgets.js';
14
15
  import { registerView } from './index.js';
@@ -35,15 +36,21 @@ function ageBare(then, now) {
35
36
  function lowerFirst(text) {
36
37
  return text.length === 0 ? text : text[0].toLowerCase() + text.slice(1);
37
38
  }
38
- /** The dashboard title line: ` main ● DEPLOYED · demand@1.4.2 · deployed 3d ago · lock: none`. */
39
+ /**
40
+ * The dashboard title line: ` main ● DEPLOYED · demand@1.4.2 · deployed 3d ago · lock: none`;
41
+ * while a deploy holds the workspace, the package it deploys, who deploys it
42
+ * and since when, in place of the lock (`◔ DEPLOYING · demand@1.5.0 · pid 4242 · started 9s ago`).
43
+ */
39
44
  export function dashboardTitle(state, ws, ctx) {
40
45
  const g = ctx.g;
41
46
  const info = (state.data.workspaces ?? []).find(w => w.name === ws);
42
47
  const wsState = state.data.workspaceState[ws];
43
48
  const status = state.data.status[ws]?.result;
49
+ const deploying = deployOf(state.data.lock[ws]);
44
50
  const right = [];
45
51
  if (info === undefined || !info.deployed) {
46
- right.push(b(`${g.empty} EMPTY`, 'muted'));
52
+ if (deploying === null)
53
+ right.push(b(`${g.empty} EMPTY`, 'muted'));
47
54
  }
48
55
  else {
49
56
  const pkg = info.packageName.type === 'some' ? `${info.packageName.value}${info.packageVersion.type === 'some' ? `@${info.packageVersion.value}` : ''}` : '';
@@ -52,7 +59,18 @@ export function dashboardTitle(state, ws, ctx) {
52
59
  right.push(d(` ${g.sep} deployed ${timeAgo(wsState.deployedAt, ctx.now)}`));
53
60
  }
54
61
  const lock = status?.lock;
55
- if (lock !== undefined) {
62
+ if (deploying !== null) {
63
+ if (right.length > 0)
64
+ right.push(t(` ${g.sep} `));
65
+ right.push(b(`${g.quarter} DEPLOYING`, 'info'));
66
+ const progress = deployProgressOf(deploying);
67
+ const holder = deploying.state.holder;
68
+ // The process's pid, not its command: the command is the whole argv, paths and all.
69
+ const who = holder.type === 'process' ? `pid ${holder.value.pid}` : holder.value.functionName;
70
+ const pkg = progress !== null ? ` ${g.sep} ${progress.package.name}@${progress.package.version}` : '';
71
+ right.push(d(`${pkg} ${g.sep} ${who} ${g.sep} started ${timeAgo(deploying.state.acquiredAt, ctx.now)}`));
72
+ }
73
+ else if (lock !== undefined) {
56
74
  if (lock.type === 'some') {
57
75
  right.push(t(` ${g.sep} `), b(`lock: ${lockHolderText(lock.value, ctx.now, g)}`, 'warn'));
58
76
  }
@@ -63,6 +81,83 @@ export function dashboardTitle(state, ws, ctx) {
63
81
  right.push(t(' '));
64
82
  return lrLine([t(' '), b(ws)], right, ctx.layout.columns);
65
83
  }
84
+ // ---------------------------------------------------------------------------
85
+ // A deploy in progress
86
+ // ---------------------------------------------------------------------------
87
+ /** The lock, when a deploy holds the workspace; null otherwise. */
88
+ function deployOf(lock) {
89
+ return lock !== undefined && lock !== null && lock.state.operation.type === 'deployment' ? lock : null;
90
+ }
91
+ /** How far a deploy says it has got, once it has reported. */
92
+ function deployProgressOf(lock) {
93
+ return lock.progress.type === 'some' && lock.progress.value.type === 'deployment' ? lock.progress.value.value : null;
94
+ }
95
+ /**
96
+ * The DEPLOY header's right side — what the deploy is doing and how far it has
97
+ * got: `◔ TAKING IN · 2 of 6 files · 134.9 of 202.4 MB · 14.3 MB/s · ~5.2s left`
98
+ * while its files come in, then `◔ MIGRATING` or `◔ BUILDING INDEXES` while a
99
+ * record's step runs, and `◔ FINISHING` once its files and records are done.
100
+ *
101
+ * @param progress - What the deploy last reported
102
+ * @param dctx - The dashboard context
103
+ * @returns The spans
104
+ */
105
+ function deployPhase(progress, dctx) {
106
+ const { sep, quarter } = dctx.g;
107
+ const files = progress.files;
108
+ const done = files.filter(file => file.step.type === 'done').length;
109
+ const total = files.reduce((sum, file) => sum + Number(file.total), 0);
110
+ const plural = `${files.length} file${files.length === 1 ? '' : 's'}`;
111
+ if (done < files.length) {
112
+ // The bytes past their hash: a file taking in, and every file in.
113
+ const moved = files.reduce((sum, file) => sum + (file.step.type === 'done' ? Number(file.total) : file.step.type === 'taking_in' ? Number(file.bytes) : 0), 0);
114
+ // `134.9 of 202.4 MB`: the unit once when both share it.
115
+ const whole = formatSize(total);
116
+ const part = formatSize(moved);
117
+ const unit = whole.slice(whole.indexOf(' '));
118
+ const seconds = (dctx.now - progress.startedAt.getTime()) / 1000;
119
+ const rate = seconds > 0 ? moved / seconds : 0;
120
+ const pace = rate > 0 ? ` ${sep} ${formatSize(rate)}/s ${sep} ~${formatDuration(((total - moved) / rate) * 1000)} left` : '';
121
+ return [b(`${quarter} TAKING IN`, 'info'), d(` ${sep} ${done} of ${plural} ${sep} ${part.endsWith(unit) ? part.slice(0, -unit.length) : part} of ${whole}${pace}`)];
122
+ }
123
+ const running = progress.records.find(record => record.step.type === 'migrating' || record.step.type === 'indexing')?.step.type;
124
+ const head = running === 'migrating' ? 'MIGRATING' : running === 'indexing' ? 'BUILDING INDEXES' : 'FINISHING';
125
+ return [b(`${quarter} ${head}`, 'info'), ...(files.length > 0 ? [d(` ${sep} took in ${plural} ${sep} ${formatSize(total)}`)] : [])];
126
+ }
127
+ /**
128
+ * The column while a deploy holds the workspace: the DEPLOY header, then the
129
+ * normal screen's tables by type — INPUTS, a row per file source the deploy
130
+ * takes in, and RECORDS, a row per record it deploys — each row with the step
131
+ * the deploy is at with it.
132
+ *
133
+ * @param lock - The deploy's lock
134
+ * @param dctx - The dashboard context
135
+ * @returns The lines
136
+ */
137
+ function deployLines(lock, dctx) {
138
+ const g = dctx.g;
139
+ const width = dctx.columns - 1;
140
+ const progress = deployProgressOf(lock);
141
+ if (progress === null)
142
+ return [lrLine([t(' '), b('DEPLOY')], [b(`${g.quarter} STARTING`, 'info'), t(' ')], width)];
143
+ const lines = [lrLine([t(' '), b('DEPLOY')], [...deployPhase(progress, dctx), t(' ')], width)];
144
+ const table = (title, kind, rows) => {
145
+ if (rows.length === 0)
146
+ return;
147
+ const plan = tablePlan(columnPlan(kind, dctx.bp), rows, width);
148
+ lines.push(blank(width), sectionLine(title, '', width), tableLine(plan, null, false, width, g), ...rows.map(row => tableLine(plan, row, false, width, g)));
149
+ };
150
+ table('INPUTS', 'deployInputs', progress.files.map(file => {
151
+ const cell = intakeCell(file, g);
152
+ return { cells: { name: file.path.split('/').pop() ?? file.path, status: { text: statusText(cell, ' '), tone: cell.tone }, size: formatSize(Number(file.total)) } };
153
+ }));
154
+ table('RECORDS', 'deployRecords', progress.records.map(record => {
155
+ const cell = recordDeployCell(record, g);
156
+ const path = record.plan.record;
157
+ return { cells: { name: path.split('/').pop() ?? path, status: { text: statusText(cell), tone: cell.tone }, indexes: record.indexes.length > 0 ? record.indexes.join(', ') : '—' } };
158
+ }));
159
+ return lines;
160
+ }
66
161
  /** The task stats: the four the design shows, plus in-progress / stale-running when non-zero. */
67
162
  function taskStats(s, g) {
68
163
  const stats = [
@@ -150,7 +245,8 @@ function countsLines(status, dctx) {
150
245
  // The model
151
246
  // ---------------------------------------------------------------------------
152
247
  /**
153
- * The latest event of each task, in order of first appearance.
248
+ * The latest event of each task, in order of first appearance. A unit's
249
+ * requeue is not a task's event, so it never stands for its task.
154
250
  *
155
251
  * @param events - The execution's events
156
252
  * @returns One event per task
@@ -159,6 +255,8 @@ export function latestPerTask(events) {
159
255
  const order = [];
160
256
  const latest = new Map();
161
257
  for (const event of events) {
258
+ if (event.type === 'requeued')
259
+ continue;
162
260
  const task = event.value.task;
163
261
  if (!latest.has(task))
164
262
  order.push(task);
@@ -174,16 +272,67 @@ function isLive(execution) {
174
272
  function opensLogs(event) {
175
273
  return event.type === 'failed' || event.type === 'error';
176
274
  }
275
+ /** An event's row, with nothing after the task's name. */
276
+ function eventRow(event, g) {
277
+ const cell = eventCell(event, g);
278
+ return { cell, task: cell.task, place: '', timestamp: cell.timestamp, running: event.type === 'start', logs: opensLogs(event) };
279
+ }
280
+ /**
281
+ * The rows of the live panel, in the order each happened or began: each
282
+ * task's latest event, a split task's start naming its progress; each unit's
283
+ * latest requeue; and each task or unit waiting for room, whose wait takes the
284
+ * place of the start of a task that waits whole.
285
+ *
286
+ * @param execution - The execution
287
+ * @param latest - Each task's latest event
288
+ * @param g - The glyph set
289
+ * @returns The rows
290
+ */
291
+ export function liveRows(execution, latest, g) {
292
+ const state = execution.state;
293
+ const budget = state !== null && state.budget.type === 'some' ? state.budget.value : null;
294
+ const waiting = state?.waiting ?? [];
295
+ const splits = new Map((state?.splits ?? []).map(split => [split.task, split]));
296
+ const waitsWhole = new Set(waiting.filter(wait => wait.unit.type === 'none').map(wait => wait.task));
297
+ const rows = [];
298
+ for (const event of latest) {
299
+ if (event.type === 'start' && waitsWhole.has(event.value.task))
300
+ continue;
301
+ const split = event.type === 'start' ? splits.get(event.value.task) : undefined;
302
+ rows.push({ ...eventRow(event, g), place: split === undefined ? '' : splitPlace(split, g.sep) });
303
+ }
304
+ // A unit requeued more than once shows its latest.
305
+ const requeues = new Map();
306
+ for (const event of execution.events) {
307
+ if (event.type !== 'requeued')
308
+ continue;
309
+ const place = unitPlace(event.value.unit, g.sep);
310
+ requeues.set(`${event.value.task} ${place}`, { ...eventRow(event, g), place });
311
+ }
312
+ rows.push(...requeues.values());
313
+ for (const wait of waiting) {
314
+ rows.push({
315
+ cell: waitCell(wait, budget, g),
316
+ task: wait.task,
317
+ place: wait.unit.type === 'some' ? unitPlace(wait.unit.value, g.sep) : '',
318
+ timestamp: wait.since,
319
+ running: false,
320
+ logs: false,
321
+ });
322
+ }
323
+ return rows.sort((a, c) => Date.parse(a.timestamp) - Date.parse(c.timestamp));
324
+ }
177
325
  let cached = null;
178
326
  function sameKey(a, b) {
179
327
  return a.ws === b.ws && a.status === b.status && a.statusError === b.statusError && a.workspaceState === b.workspaceState
180
- && a.execution === b.execution && a.datasets === b.datasets && a.taskList === b.taskList
328
+ && a.lock === b.lock && a.execution === b.execution && a.datasets === b.datasets && a.taskList === b.taskList && a.records === b.records
181
329
  && a.columns === b.columns && a.bp === b.bp && a.g === b.g;
182
330
  }
183
331
  /**
184
332
  * The dashboard model for a workspace — built once per data change and
185
333
  * returned as the same object until the status, the execution, the
186
- * dataset list, the task list, the width or the breakpoint changes. The
334
+ * dataset list, the task list, the records read, the width or the
335
+ * breakpoint changes. The
187
336
  * clock, the spinner and the selection are not inputs: they restyle lines
188
337
  * at render ({@link dashboardLines}).
189
338
  *
@@ -198,9 +347,11 @@ export function dashboardModel(state, ws, dctx) {
198
347
  status: state.data.status[ws]?.result,
199
348
  statusError: state.data.statusError[ws],
200
349
  workspaceState: state.data.workspaceState[ws],
350
+ lock: state.data.lock[ws],
201
351
  execution: state.data.execution[ws],
202
352
  datasets: state.data.datasets[ws],
203
353
  taskList: state.data.taskList[ws],
354
+ records: state.data.records[ws],
204
355
  columns: dctx.columns,
205
356
  bp: dctx.bp,
206
357
  g: dctx.g,
@@ -218,9 +369,17 @@ function buildModel(state, ws, dctx, key) {
218
369
  const status = key.status;
219
370
  const execution = key.execution;
220
371
  const empty = {
221
- status, placeholder: [], counts: [], execution, live: false, done: 0, events: [], latest: new Map(),
222
- taskPlan: [], tasks: [], inputPlan: [], inputs: [], inputNames: [], rows: [], total: 0, panelAt: 0, tasksAt: 0, inputsAt: 0,
372
+ status, placeholder: [], counts: [], execution, live: false, done: 0, feed: [], latest: new Map(),
373
+ taskPlan: [], tasks: [], inputPlan: [], inputs: [], inputNames: [], recordPlan: [], records: [], recordNames: [],
374
+ rows: [], total: 0, panelAt: 0, tasksAt: 0, inputsAt: 0, recordsAt: -1,
223
375
  };
376
+ // While a deploy holds the workspace, the column is the deploy: what the
377
+ // workspace held before is going, and a first deploy has nothing else.
378
+ const deploying = deployOf(key.lock);
379
+ if (deploying !== null) {
380
+ const placeholder = deployLines(deploying, dctx);
381
+ return { ...empty, status: undefined, placeholder, total: placeholder.length };
382
+ }
224
383
  if (status === undefined) {
225
384
  const placeholder = [];
226
385
  if (key.workspaceState === null) {
@@ -236,12 +395,14 @@ function buildModel(state, ws, dctx, key) {
236
395
  }
237
396
  // The dataset map and the latest event per task: once per build, shared by every row.
238
397
  const entries = datasetEntries(state, ws);
239
- const latest = new Map(latestPerTask(execution?.events ?? []).map(e => [e.value.task, e]));
398
+ const latestEvents = latestPerTask(execution?.events ?? []);
399
+ const latest = new Map(latestEvents.map(e => [e.value.task, e]));
240
400
  const live = isLive(execution);
241
- const done = execution === undefined ? 0 : execution.events.filter(e => e.type !== 'start').length;
242
- const events = execution === undefined || execution.state === null ? []
243
- : live ? [...latest.values()].slice(-MAX_EVENT_ROWS)
244
- : [...latest.values()].filter(opensLogs);
401
+ // A requeue is a unit's, and ends no task.
402
+ const done = execution === undefined ? 0 : execution.events.filter(e => e.type !== 'start' && e.type !== 'requeued').length;
403
+ const feed = execution === undefined || execution.state === null ? []
404
+ : live ? liveRows(execution, latestEvents, g).slice(-MAX_EVENT_ROWS)
405
+ : latestEvents.filter(opensLogs).map(event => eventRow(event, g));
245
406
  const tasks = status.tasks.map(task => {
246
407
  const cell = taskStatusCell(task.status, g);
247
408
  // The reason / pid / cached detail lives in the last column; only a failure's exit code / message stays inline.
@@ -258,6 +419,7 @@ function buildModel(state, ws, dctx, key) {
258
419
  dependsOn: task.dependsOn.length > 0 ? task.dependsOn.join(', ') : '—',
259
420
  inputs: inputs.length > 0 ? inputs.join(', ') : '—',
260
421
  output: entry?.type ?? '—',
422
+ peak: task.peakBytes.type === 'some' ? formatSize(Number(task.peakBytes.value)) : '—',
261
423
  },
262
424
  };
263
425
  });
@@ -276,19 +438,36 @@ function buildModel(state, ws, dctx, key) {
276
438
  },
277
439
  };
278
440
  });
441
+ // The dataset list names the records; what the records loader read of each fills its row.
442
+ const recordNames = recordEntries(key.datasets ?? []).map(entry => entry.name);
443
+ const records = recordNames.map(name => {
444
+ const facts = key.records?.[name];
445
+ const entry = entries.get(`.records.${name}`);
446
+ const indexes = facts?.signature?.indexes.map(index => index.name) ?? null;
447
+ return {
448
+ head: facts?.head ?? null,
449
+ cells: {
450
+ name,
451
+ rows: facts?.rows != null ? formatInt(facts.rows) : '—',
452
+ size: entry?.size != null ? formatSize(entry.size) : '—',
453
+ indexes: indexes === null ? '…' : indexes.length > 0 ? indexes.join(', ') : '—',
454
+ },
455
+ };
456
+ });
279
457
  const taskPlan = tablePlan(columnPlan('tasks', dctx.bp), tasks, width);
280
458
  const inputPlan = tablePlan(columnPlan('inputs', dctx.bp), inputs, width);
459
+ const recordPlan = records.length > 0 ? tablePlan(columnPlan('records', dctx.bp), records, width) : [];
281
460
  const counts = countsLines(status, dctx);
282
- // The geometry: every selectable row's line, numbered in column order — failures, tasks, inputs.
461
+ // The geometry: every selectable row's line, numbered in column order — failures, tasks, inputs, records.
283
462
  const rows = [];
284
463
  let line = counts.length + 1;
285
464
  const panelAt = line;
286
- const shown = events.slice(0, MAX_EVENT_ROWS);
287
- shown.forEach((event, i) => {
288
- if (opensLogs(event))
289
- rows.push({ kind: 'logs', name: event.value.task, line: panelAt + 1 + i });
465
+ const shown = feed.slice(0, MAX_EVENT_ROWS);
466
+ shown.forEach((row, i) => {
467
+ if (row.logs)
468
+ rows.push({ kind: 'logs', name: row.task, line: panelAt + 1 + i });
290
469
  });
291
- line += 1 + shown.length + (events.length > shown.length ? 1 : 0) + 1;
470
+ line += 1 + shown.length + (feed.length > shown.length ? 1 : 0) + 1;
292
471
  const tasksAt = line;
293
472
  line += 2;
294
473
  status.tasks.forEach((task, i) => rows.push({ kind: 'task', name: task.name, line: line + i }));
@@ -297,7 +476,19 @@ function buildModel(state, ws, dctx, key) {
297
476
  line += 2;
298
477
  inputNames.forEach((name, i) => rows.push({ kind: 'input', name, line: line + i }));
299
478
  line += Math.max(1, inputs.length);
300
- return { ...empty, counts, live, done, events, latest, taskPlan, tasks, inputPlan, inputs, inputNames, rows, total: line, panelAt, tasksAt, inputsAt };
479
+ // A workspace without records has no RECORDS section at all.
480
+ let recordsAt = -1;
481
+ if (records.length > 0) {
482
+ line += 1;
483
+ recordsAt = line;
484
+ line += 2;
485
+ recordNames.forEach((name, i) => rows.push({ kind: 'record', name, line: line + i }));
486
+ line += records.length;
487
+ }
488
+ return {
489
+ ...empty, counts, live, done, feed, latest, taskPlan, tasks, inputPlan, inputs, inputNames, recordPlan, records, recordNames,
490
+ rows, total: line, panelAt, tasksAt, inputsAt, recordsAt,
491
+ };
301
492
  }
302
493
  // ---------------------------------------------------------------------------
303
494
  // Rendering
@@ -324,7 +515,21 @@ function executionLines(model, sel, dctx) {
324
515
  else if (model.live) {
325
516
  title = 'EXECUTION';
326
517
  const head = execution.stopping ? b(`${g.square} STOPPING`, 'warn') : b(`${g.quarter} RUNNING`, 'info');
327
- right = [head, d(` ${sep} started ${timeAgo(execution.state.startedAt, dctx.now)} ${sep} ${model.done} of ${tasksTotal} tasks ${sep} ${spin}`)];
518
+ // What the server's budget holds now, where it has one: `memory 9.4 of 14 GB`, the unit once when both share it.
519
+ const budget = execution.state.budget.type === 'some' ? execution.state.budget.value : null;
520
+ let room = '';
521
+ if (budget !== null) {
522
+ const memory = formatSize(Number(budget.memory));
523
+ const inUse = formatSize(Number(budget.memoryInUse));
524
+ const unit = memory.slice(memory.indexOf(' '));
525
+ room = ` ${sep} cores ${budget.coresInUse} of ${budget.cores} ${sep} memory ${inUse.endsWith(unit) ? inUse.slice(0, -unit.length) : inUse} of ${memory}`;
526
+ }
527
+ const age = ` ${sep} started ${timeAgo(execution.state.startedAt, dctx.now)}`;
528
+ const count = ` ${sep} ${model.done} of ${tasksTotal} tasks`;
529
+ const end = ` ${sep} ${spin}`;
530
+ // Short of room, the run's age gives way to the budget (each feed row carries its own age), then the budget to the age.
531
+ const detail = [age + count + room, count + room, age + count].find(text => lineWidth([t(` ${title} `), head, t(`${text}${end} `)]) <= width) ?? age + count;
532
+ right = [head, d(detail + end)];
328
533
  }
329
534
  else {
330
535
  const state = execution.state;
@@ -337,31 +542,31 @@ function executionLines(model, sel, dctx) {
337
542
  right = [b(`${cell.glyph} ${cell.word}`, cell.tone), d(detail)];
338
543
  }
339
544
  lines.push(lrLine([t(' '), b(title)], [...right, t(' ')], width));
340
- const shown = model.events.slice(0, MAX_EVENT_ROWS);
341
- for (const event of shown) {
342
- const cell = eventCell(event, g);
343
- const logs = opensLogs(event);
344
- const selected = logs && sel?.kind === 'logs' && sel.name === cell.task;
545
+ const shown = model.feed.slice(0, MAX_EVENT_ROWS);
546
+ for (const row of shown) {
547
+ const selected = row.logs && sel?.kind === 'logs' && sel.name === row.task;
345
548
  const left = [
346
549
  t(' '),
347
550
  selected ? b(g.sel, 'brand') : t(' '),
348
- t(padStart(ageBare(cell.timestamp, dctx.now), 4)),
551
+ t(padStart(ageBare(row.timestamp, dctx.now), 4)),
349
552
  t(' '),
350
- b(cell.glyph, cell.tone),
351
- t(padEnd(` ${cell.word}`, 13)),
352
- selected ? b(cell.task) : t(cell.task),
553
+ b(row.cell.glyph, row.cell.tone),
554
+ t(padEnd(` ${row.cell.word}`, 13)),
555
+ selected ? b(row.task) : t(row.task),
353
556
  ];
557
+ if (row.place !== '')
558
+ left.push(d(` ${sep} ${row.place}`));
354
559
  const detail = [];
355
- if (event.type === 'start')
356
- detail.push(d(`${spin} ${ageBare(cell.timestamp, dctx.now)}`));
357
- else if (cell.detail !== '')
358
- detail.push(d(cell.detail));
359
- if (logs)
560
+ if (row.running)
561
+ detail.push(d(`${spin} ${ageBare(row.timestamp, dctx.now)}`));
562
+ else if (row.cell.detail !== '')
563
+ detail.push(d(row.cell.detail));
564
+ if (row.logs)
360
565
  detail.push(t(' '), b(`${g.enter} logs`, 'brand'));
361
566
  lines.push(lrLine(left, [...detail, t(' ')], width));
362
567
  }
363
- if (model.events.length > shown.length) {
364
- lines.push([t(' '), d(`… ${model.events.length - shown.length} more failed ${sep} /logs <task>`)]);
568
+ if (model.feed.length > shown.length) {
569
+ lines.push([t(' '), d(`… ${model.feed.length - shown.length} more failed ${sep} /logs <task>`)]);
365
570
  }
366
571
  return lines;
367
572
  }
@@ -387,6 +592,12 @@ function lastRunText(task, event, size, dctx) {
387
592
  }
388
593
  }
389
594
  }
595
+ /** The `LAST COMMIT` cell of a record row: `set_status · alice · 3m ago`. */
596
+ function lastCommitText(head, dctx) {
597
+ if (head === null)
598
+ return '—';
599
+ return `${head.mutation} ${dctx.g.sep} ${head.actor} ${dctx.g.sep} ${timeAgo(head.at, dctx.now)}`;
600
+ }
390
601
  /**
391
602
  * The column's lines in `[top, top + visible)` — the window the screen
392
603
  * shows, rendered from the model with the selected row restyled and the
@@ -410,8 +621,10 @@ export function dashboardLines(model, dctx, sel, top, visible) {
410
621
  const panel = executionLines(model, selected, dctx);
411
622
  const taskSel = selected?.kind === 'task' ? model.tasks.findIndex(row => row.task.name === selected.name) : -1;
412
623
  const inputSel = selected?.kind === 'input' ? model.inputNames.indexOf(selected.name) : -1;
624
+ const recordSel = selected?.kind === 'record' ? model.recordNames.indexOf(selected.name) : -1;
413
625
  const tasksFirst = model.tasksAt + 2;
414
626
  const inputsFirst = model.inputsAt + 2;
627
+ const recordsFirst = model.recordsAt + 2;
415
628
  const out = [];
416
629
  for (let i = first; i < end; i++) {
417
630
  if (i < model.counts.length)
@@ -439,13 +652,23 @@ export function dashboardLines(model, dctx, sel, top, visible) {
439
652
  out.push(sectionLine('INPUTS', '', width));
440
653
  else if (i === model.inputsAt + 1)
441
654
  out.push(tableLine(model.inputPlan, null, false, width, g));
442
- else {
655
+ else if (model.recordsAt === -1 || i < model.recordsAt - 1) {
443
656
  const row = model.inputs[i - inputsFirst];
444
657
  if (row === undefined)
445
658
  out.push([t(' '), d('no inputs')]);
446
659
  else
447
660
  out.push(tableLine(model.inputPlan, row, i - inputsFirst === inputSel, width, g));
448
661
  }
662
+ else if (i < model.recordsAt)
663
+ out.push(blank(width));
664
+ else if (i === model.recordsAt)
665
+ out.push(sectionLine('RECORDS', '', width));
666
+ else if (i === model.recordsAt + 1)
667
+ out.push(tableLine(model.recordPlan, null, false, width, g));
668
+ else {
669
+ const row = model.records[i - recordsFirst];
670
+ out.push(tableLine(model.recordPlan, { cells: { ...row.cells, lastCommit: lastCommitText(row.head, dctx) } }, i - recordsFirst === recordSel, width, g));
671
+ }
449
672
  }
450
673
  return out;
451
674
  }
@@ -638,6 +861,8 @@ registerViewHooks('dashboard', {
638
861
  controller.openTask(ws, row.name, 'stdout');
639
862
  else if (row.kind === 'task')
640
863
  controller.openTask(ws, row.name);
864
+ else if (row.kind === 'record')
865
+ controller.openRecord(ws, row.name);
641
866
  else
642
867
  controller.openInput(ws, row.name);
643
868
  },