feat(boulder-state): add task timer + completion helpers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -5,6 +5,8 @@ import { tmpdir } from "node:os"
|
|||||||
import {
|
import {
|
||||||
addBoulderWork,
|
addBoulderWork,
|
||||||
appendSessionIdForWork,
|
appendSessionIdForWork,
|
||||||
|
completeBoulder,
|
||||||
|
endTaskTimer,
|
||||||
getActiveWorks,
|
getActiveWorks,
|
||||||
getBoulderWorks,
|
getBoulderWorks,
|
||||||
readBoulderState,
|
readBoulderState,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
resolveBoulderPlanPath,
|
resolveBoulderPlanPath,
|
||||||
resolveBoulderPlanPathForWork,
|
resolveBoulderPlanPathForWork,
|
||||||
selectActiveWork,
|
selectActiveWork,
|
||||||
|
startTaskTimer,
|
||||||
upsertTaskSessionState,
|
upsertTaskSessionState,
|
||||||
upsertTaskSessionStateForWork,
|
upsertTaskSessionStateForWork,
|
||||||
} from "./storage"
|
} 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", () => {
|
describe("readCurrentTopLevelTask", () => {
|
||||||
test("should return the first unchecked top-level task in TODOs", () => {
|
test("should return the first unchecked top-level task in TODOs", () => {
|
||||||
// given - plan with nested and top-level unchecked tasks
|
// given - plan with nested and top-level unchecked tasks
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ function parseIsoToMs(value: string | undefined): number | null {
|
|||||||
return Number.isNaN(parsed) ? null : parsed
|
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 {
|
function isValidWorkStatus(status: unknown): status is BoulderWorkStatus {
|
||||||
return status === "active" || status === "completed" || status === "paused" || status === "abandoned"
|
return status === "active" || status === "completed" || status === "paused" || status === "abandoned"
|
||||||
}
|
}
|
||||||
@@ -856,3 +866,108 @@ export function upsertTaskSessionStateForWork(
|
|||||||
|
|
||||||
return nextState
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user