@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.
- package/README.md +1 -1
- package/dist/lib/index.mjs +1722 -561
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/testing.mjs +9 -8
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/lib/translations.mjs +5 -0
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
- package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/index.d.ts +2 -0
- package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
- package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
- package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
- package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +1 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -1
- package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
- package/dist/types/src/session-timeline/types.d.ts +32 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -1
- package/dist/types/src/testing/simulated-agent.d.ts +5 -4
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
- package/dist/types/src/testing/test-trace.d.ts +2 -2
- package/dist/types/src/testing/test-trace.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +5 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -26
- package/src/components/Gantt/Gantt.stories.tsx +914 -96
- package/src/components/Gantt/Gantt.tsx +964 -261
- package/src/components/Gantt/gantt-rows.test.ts +123 -0
- package/src/components/Gantt/gantt-rows.ts +71 -0
- package/src/components/Gantt/gantt-scale.test.ts +105 -0
- package/src/components/Gantt/gantt-scale.ts +143 -0
- package/src/components/Gantt/index.ts +2 -0
- package/src/components/Gantt/useEnter.ts +33 -0
- package/src/components/Gantt/useRangeTween.ts +61 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
- package/src/components/ProcessTree/ProcessTree.tsx +99 -90
- package/src/components/Timeline/Timeline.stories.tsx +110 -22
- package/src/components/Timeline/Timeline.tsx +319 -271
- package/src/components/Timeline/timeline-layout.test.ts +177 -0
- package/src/components/Timeline/timeline-layout.ts +153 -0
- package/src/components/TracePanel/SpanTreeView.tsx +39 -0
- package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
- package/src/components/TracePanel/TracePanel.tsx +113 -106
- package/src/components/TracePanel/trace-filter.test.ts +31 -2
- package/src/components/TracePanel/trace-filter.ts +28 -3
- package/src/components/TracePanel/useTraceMenu.ts +0 -1
- package/src/execution-graph/SPEC.md +10 -10
- package/src/execution-graph/execution-graph.test.ts +16 -15
- package/src/execution-graph/execution-graph.ts +108 -60
- package/src/execution-graph/pending-block-status.ts +1 -1
- package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
- package/src/hooks/useExecutionGraph.ts +14 -11
- package/src/hooks/useSessionTimeline.ts +35 -9
- package/src/hooks/useTraceMessages.ts +1 -1
- package/src/session-timeline/TaskHistory.stories.tsx +142 -0
- package/src/session-timeline/gantt-mapping.test.ts +118 -0
- package/src/session-timeline/gantt-mapping.ts +158 -0
- package/src/session-timeline/index.ts +1 -0
- package/src/session-timeline/session-timeline.test.ts +585 -35
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +17 -16
- package/src/testing/test-trace.ts +14 -12
- package/src/translations.ts +8 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
- 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({
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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({
|
|
327
|
-
|
|
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({
|
|
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({
|
|
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
|
|
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
|
|
492
|
-
//
|
|
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({
|
|
505
|
-
|
|
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(`
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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,
|