From 5d823b5078f46a942f382d6bb3ae0f12cd0561c7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:30:29 +0900 Subject: [PATCH] feat(boulder-state): add task timer + completion helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/storage.test.ts | 80 ++++++++++++++ src/features/boulder-state/storage.ts | 115 +++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 6675b0e97..63e43faff 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os" import { addBoulderWork, appendSessionIdForWork, + completeBoulder, + endTaskTimer, getActiveWorks, getBoulderWorks, readBoulderState, @@ -23,6 +25,7 @@ import { resolveBoulderPlanPath, resolveBoulderPlanPathForWork, selectActiveWork, + startTaskTimer, upsertTaskSessionState, upsertTaskSessionStateForWork, } from "./storage" @@ -544,6 +547,83 @@ describe("boulder-state", () => { }) }) + describe("task timer and completion helpers", () => { + test("should keep started_at stable when starting timer repeatedly", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + + // when + startTaskTimer(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "session-a", + startedAt: "2026-01-01T00:00:00.000Z", + }) + startTaskTimer(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "session-a", + startedAt: "2026-01-02T00:00:00.000Z", + }) + + // then + const taskSession = readBoulderState(TEST_DIR)?.works?.[workId]?.task_sessions?.["todo:1"] + expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z") + expect(taskSession?.status).toBe("running") + }) + + test("should compute elapsed_ms when ending task timer", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + startTaskTimer(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "session-a", + startedAt: "2026-01-01T00:00:00.000Z", + }) + + // when + const endedState = endTaskTimer(TEST_DIR, workId, "todo:1", "2026-01-01T00:00:01.500Z") + + // then + const taskSession = endedState?.works?.[workId]?.task_sessions?.["todo:1"] + expect(taskSession?.ended_at).toBe("2026-01-01T00:00:01.500Z") + expect(taskSession?.elapsed_ms).toBe(1500) + expect(taskSession?.status).toBe("completed") + }) + + test("should complete one work and keep other work untouched", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const firstWorkId = initialState.active_work_id! + const withSecond = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + }) + const secondWorkId = Object.keys(withSecond!.works!).find((workId) => workId !== firstWorkId)! + + // when + const completedState = completeBoulder(TEST_DIR, firstWorkId, "2026-01-01T01:00:00.000Z") + + // then + expect(completedState?.works?.[firstWorkId]?.status).toBe("completed") + expect(completedState?.works?.[firstWorkId]?.ended_at).toBe("2026-01-01T01:00:00.000Z") + expect(completedState?.works?.[firstWorkId]?.elapsed_ms).toBe( + Date.parse("2026-01-01T01:00:00.000Z") - Date.parse(completedState!.works![firstWorkId]!.started_at), + ) + expect(completedState?.works?.[secondWorkId]?.status).not.toBe("completed") + expect(existsSync(join(SISYPHUS_DIR, "boulder.json"))).toBe(true) + }) + }) + describe("readCurrentTopLevelTask", () => { test("should return the first unchecked top-level task in TODOs", () => { // given - plan with nested and top-level unchecked tasks diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index b9561562e..f5f03109c 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -32,6 +32,16 @@ function parseIsoToMs(value: string | undefined): number | null { return Number.isNaN(parsed) ? null : parsed } +function getElapsedMs(startedAt: string | undefined, endedAt: string | undefined): number | undefined { + const startedMs = parseIsoToMs(startedAt) + const endedMs = parseIsoToMs(endedAt) + if (startedMs === null || endedMs === null) { + return undefined + } + + return endedMs - startedMs +} + function isValidWorkStatus(status: unknown): status is BoulderWorkStatus { return status === "active" || status === "completed" || status === "paused" || status === "abandoned" } @@ -856,3 +866,108 @@ export function upsertTaskSessionStateForWork( return nextState } + +export function startTaskTimer( + directory: string, + workId: string, + input: { + taskKey: string + taskLabel: string + taskTitle: string + sessionId: string + agent?: string + category?: string + startedAt?: string + }, +): BoulderState | null { + const nextState = upsertTaskSessionStateForWork(directory, workId, input) + if (!nextState) { + return null + } + + const work = nextState.works?.[workId] + const taskSession = work?.task_sessions?.[input.taskKey] + if (!work || !taskSession) { + return null + } + + const startedAt = taskSession.started_at ?? input.startedAt ?? nowIsoString() + taskSession.started_at = startedAt + taskSession.status = "running" + taskSession.updated_at = nowIsoString() + work.updated_at = nowIsoString() + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function endTaskTimer( + directory: string, + workId: string, + taskKey: string, + endedAt?: string, +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const work = state.works?.[workId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === workId) + if (!work?.task_sessions?.[taskKey]) { + return null + } + + const taskSession = work.task_sessions[taskKey] + const endAt = endedAt ?? nowIsoString() + taskSession.ended_at = endAt + taskSession.elapsed_ms = getElapsedMs(taskSession.started_at, endAt) + taskSession.status = "completed" + taskSession.updated_at = nowIsoString() + work.updated_at = nowIsoString() + + if (state.active_work_id === workId) { + projectWorkToMirror(state, work) + } + + if (!writeBoulderState(directory, state)) { + return null + } + + return state +} + +export function completeBoulder(directory: string, workId?: string, endedAt?: string): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const targetWorkId = workId ?? state.active_work_id + if (!targetWorkId) { + return null + } + + const work = state.works?.[targetWorkId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === targetWorkId) + if (!work) { + return null + } + + const endAt = endedAt ?? nowIsoString() + work.ended_at = endAt + work.elapsed_ms = getElapsedMs(work.started_at, endAt) + work.status = "completed" + work.updated_at = nowIsoString() + + if (state.active_work_id === targetWorkId) { + projectWorkToMirror(state, work) + } + + if (!writeBoulderState(directory, state)) { + return null + } + + return state +}