@dxos/react-ui-trace 0.11.1 → 0.14.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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/index.mjs +1722 -561
  3. package/dist/lib/index.mjs.map +1 -1
  4. package/dist/lib/testing.mjs +9 -8
  5. package/dist/lib/testing.mjs.map +1 -1
  6. package/dist/lib/translations.mjs +5 -0
  7. package/dist/lib/translations.mjs.map +1 -1
  8. package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
  9. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
  10. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
  13. package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
  14. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
  15. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
  16. package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
  17. package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
  18. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
  19. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
  20. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  21. package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
  22. package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
  23. package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
  24. package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
  25. package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
  26. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
  27. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
  28. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
  30. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
  31. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
  32. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
  34. package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
  35. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
  36. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
  38. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
  40. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
  41. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
  42. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
  44. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
  46. package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
  47. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
  49. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
  50. package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
  51. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
  52. package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
  53. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
  54. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
  55. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
  56. package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
  57. package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
  58. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
  59. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
  60. package/dist/types/src/session-timeline/index.d.ts +1 -0
  61. package/dist/types/src/session-timeline/index.d.ts.map +1 -1
  62. package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
  63. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
  64. package/dist/types/src/session-timeline/types.d.ts +32 -0
  65. package/dist/types/src/session-timeline/types.d.ts.map +1 -1
  66. package/dist/types/src/testing/simulated-agent.d.ts +5 -4
  67. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
  68. package/dist/types/src/testing/test-trace.d.ts +2 -2
  69. package/dist/types/src/testing/test-trace.d.ts.map +1 -1
  70. package/dist/types/src/translations.d.ts +5 -0
  71. package/dist/types/src/translations.d.ts.map +1 -1
  72. package/dist/types/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +30 -26
  74. package/src/components/Gantt/Gantt.stories.tsx +914 -96
  75. package/src/components/Gantt/Gantt.tsx +964 -261
  76. package/src/components/Gantt/gantt-rows.test.ts +123 -0
  77. package/src/components/Gantt/gantt-rows.ts +71 -0
  78. package/src/components/Gantt/gantt-scale.test.ts +105 -0
  79. package/src/components/Gantt/gantt-scale.ts +143 -0
  80. package/src/components/Gantt/index.ts +2 -0
  81. package/src/components/Gantt/useEnter.ts +33 -0
  82. package/src/components/Gantt/useRangeTween.ts +61 -0
  83. package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
  84. package/src/components/ProcessTree/ProcessTree.tsx +99 -90
  85. package/src/components/Timeline/Timeline.stories.tsx +110 -22
  86. package/src/components/Timeline/Timeline.tsx +319 -271
  87. package/src/components/Timeline/timeline-layout.test.ts +177 -0
  88. package/src/components/Timeline/timeline-layout.ts +153 -0
  89. package/src/components/TracePanel/SpanTreeView.tsx +39 -0
  90. package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
  91. package/src/components/TracePanel/TracePanel.tsx +113 -106
  92. package/src/components/TracePanel/trace-filter.test.ts +31 -2
  93. package/src/components/TracePanel/trace-filter.ts +28 -3
  94. package/src/components/TracePanel/useTraceMenu.ts +0 -1
  95. package/src/execution-graph/SPEC.md +10 -10
  96. package/src/execution-graph/execution-graph.test.ts +16 -15
  97. package/src/execution-graph/execution-graph.ts +108 -60
  98. package/src/execution-graph/pending-block-status.ts +1 -1
  99. package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
  100. package/src/hooks/useExecutionGraph.ts +14 -11
  101. package/src/hooks/useSessionTimeline.ts +35 -9
  102. package/src/hooks/useTraceMessages.ts +1 -1
  103. package/src/session-timeline/TaskHistory.stories.tsx +142 -0
  104. package/src/session-timeline/gantt-mapping.test.ts +118 -0
  105. package/src/session-timeline/gantt-mapping.ts +158 -0
  106. package/src/session-timeline/index.ts +1 -0
  107. package/src/session-timeline/session-timeline.test.ts +585 -35
  108. package/src/session-timeline/session-timeline.ts +433 -62
  109. package/src/session-timeline/types.ts +14 -0
  110. package/src/testing/simulated-agent.ts +17 -16
  111. package/src/testing/test-trace.ts +14 -12
  112. package/src/translations.ts +8 -2
  113. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
  114. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
  115. package/src/execution-graph/remote-snapshot.test.ts +0 -116
@@ -7,15 +7,18 @@ import * as Effect from 'effect/Effect';
7
7
  import * as Option from 'effect/Option';
8
8
  import { describe, test } from 'vitest';
9
9
 
10
+ import { makeTestProcess } from '@dxos/compute-runtime/testing';
10
11
  import * as Process from '@dxos/compute/Process';
11
12
  import { TestTraceService } from '@dxos/compute/testing';
12
13
  import * as Trace from '@dxos/compute/Trace';
13
- import { Annotation, Feed, Obj, Ref } from '@dxos/echo';
14
+ import { Annotation, Database, Feed, Obj, Ref } from '@dxos/echo';
15
+ import { TestDatabaseLayer } from '@dxos/echo-client/testing';
14
16
  import { EID, EntityId, URI } from '@dxos/keys';
