feat: add task duration tracking
This commit is contained in:
@@ -68,6 +68,144 @@ test("enforces done descendants against the final atomic update state", () => {
|
||||
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
|
||||
});
|
||||
|
||||
test("preserves and accumulates timing across repeated updates and status changes", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
|
||||
const [started] = state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
assert.equal(started?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(started?.stoppedAt, undefined);
|
||||
assert.equal(started?.durationMs, 0);
|
||||
|
||||
now += 5_000;
|
||||
const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]);
|
||||
assert.equal(repeated?.startedAt, started?.startedAt);
|
||||
assert.equal(repeated?.stoppedAt, undefined);
|
||||
assert.equal(repeated?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
const [paused] = state.update([{ id: 1, status: "blocked" }]);
|
||||
assert.equal(paused?.startedAt, started?.startedAt);
|
||||
assert.equal(paused?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(paused?.durationMs, 7_000);
|
||||
|
||||
now += 10_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
const [resumed] = state.update([{ id: 1, status: "in-progress" }]);
|
||||
assert.equal(resumed?.startedAt, started?.startedAt);
|
||||
assert.equal(resumed?.stoppedAt, undefined);
|
||||
assert.equal(resumed?.durationMs, 7_000);
|
||||
|
||||
now += 3_000;
|
||||
const [done] = state.update([{ id: 1, status: "done" }]);
|
||||
assert.equal(done?.startedAt, started?.startedAt);
|
||||
assert.equal(done?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(done?.durationMs, 10_000);
|
||||
assert.deepEqual(state.snapshot().timings, [
|
||||
{ taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not stop a timer when an atomic update fails", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Unfinished child" }],
|
||||
},
|
||||
]);
|
||||
|
||||
now += 5_000;
|
||||
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||
const afterFailure = state.get([1])[0];
|
||||
assert.equal(afterFailure?.stoppedAt, undefined);
|
||||
assert.equal(afterFailure?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
});
|
||||
|
||||
test("restores timers and starts legacy in-progress snapshots at load time", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const source = new TaskState(() => now);
|
||||
source.add([{ name: "Persisted", status: "in-progress" }]);
|
||||
now += 2_500;
|
||||
source.update([{ id: 1, status: "pending" }]);
|
||||
|
||||
now += 20_000;
|
||||
const restored = new TaskState(() => now);
|
||||
restored.restore(source.snapshot());
|
||||
assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.durationMs, 2_500);
|
||||
|
||||
const legacy = new TaskState(() => now);
|
||||
legacy.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Legacy active",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
});
|
||||
assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString());
|
||||
assert.equal(legacy.get([1])[0]?.durationMs, 0);
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Corrupt timer",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
timings: [
|
||||
{
|
||||
taskId: 1,
|
||||
startedAt: Number.MAX_SAFE_INTEGER,
|
||||
activeStartedAt: Number.MAX_SAFE_INTEGER,
|
||||
accumulatedMs: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
/startedAt must be a valid non-negative timestamp/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("finalizes an active timer in removed task details", () => {
|
||||
let now = Date.UTC(2026, 0, 1);
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Canceled", status: "in-progress" }]);
|
||||
now += 4_000;
|
||||
|
||||
const [removed] = state.remove([1]);
|
||||
assert.equal(removed?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(removed?.durationMs, 4_000);
|
||||
assert.deepEqual(state.list(), []);
|
||||
});
|
||||
|
||||
test("rejects cycles and duplicate patches without partial changes", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
|
||||
|
||||
@@ -113,3 +113,42 @@ test("list is compact and get returns available details for multiple IDs", async
|
||||
assert.equal(parsed[1].description, undefined);
|
||||
assert.equal(parsed[1].parentId, 1);
|
||||
});
|
||||
|
||||
test("get_task exposes cumulative timing while list output stays compact", async () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const harness = registeredTools(new TaskState(() => now));
|
||||
const added = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Timed", status: "in-progress" }],
|
||||
});
|
||||
assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done.");
|
||||
assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt);
|
||||
|
||||
now += 65_432;
|
||||
const list = await execute(harness.tools.get("list_task"), {});
|
||||
assert.equal(list.content[0].text, "1 [in-progress] Timed");
|
||||
|
||||
const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.deepEqual(active[0], {
|
||||
id: 1,
|
||||
name: "Timed",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMs: 65_432,
|
||||
});
|
||||
|
||||
now += 568;
|
||||
const updated = await execute(harness.tools.get("update_task"), {
|
||||
tasks: [{ id: 1, status: "blocked" }],
|
||||
});
|
||||
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
||||
|
||||
now += 10_000;
|
||||
const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.equal(paused[0].startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString());
|
||||
assert.equal(paused[0].durationMs, 66_000);
|
||||
});
|
||||
|
||||
+63
-2
@@ -4,6 +4,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import {
|
||||
formatDuration,
|
||||
renderProgressHeader,
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
@@ -23,7 +24,7 @@ function stripAnsi(text: string): string {
|
||||
}
|
||||
|
||||
function fixture(): TaskState {
|
||||
const state = new TaskState();
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Build auth",
|
||||
@@ -58,7 +59,7 @@ test("renders an adaptive progress bar and stays within width", () => {
|
||||
test("renders lightweight connectors without a detached root continuation", () => {
|
||||
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(output, [
|
||||
" ◉ 1. Build auth",
|
||||
" ◉ 1. Build auth · 00:00",
|
||||
" ├─ ! 2. Resolve token issue",
|
||||
" │ └─ ○ 3. Inspect provider response",
|
||||
" └─ ○ 4. Add middleware",
|
||||
@@ -66,6 +67,21 @@ test("renders lightweight connectors without a detached root continuation", () =
|
||||
]);
|
||||
});
|
||||
|
||||
test("formats compact timers and adds hours only when needed", () => {
|
||||
assert.equal(formatDuration(0), "00:00");
|
||||
assert.equal(formatDuration(65_432), "01:05");
|
||||
assert.equal(formatDuration(3_599_999), "59:59");
|
||||
assert.equal(formatDuration(3_600_000), "01:00:00");
|
||||
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
now = 65_432;
|
||||
const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24));
|
||||
assert.match(line, / · 01:05$/);
|
||||
assert.ok(visibleWidth(line) <= 24);
|
||||
});
|
||||
|
||||
test("truncates every task row at narrow widths", () => {
|
||||
const lines = renderTree(fixture().list(), plainTheme as never, 20);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
|
||||
@@ -147,6 +163,51 @@ test("hides an empty widget and renders a populated one", () => {
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||
});
|
||||
|
||||
test("live widgets request rerenders and dispose their interval", async () => {
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
|
||||
let renderRequests = 0;
|
||||
const component = (
|
||||
widget as unknown as (
|
||||
tui: { requestRender(): void },
|
||||
theme: unknown,
|
||||
) => { render(width: number): string[]; dispose?(): void }
|
||||
)(
|
||||
{
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
},
|
||||
},
|
||||
plainTheme,
|
||||
);
|
||||
|
||||
try {
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 00:00$/);
|
||||
now = 65_000;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(renderRequests > 0);
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 01:05$/);
|
||||
} finally {
|
||||
component.dispose?.();
|
||||
}
|
||||
|
||||
const requestsAfterDispose = renderRequests;
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(renderRequests, requestsAfterDispose);
|
||||
});
|
||||
|
||||
test("tool results collapse summaries and expand themed details", () => {
|
||||
const state = fixture();
|
||||
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
||||
|
||||
Reference in New Issue
Block a user