From 9f500743d12c805954609ee49ac49be7081fe98d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:28:58 +0900 Subject: [PATCH] feat(boulder-state): add session-aware multi-work storage helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/storage.test.ts | 157 +++++++ src/features/boulder-state/storage.ts | 499 ++++++++++++++++++++- 2 files changed, 651 insertions(+), 5 deletions(-) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index c424e02eb..6675b0e97 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -3,17 +3,28 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { tmpdir } from "node:os" import { + addBoulderWork, + appendSessionIdForWork, + getActiveWorks, + getBoulderWorks, readBoulderState, writeBoulderState, appendSessionId, clearBoulderState, + getWorkById, + getWorkByPlanName, + getWorkForSession, + getWorkResumeOptions, getPlanProgress, getPlanName, createBoulderState, findPrometheusPlans, getTaskSessionState, resolveBoulderPlanPath, + resolveBoulderPlanPathForWork, + selectActiveWork, upsertTaskSessionState, + upsertTaskSessionStateForWork, } from "./storage" import type { BoulderState } from "./types" import { readCurrentTopLevelTask } from "./top-level-task" @@ -39,6 +50,31 @@ describe("boulder-state", () => { }) describe("readBoulderState", () => { + test("should preserve legacy boulder.json fields during round-trip", () => { + // given + const boulderFile = join(SISYPHUS_DIR, "boulder.json") + const legacyRawState = { + active_plan: "/path/to/legacy-plan.md", + started_at: "2026-01-01T00:00:00.000Z", + session_ids: ["legacy-session"], + plan_name: "legacy-plan", + } + writeFileSync(boulderFile, JSON.stringify(legacyRawState, null, 2), "utf-8") + + // when + const state = readBoulderState(TEST_DIR) + expect(state).not.toBeNull() + const writeSucceeded = writeBoulderState(TEST_DIR, state!) + const roundTripState = readBoulderState(TEST_DIR) + + // then + expect(writeSucceeded).toBe(true) + expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan) + expect(roundTripState?.started_at).toBe(legacyRawState.started_at) + expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids) + expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name) + }) + test("should return null when no boulder.json exists", () => { // given - no boulder.json file // when @@ -387,6 +423,127 @@ describe("boulder-state", () => { }) }) + describe("multi-work helpers", () => { + test("should add second work and keep both active works", () => { + // given + const firstState = createBoulderState( + join(TEST_DIR, ".sisyphus/plans/plan-a.md"), + "session-a", + "atlas", + "/worktree-a", + ) + writeBoulderState(TEST_DIR, firstState) + const firstWorkId = firstState.active_work_id + + // when + const updatedState = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + agent: "atlas", + worktreePath: "/worktree-b", + }) + + // then + expect(updatedState).not.toBeNull() + const works = updatedState?.works ?? {} + expect(Object.keys(works).length).toBe(2) + expect(firstWorkId).toBeDefined() + expect(works[firstWorkId!]).toBeDefined() + expect(updatedState?.active_plan).toContain("plan-b.md") + expect(getActiveWorks(TEST_DIR).length).toBe(2) + }) + + test("should resolve work for session using updated_at tie-break", () => { + // given + const baseState = createBoulderState( + join(TEST_DIR, ".sisyphus/plans/plan-a.md"), + "session-a", + ) + writeBoulderState(TEST_DIR, baseState) + const stateWithSecond = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + }) + expect(stateWithSecond).not.toBeNull() + + const workIds = Object.keys(stateWithSecond!.works ?? {}) + expect(workIds.length).toBe(2) + const firstWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-a")! + const secondWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-b")! + + appendSessionIdForWork(TEST_DIR, secondWorkId, "session-a", "appended") + appendSessionIdForWork(TEST_DIR, firstWorkId, "session-a", "appended") + + // when + const resolvedWork = getWorkForSession(TEST_DIR, "session-a") + + // then + expect(resolvedWork?.work_id).toBe(firstWorkId) + }) + + test("should support selecting active work and read helpers", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const added = addBoulderWork(TEST_DIR, { + planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"), + sessionId: "session-b", + worktreePath: "/tmp/worktree-b", + }) + expect(added).not.toBeNull() + const firstWork = getWorkByPlanName(TEST_DIR, "plan-a") + expect(firstWork).not.toBeNull() + + // when + const selected = selectActiveWork(TEST_DIR, firstWork!.work_id) + const selectedById = getWorkById(TEST_DIR, firstWork!.work_id) + const byPlanNameWithWorktree = getWorkByPlanName(TEST_DIR, "plan-b", { worktreePath: "/tmp/worktree-b" }) + const byPlanPath = resolveBoulderPlanPathForWork(TEST_DIR, firstWork!) + const resumeOptions = getWorkResumeOptions(TEST_DIR) + const worksFromState = getBoulderWorks(selected!) + + // then + expect(selected?.active_work_id).toBe(firstWork!.work_id) + expect(selectedById?.work_id).toBe(firstWork!.work_id) + expect(byPlanNameWithWorktree?.plan_name).toBe("plan-b") + expect(byPlanPath.endsWith("plan-a.md")).toBe(true) + expect(resumeOptions.length).toBe(2) + expect(worksFromState.length).toBe(2) + }) + + test("should upsert task session for specific work and keep first started_at", () => { + // given + const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a") + writeBoulderState(TEST_DIR, initialState) + const workId = initialState.active_work_id! + + upsertTaskSessionStateForWork(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "task-session-a", + }) + + const seededState = readBoulderState(TEST_DIR)! + seededState.works![workId]!.task_sessions!["todo:1"]!.started_at = "2026-01-01T00:00:00.000Z" + writeBoulderState(TEST_DIR, seededState) + + // when + const updated = upsertTaskSessionStateForWork(TEST_DIR, workId, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "task one", + sessionId: "task-session-b", + }) + + // then + expect(updated).not.toBeNull() + const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"] + expect(taskSession?.session_id).toBe("task-session-b") + expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z") + }) + }) + 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 e11aa31ca..b9561562e 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -6,11 +6,93 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs" import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" -import type { BoulderState, PlanProgress, TaskSessionState } from "./types" +import type { + BoulderSessionOrigin, + BoulderState, + BoulderWorkResumeOption, + BoulderWorkState, + BoulderWorkStatus, + PlanProgress, + TaskSessionState, +} from "./types" import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants" const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"]) +function nowIsoString(): string { + return new Date().toISOString() +} + +function parseIsoToMs(value: string | undefined): number | null { + if (!value) { + return null + } + + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed +} + +function isValidWorkStatus(status: unknown): status is BoulderWorkStatus { + return status === "active" || status === "completed" || status === "paused" || status === "abandoned" +} + +function buildWorkFromMirror(state: BoulderState): BoulderWorkState { + const planName = state.plan_name ?? getPlanName(state.active_plan) + const workId = `${planName}-legacy` + return { + work_id: workId, + active_plan: state.active_plan, + plan_name: planName, + status: state.status, + started_at: state.started_at, + ended_at: state.ended_at, + elapsed_ms: state.elapsed_ms, + updated_at: state.updated_at, + session_ids: Array.isArray(state.session_ids) ? [...state.session_ids] : [], + session_origins: state.session_origins, + agent: state.agent, + worktree_path: state.worktree_path, + task_sessions: state.task_sessions, + } +} + +function projectWorkToMirror(state: BoulderState, work: BoulderWorkState): void { + state.active_plan = work.active_plan + state.plan_name = work.plan_name + state.status = work.status + state.started_at = work.started_at + state.ended_at = work.ended_at + state.elapsed_ms = work.elapsed_ms + state.updated_at = work.updated_at + state.session_ids = [...work.session_ids] + state.session_origins = work.session_origins ? { ...work.session_origins } : {} + state.agent = work.agent + state.worktree_path = work.worktree_path + state.task_sessions = work.task_sessions ? { ...work.task_sessions } : {} +} + +function selectMirrorWork(state: BoulderState): BoulderWorkState | null { + const works = getBoulderWorks(state) + if (works.length === 0) { + return null + } + + if (state.active_work_id) { + const matched = works.find((work) => work.work_id === state.active_work_id) + if (matched) { + return matched + } + } + + const sorted = [...works].sort((left, right) => { + const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0 + const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0 + return rightMs - leftMs + }) + + return sorted[0] ?? null +} + export function getBoulderFilePath(directory: string): string { return join(directory, BOULDER_DIR, BOULDER_FILE) } @@ -80,7 +162,15 @@ export function readBoulderState(directory: string): BoulderState | null { if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) { parsed.task_sessions = {} } - return parsed as BoulderState + + const state = parsed as BoulderState + const mirrorWork = selectMirrorWork(state) + if (mirrorWork) { + state.active_work_id = mirrorWork.work_id + projectWorkToMirror(state, mirrorWork) + } + + return state } catch { return null } @@ -95,7 +185,33 @@ export function writeBoulderState(directory: string, state: BoulderState): boole mkdirSync(dir, { recursive: true }) } - writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8") + const stateToWrite: BoulderState = { ...state } + if (stateToWrite.works && stateToWrite.active_work_id) { + const activeWork = stateToWrite.works[stateToWrite.active_work_id] + if (activeWork) { + const nextActiveWork: BoulderWorkState = { + ...activeWork, + active_plan: stateToWrite.active_plan, + plan_name: stateToWrite.plan_name, + status: stateToWrite.status, + started_at: stateToWrite.started_at, + ended_at: stateToWrite.ended_at, + elapsed_ms: stateToWrite.elapsed_ms, + updated_at: stateToWrite.updated_at, + session_ids: [...stateToWrite.session_ids], + session_origins: stateToWrite.session_origins ? { ...stateToWrite.session_origins } : {}, + agent: stateToWrite.agent, + worktree_path: stateToWrite.worktree_path, + task_sessions: stateToWrite.task_sessions ? { ...stateToWrite.task_sessions } : {}, + } + stateToWrite.works = { + ...stateToWrite.works, + [stateToWrite.active_work_id]: nextActiveWork, + } + } + } + + writeFileSync(filePath, JSON.stringify(stateToWrite, null, 2), "utf-8") return true } catch { return false @@ -107,6 +223,11 @@ export function appendSessionId( sessionId: string, origin: "direct" | "appended" = "direct", ): BoulderState | null { + const activeWorkId = readBoulderState(directory)?.active_work_id + if (activeWorkId) { + return appendSessionIdForWork(directory, activeWorkId, sessionId, origin) + } + const state = readBoulderState(directory) if (!state) return null @@ -156,6 +277,14 @@ export function clearBoulderState(directory: string): boolean { export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null { const state = readBoulderState(directory) + if (state?.active_work_id) { + const work = state.works?.[state.active_work_id] + const taskSession = work?.task_sessions?.[taskKey] + if (taskSession) { + return taskSession + } + } + if (!state?.task_sessions) { return null } @@ -174,6 +303,11 @@ export function upsertTaskSessionState( category?: string }, ): BoulderState | null { + const stateForWork = readBoulderState(directory) + if (stateForWork?.active_work_id) { + return upsertTaskSessionStateForWork(directory, stateForWork.active_work_id, input) + } + const state = readBoulderState(directory) if (!state) { return null @@ -355,15 +489,370 @@ export function createBoulderState( agent?: string, worktreePath?: string, ): BoulderState { - return { + const startedAt = nowIsoString() + const workId = generateWorkId(getPlanName(planPath)) + const work: BoulderWorkState = { + work_id: workId, active_plan: planPath, - started_at: new Date().toISOString(), + plan_name: getPlanName(planPath), + status: "active", + started_at: startedAt, + updated_at: startedAt, + session_ids: [sessionId], + session_origins: { + [sessionId]: "direct", + }, + ...(agent !== undefined ? { agent } : {}), + ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), + task_sessions: {}, + } + + return { + schema_version: 2, + active_work_id: workId, + works: { + [workId]: work, + }, + active_plan: planPath, + started_at: startedAt, + status: "active", + updated_at: startedAt, session_ids: [sessionId], session_origins: { [sessionId]: "direct", }, plan_name: getPlanName(planPath), + task_sessions: {}, ...(agent !== undefined ? { agent } : {}), ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), } } + +export function generateWorkId(planName: string): string { + const slug = planName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + const randomHex = Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, "0") + const safeSlug = slug.length > 0 ? slug : "work" + return `${safeSlug}-${randomHex}` +} + +export function getBoulderWorks(state: BoulderState): BoulderWorkState[] { + if (state.works && typeof state.works === "object") { + return Object.values(state.works) + } + + if (!state.active_plan || !state.plan_name || !state.started_at) { + return [] + } + + return [buildWorkFromMirror(state)] +} + +export function getActiveWorks(directory: string): BoulderWorkState[] { + const state = readBoulderState(directory) + if (!state) { + return [] + } + + return getBoulderWorks(state).filter((work) => work.status !== "completed" && work.status !== "abandoned") +} + +export function getWorkById(directory: string, workId: string): BoulderWorkState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + return getBoulderWorks(state).find((work) => work.work_id === workId) ?? null +} + +export function getWorkByPlanName( + directory: string, + planName: string, + options?: { worktreePath?: string }, +): BoulderWorkState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const worktreePath = options?.worktreePath + return getBoulderWorks(state).find((work) => { + if (work.plan_name !== planName) { + return false + } + + if (!worktreePath) { + return true + } + + return work.worktree_path === worktreePath + }) ?? null +} + +export function getWorkForSession(directory: string, sessionId: string): BoulderWorkState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + .filter((work) => work.session_ids.includes(sessionId)) + .sort((left, right) => { + const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0 + const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0 + return rightMs - leftMs + }) + + if (works.length > 0) { + return works[0] ?? null + } + + if (state.session_ids.includes(sessionId)) { + return buildWorkFromMirror(state) + } + + return null +} + +export function resolveBoulderPlanPathForWork( + directory: string, + work: Pick, +): string { + return resolveBoulderPlanPath(directory, work) +} + +export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] { + const state = readBoulderState(directory) + if (!state) { + return [] + } + + return getActiveWorks(directory).map((work) => { + const progress = getPlanProgress(resolveBoulderPlanPathForWork(directory, work)) + return { + work_id: work.work_id, + plan_name: work.plan_name, + active_plan: work.active_plan, + worktree_path: work.worktree_path, + status: work.status && isValidWorkStatus(work.status) ? work.status : "active", + started_at: work.started_at, + updated_at: work.updated_at ?? work.started_at, + ended_at: work.ended_at, + elapsed_ms: work.elapsed_ms, + session_count: work.session_ids.length, + progress, + is_current_mirror: state.active_work_id === work.work_id, + } + }) +} + +export function selectActiveWork(directory: string, workId: string): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + const nextWork = works.find((work) => work.work_id === workId) + if (!nextWork) { + return null + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + active_work_id: workId, + works: state.works ?? Object.fromEntries(works.map((work) => [work.work_id, work])), + } + projectWorkToMirror(nextState, nextWork) + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function addBoulderWork( + directory: string, + input: { + planPath: string + sessionId: string + agent?: string + worktreePath?: string + startedAt?: string + }, +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const workId = generateWorkId(getPlanName(input.planPath)) + const startedAt = input.startedAt ?? nowIsoString() + const nextWork: BoulderWorkState = { + work_id: workId, + active_plan: input.planPath, + plan_name: getPlanName(input.planPath), + status: "active", + started_at: startedAt, + updated_at: startedAt, + session_ids: [input.sessionId], + session_origins: { + [input.sessionId]: "direct", + }, + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}), + task_sessions: {}, + } + + const works = getBoulderWorks(state) + const nextWorks: Record = { + ...Object.fromEntries(works.map((work) => [work.work_id, work])), + [workId]: nextWork, + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + works: nextWorks, + active_work_id: workId, + } + projectWorkToMirror(nextState, nextWork) + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function appendSessionIdForWork( + directory: string, + workId: string, + sessionId: string, + origin: BoulderSessionOrigin = "direct", +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + const targetWork = works.find((work) => work.work_id === workId) + if (!targetWork) { + return null + } + + const sessionIds = targetWork.session_ids.includes(sessionId) + ? [...targetWork.session_ids] + : [...targetWork.session_ids, sessionId] + const sessionOrigins = { + ...(targetWork.session_origins ?? {}), + [sessionId]: origin, + } + + const updatedWork: BoulderWorkState = { + ...targetWork, + session_ids: sessionIds, + session_origins: sessionOrigins, + updated_at: nowIsoString(), + } + const nextWorks = { + ...Object.fromEntries(works.map((work) => [work.work_id, work])), + [workId]: updatedWork, + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + works: nextWorks, + } + if (state.active_work_id === workId) { + projectWorkToMirror(nextState, updatedWork) + } + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +} + +export function upsertTaskSessionStateForWork( + directory: string, + workId: string, + input: { + taskKey: string + taskLabel: string + taskTitle: string + sessionId: string + agent?: string + category?: string + }, +): BoulderState | null { + if (RESERVED_KEYS.has(input.taskKey)) { + return null + } + + const state = readBoulderState(directory) + if (!state) { + return null + } + + const works = getBoulderWorks(state) + const targetWork = works.find((work) => work.work_id === workId) + if (!targetWork) { + return null + } + + const previousTaskSession = targetWork.task_sessions?.[input.taskKey] + const nextTaskSession: TaskSessionState = { + task_key: input.taskKey, + task_label: input.taskLabel, + task_title: input.taskTitle, + session_id: input.sessionId, + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(input.category !== undefined ? { category: input.category } : {}), + ...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}), + ...(previousTaskSession?.ended_at !== undefined ? { ended_at: previousTaskSession.ended_at } : {}), + ...(previousTaskSession?.elapsed_ms !== undefined ? { elapsed_ms: previousTaskSession.elapsed_ms } : {}), + ...(previousTaskSession?.status !== undefined ? { status: previousTaskSession.status } : {}), + updated_at: nowIsoString(), + } + + const nextWork: BoulderWorkState = { + ...targetWork, + task_sessions: { + ...(targetWork.task_sessions ?? {}), + [input.taskKey]: nextTaskSession, + }, + updated_at: nowIsoString(), + } + + const nextWorks = { + ...Object.fromEntries(works.map((work) => [work.work_id, work])), + [workId]: nextWork, + } + + const nextState: BoulderState = { + ...state, + schema_version: 2, + works: nextWorks, + } + if (state.active_work_id === workId) { + projectWorkToMirror(nextState, nextWork) + } + + if (!writeBoulderState(directory, nextState)) { + return null + } + + return nextState +}