15
- import { Task } from '@dxos/types';
17
+ import { Milestone, Task } from '@dxos/types';
18
+ import { getHashHue } from '@dxos/ui-theme';
16
19
 
17
20
  import subAgentFixture from '../execution-graph/testing/sub-agent-delegation.json';
18
- import { buildSessionTimeline } from './session-timeline.ts';
21
+ import { buildSessionTimeline, readTaskStatusChanges } from './session-timeline.ts';
19
22
  import { type Session } from './types.ts';
20
23
 
21
24
  /** The plain-JSON shape the fixture was captured in, before being replayed as real trace messages. */
@@ -257,7 +260,11 @@ describe('buildSessionTimeline', () => {
257
260
  );
258
261
 
259
262
  const messages = yield* TestTraceService.messages;
260
- const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [first, second] });
263
+ const timeline = buildSessionTimeline({
264
+ traceMessages: messages,
265
+ sessions: [chat.session],
266
+ tasks: [first, second],
267
+ });
261
268
  const firstLane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
262
269
  const secondLane = timeline.lanes.find((lane) => lane.id === `task:${second.id}`);
263
270
  expect(firstLane).toMatchObject({ start: 1, end: 3 });
@@ -295,7 +302,11 @@ describe('buildSessionTimeline', () => {
295
302
  );
296
303
 
297
304
  const messages = yield* TestTraceService.messages;
298
- const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [mine, other, later] });
305
+ const timeline = buildSessionTimeline({
306
+ traceMessages: messages,
307
+ sessions: [chat.session],
308
+ tasks: [mine, other, later],
309
+ });
299
310
  const laneId = `task:${mine.id}`;
300
311
  expect(timeline.lanes.find((lane) => lane.id === laneId)).toMatchObject({ start: 2, end: 6 });
301
312
  const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
@@ -305,7 +316,7 @@ describe('buildSessionTimeline', () => {
305
316
  );
306
317
 
307
318
  it.effect(
308
- "a task started over an open one closes it at the boundary, which is the newcomer's",
319
+ 'a task started over an open one runs alongside it, and work while both are open stays with the session',
309
320
  Effect.fnUntraced(function* ({ expect }) {
310
321
  const first = Task.make({ title: 'First', status: 'started' });
311
322
  const second = Task.make({ title: 'Second', status: 'started' });
@@ -316,25 +327,35 @@ describe('buildSessionTimeline', () => {
316
327
  yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
317
328
  yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
318
329
  yield* toolCall('Read file'); // 3.
319
- // No close for the first: starting the second is what ends it.
330
+ // No close for the first: the agent holds both.
320
331
  yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 4.
321
332
  yield* toolCall('Write file'); // 5.
322
333
  }),
323
334
  );
324
335
 
325
336
  const messages = yield* TestTraceService.messages;
326
- const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [first, second] });
327
- expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 2, end: 4 });
337
+ const timeline = buildSessionTimeline({
338
+ traceMessages: messages,
339
+ sessions: [chat.session],
340
+ tasks: [first, second],
341
+ });
342
+ expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({
343
+ start: 2,
344
+ end: undefined,
345
+ });
328
346
  expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)).toMatchObject({
329
347
  start: 4,
330
348
  end: undefined,
331
349
  });
332
- // The two segments share the boundary at 4; the second task's own start node sits on its lane.
333
350
  const taskMarkers = timeline.markers.filter((marker) => marker.kind === 'task');
334
351
  expect(taskMarkers.map(({ laneId, timestamp }) => ({ laneId, timestamp }))).toEqual([
335
352
  { laneId: `task:${first.id}`, timestamp: 2 },
336
353
  { laneId: `task:${second.id}`, timestamp: 4 },
337
354
  ]);
355
+ // One task open: its work. Two open: the session's, since it cannot be told which it was for.
356
+ const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
357
+ expect(lanesByLabel.get('Read file')).toBe(`task:${first.id}`);
358
+ expect(lanesByLabel.get('Write file')).toBe(`session:${chat.session.id}`);
338
359
  }, Effect.provide(TestTraceService.layer)),
339
360
  );
340
361
 
@@ -361,6 +382,109 @@ describe('buildSessionTimeline', () => {
361
382
  }, Effect.provide(TestTraceService.layer)),
362
383
  );
363
384
 
