Implement unified Claude Tasks system with single multi-action tool (#1356)
* chore: pin bun-types to 1.3.6 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * chore: exclude test files and script from tsconfig 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor: remove sisyphus-swarm feature Remove mailbox types and swarm config schema. Update docs. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor: remove legacy sisyphus-tasks feature Remove old storage and types implementation, replaced by claude-tasks. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(claude-tasks): add task schema and storage utilities - Task schema with Zod validation (pending, in_progress, completed, deleted) - Storage utilities: getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock - Atomic writes with temp file + rename - File-based locking with 30s stale threshold 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools/task): add task object schemas Add Zod schemas for task CRUD operations input validation. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskCreate tool Create new tasks with sequential ID generation and lock-based concurrency. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskGet tool Retrieve task by ID with null-safe handling. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskUpdate tool with claim validation Update tasks with status transitions and owner claim validation. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskList tool and exports - TaskList for summary view of all tasks - Export all claude-tasks tool factories from index 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(hooks): add task-reminder hook Remind agents to use task tools after 10 turns without task operations. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config): add disabled_tools setting and tasks-todowrite-disabler hook - Add disabled_tools config option to disable specific tools by name - Register tasks-todowrite-disabler hook name in schema 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config-handler): add task_* and teammate tool permissions Grant task_* and teammate permissions to atlas, sisyphus, prometheus, and sisyphus-junior agents. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(delegate-task): add execute option for task execution Add optional execute field with task_id and task_dir for task-based delegation. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * fix(truncator): add type guard for non-string outputs Prevent crashes when output is not a string by adding typeof checks. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * chore: export config types and update task-resume-info - Export SisyphusConfig and SisyphusTasksConfig types - Add task_tool to TARGET_TOOLS list 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor(storage): remove team namespace, use flat task directory * feat(task): implement unified task tool with all 5 actions * fix(hooks): update task-reminder to track unified task tool * refactor(tools): register unified task tool, remove 4 separate tools * chore(cleanup): remove old 4-tool task implementation * refactor(config): use new_task_system_enabled as top-level flag - Add new_task_system_enabled to OhMyOpenCodeConfigSchema - Remove enabled from SisyphusTasksConfigSchema (keep storage_path, claude_code_compat) - Update index.ts to gate on new_task_system_enabled - Update plugin-config.ts default for config initialization - Update test configs in task.test.ts and storage.test.ts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: resolve typecheck and test failures - Add explicit ToolDefinition return type to createTask function - Fix planDemoteConfig to use 'subagent' mode instead of 'all' --------- Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# CLAUDE TASKS FEATURE KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Claude Code compatible task schema and storage. Provides core task management utilities used by task-related tools and features.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
claude-tasks/
|
||||
├── types.ts # Task schema (Zod)
|
||||
├── types.test.ts # Schema validation tests (8 tests)
|
||||
├── storage.ts # File operations
|
||||
├── storage.test.ts # Storage tests (14 tests)
|
||||
└── index.ts # Barrel exports
|
||||
```
|
||||
|
||||
## TASK SCHEMA
|
||||
|
||||
```typescript
|
||||
type TaskStatus = "pending" | "in_progress" | "completed" | "deleted"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
subject: string // Imperative: "Run tests"
|
||||
description: string
|
||||
status: TaskStatus
|
||||
activeForm?: string // Present continuous: "Running tests"
|
||||
blocks: string[] // Task IDs this task blocks
|
||||
blockedBy: string[] // Task IDs blocking this task
|
||||
owner?: string // Agent name
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
```
|
||||
|
||||
**Key Differences from Legacy**:
|
||||
- `subject` (was `title`)
|
||||
- `blockedBy` (was `dependsOn`)
|
||||
- No `parentID`, `repoURL`, `threadID` fields
|
||||
|
||||
## STORAGE UTILITIES
|
||||
|
||||
### getTaskDir(teamName, config)
|
||||
|
||||
Returns: `.sisyphus/tasks/{teamName}` (or custom path from config)
|
||||
|
||||
### readJsonSafe(filePath, schema)
|
||||
|
||||
- Returns parsed & validated data or `null`
|
||||
- Safe for missing files, invalid JSON, schema violations
|
||||
|
||||
### writeJsonAtomic(filePath, data)
|
||||
|
||||
- Atomic write via temp file + rename
|
||||
- Creates parent directories automatically
|
||||
- Cleans up temp file on error
|
||||
|
||||
### acquireLock(dirPath)
|
||||
|
||||
- File-based lock: `.lock` file with timestamp
|
||||
- 30-second stale threshold
|
||||
- Returns `{ acquired: boolean, release: () => void }`
|
||||
|
||||
## TESTING
|
||||
|
||||
**types.test.ts** (8 tests):
|
||||
- Valid status enum values
|
||||
- Required vs optional fields
|
||||
- Array validation (blocks, blockedBy)
|
||||
- Schema rejection for invalid data
|
||||
|
||||
**storage.test.ts** (14 tests):
|
||||
- Path construction
|
||||
- Safe JSON reading (missing files, invalid JSON, schema failures)
|
||||
- Atomic writes (directory creation, overwrites)
|
||||
- Lock acquisition (fresh locks, stale locks, release)
|
||||
|
||||
## USAGE
|
||||
|
||||
```typescript
|
||||
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
|
||||
|
||||
const taskDir = getTaskDir("my-team", config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
try {
|
||||
const task = readJsonSafe(join(taskDir, "1.json"), TaskSchema)
|
||||
if (task) {
|
||||
task.status = "completed"
|
||||
writeJsonAtomic(join(taskDir, "1.json"), task)
|
||||
}
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Direct fs operations (use storage utilities)
|
||||
- Skipping lock acquisition for writes
|
||||
- Ignoring null returns from readJsonSafe
|
||||
- Using old schema field names (title, dependsOn)
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./storage"
|
||||
@@ -0,0 +1,361 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { z } from "zod"
|
||||
import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
const TEST_DIR = ".test-claude-tasks"
|
||||
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
test("returns correct path for default config", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {}
|
||||
|
||||
//#when
|
||||
const result = getTaskDir(config)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
|
||||
})
|
||||
|
||||
test("returns correct path with custom storage_path", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: ".custom/tasks",
|
||||
claude_code_compat: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = getTaskDir(config)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(process.cwd(), ".custom/tasks"))
|
||||
})
|
||||
|
||||
test("returns correct path with default config parameter", () => {
|
||||
//#when
|
||||
const result = getTaskDir()
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateTaskId", () => {
|
||||
test("generates task ID with T- prefix and UUID", () => {
|
||||
//#when
|
||||
const taskId = generateTaskId()
|
||||
|
||||
//#then
|
||||
expect(taskId).toMatch(/^T-[a-f0-9-]{36}$/)
|
||||
})
|
||||
|
||||
test("generates unique task IDs", () => {
|
||||
//#when
|
||||
const id1 = generateTaskId()
|
||||
const id2 = generateTaskId()
|
||||
|
||||
//#then
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listTaskFiles", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns empty array for non-existent directory", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
new_task_system_enabled: false,
|
||||
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = listTaskFiles(config)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty array for directory with no task files", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
new_task_system_enabled: false,
|
||||
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
||||
}
|
||||
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
||||
writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8")
|
||||
|
||||
//#when
|
||||
const result = listTaskFiles(config)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("lists task files with T- prefix and .json extension", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
new_task_system_enabled: false,
|
||||
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
||||
}
|
||||
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
||||
writeFileSync(join(TEST_DIR_ABS, "T-abc123.json"), "{}", "utf-8")
|
||||
writeFileSync(join(TEST_DIR_ABS, "T-def456.json"), "{}", "utf-8")
|
||||
writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8")
|
||||
writeFileSync(join(TEST_DIR_ABS, "notes.md"), "# notes", "utf-8")
|
||||
|
||||
//#when
|
||||
const result = listTaskFiles(config)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toContain("T-abc123")
|
||||
expect(result).toContain("T-def456")
|
||||
})
|
||||
|
||||
test("returns task IDs without .json extension", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
new_task_system_enabled: false,
|
||||
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
||||
}
|
||||
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
||||
writeFileSync(join(TEST_DIR_ABS, "T-test-id.json"), "{}", "utf-8")
|
||||
|
||||
//#when
|
||||
const result = listTaskFiles(config)
|
||||
|
||||
//#then
|
||||
expect(result[0]).toBe("T-test-id")
|
||||
expect(result[0]).not.toContain(".json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
const testSchema = z.object({
|
||||
id: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns null for non-existent file", () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "nonexistent.json")
|
||||
|
||||
//#when
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns parsed data for valid file", () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "valid.json")
|
||||
const data = { id: "test", value: 42 }
|
||||
writeFileSync(filePath, JSON.stringify(data), "utf-8")
|
||||
|
||||
//#when
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
test("returns null for invalid JSON", () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "invalid.json")
|
||||
writeFileSync(filePath, "{ invalid json", "utf-8")
|
||||
|
||||
//#when
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for data that fails schema validation", () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "invalid-schema.json")
|
||||
const data = { id: "test", value: "not-a-number" }
|
||||
writeFileSync(filePath, JSON.stringify(data), "utf-8")
|
||||
|
||||
//#when
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("creates directory if it does not exist", () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "nested", "dir", "file.json")
|
||||
const data = { test: "data" }
|
||||
|
||||
//#when
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
//#then
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
|
||||
test("writes data atomically", async () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "atomic.json")
|
||||
const data = { id: "test", value: 123 }
|
||||
|
||||
//#when
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
//#then
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
const content = await Bun.file(filePath).text()
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
test("overwrites existing file", async () => {
|
||||
//#given
|
||||
const filePath = join(TEST_DIR_ABS, "overwrite.json")
|
||||
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
||||
writeFileSync(filePath, JSON.stringify({ old: "data" }), "utf-8")
|
||||
|
||||
//#when
|
||||
const newData = { new: "data" }
|
||||
writeJsonAtomic(filePath, newData)
|
||||
|
||||
//#then
|
||||
const content = await Bun.file(filePath).text()
|
||||
expect(JSON.parse(content)).toEqual(newData)
|
||||
})
|
||||
})
|
||||
|
||||
describe("acquireLock", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR_ABS)) {
|
||||
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("acquires lock when no lock exists", () => {
|
||||
//#given
|
||||
const dirPath = TEST_DIR_ABS
|
||||
|
||||
//#when
|
||||
const lock = acquireLock(dirPath)
|
||||
|
||||
//#then
|
||||
expect(lock.acquired).toBe(true)
|
||||
expect(existsSync(join(dirPath, ".lock"))).toBe(true)
|
||||
|
||||
//#cleanup
|
||||
lock.release()
|
||||
})
|
||||
|
||||
test("fails to acquire lock when fresh lock exists", () => {
|
||||
//#given
|
||||
const dirPath = TEST_DIR
|
||||
const firstLock = acquireLock(dirPath)
|
||||
|
||||
//#when
|
||||
const secondLock = acquireLock(dirPath)
|
||||
|
||||
//#then
|
||||
expect(secondLock.acquired).toBe(false)
|
||||
|
||||
//#cleanup
|
||||
firstLock.release()
|
||||
})
|
||||
|
||||
test("acquires lock when stale lock exists (>30s)", () => {
|
||||
//#given
|
||||
const dirPath = TEST_DIR
|
||||
const lockPath = join(dirPath, ".lock")
|
||||
const staleTimestamp = Date.now() - 31000 // 31 seconds ago
|
||||
writeFileSync(lockPath, JSON.stringify({ timestamp: staleTimestamp }), "utf-8")
|
||||
|
||||
//#when
|
||||
const lock = acquireLock(dirPath)
|
||||
|
||||
//#then
|
||||
expect(lock.acquired).toBe(true)
|
||||
|
||||
//#cleanup
|
||||
lock.release()
|
||||
})
|
||||
|
||||
test("release removes lock file", () => {
|
||||
//#given
|
||||
const dirPath = TEST_DIR
|
||||
const lock = acquireLock(dirPath)
|
||||
const lockPath = join(dirPath, ".lock")
|
||||
|
||||
//#when
|
||||
lock.release()
|
||||
|
||||
//#then
|
||||
expect(existsSync(lockPath)).toBe(false)
|
||||
})
|
||||
|
||||
test("release is safe to call multiple times", () => {
|
||||
//#given
|
||||
const dirPath = TEST_DIR
|
||||
const lock = acquireLock(dirPath)
|
||||
|
||||
//#when
|
||||
lock.release()
|
||||
lock.release()
|
||||
|
||||
//#then
|
||||
expect(existsSync(join(dirPath, ".lock"))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs"
|
||||
import { randomUUID } from "crypto"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(config: Partial<OhMyOpenCodeConfig> = {}): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_LOCK_THRESHOLD_MS = 30000
|
||||
|
||||
export function generateTaskId(): string {
|
||||
return `T-${randomUUID()}`
|
||||
}
|
||||
|
||||
export function listTaskFiles(config: Partial<OhMyOpenCodeConfig> = {}): string[] {
|
||||
const dir = getTaskDir(config)
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json') && f.startsWith('T-'))
|
||||
.map((f) => f.replace('.json', ''))
|
||||
}
|
||||
|
||||
export function acquireLock(dirPath: string): { acquired: boolean; release: () => void } {
|
||||
const lockPath = join(dirPath, ".lock")
|
||||
const now = Date.now()
|
||||
|
||||
if (existsSync(lockPath)) {
|
||||
try {
|
||||
const lockContent = readFileSync(lockPath, "utf-8")
|
||||
const lockData = JSON.parse(lockContent)
|
||||
const lockAge = now - lockData.timestamp
|
||||
|
||||
if (lockAge <= STALE_LOCK_THRESHOLD_MS) {
|
||||
return {
|
||||
acquired: false,
|
||||
release: () => {
|
||||
// No-op release for failed acquisition
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If lock file is corrupted, treat as stale and override
|
||||
}
|
||||
}
|
||||
|
||||
ensureDir(dirPath)
|
||||
writeFileSync(lockPath, JSON.stringify({ timestamp: now }), "utf-8")
|
||||
|
||||
return {
|
||||
acquired: true,
|
||||
release: () => {
|
||||
try {
|
||||
if (existsSync(lockPath)) {
|
||||
unlinkSync(lockPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task, type TaskStatus } from "./types"
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
test("accepts valid status values", () => {
|
||||
//#given
|
||||
const validStatuses: TaskStatus[] = ["pending", "in_progress", "completed", "deleted"]
|
||||
|
||||
//#when
|
||||
const results = validStatuses.map((status) => TaskStatusSchema.safeParse(status))
|
||||
|
||||
//#then
|
||||
results.forEach((result) => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid status values", () => {
|
||||
//#given
|
||||
const invalidStatuses = ["open", "closed", "archived", ""]
|
||||
|
||||
//#when
|
||||
const results = invalidStatuses.map((status) => TaskStatusSchema.safeParse(status))
|
||||
|
||||
//#then
|
||||
results.forEach((result) => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
test("parses valid Task with all required fields", () => {
|
||||
//#given
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Run tests",
|
||||
description: "Execute test suite",
|
||||
status: "pending" as TaskStatus,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe("1")
|
||||
expect(result.data.subject).toBe("Run tests")
|
||||
expect(result.data.status).toBe("pending")
|
||||
expect(result.data.blocks).toEqual([])
|
||||
expect(result.data.blockedBy).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test("parses Task with optional fields", () => {
|
||||
//#given
|
||||
const taskWithOptionals: Task = {
|
||||
id: "2",
|
||||
subject: "Deploy app",
|
||||
description: "Deploy to production",
|
||||
status: "in_progress",
|
||||
activeForm: "Deploying app",
|
||||
blocks: ["3", "4"],
|
||||
blockedBy: ["1"],
|
||||
owner: "sisyphus",
|
||||
metadata: { priority: "high", tags: ["urgent"] },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.activeForm).toBe("Deploying app")
|
||||
expect(result.data.owner).toBe("sisyphus")
|
||||
expect(result.data.metadata).toEqual({ priority: "high", tags: ["urgent"] })
|
||||
}
|
||||
})
|
||||
|
||||
test("validates blocks and blockedBy as arrays", () => {
|
||||
//#given
|
||||
const taskWithDeps = {
|
||||
id: "3",
|
||||
subject: "Test feature",
|
||||
description: "Test new feature",
|
||||
status: "pending" as TaskStatus,
|
||||
blocks: ["4", "5", "6"],
|
||||
blockedBy: ["1", "2"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(taskWithDeps)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(Array.isArray(result.data.blocks)).toBe(true)
|
||||
expect(result.data.blocks).toHaveLength(3)
|
||||
expect(Array.isArray(result.data.blockedBy)).toBe(true)
|
||||
expect(result.data.blockedBy).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects Task missing required fields", () => {
|
||||
//#given
|
||||
const invalidTasks = [
|
||||
{ subject: "No ID", description: "Missing id", status: "pending", blocks: [], blockedBy: [] },
|
||||
{ id: "1", description: "No subject", status: "pending", blocks: [], blockedBy: [] },
|
||||
{ id: "1", subject: "No description", status: "pending", blocks: [], blockedBy: [] },
|
||||
{ id: "1", subject: "No status", description: "Missing status", blocks: [], blockedBy: [] },
|
||||
{ id: "1", subject: "No blocks", description: "Missing blocks", status: "pending", blockedBy: [] },
|
||||
{ id: "1", subject: "No blockedBy", description: "Missing blockedBy", status: "pending", blocks: [] },
|
||||
]
|
||||
|
||||
//#when
|
||||
const results = invalidTasks.map((task) => TaskSchema.safeParse(task))
|
||||
|
||||
//#then
|
||||
results.forEach((result) => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects Task with invalid status", () => {
|
||||
//#given
|
||||
const taskWithInvalidStatus = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test task",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(taskWithInvalidStatus)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects Task with non-array blocks or blockedBy", () => {
|
||||
//#given
|
||||
const taskWithInvalidBlocks = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test task",
|
||||
status: "pending",
|
||||
blocks: "not-an-array",
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const taskWithInvalidBlockedBy = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: "not-an-array",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result1 = TaskSchema.safeParse(taskWithInvalidBlocks)
|
||||
const result2 = TaskSchema.safeParse(taskWithInvalidBlockedBy)
|
||||
|
||||
//#then
|
||||
expect(result1.success).toBe(false)
|
||||
expect(result2.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
status: TaskStatusSchema,
|
||||
activeForm: z.string().optional(),
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
MailboxMessageSchema,
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
TaskAssignmentSchema,
|
||||
JoinRequestSchema,
|
||||
ProtocolMessageSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("MailboxMessageSchema", () => {
|
||||
// given a valid mailbox message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses valid message", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: '{"type":"idle_notification"}',
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
// given a message with optional color
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses message with color", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: "{}",
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
color: "blue",
|
||||
read: true,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProtocolMessageSchema", () => {
|
||||
// given permission_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses permission_request", () => {
|
||||
const msg = {
|
||||
type: "permission_request",
|
||||
requestId: "req-123",
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
agentId: "agent-001",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
// given permission_response message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses permission_response", () => {
|
||||
const approved = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "approved",
|
||||
updatedInput: { command: "ls" },
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
|
||||
|
||||
const rejected = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "rejected",
|
||||
feedback: "Too dangerous",
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
|
||||
})
|
||||
|
||||
// given shutdown_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses shutdown messages", () => {
|
||||
const request = { type: "shutdown_request" }
|
||||
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
|
||||
})
|
||||
|
||||
// given task_assignment message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses task_assignment", () => {
|
||||
const msg = {
|
||||
type: "task_assignment",
|
||||
taskId: "1",
|
||||
subject: "Fix bug",
|
||||
description: "Fix the auth bug",
|
||||
assignedBy: "team-lead",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
// given join_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses join_request", () => {
|
||||
const msg = {
|
||||
type: "join_request",
|
||||
agentName: "new-agent",
|
||||
sessionId: "sess-123",
|
||||
}
|
||||
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const MailboxMessageSchema = z.object({
|
||||
from: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.string(),
|
||||
color: z.string().optional(),
|
||||
read: z.boolean(),
|
||||
})
|
||||
|
||||
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
|
||||
|
||||
export const PermissionRequestSchema = z.object({
|
||||
type: z.literal("permission_request"),
|
||||
requestId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
|
||||
|
||||
export const PermissionResponseSchema = z.object({
|
||||
type: z.literal("permission_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
updatedInput: z.unknown().optional(),
|
||||
feedback: z.string().optional(),
|
||||
permissionUpdates: z.unknown().optional(),
|
||||
})
|
||||
|
||||
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
|
||||
|
||||
export const ShutdownRequestSchema = z.object({
|
||||
type: z.literal("shutdown_request"),
|
||||
})
|
||||
|
||||
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
|
||||
|
||||
export const ShutdownApprovedSchema = z.object({
|
||||
type: z.literal("shutdown_approved"),
|
||||
})
|
||||
|
||||
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
|
||||
|
||||
export const ShutdownRejectedSchema = z.object({
|
||||
type: z.literal("shutdown_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
|
||||
|
||||
export const TaskAssignmentSchema = z.object({
|
||||
type: z.literal("task_assignment"),
|
||||
taskId: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
assignedBy: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
|
||||
|
||||
export const TaskCompletedSchema = z.object({
|
||||
type: z.literal("task_completed"),
|
||||
taskId: z.string(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
|
||||
|
||||
export const IdleNotificationSchema = z.object({
|
||||
type: z.literal("idle_notification"),
|
||||
})
|
||||
|
||||
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
|
||||
|
||||
export const JoinRequestSchema = z.object({
|
||||
type: z.literal("join_request"),
|
||||
agentName: z.string(),
|
||||
sessionId: z.string(),
|
||||
})
|
||||
|
||||
export type JoinRequest = z.infer<typeof JoinRequestSchema>
|
||||
|
||||
export const JoinApprovedSchema = z.object({
|
||||
type: z.literal("join_approved"),
|
||||
agentName: z.string(),
|
||||
teamName: z.string(),
|
||||
})
|
||||
|
||||
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
|
||||
|
||||
export const JoinRejectedSchema = z.object({
|
||||
type: z.literal("join_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
|
||||
|
||||
export const PlanApprovalRequestSchema = z.object({
|
||||
type: z.literal("plan_approval_request"),
|
||||
requestId: z.string(),
|
||||
plan: z.string(),
|
||||
agentId: z.string(),
|
||||
})
|
||||
|
||||
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
|
||||
|
||||
export const PlanApprovalResponseSchema = z.object({
|
||||
type: z.literal("plan_approval_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
|
||||
|
||||
export const ModeSetRequestSchema = z.object({
|
||||
type: z.literal("mode_set_request"),
|
||||
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
|
||||
})
|
||||
|
||||
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
|
||||
|
||||
export const TeamPermissionUpdateSchema = z.object({
|
||||
type: z.literal("team_permission_update"),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
|
||||
|
||||
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
ShutdownApprovedSchema,
|
||||
ShutdownRejectedSchema,
|
||||
TaskAssignmentSchema,
|
||||
TaskCompletedSchema,
|
||||
IdleNotificationSchema,
|
||||
JoinRequestSchema,
|
||||
JoinApprovedSchema,
|
||||
JoinRejectedSchema,
|
||||
PlanApprovalRequestSchema,
|
||||
PlanApprovalResponseSchema,
|
||||
ModeSetRequestSchema,
|
||||
TeamPermissionUpdateSchema,
|
||||
])
|
||||
|
||||
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { join } from "path"
|
||||
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getTaskDir,
|
||||
getTaskPath,
|
||||
getTeamDir,
|
||||
getInboxPath,
|
||||
ensureDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "./storage"
|
||||
|
||||
const TEST_DIR = join(import.meta.dirname, ".test-storage")
|
||||
|
||||
describe("Storage Utilities", () => {
|
||||
beforeEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
// given default config (no claude_code_compat)
|
||||
// when getting task directory
|
||||
// then it should return .sisyphus/tasks/{listId}
|
||||
it("returns sisyphus path by default", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".sisyphus/tasks/list-123")
|
||||
})
|
||||
|
||||
// given claude_code_compat enabled
|
||||
// when getting task directory
|
||||
// then it should return Claude Code path
|
||||
it("returns claude code path when compat enabled", () => {
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: ".sisyphus/tasks",
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".cache/claude-code/tasks/list-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTaskPath", () => {
|
||||
// given list and task IDs
|
||||
// when getting task path
|
||||
// then it should return path to task JSON file
|
||||
it("returns path to task JSON", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskPath("list-123", "1", config as any)
|
||||
expect(result).toContain("list-123/1.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTeamDir", () => {
|
||||
// given team name and default config
|
||||
// when getting team directory
|
||||
// then it should return .sisyphus/teams/{teamName}
|
||||
it("returns sisyphus team path", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getTeamDir("my-team", config as any)
|
||||
expect(result).toContain(".sisyphus/teams/my-team")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getInboxPath", () => {
|
||||
// given team and agent names
|
||||
// when getting inbox path
|
||||
// then it should return path to inbox JSON file
|
||||
it("returns path to inbox JSON", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getInboxPath("my-team", "agent-001", config as any)
|
||||
expect(result).toContain("my-team/inboxes/agent-001.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ensureDir", () => {
|
||||
// given a non-existent directory path
|
||||
// when calling ensureDir
|
||||
// then it should create the directory
|
||||
it("creates directory if not exists", () => {
|
||||
const dirPath = join(TEST_DIR, "new-dir", "nested")
|
||||
ensureDir(dirPath)
|
||||
expect(existsSync(dirPath)).toBe(true)
|
||||
})
|
||||
|
||||
// given an existing directory
|
||||
// when calling ensureDir
|
||||
// then it should not throw
|
||||
it("does not throw for existing directory", () => {
|
||||
const dirPath = join(TEST_DIR, "existing")
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
expect(() => ensureDir(dirPath)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
// given a valid JSON file matching schema
|
||||
// when reading with readJsonSafe
|
||||
// then it should return parsed object
|
||||
it("reads and parses valid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string(), value: z.number() })
|
||||
const filePath = join(TEST_DIR, "test.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toEqual({ name: "test", value: 42 })
|
||||
})
|
||||
|
||||
// given a non-existent file
|
||||
// when reading with readJsonSafe
|
||||
// then it should return null
|
||||
it("returns null for non-existent file", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// given invalid JSON content
|
||||
// when reading with readJsonSafe
|
||||
// then it should return null
|
||||
it("returns null for invalid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const filePath = join(TEST_DIR, "invalid.json")
|
||||
writeFileSync(filePath, "not valid json")
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// given JSON that doesn't match schema
|
||||
// when reading with readJsonSafe
|
||||
// then it should return null
|
||||
it("returns null for schema mismatch", () => {
|
||||
const testSchema = z.object({ name: z.string(), required: z.number() })
|
||||
const filePath = join(TEST_DIR, "mismatch.json")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test" }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
// given data to write
|
||||
// when calling writeJsonAtomic
|
||||
// then it should write to file atomically
|
||||
it("writes JSON atomically", () => {
|
||||
const filePath = join(TEST_DIR, "atomic.json")
|
||||
const data = { key: "value", number: 123 }
|
||||
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
// given a deeply nested path
|
||||
// when calling writeJsonAtomic
|
||||
// then it should create parent directories
|
||||
it("creates parent directories", () => {
|
||||
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
|
||||
writeJsonAtomic(filePath, { test: true })
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
|
||||
if (tasksConfig?.claude_code_compat) {
|
||||
return join(homedir(), ".cache", "claude-code", "tasks", listId)
|
||||
}
|
||||
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath, listId)
|
||||
}
|
||||
|
||||
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTaskDir(listId, config), `${taskId}.json`)
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const swarmConfig = config.sisyphus?.swarm
|
||||
|
||||
if (swarmConfig?.storage_path?.includes("claude")) {
|
||||
return join(homedir(), ".claude", "teams", teamName)
|
||||
}
|
||||
|
||||
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
|
||||
return join(process.cwd(), storagePath, teamName)
|
||||
}
|
||||
|
||||
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
// given a valid task object
|
||||
// when parsing with TaskSchema
|
||||
// then it should succeed
|
||||
it("parses valid task object", () => {
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Fix authentication bug",
|
||||
description: "Users report 401 errors",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
// given a task with all optional fields
|
||||
// when parsing with TaskSchema
|
||||
// then it should succeed
|
||||
it("parses task with optional fields", () => {
|
||||
const taskWithOptionals = {
|
||||
id: "2",
|
||||
subject: "Add unit tests",
|
||||
description: "Write tests for auth module",
|
||||
activeForm: "Adding unit tests",
|
||||
owner: "agent-001",
|
||||
status: "in_progress",
|
||||
blocks: ["3"],
|
||||
blockedBy: ["1"],
|
||||
metadata: { priority: "high", labels: ["bug"] },
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
// given an invalid status value
|
||||
// when parsing with TaskSchema
|
||||
// then it should fail
|
||||
it("rejects invalid status", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
// given missing required fields
|
||||
// when parsing with TaskSchema
|
||||
// then it should fail
|
||||
it("rejects missing required fields", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
// missing subject, description, status, blocks, blockedBy
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
// given valid status values
|
||||
// when parsing
|
||||
// then all should succeed
|
||||
it("accepts valid statuses", () => {
|
||||
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
status: TaskStatusSchema,
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
|
||||
export const TaskCreateInputSchema = z.object({
|
||||
subject: z.string().describe("Task title"),
|
||||
description: z.string().describe("Detailed description"),
|
||||
activeForm: z.string().optional().describe("Text shown when in progress"),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
|
||||
export const TaskUpdateInputSchema = z.object({
|
||||
taskId: z.string().describe("Task ID to update"),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
|
||||
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
Reference in New Issue
Block a user