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