385
+ it.effect(
386
+ 'a folded lane begins no later than its task, and a traced move takes the trace instant over the history second',
387
+ Effect.fnUntraced(function* ({ expect }) {
388
+ const task = Task.make({ title: 'Only', status: 'started' });
389
+ const chat = makeChat('Only', [task]);
390
+ yield* TestTraceService.withMeta(
391
+ { pid: 'agent', conversation: chat.feed },
392
+ Effect.gen(function* () {
393
+ yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
394
+ yield* Trace.write(Trace.TaskStatusChanged, { taskId: task.id, title: 'Only', status: 'started' }); // 2.
395
+ }),
396
+ );
397
+
398
+ const messages = yield* TestTraceService.messages;
399
+ const timeline = buildSessionTimeline({
400
+ traceMessages: messages,
401
+ sessions: [chat.session],
402
+ tasks: [task],
403
+ // Delegation started the task before the first request, recorded at whole-second precision.
404
+ taskStatusChanges: new Map([[task.id, [{ timestamp: 0, status: 'started', previousStatus: 'todo' }]]]),
405
+ });
406
+ expect(timeline.lanes).toHaveLength(1);
407
+ // The history's rounded-down second is replaced by the traced instant, so the lane begins at the
408
+ // session's first request (1) rather than before any node.
409
+ expect(timeline.lanes[0]).toMatchObject({ kind: 'session', taskId: task.id, start: 1 });
410
+ const started = timeline.markers.find((marker) => marker.label === 'Task started');
411
+ expect(started?.timestamp).toBe(2);
412
+ }, Effect.provide(TestTraceService.layer)),
413
+ );
414
+
415
+ it.effect(
416
+ 'a task lane starts at its first node, not at the rounded-down second its history records',
417
+ Effect.fnUntraced(function* ({ expect }) {
418
+ const first = Task.make({ title: 'First', status: 'started' });
419
+ const second = Task.make({ title: 'Second', status: 'todo' });
420
+ const chat = makeChat('Precise', [first, second]);
421
+ yield* TestTraceService.withMeta(
422
+ { pid: 'agent', conversation: chat.feed },
423
+ Effect.gen(function* () {
424
+ yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
425
+ yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
426
+ }),
427
+ );
428
+
429
+ const messages = yield* TestTraceService.messages;
430
+ const timeline = buildSessionTimeline({
431
+ traceMessages: messages,
432
+ sessions: [chat.session],
433
+ tasks: [first, second],
434
+ taskStatusChanges: new Map([[first.id, [{ timestamp: 0, status: 'started', previousStatus: 'todo' }]]]),
435
+ });
436
+ const lane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
437
+ const node = timeline.markers.find((marker) => marker.laneId === lane?.id);
438
+ expect(lane?.start).toBe(2);
439
+ expect(node?.timestamp).toBe(lane?.start);
440
+ }, Effect.provide(TestTraceService.layer)),
441
+ );
442
+
443
+ it.effect(
444
+ "a parent task's lane carries each sub-task's start and finish",
445
+ Effect.fnUntraced(function* ({ expect }) {
446
+ const parent = Task.make({ title: 'Parent', status: 'started' });
447
+ const child = Task.make({ [Obj.Parent]: parent, title: 'Child', status: 'done' });
448
+ const chat = makeChat('Tree', [parent, child]);
449
+ yield* TestTraceService.withMeta(
450
+ { pid: 'agent', conversation: chat.feed },
451
+ Effect.gen(function* () {
452
+ yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
453
+ yield* Trace.write(Trace.TaskStatusChanged, { taskId: child.id, title: 'Child', status: 'started' }); // 2.
454
+ yield* Trace.write(Trace.TaskStatusChanged, {
455
+ taskId: child.id,
456
+ title: 'Child',
457
+ status: 'done',
458
+ previousStatus: 'started',
459
+ }); // 3.
460
+ }),
461
+ );
462
+
463
+ const messages = yield* TestTraceService.messages;
464
+ const timeline = buildSessionTimeline({
465
+ traceMessages: messages,
466
+ sessions: [chat.session],
467
+ tasks: [parent, child],
468
+ taskStatusChanges: new Map([
469
+ [
470
+ child.id,
471
+ [
472
+ { timestamp: 0, status: 'started', previousStatus: 'todo' },
473
+ { timestamp: 0, status: 'done', previousStatus: 'started' },
474
+ ],
475
+ ],
476
+ ]),
477
+ });
478
+ const onParent = timeline.markers
479
+ .filter((marker) => marker.laneId === `task:${parent.id}`)
480
+ .map(({ label, timestamp }) => ({ label, timestamp }));
481
+ expect(onParent).toEqual([
482
+ { label: 'Child: started', timestamp: 2 },
483
+ { label: 'Child: done', timestamp: 3 },
484
+ ]);
485
+ }, Effect.provide(TestTraceService.layer)),
486
+ );
487
+
364
488
  it.effect(
365
489
  'a run whose process ended without a request end closes at its last event, and the axis stops following now',
366
490
  Effect.fnUntraced(function* ({ expect }) {
@@ -419,7 +543,11 @@ describe('buildSessionTimeline', () => {
419
543
  );
420
544
 
421
545
  const messages = yield* TestTraceService.messages;
422
- const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task, later] });
546
+ const timeline = buildSessionTimeline({
547
+ traceMessages: messages,
548
+ sessions: [chat.session],
549
+ tasks: [task, later],
550
+ });
423
551
  expect(timeline.lanes.find((lane) => lane.id === `task:${task.id}`)).toMatchObject({ start: 2, end: 4 });
424
552
  const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
425
553
  expect(lanesByLabel.get('Read file')).toBe(`task:${task.id}`);
@@ -464,7 +592,11 @@ describe('buildSessionTimeline', () => {
464
592
  );
465
593
 
466
594
  const messages = yield* TestTraceService.messages;
467
- const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [dismissed, worked] });
595
+ const timeline = buildSessionTimeline({
596
+ traceMessages: messages,
597
+ sessions: [chat.session],
598
+ tasks: [dismissed, worked],
599
+ });
468
600
  expect(timeline.lanes.find((lane) => lane.id === `task:${worked.id}`)).toMatchObject({ start: 3, end: 4 });
