feat(features): add boulder-state persistence
Add boulder-state feature for persisting workflow state: - storage.ts: File I/O operations for state persistence - types.ts: State interfaces - Includes test coverage 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
13
src/features/boulder-state/constants.ts
Normal file
13
src/features/boulder-state/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Boulder State Constants
|
||||
*/
|
||||
|
||||
export const BOULDER_DIR = ".sisyphus"
|
||||
export const BOULDER_FILE = "boulder.json"
|
||||
export const BOULDER_STATE_PATH = `${BOULDER_DIR}/${BOULDER_FILE}`
|
||||
|
||||
export const NOTEPAD_DIR = "notepads"
|
||||
export const NOTEPAD_BASE_PATH = `${BOULDER_DIR}/${NOTEPAD_DIR}`
|
||||
|
||||
/** Prometheus plan directory pattern */
|
||||
export const PROMETHEUS_PLANS_DIR = ".sisyphus/plans"
|
||||
3
src/features/boulder-state/index.ts
Normal file
3
src/features/boulder-state/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./storage"
|
||||
250
src/features/boulder-state/storage.test.ts
Normal file
250
src/features/boulder-state/storage.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
readBoulderState,
|
||||
writeBoulderState,
|
||||
appendSessionId,
|
||||
clearBoulderState,
|
||||
getPlanProgress,
|
||||
getPlanName,
|
||||
createBoulderState,
|
||||
findPrometheusPlans,
|
||||
} from "./storage"
|
||||
import type { BoulderState } from "./types"
|
||||
|
||||
describe("boulder-state", () => {
|
||||
const TEST_DIR = join(tmpdir(), "boulder-state-test-" + Date.now())
|
||||
const SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(TEST_DIR)) {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
}
|
||||
if (!existsSync(SISYPHUS_DIR)) {
|
||||
mkdirSync(SISYPHUS_DIR, { recursive: true })
|
||||
}
|
||||
clearBoulderState(TEST_DIR)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("readBoulderState", () => {
|
||||
test("should return null when no boulder.json exists", () => {
|
||||
// #given - no boulder.json file
|
||||
// #when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should read valid boulder state", () => {
|
||||
// #given - valid boulder.json
|
||||
const state: BoulderState = {
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1", "session-2"],
|
||||
plan_name: "my-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.active_plan).toBe("/path/to/plan.md")
|
||||
expect(result?.session_ids).toEqual(["session-1", "session-2"])
|
||||
expect(result?.plan_name).toBe("my-plan")
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeBoulderState", () => {
|
||||
test("should write state and create .sisyphus directory if needed", () => {
|
||||
// #given - state to write
|
||||
const state: BoulderState = {
|
||||
active_plan: "/test/plan.md",
|
||||
started_at: "2026-01-02T12:00:00Z",
|
||||
session_ids: ["ses-123"],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
|
||||
// #when
|
||||
const success = writeBoulderState(TEST_DIR, state)
|
||||
const readBack = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
expect(readBack).not.toBeNull()
|
||||
expect(readBack?.active_plan).toBe("/test/plan.md")
|
||||
})
|
||||
})
|
||||
|
||||
describe("appendSessionId", () => {
|
||||
test("should append new session id to existing state", () => {
|
||||
// #given - existing state with one session
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
const result = appendSessionId(TEST_DIR, "session-2")
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.session_ids).toEqual(["session-1", "session-2"])
|
||||
})
|
||||
|
||||
test("should not duplicate existing session id", () => {
|
||||
// #given - state with session-1 already
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
appendSessionId(TEST_DIR, "session-1")
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
expect(result?.session_ids).toEqual(["session-1"])
|
||||
})
|
||||
|
||||
test("should return null when no state exists", () => {
|
||||
// #given - no boulder.json
|
||||
// #when
|
||||
const result = appendSessionId(TEST_DIR, "new-session")
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearBoulderState", () => {
|
||||
test("should remove boulder.json", () => {
|
||||
// #given - existing state
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
const success = clearBoulderState(TEST_DIR)
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should succeed even when no file exists", () => {
|
||||
// #given - no boulder.json
|
||||
// #when
|
||||
const success = clearBoulderState(TEST_DIR)
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPlanProgress", () => {
|
||||
test("should count completed and uncompleted checkboxes", () => {
|
||||
// #given - plan file with checkboxes
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [ ] Task 1
|
||||
- [x] Task 2
|
||||
- [ ] Task 3
|
||||
- [X] Task 4
|
||||
`)
|
||||
|
||||
// #when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// #then
|
||||
expect(progress.total).toBe(4)
|
||||
expect(progress.completed).toBe(2)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should return isComplete true when all checked", () => {
|
||||
// #given - all tasks completed
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [x] Task 1
|
||||
- [X] Task 2
|
||||
`)
|
||||
|
||||
// #when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// #then
|
||||
expect(progress.total).toBe(2)
|
||||
expect(progress.completed).toBe(2)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should return isComplete true for empty plan", () => {
|
||||
// #given - plan with no checkboxes
|
||||
const planPath = join(TEST_DIR, "empty-plan.md")
|
||||
writeFileSync(planPath, "# Plan\nNo tasks here")
|
||||
|
||||
// #when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// #then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle non-existent file", () => {
|
||||
// #given - non-existent file
|
||||
// #when
|
||||
const progress = getPlanProgress("/non/existent/file.md")
|
||||
// #then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPlanName", () => {
|
||||
test("should extract plan name from path", () => {
|
||||
// #given
|
||||
const path = "/home/user/.sisyphus/plans/project/my-feature.md"
|
||||
// #when
|
||||
const name = getPlanName(path)
|
||||
// #then
|
||||
expect(name).toBe("my-feature")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBoulderState", () => {
|
||||
test("should create state with correct fields", () => {
|
||||
// #given
|
||||
const planPath = "/path/to/auth-refactor.md"
|
||||
const sessionId = "ses-abc123"
|
||||
|
||||
// #when
|
||||
const state = createBoulderState(planPath, sessionId)
|
||||
|
||||
// #then
|
||||
expect(state.active_plan).toBe(planPath)
|
||||
expect(state.session_ids).toEqual([sessionId])
|
||||
expect(state.plan_name).toBe("auth-refactor")
|
||||
expect(state.started_at).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
150
src/features/boulder-state/storage.ts
Normal file
150
src/features/boulder-state/storage.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Boulder State Storage
|
||||
*
|
||||
* Handles reading/writing boulder.json for active plan tracking.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
|
||||
import { dirname, join, basename } from "node:path"
|
||||
import type { BoulderState, PlanProgress } from "./types"
|
||||
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
|
||||
|
||||
export function getBoulderFilePath(directory: string): string {
|
||||
return join(directory, BOULDER_DIR, BOULDER_FILE)
|
||||
}
|
||||
|
||||
export function readBoulderState(directory: string): BoulderState | null {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as BoulderState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBoulderState(directory: string, state: BoulderState): boolean {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function appendSessionId(directory: string, sessionId: string): BoulderState | null {
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) return null
|
||||
|
||||
if (!state.session_ids.includes(sessionId)) {
|
||||
state.session_ids.push(sessionId)
|
||||
if (writeBoulderState(directory, state)) {
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
export function clearBoulderState(directory: string): boolean {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const { unlinkSync } = require("node:fs")
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Prometheus plan files for this project.
|
||||
* Prometheus stores plans at: {project}/.sisyphus/plans/{name}.md
|
||||
*/
|
||||
export function findPrometheusPlans(directory: string): string[] {
|
||||
const plansDir = join(directory, PROMETHEUS_PLANS_DIR)
|
||||
|
||||
if (!existsSync(plansDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const files = readdirSync(plansDir)
|
||||
return files
|
||||
.filter((f) => f.endsWith(".md"))
|
||||
.map((f) => join(plansDir, f))
|
||||
.sort((a, b) => {
|
||||
// Sort by modification time, newest first
|
||||
const aStat = require("node:fs").statSync(a)
|
||||
const bStat = require("node:fs").statSync(b)
|
||||
return bStat.mtimeMs - aStat.mtimeMs
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a plan file and count checkbox progress.
|
||||
*/
|
||||
export function getPlanProgress(planPath: string): PlanProgress {
|
||||
if (!existsSync(planPath)) {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
|
||||
// Match markdown checkboxes: - [ ] or - [x] or - [X]
|
||||
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
|
||||
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
|
||||
|
||||
const total = uncheckedMatches.length + checkedMatches.length
|
||||
const completed = checkedMatches.length
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
isComplete: total === 0 || completed === total,
|
||||
}
|
||||
} catch {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plan name from file path.
|
||||
*/
|
||||
export function getPlanName(planPath: string): string {
|
||||
return basename(planPath, ".md")
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new boulder state for a plan.
|
||||
*/
|
||||
export function createBoulderState(
|
||||
planPath: string,
|
||||
sessionId: string
|
||||
): BoulderState {
|
||||
return {
|
||||
active_plan: planPath,
|
||||
started_at: new Date().toISOString(),
|
||||
session_ids: [sessionId],
|
||||
plan_name: getPlanName(planPath),
|
||||
}
|
||||
}
|
||||
26
src/features/boulder-state/types.ts
Normal file
26
src/features/boulder-state/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Boulder State Types
|
||||
*
|
||||
* Manages the active work plan state for Sisyphus orchestrator.
|
||||
* Named after Sisyphus's boulder - the eternal task that must be rolled.
|
||||
*/
|
||||
|
||||
export interface BoulderState {
|
||||
/** Absolute path to the active plan file */
|
||||
active_plan: string
|
||||
/** ISO timestamp when work started */
|
||||
started_at: string
|
||||
/** Session IDs that have worked on this plan */
|
||||
session_ids: string[]
|
||||
/** Plan name derived from filename */
|
||||
plan_name: string
|
||||
}
|
||||
|
||||
export interface PlanProgress {
|
||||
/** Total number of checkboxes */
|
||||
total: number
|
||||
/** Number of completed checkboxes */
|
||||
completed: number
|
||||
/** Whether all tasks are done */
|
||||
isComplete: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user