tickmarkr 1.84.0 → 1.85.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.
@@ -3,7 +3,14 @@ import type { RunViewId } from "./views.js";
3
3
  export declare const SPARKLINE_BUCKET_WINDOW = 12;
4
4
  export declare function selectSparklineBucketWidthMs(elapsedMs: number): number;
5
5
  type MetricSample = number | null;
6
- export type RunStatus = "done" | "failed" | "interrupted" | "running";
6
+ /**
7
+ * What the run is, in one word. `parked` is first-class beside the rest because
8
+ * a run holding an unresolved `human` task owes the operator a verb — it is not
9
+ * done, and calling it done is the render OBS-252 caught. The task-level word
10
+ * for the same fact stays `human` (TaskState): that is the event the journal
11
+ * writes, and `parked` is what the fold of it means for the run.
12
+ */
13
+ export type RunStatus = "done" | "failed" | "interrupted" | "parked" | "running";
7
14
  export type RunCockpitData = {
8
15
  readonly binaryVersion: string;
9
16
  readonly runId: string;
@@ -80,12 +87,30 @@ export type TaskRow = {
80
87
  readonly attempts?: number;
81
88
  readonly actor?: string;
82
89
  readonly lastEventTime?: string;
90
+ /** Complete UTC instant paired with `lastEventTime`; additive for existing consumers. */
91
+ readonly lastEventTimestamp?: string;
83
92
  readonly title?: string;
93
+ /**
94
+ * The park the task is standing in right now, as the daemon recorded it
95
+ * (`task-human` data.kind). A resolved park has no kind at all: the badge
96
+ * retires with the state it belonged to, so a merged task never wears the
97
+ * `gate-fail` it was released from an hour earlier (OBS-256).
98
+ */
99
+ readonly parkKind?: string;
100
+ /**
101
+ * Recorded merged, and only then. A task the journal carried to `done` has
102
+ * finished its work; a task the journal recorded a `merge` for has landed.
103
+ * The two are different rows, so a consumer drawing a check-mark has the fact
104
+ * that earns one rather than having to read `done` as if it were the same.
105
+ */
106
+ readonly merged?: true;
84
107
  };
85
108
  /** One recorded gate result. `details` is the record's own text, verbatim. */
86
109
  export type GateRow = {
87
110
  readonly id: string;
88
111
  readonly time: string;
112
+ /** Complete UTC instant paired with `time`; additive for existing consumers. */
113
+ readonly timestamp?: string;
89
114
  readonly state: ComponentState;
90
115
  readonly gate?: string;
91
116
  readonly taskId?: string;
@@ -99,8 +124,10 @@ export type FleetRow = {
99
124
  readonly model: string;
100
125
  readonly dispatches?: number;
101
126
  readonly lastEventTime?: string;
127
+ /** Complete UTC instant paired with `lastEventTime`; additive for existing consumers. */
128
+ readonly lastEventTimestamp?: string;
102
129
  };
103
- export type TaskState = "done" | "failed" | "human" | "pending" | "running" | "interrupted";
130
+ export type TaskState = "completed" | "done" | "failed" | "human" | "pending" | "running" | "interrupted";
104
131
  /**
105
132
  * The source identities the named view's rows carry, in draw order. Selection
106
133
  * is repaired against these, so it follows a row across a refresh instead of
@@ -116,6 +116,75 @@ function daemonPid(events) {
116
116
  }
117
117
  return undefined;
118
118
  }
119
+ /**
120
+ * The state an event writes onto the task it names — the whole list of them, so
121
+ * "did anything move this task" is asked in one place. `task-dispatch` and
122
+ * `merge` carry more than a state and are folded by hand below; they are named
123
+ * here because they move one all the same.
124
+ *
125
+ * A Map, because an event name is whatever the journal wrote: an object's own
126
+ * lookup answers `constructor` and `toString` with something inherited and
127
+ * truthy, so a line naming one would masquerade as a state transition — writing
128
+ * a function where a TaskState belongs and releasing a park the run declared.
129
+ * A Map answers only for the keys stated here.
130
+ */
131
+ const TASK_STATE_BY_EVENT = new Map([
132
+ ["task-dispatch", "running"],
133
+ ["merge", "done"],
134
+ ["task-done", "done"],
135
+ ["task-failed", "failed"],
136
+ ["task-human", "human"],
137
+ ["task-approved", "pending"],
138
+ ]);
139
+ /** Only task identities the summary actually records can enter its fold. */
140
+ function summaryTaskIds(value) {
141
+ return Array.isArray(value)
142
+ ? value.filter((id) => typeof id === "string")
143
+ : [];
144
+ }
145
+ /**
146
+ * The parks the latest `run-end` says the daemon stopped holding, that nothing
147
+ * since resolved. The summary is the daemon's own closing statement, so a run
148
+ * whose end names a task in `human` owes the operator a verb whether or not a
149
+ * `task-human` line happens to precede it — that dependence is what let a run
150
+ * end read `done` over a parked task (OBS-252).
151
+ *
152
+ * "Since" is measured from the summary forward, and only forward. The summary
153
+ * describes the moment the daemon exited, so a state written *before* it —
154
+ * the dispatch it parked, the completion of a round it then held for review —
155
+ * is history the summary already accounts for and cannot answer it. Weighing
156
+ * the whole-journal fold against it let a line above the summary retire a park
157
+ * the summary itself declares: `run-start · task-dispatch T1 · run-end
158
+ * human:[T1]` read `interrupted`, and the same journal with a `task-done T1`
159
+ * read `done` — OBS-252 again, one line lower.
160
+ *
161
+ * So a name is resolved only when a later event moved that task: approved,
162
+ * redispatched, finished, landed. Nothing later — including a task the record
163
+ * never mentions at all — leaves the park standing, because an uncorroborated
164
+ * park is still the run's own claim that it is waiting and withholding `done`
165
+ * on a claim the surface cannot check is the fail-closed reading. It stays a
166
+ * reading of the run and of the tasks it names: a summary is a journal event,
167
+ * so its explicit task outcomes are facts the board must count and draw even
168
+ * when the more detailed task event is absent.
169
+ */
170
+ function unresolvedSummaryParks(events) {
171
+ for (let index = events.length - 1; index >= 0; index -= 1) {
172
+ const event = events[index];
173
+ if (event.event !== "run-end")
174
+ continue;
175
+ const parked = Array.isArray(event.data.human) ? event.data.human : [];
176
+ const released = new Map();
177
+ for (const later of events.slice(index + 1)) {
178
+ if (later.taskId === undefined)
179
+ continue;
180
+ if (!TASK_STATE_BY_EVENT.has(later.event))
181
+ continue;
182
+ released.set(later.taskId, later.event !== "task-human");
183
+ }
184
+ return parked.filter((id) => typeof id === "string" && released.get(id) !== true);
185
+ }
186
+ return [];
187
+ }
119
188
  function deriveTasks(events, interrupted) {
120
189
  const tasks = new Map();
121
190
  const task = (taskId, index, ts) => {
@@ -126,6 +195,8 @@ function deriveTasks(events, interrupted) {
126
195
  id: taskId,
127
196
  dispatches: [],
128
197
  state: undefined,
198
+ parkKind: undefined,
199
+ merged: false,
129
200
  lastIndex: index,
130
201
  lastTs: ts,
131
202
  phase: "pending",
@@ -134,6 +205,30 @@ function deriveTasks(events, interrupted) {
134
205
  return created;
135
206
  };
136
207
  for (const [index, event] of events.entries()) {
208
+ if (event.event === "run-end") {
209
+ // The closing summary is a recorded fold, not commentary. Apply all
210
+ // task outcomes it owns before later resume events get their ordinary
211
+ // chance to supersede them. Failure comes last so a malformed overlap
212
+ // fails closed instead of repainting a failed task done.
213
+ const summaries = [
214
+ [event.data.done, "done"],
215
+ [event.data.human, "human"],
216
+ [event.data.failed, "failed"],
217
+ ];
218
+ for (const [ids, state] of summaries) {
219
+ for (const taskId of summaryTaskIds(ids)) {
220
+ const fact = task(taskId, index, event.ts);
221
+ fact.state = state;
222
+ // A summary does not invent a park kind; it preserves one only when
223
+ // it confirms that the same park still stands.
224
+ if (state !== "human")
225
+ fact.parkKind = undefined;
226
+ fact.lastIndex = index;
227
+ fact.lastTs = event.ts;
228
+ }
229
+ }
230
+ continue;
231
+ }
137
232
  if (!event.taskId)
138
233
  continue;
139
234
  if (event.event === "task-dispatch") {
@@ -148,11 +243,25 @@ function deriveTasks(events, interrupted) {
148
243
  assignment: assignmentFrom(event.data.assignment),
149
244
  });
150
245
  fact.state = "running";
246
+ fact.parkKind = undefined;
151
247
  fact.phase = "worker";
152
248
  fact.lastIndex = index;
153
249
  fact.lastTs = event.ts;
154
250
  continue;
155
251
  }
252
+ if (event.event === "merge") {
253
+ const fact = task(event.taskId, index, event.ts);
254
+ fact.merged = true;
255
+ // A merge is a landing, and it is a state the journal recorded: the task
256
+ // is done, and whatever park it was released from is retired by the merge
257
+ // itself rather than by whichever event happened to precede it. Nothing
258
+ // here depends on a `task-done` line arriving first (OBS-256).
259
+ fact.state = "done";
260
+ fact.parkKind = undefined;
261
+ fact.lastIndex = index;
262
+ fact.lastTs = event.ts;
263
+ continue;
264
+ }
156
265
  if (event.event === "phase-start") {
157
266
  const fact = task(event.taskId, index, event.ts);
158
267
  fact.phase = typeof event.data.phase === "string" ? event.data.phase : fact.phase;
@@ -160,15 +269,19 @@ function deriveTasks(events, interrupted) {
160
269
  fact.lastTs = event.ts;
161
270
  continue;
162
271
  }
163
- const state = {
164
- "task-done": "done",
165
- "task-failed": "failed",
166
- "task-human": "human",
167
- "task-approved": "pending",
168
- }[event.event];
272
+ // `task-dispatch` and `merge` returned above; the rest write their state and
273
+ // nothing else. One list, read from one place, so a new state-writing event
274
+ // cannot move the fold without also moving what resolves a park.
275
+ const state = TASK_STATE_BY_EVENT.get(event.event);
169
276
  if (state) {
170
277
  const fact = task(event.taskId, index, event.ts);
171
278
  fact.state = state;
279
+ // The park kind travels with the park: `task-human` records it, and the
280
+ // next recorded state — approved, done, failed — retires it with the park
281
+ // it named. A resolved state must not leave its badge behind (OBS-256).
282
+ fact.parkKind = state === "human" && typeof event.data.kind === "string"
283
+ ? event.data.kind
284
+ : undefined;
172
285
  fact.lastIndex = index;
173
286
  fact.lastTs = event.ts;
174
287
  }
@@ -250,14 +363,42 @@ function recordsFailure(event) {
250
363
  || event.data.pass === false
251
364
  || event.data.ok === false
252
365
  || (typeof event.data.exitCode === "number" && event.data.exitCode !== 0)
253
- || (event.event === "run-end" && event.data.tipVerify === "failed");
366
+ || (event.event === "run-end"
367
+ && (event.data.tipVerify === "failed"
368
+ || summaryTaskIds(event.data.failed).length > 0));
254
369
  }
370
+ /**
371
+ * The outcome an event records as a success. `merge` is here and `task-done` is
372
+ * not: a merge records the landing, and a completion records only that the
373
+ * worker stopped working — the gates that judge it and the merge that lands it
374
+ * come after. A `run-end` counts only when its tip verification reads passed:
375
+ * an absent verdict is nothing verified, and a check-mark on nothing verified
376
+ * is the run claiming a pass tickmarkr never corroborated — the same
377
+ * fail-closed reading the status strip already draws (OBS-244), now on the row.
378
+ */
255
379
  function recordsSuccess(event) {
256
- return event.event === "task-done"
257
- || event.event === "merge"
380
+ return event.event === "merge"
258
381
  || event.data.pass === true
259
382
  || event.data.ok === true
260
- || (event.event === "run-end" && event.data.tipVerify !== "failed");
383
+ || (event.event === "run-end" && event.data.tipVerify === "passed");
384
+ }
385
+ /** The newest run-end speaks for the run; spotlight is context, not outcome. */
386
+ function runPresentation(runStatus, spotlight) {
387
+ switch (runStatus) {
388
+ case "failed":
389
+ return { state: "fail", word: "failed" };
390
+ case "parked":
391
+ return { state: "warn", word: "parked" };
392
+ case "interrupted":
393
+ return { state: "warn", word: "interrupted" };
394
+ case "running":
395
+ return { state: "neutral", word: "running" };
396
+ case "done":
397
+ // A task named beside the run still earns a check only from its merge.
398
+ return spotlight === undefined || spotlight.merged
399
+ ? { state: "pass", word: "pass" }
400
+ : { state: "neutral", word: "done" };
401
+ }
261
402
  }
262
403
  /**
263
404
  * A row depicts the event it names: the event's own recorded outcome decides
@@ -268,11 +409,25 @@ function recordsSuccess(event) {
268
409
  * Every state carries its word, so meaning never rides on hue alone.
269
410
  */
270
411
  function eventPresentation(event, task) {
271
- if (event.event === "escalation" || event.event === "task-human") {
412
+ if (event.event === "escalation")
272
413
  return { state: "warn", word: "warn" };
273
- }
414
+ // A park is a state, not a severity: the row says what the engagement is
415
+ // waiting for, which is the operator.
416
+ if (event.event === "task-human")
417
+ return { state: "warn", word: "parked" };
274
418
  if (recordsFailure(event))
275
419
  return { state: "fail", word: "fail" };
420
+ if (event.event === "run-end"
421
+ && summaryTaskIds(event.data.human).length > 0) {
422
+ return { state: "warn", word: "parked" };
423
+ }
424
+ // Work finished is not work landed. `task-done` records the first, so its row
425
+ // says `done` and carries no check: the check-mark belongs to the `merge` that
426
+ // records the landing, and until the journal writes one there is nothing to
427
+ // put it on (OBS-256). This is the same reading `taskPresentation` gives the
428
+ // task itself, stated here for the event because a row depicts its own event.
429
+ if (event.event === "task-done")
430
+ return { state: "neutral", word: "done" };
276
431
  if (recordsSuccess(event))
277
432
  return { state: "pass", word: "pass" };
278
433
  if (task?.state === "interrupted") {
@@ -282,19 +437,29 @@ function eventPresentation(event, task) {
282
437
  }
283
438
  function historyRow(event, tasks, spotlight, newest, runStatus) {
284
439
  const task = event.taskId === undefined ? undefined : tasks.get(event.taskId);
285
- const { state, word } = eventPresentation(event, task);
440
+ // Spotlight is context only for the taskless closing summary. A task-owned
441
+ // newest event keeps its own identity and outcome: T2's dispatch or failure
442
+ // cannot become a pass for merged T1 merely because T1 wins the spotlight.
443
+ const summary = newest && event.event === "run-end"
444
+ ? runPresentation(runStatus, spotlight)
445
+ : undefined;
446
+ const { state, word } = summary ?? eventPresentation(event, task);
447
+ const displayedTask = newest
448
+ ? event.taskId === undefined ? spotlight : task
449
+ : task;
286
450
  // The spotlight entry must keep the task, its attempt and its acting adapter
287
451
  // inside the narrowest band's width budget — that budget cannot pay for the
288
452
  // separators and the event name, so the state word and the run's status
289
453
  // (what the newest event recorded) ride after the adapter instead.
290
- const text = newest && spotlight !== undefined
291
- ? `${spotlight.id} attempt ${taskAttempt(spotlight)} ${taskActor(spotlight)} ${word}${word === runStatus ? "" : ` ${runStatus}`}`
454
+ const text = newest && displayedTask !== undefined
455
+ ? `${displayedTask.id} attempt ${taskAttempt(displayedTask)} ${taskActor(displayedTask)} ${word}${word === runStatus ? "" : ` ${runStatus}`}`
292
456
  : task !== undefined
293
457
  ? `${task.id} ${word} · ${event.event}`
294
458
  : `${word} · ${event.event}`;
295
459
  return {
296
460
  id: `event:${event.line}`,
297
461
  time: eventTime(event.ts),
462
+ timestamp: event.ts,
298
463
  state,
299
464
  text,
300
465
  };
@@ -332,14 +497,28 @@ function taskRows(events, tasks, graph) {
332
497
  // zero when a run resumes, so a task dispatched 0,1,0 was tried three times
333
498
  // and reporting its last label would say one.
334
499
  const attempts = fact?.dispatches.length ?? 0;
500
+ // `task-done` records completion; only `merge` records landing. The fixed
501
+ // Tasks-view consumer maps `done` to a check, so the derived row must keep
502
+ // an unmerged completion in the neutral `completed` state and reserve
503
+ // `done` for a fact whose merge the journal actually carries.
504
+ const state = fact?.state === "done" && fact.merged !== true
505
+ ? "completed"
506
+ : fact?.state;
335
507
  return {
336
508
  id: `task:${id}`,
337
509
  taskId: id,
338
- ...(fact?.state === undefined ? {} : { state: fact.state }),
510
+ ...(state === undefined ? {} : { state }),
339
511
  ...(attempts === 0 ? {} : { attempts }),
340
512
  ...(assignment === undefined ? {} : { actor: channelKey(assignment) }),
341
513
  ...(lastTs === undefined ? {} : { lastEventTime: eventTime(lastTs) }),
514
+ ...(lastTs === undefined ? {} : { lastEventTimestamp: lastTs }),
342
515
  ...(title === undefined ? {} : { title }),
516
+ // Only while the park stands: the fold cleared it the moment the journal
517
+ // recorded what the park resolved to.
518
+ ...(fact?.parkKind === undefined ? {} : { parkKind: fact.parkKind }),
519
+ // Landing is its own fact. `done` is work finished; only a recorded merge
520
+ // is work landed, and only that earns a check-mark.
521
+ ...(fact?.merged === true ? { merged: true } : {}),
343
522
  };
344
523
  });
345
524
  }
@@ -356,6 +535,7 @@ function gateRows(events) {
356
535
  return [{
357
536
  id: `gate:${event.line}`,
358
537
  time: eventTime(event.ts),
538
+ timestamp: event.ts,
359
539
  state: pass === true ? "pass" : pass === false ? "fail" : "neutral",
360
540
  ...(typeof event.data.gate === "string" ? { gate: event.data.gate } : {}),
361
541
  ...(event.taskId === undefined ? {} : { taskId: event.taskId }),
@@ -421,6 +601,7 @@ function fleetRows(events) {
421
601
  model: channel.model,
422
602
  ...(channel.dispatches === 0 ? {} : { dispatches: channel.dispatches }),
423
603
  lastEventTime: eventTime(channel.lastTs),
604
+ lastEventTimestamp: channel.lastTs,
424
605
  }));
425
606
  }
426
607
  /**
@@ -535,15 +716,30 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
535
716
  const tipPassed = tipVerificationPassed(events);
536
717
  const spotlight = spotlightTask(tasks);
537
718
  const samples = bucketedMetricSamples(events);
719
+ // A task the journal carried somewhere other than done — approved and awaiting
720
+ // the resume that redispatches it, most of all. `done` is a claim about all of
721
+ // them, so one outstanding task retires it.
722
+ const outstanding = taskFacts.some((task) => task.state !== undefined && task.state !== "done");
723
+ // Parked outranks every reading but failure: a run holding an unresolved
724
+ // `human` task owes the operator a verb, and the daemon's exit after a park is
725
+ // the park's own consequence — not an interruption, and never done. A run that
726
+ // also failed a task owes a fix first, so failure still leads (OBS-252).
727
+ //
728
+ // The word is owed on two readings, not one: the parks the fold holds, and
729
+ // the parks the latest run-end names and nothing since resolved. The second
730
+ // is what keeps the reading off the luck of a `task-human` line preceding the
731
+ // run-end — the run's own closing statement is enough.
538
732
  const runStatus = failed > 0
539
733
  ? "failed"
540
- : lifecycle === "superseded" || hasInterruptedTask
541
- ? "interrupted"
542
- : lifecycle === "completed"
543
- ? "done"
544
- : interrupted
545
- ? "interrupted"
546
- : "running";
734
+ : human > 0 || unresolvedSummaryParks(events).length > 0
735
+ ? "parked"
736
+ : lifecycle === "superseded" || hasInterruptedTask
737
+ ? "interrupted"
738
+ : lifecycle === "completed"
739
+ ? outstanding ? "interrupted" : "done"
740
+ : interrupted
741
+ ? "interrupted"
742
+ : "running";
547
743
  return {
548
744
  binaryVersion,
549
745
  runId: source.fileName.replace(/(?:\.interrupted)?\.journal\.jsonl$/, ""),