469
601
  expect(timeline.lanes.find((lane) => lane.id === `task:${dismissed.id}`)).toMatchObject({
470
602
  start: 5,
@@ -476,7 +608,7 @@ describe('buildSessionTimeline', () => {
476
608
  );
477
609
 
478
610
  it.effect(
479
- 'a close arriving after the next task started leaves that task the stretch',
611
+ 'a close arriving after the next task started ends the first where it was closed',
480
612
  Effect.fnUntraced(function* ({ expect }) {
481
613
  const first = Task.make({ title: 'First', status: 'done' });
482
614
  const second = Task.make({ title: 'Second', status: 'started' });
@@ -488,8 +620,8 @@ describe('buildSessionTimeline', () => {
488
620
  yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
489
621
  yield* Trace.write(Trace.TaskStatusChanged, { taskId: second.id, title: 'Second', status: 'started' }); // 3.
490
622
  yield* toolCall('Write file'); // 4.
491
- // The first task's close lands after the second one is under way; the stretch is the
492
- // second task's, so nothing may be minted over it.
623
+ // The first task's close lands after the second one is under way: both were in progress
624
+ // until then.
493
625
  yield* Trace.write(Trace.TaskStatusChanged, {
494
626
  taskId: first.id,
495
627
  title: 'First',
@@ -501,14 +633,18 @@ describe('buildSessionTimeline', () => {
501
633
  );
502
634
 
503
635
  const messages = yield* TestTraceService.messages;
504
- const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [first, second] });
505
- expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 2, end: 3 });
636
+ const timeline = buildSessionTimeline({
637
+ traceMessages: messages,
638
+ sessions: [chat.session],
639
+ tasks: [first, second],
640
+ });
641
+ expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 2, end: 5 });
506
642
  expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)).toMatchObject({
507
643
  start: 3,
508
644
  end: undefined,
509
645
  });
510
646
  const lanesByLabel = new Map(timeline.markers.map((marker) => [marker.label, marker.laneId]));
511
- expect(lanesByLabel.get('Write file')).toBe(`task:${second.id}`);
647
+ expect(lanesByLabel.get('Write file')).toBe(`session:${chat.session.id}`);
512
648
  expect(lanesByLabel.get('Read file')).toBe(`task:${second.id}`);
513
649
  }, Effect.provide(TestTraceService.layer)),
514
650
  );
@@ -603,27 +739,438 @@ describe('buildSessionTimeline', () => {
603
739
  expect(subSession?.delegatedFrom).toEqual({ laneId: session?.id, markerId: spawn?.id });
604
740
  }, Effect.provide(TestTraceService.layer)),
605
741
  );
742
+ it.effect(
743
+ 'pairs a sub-agent with the node its result returned into',
744
+ Effect.fnUntraced(function* ({ expect }) {
745
+ const task = Task.make({ title: 'Compute', status: 'done' });
746
+ const chat = makeChat('Return', [task]);
747
+ yield* TestTraceService.withMeta(
748
+ { pid: 'agent', conversation: chat.feed },
749
+ Effect.gen(function* () {
750
+ yield* Trace.write(Trace.AgentRequestBegin, {});
751
+ yield* Trace.write(Trace.DelegationSpawned, { taskId: task.id, pid: 'sub' });
752
+ yield* TestTraceService.withMeta(
753
+ { pid: 'sub', parentPid: 'agent' },
754
+ Effect.gen(function* () {
755
+ yield* Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' });
756
+ yield* Trace.write(Trace.OperationEnd, { key: 'run', outcome: 'success' });
757
+ }),
758
+ );
759
+ yield* Trace.write(Trace.DelegationCompleted, {
760
+ taskId: task.id,
761
+ pid: 'sub',
762
+ status: 'success',
763
+ result: '3628800',
764
+ });
765
+ yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
766
+ }),
767
+ );
768
+
769
+ const messages = yield* TestTraceService.messages;
770
+ const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
771
+ const session = timeline.lanes.find((lane) => lane.id === `session:${chat.id}`);
772
+ const subSession = timeline.lanes.find((lane) => lane.id === 'session:sub');
773
+
774
+ // Both edges land on the supervisor's lane, on different nodes: handing the work over and
775
+ // hearing back are separate moments, and the chart draws them as separate connectors.
776
+ const [spawn, returned] = timeline.markers.filter((marker) => marker.kind === 'delegation');
777
+ expect(returned?.laneId).toBe(session?.id);
778
+ expect(returned?.label).toBe('Returned: 3628800');
779
+ expect(subSession?.delegatedFrom).toEqual({ laneId: session?.id, markerId: spawn?.id });
780
+ expect(subSession?.returnedTo).toEqual({ laneId: session?.id, markerId: returned?.id });
781
+ }, Effect.provide(TestTraceService.layer)),
782
+ );
783
+
784
+ it.effect(
785
+ 'leaves a sub-agent that never reported back with no return edge',
786
+ Effect.fnUntraced(function* ({ expect }) {
787
+ // Deliberately not inferred from the supervisor's next node: a child can end without ever
788
+ // answering, and guessing would draw a causal edge that does not exist.
789
+ const task = Task.make({ title: 'Compute', status: 'done' });
790
+ const chat = makeChat('No return', [task]);
791
+ yield* TestTraceService.withMeta(
792
+ { pid: 'agent', conversation: chat.feed },
793
+ Effect.gen(function* () {
794
+ yield* Trace.write(Trace.AgentRequestBegin, {});
795
+ yield* Trace.write(Trace.DelegationSpawned, { taskId: task.id, pid: 'sub' });
796
+ yield* TestTraceService.withMeta(
797
+ { pid: 'sub', parentPid: 'agent' },
798
+ Effect.gen(function* () {
799
+ yield* Trace.write(Trace.OperationStart, { key: 'run', name: 'Run Instructions' });
800
+ yield* Trace.write(Trace.OperationEnd, { key: 'run', outcome: 'success' });
801
+ }),
802
+ );
803
+ yield* Trace.write(Trace.AgentRequestEnd, { status: 'success' });
804
+ }),
805
+ );
806
+
807
+ const messages = yield* TestTraceService.messages;
808
+ const timeline = buildSessionTimeline({ traceMessages: messages, sessions: [chat.session], tasks: [task] });
809
+ const subSession = timeline.lanes.find((lane) => lane.id === 'session:sub');
810
+ expect(subSession?.delegatedFrom).toBeDefined();
811
+ expect(subSession?.returnedTo).toBeUndefined();
812
+ }, Effect.provide(TestTraceService.layer)),
813
+ );
814
+
815
+ test('bounds task lanes and threads their nodes from the edit history alone', ({ expect }) => {
816
+ const first = Task.make({ title: 'First', status: 'done' });
817
+ const second = Task.make({ title: 'Second', status: 'started' });
818
+ Task.ask(first, { text: 'Which schema?', date: at(1_500) });
819
+ const chat = makeChat('History', [first, second]);
820
+
821
+ const timeline = buildSessionTimeline({
822
+ traceMessages: [],
823
+ sessions: [chat.session],
824
+ tasks: [first, second],
825
+ taskStatusChanges: new Map([
826
+ [
827
+ first.id,
828
+ [
829
+ { timestamp: 1_000, status: 'started', previousStatus: 'todo' },
830
+ { timestamp: 2_000, status: 'done', previousStatus: 'started' },
831
+ ],
832
+ ],
833
+ [second.id, [{ timestamp: 3_000, status: 'started', previousStatus: 'todo' }]],
834
+ ]),
835
+ });
836
+
837
+ const firstLane = timeline.lanes.find((lane) => lane.id === `task:${first.id}`);
838
+ const secondLane = timeline.lanes.find((lane) => lane.id === `task:${second.id}`);
839
+ expect(firstLane).toMatchObject({ status: 'done', start: 1_000, end: 2_000 });
840
+ expect(secondLane).toMatchObject({ status: 'running', start: 3_000, end: undefined });
841
+ expect(
842
+ timeline.markers
843
+ .filter((marker) => marker.laneId === firstLane?.id)
844
+ .toSorted((a, b) => a.timestamp - b.timestamp)
845
+ .map(({ label, timestamp, level }) => ({ label, timestamp, level })),
846
+ ).toEqual([
847
+ { label: 'Task started', timestamp: 1_000, level: undefined },
848
+ { label: 'Which schema?', timestamp: 1_500, level: 'warn' },
849
+ { label: 'Task done', timestamp: 2_000, level: undefined },
850
+ ]);
851
+ expect(timeline.range).toEqual({ start: 1_000, end: 3_000 });
852
+ });
853
+
854
+ test('draws no node for the wait before a task was first picked up', ({ expect }) => {
855
+ const task = Task.make({ title: 'Queued', status: 'todo' });
856
+ const chat = makeChat('Queue', [task]);
857
+ const timeline = buildSessionTimeline({
858
+ traceMessages: [],
859
+ sessions: [chat.session],
860
+ tasks: [task],
861
+ taskStatusChanges: new Map([
862
+ [
863
+ task.id,
864
+ [
865
+ { timestamp: 1_000, status: 'todo' },
866
+ { timestamp: 2_000, status: 'started', previousStatus: 'todo' },
867
+ // Put back: a move of its own, unlike the wait before the first pick-up.
868
+ { timestamp: 3_000, status: 'todo', previousStatus: 'started' },
869
+ ],
870
+ ],
871
+ ]),
872
+ });
873
+
874
+ expect(
875
+ timeline.markers.filter((marker) => marker.kind === 'task').map(({ label, timestamp }) => ({ label, timestamp })),
876
+ ).toEqual([
877
+ { label: 'Task started', timestamp: 2_000 },
878
+ { label: 'Task todo', timestamp: 3_000 },
879
+ ]);
880
+ expect(timeline.range.start).toBe(2_000);
881
+ });
882
+
883
+ test('nests a sub-task under its parent task when both are on the checklist', ({ expect }) => {
884
+ const parent = Task.make({ title: 'Parent', status: 'started' });
885
+ const child = Task.make({ [Obj.Parent]: parent, title: 'Child', status: 'todo' });
886
+ const chat = makeChat('Tree', [parent, child]);
887
+ const timeline = buildSessionTimeline({ traceMessages: [], sessions: [chat.session], tasks: [parent, child] });
888
+
889
+ expect(timeline.lanes.find((lane) => lane.id === `task:${parent.id}`)?.parentId).toBe(`session:${chat.session.id}`);
890
+ expect(timeline.lanes.find((lane) => lane.id === `task:${child.id}`)?.parentId).toBe(`task:${parent.id}`);
891
+ });
892
+
893
+ test("a parent task's lane begins no later than its sub-tasks', even when they are not on one checklist", ({
894
+ expect,
895
+ }) => {
896
+ const parent = Task.make({ title: 'Parent', status: 'started' });
897
+ const child = Task.make({ [Obj.Parent]: parent, title: 'Child', status: 'done' });
898
+ const grandchild = Task.make({ [Obj.Parent]: child, title: 'Grandchild', status: 'done' });
899
+ // The parent is not on the checklist, so it is drawn from its edit history alone, unnested.
900
+ const chat = makeChat('Run', [child, grandchild]);
901
+ const timeline = buildSessionTimeline({
902
+ traceMessages: [],
903
+ sessions: [chat.session],
904
+ tasks: [parent, child, grandchild],
905
+ taskStatusChanges: new Map([
906
+ [parent.id, [{ timestamp: 3_000, status: 'started', previousStatus: 'todo' }]],
907
+ [
908
+ child.id,
909
+ [
910
+ { timestamp: 2_000, status: 'started', previousStatus: 'todo' },
911
+ { timestamp: 4_000, status: 'done', previousStatus: 'started' },
912
+ ],
913
+ ],
914
+ [
915
+ grandchild.id,
916
+ [
917
+ { timestamp: 1_000, status: 'started', previousStatus: 'todo' },
918
+ { timestamp: 2_500, status: 'done', previousStatus: 'started' },
919
+ ],
920
+ ],
921
+ ]),
922
+ });
923
+
924
+ const laneOf = (task: Task.Task) => timeline.lanes.find((lane) => lane.taskId === task.id);
925
+ expect(laneOf(parent)?.parentId).toBeUndefined();
926
+ expect(laneOf(parent)?.start).toBe(1_000);
927
+ expect(laneOf(child)?.start).toBe(1_000);
928
+ expect(laneOf(grandchild)?.start).toBe(1_000);
929
+ // It carries every descendant's start and finish beside its own move, so its first node is where the work began.
930
+ const nodesOf = (task: Task.Task) =>
931
+ timeline.markers
932
+ .filter((marker) => marker.laneId === laneOf(task)?.id)
933
+ .map(({ timestamp }) => timestamp)
934
+ .sort((left, right) => left - right);
935
+ expect(nodesOf(parent)).toEqual([1_000, 2_000, 2_500, 3_000, 4_000]);
936
+ expect(nodesOf(child)).toEqual([1_000, 2_000, 2_500, 4_000]);
937
+ });
938
+
939
+ it.effect(
940
+ 'draws a status move once when both the trace and the edit history record it',
941
+ Effect.fnUntraced(function* ({ expect }) {
942
+ const first = Task.make({ title: 'First', status: 'started' });
943
+ const second = Task.make({ title: 'Second', status: 'todo' });
944
+ const chat = makeChat('Both', [first, second]);
945
+ yield* TestTraceService.withMeta(
946
+ { pid: 'agent', conversation: chat.feed },
947
+ Effect.gen(function* () {
948
+ yield* Trace.write(Trace.AgentRequestBegin, {}); // 1.
949
+ yield* Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }); // 2.
950
+ }),
951
+ );
952
+
953
+ const messages = yield* TestTraceService.messages;
954
+ const timeline = buildSessionTimeline({
955
+ traceMessages: messages,
956
+ sessions: [chat.session],
957
+ tasks: [first, second],
958
+ taskStatusChanges: new Map([[first.id, [{ timestamp: 2, status: 'started', previousStatus: 'todo' }]]]),
959
+ });
960
+
961
+ const markers = timeline.markers.filter((marker) => marker.kind === 'task');
962
+ // The trace event is drawn through the history, which takes its pid.
963
+ expect(markers.map(({ laneId, label, pid }) => ({ laneId, label, pid }))).toEqual([
964
+ { laneId: `task:${first.id}`, label: 'Task started', pid: 'agent' },
965
+ ]);
966
+ }, Effect.provide(TestTraceService.layer)),
967
+ );
968
+
969
+ test('closes a finished task whose history is missing its closing move', ({ expect }) => {
970
+ const first = Task.make({ title: 'First', status: 'done' });
971
+ const second = Task.make({ title: 'Second', status: 'todo' });
972
+ const chat = makeChat('Unrecorded', [first, second]);
973
+
974
+ const timeline = buildSessionTimeline({
975
+ traceMessages: [],
976
+ sessions: [chat.session],
977
+ tasks: [first, second],
978
+ taskStatusChanges: new Map([[first.id, [{ timestamp: 1_000, status: 'started', previousStatus: 'todo' }]]]),
979
+ });
980
+
981
+ expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({ start: 1_000, end: 1_000 });
982
+ });
983
+
984
+ it.effect(
985
+ 'draws a recorded status move the trace recorded off the chart',
986
+ Effect.fnUntraced(function* ({ expect }) {
987
+ const first = Task.make({ title: 'First', status: 'started' });
988
+ const second = Task.make({ title: 'Second', status: 'todo' });
989
+ const chat = makeChat('Elsewhere', [first, second]);
990
+ // A process belonging to no session on this chart moved the task.
991
+ yield* TestTraceService.withMeta(
992
+ { pid: 'stranger' },
993
+ Trace.write(Trace.TaskStatusChanged, { taskId: first.id, title: 'First', status: 'started' }), // 1.
994
+ );
995
+
996
+ const messages = yield* TestTraceService.messages;
997
+ const timeline = buildSessionTimeline({
998
+ traceMessages: messages,
999
+ sessions: [chat.session],
1000
+ tasks: [first, second],
1001
+ taskStatusChanges: new Map([[first.id, [{ timestamp: 1, status: 'started', previousStatus: 'todo' }]]]),
1002
+ });
1003
+
1004
+ expect(
1005
+ timeline.markers
1006
+ .filter((marker) => marker.laneId === `task:${first.id}`)
1007
+ .map(({ label, pid }) => ({ label, pid })),
1008
+ ).toEqual([{ label: 'Task started', pid: undefined }]);
1009
+ }, Effect.provide(TestTraceService.layer)),
1010
+ );
1011
+
1012
+ test('records the stretch a task was put down between two runs as a gap', ({ expect }) => {
1013
+ const task = Task.make({ title: 'Cup the samples', status: 'done' });
1014
+ const timeline = buildSessionTimeline({
1015
+ traceMessages: [],
1016
+ sessions: [],
1017
+ tasks: [task],
1018
+ taskStatusChanges: new Map([
1019
+ [
1020
+ task.id,
1021
+ [
1022
+ { timestamp: 1_000, status: 'started', previousStatus: 'todo' },
1023
+ { timestamp: 2_000, status: 'blocked', previousStatus: 'started' },
1024
+ { timestamp: 5_000, status: 'started', previousStatus: 'blocked' },
1025
+ { timestamp: 6_000, status: 'done', previousStatus: 'started' },
1026
+ ],
1027
+ ],
1028
+ ]),
1029
+ });
1030
+
1031
+ expect(timeline.lanes).toMatchObject([{ start: 1_000, end: 6_000, gaps: [{ start: 2_000, end: 5_000 }] }]);
1032
+ });
1033
+
1034
+ test('a task still blocked has no gap: its lane ends where it was put down', ({ expect }) => {
1035
+ const task = Task.make({ title: 'Cup the samples', status: 'blocked' });
1036
+ const timeline = buildSessionTimeline({
1037
+ traceMessages: [],
1038
+ sessions: [],
1039
+ tasks: [task],
1040
+ taskStatusChanges: new Map([
1041
+ [
1042
+ task.id,
1043
+ [
1044
+ { timestamp: 1_000, status: 'started', previousStatus: 'todo' },
1045
+ { timestamp: 2_000, status: 'blocked', previousStatus: 'started' },
1046
+ ],
1047
+ ],
1048
+ ]),
1049
+ });
1050
+
1051
+ expect(timeline.lanes[0]).toMatchObject({ start: 1_000, end: 2_000 });
1052
+ expect(timeline.lanes[0].gaps).toBeUndefined();
1053
+ });
1054
+
1055
+ test('draws a task no session works as a lane of its own, from its edit history', ({ expect }) => {
1056
+ const done = Task.make({ title: 'Done elsewhere', status: 'done' });
1057
+ const running = Task.make({ title: 'Running elsewhere', status: 'started', dependsOn: [Ref.make(done)] });
1058
+ const untouched = Task.make({ title: 'Never started', status: 'todo' });
1059
+
1060
+ const timeline = buildSessionTimeline({
1061
+ traceMessages: [],
1062
+ sessions: [],
1063
+ tasks: [done, running, untouched],
1064
+ taskStatusChanges: new Map([
1065
+ [
1066
+ done.id,
1067
+ [
1068
+ { timestamp: 1_000, status: 'started', previousStatus: 'todo' },
1069
+ { timestamp: 2_000, status: 'done', previousStatus: 'started' },
1070
+ ],
1071
+ ],
1072
+ [running.id, [{ timestamp: 3_000, status: 'started', previousStatus: 'todo' }]],
1073
+ ]),
1074
+ });
1075
+
1076
+ expect(timeline.lanes).toEqual([
1077
+ {
1078
+ id: `task:${done.id}`,
1079
+ kind: 'task',
1080
+ label: 'Done elsewhere',
1081
+ status: 'done',
1082
+ start: 1_000,
1083
+ end: 2_000,
1084
+ taskId: done.id,
1085
+ hue: getHashHue(Obj.getMnemonic(done)),
1086
+ },
1087
+ {
1088
+ id: `task:${running.id}`,
1089
+ kind: 'task',
1090
+ label: 'Running elsewhere',
1091
+ status: 'running',
1092
+ start: 3_000,
1093
+ end: undefined,
1094
+ taskId: running.id,
1095
+ hue: getHashHue(Obj.getMnemonic(running)),
1096
+ blockedOn: [`task:${done.id}`],
1097
+ },
1098
+ ]);
1099
+ expect(timeline.markers.filter((marker) => marker.laneId === `task:${done.id}`).map(({ label }) => label)).toEqual([
1100
+ 'Task started',
1101
+ 'Task done',
1102
+ ]);
1103
+ });
1104
+
1105
+ test('reads no status moves for a task outside a database, which keeps no history', ({ expect }) => {
1106
+ expect(readTaskStatusChanges(Task.make({ title: 'Loose', status: 'done' }))).toEqual([]);
1107
+ });
606
1108
  });
607
1109
 
608
- const agentProcess = (pid: string, chat: TestChat, state: Process.State): Process.Info => ({
609
- pid: Process.ID.make(pid),
610
- parentPid: null,
611
- key: 'agent',
612
- params: {
613
- name: null,
614
- annotations: Annotation.buildDictionary((dictionary) => {
615
- Annotation.setDictionary(dictionary, Process.HarnessHostAnnotation, true);
616
- Annotation.setDictionary(dictionary, Process.TargetAnnotation, URI.make(chat.session.uri));
617
- }),
618
- },
619
- environment: {},
620
- state,
621
- error: null,
622
- startedAt: 0,
623
- completedAt: Option.none(),
624
- metrics: { wallTime: 0, inputCount: 0, outputCount: 0 },
1110
+ describe('readTaskStatusChanges', () => {
1111
+ it.effect(
1112
+ "reads the status moves a stored task's edit history records, and the chart bounds its lane by them",
1113
+ Effect.fnUntraced(
1114
+ function* ({ expect }) {
1115
+ const first = yield* Database.add(Task.make({ title: 'First', status: 'todo' }));
1116
+ const second = yield* Database.add(Task.make({ title: 'Second', status: 'todo' }));
1117
+ yield* Database.flush();
1118
+ Task.setStatus(first, 'started');
1119
+ // An edit to another field is no status move.
1120
+ Task.update(first, { priority: 'high' });
1121
+ Task.setStatus(first, 'done');
1122
+ yield* Database.flush();
1123
+
1124
+ const changes = readTaskStatusChanges(first);
1125
+ expect(changes.map(({ status, previousStatus }) => ({ status, previousStatus }))).toEqual([
1126
+ { status: 'todo', previousStatus: undefined },
1127
+ { status: 'started', previousStatus: 'todo' },
1128
+ { status: 'done', previousStatus: 'started' },
1129
+ ]);
1130
+ expect(readTaskStatusChanges(second).map(({ status }) => status)).toEqual(['todo']);
1131
+
1132
+ const chat = makeChat('Stored', [first, second]);
1133
+ const timeline = buildSessionTimeline({
1134
+ traceMessages: [],
1135
+ sessions: [chat.session],
1136
+ tasks: [first, second],
1137
+ taskStatusChanges: new Map([
1138
+ [first.id, changes],
1139
+ [second.id, readTaskStatusChanges(second)],
1140
+ ]),
1141
+ });
1142
+ expect(timeline.lanes.find((lane) => lane.id === `task:${first.id}`)).toMatchObject({
1143
+ status: 'done',
1144
+ start: changes[1]?.timestamp,
1145
+ end: changes[2]?.timestamp,
1146
+ });
1147
+ expect(timeline.lanes.find((lane) => lane.id === `task:${second.id}`)?.start).toBeUndefined();
1148
+ },
1149
+ Effect.provide(TestDatabaseLayer({ types: [Milestone.Milestone, Task.Task] })),
1150
+ ),
1151
+ );
625
1152
  });
626
1153
 
1154
+ const agentProcess = (pid: string, chat: TestChat, state: Process.State): Process.Process =>
1155
+ makeTestProcess({
1156
+ pid: Process.ID.make(pid),
1157
+ parentPid: null,
1158
+ key: 'agent',
1159
+ params: {
1160
+ name: null,
1161
+ annotations: Annotation.buildDictionary((dictionary) => {
1162
+ Annotation.setDictionary(dictionary, Process.HarnessHostAnnotation, true);
1163
+ Annotation.setDictionary(dictionary, Process.TargetAnnotation, URI.make(chat.session.uri));
1164
+ }),
1165
+ },
1166
+ environment: {},
1167
+ state,
1168
+ error: null,
1169
+ startedAt: 0,
1170
+ completedAt: Option.none(),
1171
+ metrics: { wallTime: 0, inputCount: 0, outputCount: 0 },
1172
+ });
1173
+
627
1174
  /** A chat reduced to what the builder joins on, with the feed ref its trace meta would carry. */
628
1175
  type TestChat = { id: string; feed: Ref.Ref<Feed.Feed>; session: Session };
629
1176
 
@@ -643,6 +1190,9 @@ const makeChat = (name: string, tasks: readonly Task.Task[]): TestChat => {
643
1190
  };
644
1191
  };
645
1192
 
1193
+ /** An ISO date `ms` after the epoch, for history entries set against the trace's clock. */
1194
+ const at = (ms: number): string => new Date(ms).toISOString();
1195
+
646
1196
  const toolCall = (name: string) =>
647
1197
  Trace.write(Trace.CompleteBlock, {
648
1198
  messageId: MESSAGE_ID,