mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: add task management CLI and improve activity curation
- Add task store with dependency tracking and CLI (packages/server/src/tasks/) - Extract principal param logic to shared util for server/app reuse - Improve activity curator: collapse timeline, dedupe tool calls, simpler output - Add MessageInput ref for focus control after agent cancel - Include recent activity in wait timeout messages 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
9
.tasks/0c32789c.md
Normal file
9
.tasks/0c32789c.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: 0c32789c
|
||||
title: Add three random numbers
|
||||
status: open
|
||||
deps: [2769910f, 7018be3f, 537563b5]
|
||||
created: 2026-01-07T16:32:47.905Z
|
||||
---
|
||||
|
||||
Epic: We will add three random numbers together. Task 1 picks a number, Task 2 adds another, Task 3 adds a third and reports the final sum.
|
||||
15
.tasks/2769910f.md
Normal file
15
.tasks/2769910f.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: 2769910f
|
||||
title: Pick first random number
|
||||
status: done
|
||||
deps: []
|
||||
created: 2026-01-07T16:32:56.827Z
|
||||
---
|
||||
|
||||
Pick a random number between 1-100. Record it in your notes as 'Number: X'. This is the starting value.
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-07T16:33:43.884Z**
|
||||
|
||||
Number: 47
|
||||
15
.tasks/537563b5.md
Normal file
15
.tasks/537563b5.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: 537563b5
|
||||
title: Add third random number and report final sum
|
||||
status: done
|
||||
deps: [7018be3f]
|
||||
created: 2026-01-07T16:32:57.495Z
|
||||
---
|
||||
|
||||
Pick a final random number between 1-100. Read previous notes to get the running total. Add your number. Record in notes: 'Added: X, FINAL SUM: Y'
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-07T16:34:56.240Z**
|
||||
|
||||
Added: 42, FINAL SUM: 112
|
||||
15
.tasks/7018be3f.md
Normal file
15
.tasks/7018be3f.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: 7018be3f
|
||||
title: Add second random number
|
||||
status: done
|
||||
deps: [2769910f]
|
||||
created: 2026-01-07T16:32:57.142Z
|
||||
---
|
||||
|
||||
Pick another random number between 1-100. Read the previous task's notes to get the running total. Add your number to it. Record in notes: 'Added: X, Running total: Y'
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-07T16:34:17.347Z**
|
||||
|
||||
Added: 23, Running total: 70
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -21246,6 +21246,7 @@
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@paseo/server": "*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paseo/server": "*",
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
MessageInput,
|
||||
type MessagePayload,
|
||||
type ImageAttachment,
|
||||
type MessageInputRef,
|
||||
} from "./message-input";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { Theme } from "@/styles/theme";
|
||||
@@ -132,6 +133,7 @@ export function AgentInputArea({
|
||||
const agentIdRef = useRef(agentId);
|
||||
const sendAgentMessageRef = useRef(sendAgentMessage);
|
||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||
const messageInputRef = useRef<MessageInputRef>(null);
|
||||
|
||||
// Expose addImages function to parent for drag-and-drop support
|
||||
const addImages = useCallback((images: ImageAttachment[]) => {
|
||||
@@ -338,6 +340,7 @@ export function AgentInputArea({
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
cancelAgentRun(agentIdRef.current);
|
||||
messageInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
@@ -470,6 +473,7 @@ export function AgentInputArea({
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -9,7 +9,14 @@ import {
|
||||
Platform,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native";
|
||||
import Animated, {
|
||||
@@ -58,6 +65,10 @@ export interface MessageInputProps {
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const MIN_INPUT_HEIGHT = 30;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
@@ -78,35 +89,45 @@ type TextAreaHandle = {
|
||||
} & Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function MessageInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
images = [],
|
||||
onPickImages,
|
||||
onRemoveImage,
|
||||
ws,
|
||||
sendAgentAudio,
|
||||
placeholder = "Message...",
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
leftContent,
|
||||
rightContent,
|
||||
isAgentRunning = false,
|
||||
onQueue,
|
||||
}: MessageInputProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const textInputRef = useRef<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
>(null);
|
||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||
const baselineInputHeightRef = useRef<number | null>(null);
|
||||
const overlayTransition = useSharedValue(0);
|
||||
const sendAfterTranscriptRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
function MessageInput(
|
||||
{
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
images = [],
|
||||
onPickImages,
|
||||
onRemoveImage,
|
||||
ws,
|
||||
sendAgentAudio,
|
||||
placeholder = "Message...",
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
leftContent,
|
||||
rightContent,
|
||||
isAgentRunning = false,
|
||||
onQueue,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { theme } = useUnistyles();
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const textInputRef = useRef<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textInputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||
const baselineInputHeightRef = useRef<number | null>(null);
|
||||
const overlayTransition = useSharedValue(0);
|
||||
const sendAfterTranscriptRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
@@ -566,7 +587,8 @@ export function MessageInput({
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create(((theme: any) => ({
|
||||
container: {
|
||||
|
||||
@@ -754,39 +754,10 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] {
|
||||
}
|
||||
|
||||
// ---- Principal Parameter Extraction ----
|
||||
|
||||
const PrincipalParamSchema = z.union([
|
||||
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
|
||||
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
|
||||
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
|
||||
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
|
||||
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
||||
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
||||
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
||||
]);
|
||||
|
||||
export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) return filePath;
|
||||
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
if (normalizedPath === normalizedCwd) {
|
||||
return ".";
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
|
||||
const parsed = PrincipalParamSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { type, value } = parsed.data;
|
||||
return type === "path" ? stripCwdPrefix(value, cwd) : value;
|
||||
}
|
||||
// Re-export from server to avoid drift
|
||||
export {
|
||||
extractPrincipalParam,
|
||||
stripCwdPrefix,
|
||||
extractTodos,
|
||||
type TodoItem,
|
||||
} from "@paseo/server/utils/tool-call-parsers";
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import type { AgentTimelineItem, ToolCallKind } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { extractPrincipalParam } from "../../utils/tool-call-parsers.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 40;
|
||||
|
||||
/**
|
||||
* Derive tool kind from the tool name for rendering purposes.
|
||||
*/
|
||||
function getToolKind(name: string): ToolCallKind {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === "read" || lower === "read_file") return "read";
|
||||
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
|
||||
if (lower === "bash" || lower === "shell") return "execute";
|
||||
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function appendText(buffer: string, text: string): string {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
@@ -36,37 +25,69 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri
|
||||
buffers.thought = "";
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is { [key: string]: unknown } {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
/**
|
||||
* Collapse timeline items:
|
||||
* - Dedupe tool calls by callId (pending/completed -> single)
|
||||
* - Merge consecutive assistant_message/reasoning into single items
|
||||
*/
|
||||
function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
|
||||
const result: AgentTimelineItem[] = [];
|
||||
const toolCallMap = new Map<string, AgentTimelineItem>();
|
||||
let assistantBuffer = "";
|
||||
let reasoningBuffer = "";
|
||||
|
||||
function isFileChange(value: unknown): value is { path: string; kind: string } {
|
||||
return (
|
||||
isObject(value) &&
|
||||
typeof value.path === "string" &&
|
||||
typeof value.kind === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function extractFileChanges(value: unknown): { path: string; kind: string }[] {
|
||||
if (!isObject(value) || !Array.isArray(value.files)) {
|
||||
return [];
|
||||
function flushAssistant() {
|
||||
if (assistantBuffer) {
|
||||
result.push({ type: "assistant_message", text: assistantBuffer });
|
||||
assistantBuffer = "";
|
||||
}
|
||||
}
|
||||
return value.files.filter(isFileChange);
|
||||
}
|
||||
|
||||
function extractWebQuery(value: unknown): string {
|
||||
if (!isObject(value) || typeof value.query !== "string") {
|
||||
return "";
|
||||
function flushReasoning() {
|
||||
if (reasoningBuffer) {
|
||||
result.push({ type: "reasoning", text: reasoningBuffer });
|
||||
reasoningBuffer = "";
|
||||
}
|
||||
}
|
||||
return value.query;
|
||||
}
|
||||
|
||||
function extractCommand(value: unknown): string {
|
||||
if (!isObject(value) || typeof value.command !== "string") {
|
||||
return "";
|
||||
function flushToolCalls() {
|
||||
for (const toolItem of toolCallMap.values()) {
|
||||
result.push(toolItem);
|
||||
}
|
||||
toolCallMap.clear();
|
||||
}
|
||||
return value.command;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "assistant_message") {
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
assistantBuffer += item.text;
|
||||
} else if (item.type === "reasoning") {
|
||||
flushAssistant();
|
||||
flushToolCalls();
|
||||
reasoningBuffer += item.text;
|
||||
} else if (item.type === "tool_call" && item.callId) {
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
toolCallMap.set(item.callId, item);
|
||||
} else if (item.type === "tool_call") {
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
result.push(item);
|
||||
} else {
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,11 +101,14 @@ export function curateAgentActivity(
|
||||
return "No activity to display.";
|
||||
}
|
||||
|
||||
// Collapse timeline: dedupe tool calls, merge consecutive messages
|
||||
const collapsed = collapseTimeline(timeline);
|
||||
|
||||
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||
const recentItems =
|
||||
maxItems > 0 && timeline.length > maxItems
|
||||
? timeline.slice(-maxItems)
|
||||
: timeline;
|
||||
maxItems > 0 && collapsed.length > maxItems
|
||||
? collapsed.slice(-maxItems)
|
||||
: collapsed;
|
||||
|
||||
const lines: string[] = [];
|
||||
const buffers = { message: "", thought: "" };
|
||||
@@ -103,26 +127,11 @@ export function curateAgentActivity(
|
||||
break;
|
||||
case "tool_call": {
|
||||
flushBuffers(lines, buffers);
|
||||
const status = item.status ? ` ${item.status}` : "";
|
||||
const kind = getToolKind(item.name);
|
||||
if (kind === "execute") {
|
||||
const command = extractCommand(item.input);
|
||||
lines.push(`[Command: ${command || item.name}]${status}`);
|
||||
} else if (kind === "edit") {
|
||||
const files = extractFileChanges(item.output);
|
||||
if (files.length > 0) {
|
||||
lines.push("[File Changes]");
|
||||
for (const file of files) {
|
||||
lines.push(`- (${file.kind}) ${file.path}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`[Edit] ${item.name}${status}`);
|
||||
}
|
||||
} else if (kind === "search") {
|
||||
const query = extractWebQuery(item.input);
|
||||
lines.push(`[Web Search] ${query || item.name}`);
|
||||
const principal = extractPrincipalParam(item.input);
|
||||
if (principal) {
|
||||
lines.push(`[${item.name}] ${principal}`);
|
||||
} else {
|
||||
lines.push(`[Tool ${item.name}]${status}`);
|
||||
lines.push(`[${item.name}]`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -143,5 +152,5 @@ export function curateAgentActivity(
|
||||
|
||||
flushBuffers(lines, buffers);
|
||||
|
||||
return lines.length > 0 ? lines.join("\n\n") : "No activity to display.";
|
||||
return lines.length > 0 ? lines.join("\n") : "No activity to display.";
|
||||
}
|
||||
|
||||
@@ -157,11 +157,13 @@ async function waitForAgentWithTimeout(
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "wait timeout") {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const recentActivity = curateAgentActivity(timeline.slice(-5));
|
||||
const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
|
||||
return {
|
||||
status: snapshot?.lifecycle ?? "idle",
|
||||
permission: null,
|
||||
lastMessage:
|
||||
"Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.",
|
||||
lastMessage: message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
|
||||
409
packages/server/src/tasks/cli.ts
Normal file
409
packages/server/src/tasks/cli.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, openSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { FileTaskStore } from "./task-store.js";
|
||||
import type { AgentType, Task } from "./types.js";
|
||||
|
||||
const TASKS_DIR = resolve(process.cwd(), ".tasks");
|
||||
const store = new FileTaskStore(TASKS_DIR);
|
||||
|
||||
const program = new Command()
|
||||
.name("task")
|
||||
.description("Minimal task management with dependency tracking")
|
||||
.version("0.1.0")
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
# Create an epic with subtasks (top-down)
|
||||
task create "Build auth system"
|
||||
task create "Add login endpoint" --parent abc123
|
||||
task create "Add logout endpoint" --parent abc123
|
||||
|
||||
# Create with dependencies (bottom-up)
|
||||
task create "Setup database"
|
||||
task create "Add user model" --deps def456
|
||||
|
||||
# Assign to specific agent
|
||||
task create "Complex refactor" --assignee codex
|
||||
|
||||
# Create as draft (not actionable until opened)
|
||||
task create "Future feature" --draft
|
||||
task open abc123 # make it actionable
|
||||
|
||||
# View the work breakdown
|
||||
task tree abc123
|
||||
|
||||
# See what's ready to work on
|
||||
task ready
|
||||
task ready --scope abc123
|
||||
|
||||
# See completed work
|
||||
task closed --scope abc123
|
||||
|
||||
# Run agent loop on an epic
|
||||
task run abc123
|
||||
task run abc123 --agent codex
|
||||
task run --watch
|
||||
`
|
||||
);
|
||||
|
||||
program
|
||||
.command("create <title>")
|
||||
.description("Create a new task")
|
||||
.option("-d, --description <text>", "Task description")
|
||||
.option("--deps <ids>", "Comma-separated dependency IDs")
|
||||
.option("--parent <id>", "Parent task (parent will depend on this new task)")
|
||||
.option("--assignee <agent>", "Agent to assign (claude or codex)")
|
||||
.option("--draft", "Create as draft (not actionable)")
|
||||
.action(async (title, opts) => {
|
||||
const task = await store.create(title, {
|
||||
description: opts.description,
|
||||
deps: opts.deps
|
||||
? opts.deps.split(",").map((s: string) => s.trim())
|
||||
: [],
|
||||
status: opts.draft ? "draft" : "open",
|
||||
assignee: opts.assignee as AgentType | undefined,
|
||||
});
|
||||
|
||||
if (opts.parent) {
|
||||
await store.addDep(opts.parent, task.id);
|
||||
}
|
||||
|
||||
console.log(task.id);
|
||||
});
|
||||
|
||||
program
|
||||
.command("list")
|
||||
.alias("ls")
|
||||
.description("List all tasks")
|
||||
.option("-s, --status <status>", "Filter by status")
|
||||
.action(async (opts) => {
|
||||
const tasks = await store.list();
|
||||
const filtered = opts.status
|
||||
? tasks.filter((t) => t.status === opts.status)
|
||||
: tasks;
|
||||
|
||||
for (const t of filtered) {
|
||||
const deps = t.deps.length ? ` <- [${t.deps.join(", ")}]` : "";
|
||||
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
||||
console.log(`${t.id} [${t.status}] ${t.title}${assignee}${deps}`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("show <id>")
|
||||
.description("Show task details")
|
||||
.action(async (id) => {
|
||||
const task = await store.get(id);
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`id: ${task.id}`);
|
||||
console.log(`title: ${task.title}`);
|
||||
console.log(`status: ${task.status}`);
|
||||
console.log(`created: ${task.created}`);
|
||||
if (task.assignee) {
|
||||
console.log(`assignee: ${task.assignee}`);
|
||||
}
|
||||
console.log(`deps: [${task.deps.join(", ")}]`);
|
||||
if (task.description) {
|
||||
console.log(`\n${task.description}`);
|
||||
}
|
||||
if (task.notes.length) {
|
||||
console.log("\n## Notes");
|
||||
for (const note of task.notes) {
|
||||
console.log(`\n**${note.timestamp}**\n${note.content}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("ready")
|
||||
.description("List tasks ready to work on (open + deps resolved)")
|
||||
.option("--scope <id>", "Scope to epic/task dep tree")
|
||||
.action(async (opts) => {
|
||||
const tasks = await store.getReady(opts.scope);
|
||||
for (const t of tasks) {
|
||||
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
||||
console.log(`${t.id} ${t.title}${assignee}`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("blocked")
|
||||
.description("List tasks blocked by unresolved deps")
|
||||
.option("--scope <id>", "Scope to epic/task dep tree")
|
||||
.action(async (opts) => {
|
||||
const tasks = await store.getBlocked(opts.scope);
|
||||
for (const t of tasks) {
|
||||
console.log(`${t.id} ${t.title} <- [${t.deps.join(", ")}]`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("closed")
|
||||
.description("List completed tasks")
|
||||
.option("--scope <id>", "Scope to epic/task dep tree")
|
||||
.action(async (opts) => {
|
||||
const tasks = await store.getClosed(opts.scope);
|
||||
for (const t of tasks) {
|
||||
console.log(`${t.id} ${t.title}`);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("tree <id>")
|
||||
.description("Show dependency tree")
|
||||
.action(async (id) => {
|
||||
const root = await store.get(id);
|
||||
if (!root) {
|
||||
console.error(`Task not found: ${id}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${root.id} [${root.status}] ${root.title}`);
|
||||
|
||||
const tree = await store.getDepTree(id);
|
||||
const taskMap = new Map(tree.map((t) => [t.id, t]));
|
||||
|
||||
const printed = new Set<string>();
|
||||
const printDeps = async (taskId: string, prefix: string) => {
|
||||
const task = await store.get(taskId);
|
||||
if (!task) return;
|
||||
|
||||
const deps = task.deps.filter((d) => !printed.has(d));
|
||||
for (let i = 0; i < deps.length; i++) {
|
||||
const depId = deps[i];
|
||||
const dep = taskMap.get(depId);
|
||||
if (!dep) continue;
|
||||
|
||||
printed.add(depId);
|
||||
const isLast = i === deps.length - 1;
|
||||
const connector = isLast ? "└── " : "├── ";
|
||||
const childPrefix = isLast ? " " : "│ ";
|
||||
|
||||
console.log(
|
||||
`${prefix}${connector}${dep.id} [${dep.status}] ${dep.title}`
|
||||
);
|
||||
await printDeps(depId, prefix + childPrefix);
|
||||
}
|
||||
};
|
||||
|
||||
await printDeps(id, "");
|
||||
});
|
||||
|
||||
program
|
||||
.command("dep <id> <dep-id>")
|
||||
.description("Add dependency (id depends on dep-id)")
|
||||
.action(async (id, depId) => {
|
||||
await store.addDep(id, depId);
|
||||
console.log(`Added: ${id} -> ${depId}`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("undep <id> <dep-id>")
|
||||
.description("Remove dependency")
|
||||
.action(async (id, depId) => {
|
||||
await store.removeDep(id, depId);
|
||||
console.log(`Removed: ${id} -> ${depId}`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("note <id> <content>")
|
||||
.description("Add a timestamped note")
|
||||
.action(async (id, content) => {
|
||||
await store.addNote(id, content);
|
||||
console.log("Note added");
|
||||
});
|
||||
|
||||
program
|
||||
.command("open <id>")
|
||||
.description("Mark draft as open (actionable)")
|
||||
.action(async (id) => {
|
||||
await store.open(id);
|
||||
console.log(`${id} -> open`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("start <id>")
|
||||
.description("Mark as in progress")
|
||||
.action(async (id) => {
|
||||
await store.start(id);
|
||||
console.log(`${id} -> in_progress`);
|
||||
});
|
||||
|
||||
program
|
||||
.command("close <id>")
|
||||
.alias("done")
|
||||
.description("Mark as done")
|
||||
.action(async (id) => {
|
||||
await store.close(id);
|
||||
console.log(`${id} -> done`);
|
||||
});
|
||||
|
||||
// Agent runner
|
||||
async function makePrompt(
|
||||
task: Task,
|
||||
scopeId: string | undefined
|
||||
): Promise<string> {
|
||||
const scopeArg = scopeId ? ` --scope ${scopeId}` : "";
|
||||
|
||||
let scopeContext = "";
|
||||
if (scopeId) {
|
||||
const scope = await store.get(scopeId);
|
||||
if (scope) {
|
||||
scopeContext = `Scope: ${scope.title} (${scopeId})
|
||||
${scope.description ? `\n${scope.description}\n` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Working directory: ${process.cwd()}
|
||||
${scopeContext}
|
||||
---
|
||||
|
||||
YOUR TASK (${task.id}): ${task.title}
|
||||
|
||||
${task.description || "(no description)"}
|
||||
|
||||
---
|
||||
|
||||
STEPS:
|
||||
1. UNDERSTAND CONTEXT FIRST - Before any implementation:
|
||||
- Run \`task tree ${task.id}\` to see the full dependency graph
|
||||
- Run \`task closed${scopeArg}\` to see completed sibling tasks
|
||||
- Run \`task show <id>\` on completed tasks to read their notes
|
||||
- Understand what's been done, what decisions were made, what's planned
|
||||
2. Implement the task described above
|
||||
3. Add a note documenting what you did: \`task note ${task.id} "what you did"\`
|
||||
4. Mark complete: \`task close ${task.id}\`
|
||||
|
||||
COMMANDS:
|
||||
- \`task tree <id>\` - see dependency graph from any task
|
||||
- \`task show <id>\` - view task details and notes
|
||||
- \`task closed${scopeArg}\` - list completed tasks in scope
|
||||
- \`task note ${task.id} "content"\` - add a note to your task
|
||||
- \`task close ${task.id}\` - mark your task done
|
||||
|
||||
You MUST run \`task close ${task.id}\` when finished.
|
||||
`;
|
||||
}
|
||||
|
||||
function runAgent(prompt: string, agent: AgentType, logFile: string): boolean {
|
||||
const args =
|
||||
agent === "claude"
|
||||
? ["--dangerously-skip-permissions", "-p", prompt]
|
||||
: ["exec", "--dangerously-bypass-approvals-and-sandbox", prompt];
|
||||
|
||||
const fd = openSync(logFile, "a");
|
||||
const result = spawnSync(agent, args, {
|
||||
stdio: ["inherit", fd, fd],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function getLogFile(): string {
|
||||
let num = 0;
|
||||
while (existsSync(`task-run.${num}.log`)) {
|
||||
num++;
|
||||
}
|
||||
return `task-run.${num}.log`;
|
||||
}
|
||||
|
||||
function log(logFile: string, message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
appendFileSync(logFile, `[${timestamp}] ${message}\n`);
|
||||
}
|
||||
|
||||
program
|
||||
.command("run [scope]")
|
||||
.description("Run agent loop on tasks")
|
||||
.option("--agent <type>", "Agent to use (claude or codex)", "claude")
|
||||
.option("-w, --watch", "Keep running and wait for new tasks")
|
||||
.action(async (scopeId: string | undefined, opts) => {
|
||||
const defaultAgent = opts.agent as AgentType;
|
||||
const watchMode = opts.watch;
|
||||
const logFile = getLogFile();
|
||||
|
||||
console.log("Task Runner started");
|
||||
console.log(`Agent: ${defaultAgent}`);
|
||||
if (scopeId) console.log(`Scope: ${scopeId}`);
|
||||
console.log(`Log: ${logFile}`);
|
||||
console.log("");
|
||||
|
||||
log(logFile, `Started with agent=${defaultAgent} scope=${scopeId || "all"}`);
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
const runLoop = async (): Promise<void> => {
|
||||
while (true) {
|
||||
const ready = await store.getReady(scopeId);
|
||||
if (ready.length === 0) break;
|
||||
|
||||
const task = ready[0];
|
||||
const agent = task.assignee || defaultAgent;
|
||||
|
||||
console.log(`⏳ ${task.title} [${agent}]`);
|
||||
log(logFile, `Starting: ${task.id} - ${task.title} [${agent}]`);
|
||||
|
||||
await store.start(task.id);
|
||||
const prompt = await makePrompt(task, scopeId);
|
||||
|
||||
let attempt = 1;
|
||||
let success = false;
|
||||
|
||||
while (attempt <= MAX_RETRIES) {
|
||||
success = runAgent(prompt, agent, logFile);
|
||||
if (success) break;
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const backoff = attempt * 10;
|
||||
console.log(`⚠️ Attempt ${attempt} failed, retrying in ${backoff}s...`);
|
||||
log(logFile, `Attempt ${attempt} failed, retrying in ${backoff}s`);
|
||||
await new Promise((r) => setTimeout(r, backoff * 1000));
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.log(`❌ ${task.title} (failed after ${MAX_RETRIES} attempts)`);
|
||||
log(logFile, `Failed: ${task.id} after ${MAX_RETRIES} attempts`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if agent closed the task
|
||||
const updated = await store.get(task.id);
|
||||
if (updated?.status !== "done") {
|
||||
console.log(`⚠️ Agent did not close task ${task.id}`);
|
||||
log(logFile, `Warning: agent did not close task ${task.id}`);
|
||||
}
|
||||
|
||||
console.log(`✅ ${task.title}`);
|
||||
log(logFile, `Completed: ${task.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
await runLoop();
|
||||
|
||||
if (watchMode) {
|
||||
console.log("💤 Waiting for new tasks...");
|
||||
while (true) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const ready = await store.getReady(scopeId);
|
||||
if (ready.length > 0) {
|
||||
await runLoop();
|
||||
console.log("💤 Waiting for new tasks...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`All tasks complete. (${new Date().toISOString()})`);
|
||||
log(logFile, "All tasks complete");
|
||||
});
|
||||
|
||||
program.parse();
|
||||
677
packages/server/src/tasks/task-store.test.ts
Normal file
677
packages/server/src/tasks/task-store.test.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FileTaskStore } from "./task-store.js";
|
||||
|
||||
describe("FileTaskStore", () => {
|
||||
let tempDir: string;
|
||||
let store: FileTaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "task-store-test-"));
|
||||
store = new FileTaskStore(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a task with default status open", async () => {
|
||||
const task = await store.create("My first task");
|
||||
|
||||
expect(task.id).toMatch(/^[a-f0-9]{8}$/);
|
||||
expect(task.title).toBe("My first task");
|
||||
expect(task.status).toBe("open");
|
||||
expect(task.deps).toEqual([]);
|
||||
expect(task.description).toBe("");
|
||||
expect(task.notes).toEqual([]);
|
||||
expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(task.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a task with custom status", async () => {
|
||||
const task = await store.create("Draft task", { status: "draft" });
|
||||
|
||||
expect(task.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("creates a task with dependencies", async () => {
|
||||
const dep1 = await store.create("Dependency 1");
|
||||
const dep2 = await store.create("Dependency 2");
|
||||
const task = await store.create("Main task", {
|
||||
deps: [dep1.id, dep2.id],
|
||||
});
|
||||
|
||||
expect(task.deps).toEqual([dep1.id, dep2.id]);
|
||||
});
|
||||
|
||||
it("creates a task with description", async () => {
|
||||
const task = await store.create("Task with desc", {
|
||||
description: "This is a **long** description\n\nWith multiple lines.",
|
||||
});
|
||||
|
||||
expect(task.description).toBe(
|
||||
"This is a **long** description\n\nWith multiple lines."
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a task with assignee", async () => {
|
||||
const task = await store.create("Task for Claude", {
|
||||
assignee: "claude",
|
||||
});
|
||||
|
||||
expect(task.assignee).toBe("claude");
|
||||
});
|
||||
|
||||
it("generates unique IDs for each task", async () => {
|
||||
const task1 = await store.create("Task 1");
|
||||
const task2 = await store.create("Task 2");
|
||||
const task3 = await store.create("Task 3");
|
||||
|
||||
const ids = [task1.id, task2.id, task3.id];
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
});
|
||||
|
||||
it("sets created timestamp", async () => {
|
||||
const before = new Date().toISOString();
|
||||
const task = await store.create("Task");
|
||||
const after = new Date().toISOString();
|
||||
|
||||
expect(task.created >= before).toBe(true);
|
||||
expect(task.created <= after).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("returns task by id", async () => {
|
||||
const created = await store.create("Test task");
|
||||
const retrieved = await store.get(created.id);
|
||||
|
||||
expect(retrieved).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns null for non-existent task", async () => {
|
||||
const result = await store.get("nonexistent");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves assignee field", async () => {
|
||||
const created = await store.create("Task", { assignee: "codex" });
|
||||
const retrieved = await store.get(created.id);
|
||||
|
||||
expect(retrieved?.assignee).toBe("codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("returns empty array when no tasks", async () => {
|
||||
const tasks = await store.list();
|
||||
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all tasks", async () => {
|
||||
await store.create("Task 1");
|
||||
await store.create("Task 2");
|
||||
await store.create("Task 3");
|
||||
|
||||
const tasks = await store.list();
|
||||
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(tasks.map((t) => t.title).sort()).toEqual([
|
||||
"Task 1",
|
||||
"Task 2",
|
||||
"Task 3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates task title", async () => {
|
||||
const task = await store.create("Original title");
|
||||
const updated = await store.update(task.id, { title: "New title" });
|
||||
|
||||
expect(updated.title).toBe("New title");
|
||||
expect(updated.id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("updates task description", async () => {
|
||||
const task = await store.create("Task");
|
||||
const updated = await store.update(task.id, {
|
||||
description: "New description",
|
||||
});
|
||||
|
||||
expect(updated.description).toBe("New description");
|
||||
});
|
||||
|
||||
it("updates task assignee", async () => {
|
||||
const task = await store.create("Task");
|
||||
const updated = await store.update(task.id, { assignee: "claude" });
|
||||
|
||||
expect(updated.assignee).toBe("claude");
|
||||
});
|
||||
|
||||
it("persists updates", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.update(task.id, { title: "Updated" });
|
||||
|
||||
const retrieved = await store.get(task.id);
|
||||
expect(retrieved?.title).toBe("Updated");
|
||||
});
|
||||
|
||||
it("preserves created timestamp on update", async () => {
|
||||
const task = await store.create("Task");
|
||||
const originalCreated = task.created;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await store.update(task.id, { title: "Updated" });
|
||||
|
||||
const retrieved = await store.get(task.id);
|
||||
expect(retrieved?.created).toBe(originalCreated);
|
||||
});
|
||||
|
||||
it("throws for non-existent task", async () => {
|
||||
await expect(
|
||||
store.update("nonexistent", { title: "New" })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("status transitions", () => {
|
||||
describe("open", () => {
|
||||
it("transitions draft to open", async () => {
|
||||
const task = await store.create("Draft", { status: "draft" });
|
||||
await store.open(task.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.status).toBe("open");
|
||||
});
|
||||
|
||||
it("throws when task is not draft", async () => {
|
||||
const task = await store.create("Open task", { status: "open" });
|
||||
|
||||
await expect(store.open(task.id)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
it("transitions open to in_progress", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.start(task.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("throws when task is draft", async () => {
|
||||
const task = await store.create("Draft", { status: "draft" });
|
||||
|
||||
await expect(store.start(task.id)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws when task is already done", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.close(task.id);
|
||||
|
||||
await expect(store.start(task.id)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
it("transitions open to done", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.close(task.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.status).toBe("done");
|
||||
});
|
||||
|
||||
it("transitions in_progress to done", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.start(task.id);
|
||||
await store.close(task.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.status).toBe("done");
|
||||
});
|
||||
|
||||
it("transitions draft to done", async () => {
|
||||
const task = await store.create("Task", { status: "draft" });
|
||||
await store.close(task.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.status).toBe("done");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependencies", () => {
|
||||
describe("addDep", () => {
|
||||
it("adds a dependency", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
const task = await store.create("Task");
|
||||
|
||||
await store.addDep(task.id, dep.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.deps).toContain(dep.id);
|
||||
});
|
||||
|
||||
it("does not duplicate dependencies", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
const task = await store.create("Task");
|
||||
|
||||
await store.addDep(task.id, dep.id);
|
||||
await store.addDep(task.id, dep.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.deps).toEqual([dep.id]);
|
||||
});
|
||||
|
||||
it("throws for non-existent task", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
|
||||
await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("throws for non-existent dependency", async () => {
|
||||
const task = await store.create("Task");
|
||||
|
||||
await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDep", () => {
|
||||
it("removes a dependency", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
const task = await store.create("Task", { deps: [dep.id] });
|
||||
|
||||
await store.removeDep(task.id, dep.id);
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.deps).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent for non-existent dep", async () => {
|
||||
const task = await store.create("Task");
|
||||
|
||||
await store.removeDep(task.id, "nonexistent");
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.deps).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("notes", () => {
|
||||
it("adds a note with timestamp", async () => {
|
||||
const task = await store.create("Task");
|
||||
const before = new Date().toISOString();
|
||||
|
||||
await store.addNote(task.id, "This is a note");
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.notes).toHaveLength(1);
|
||||
expect(updated?.notes[0].content).toBe("This is a note");
|
||||
expect(updated?.notes[0].timestamp >= before).toBe(true);
|
||||
});
|
||||
|
||||
it("appends multiple notes in order", async () => {
|
||||
const task = await store.create("Task");
|
||||
|
||||
await store.addNote(task.id, "First note");
|
||||
await store.addNote(task.id, "Second note");
|
||||
await store.addNote(task.id, "Third note");
|
||||
|
||||
const updated = await store.get(task.id);
|
||||
expect(updated?.notes).toHaveLength(3);
|
||||
expect(updated?.notes.map((n) => n.content)).toEqual([
|
||||
"First note",
|
||||
"Second note",
|
||||
"Third note",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReady", () => {
|
||||
it("returns open tasks with no deps", async () => {
|
||||
const task = await store.create("Ready task");
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0].id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("excludes draft tasks", async () => {
|
||||
await store.create("Draft task", { status: "draft" });
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes in_progress tasks", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.start(task.id);
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes done tasks", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.close(task.id);
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes tasks with unresolved deps", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
await store.create("Blocked task", { deps: [dep.id] });
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0].id).toBe(dep.id);
|
||||
});
|
||||
|
||||
it("includes tasks when all deps are done", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
const task = await store.create("Task", { deps: [dep.id] });
|
||||
await store.close(dep.id);
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0].id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("handles multiple deps correctly", async () => {
|
||||
const dep1 = await store.create("Dep 1");
|
||||
const dep2 = await store.create("Dep 2");
|
||||
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
|
||||
|
||||
// Only one dep done - task not ready
|
||||
await store.close(dep1.id);
|
||||
let ready = await store.getReady();
|
||||
expect(ready.map((t) => t.id)).not.toContain(task.id);
|
||||
|
||||
// Both deps done - task ready
|
||||
await store.close(dep2.id);
|
||||
ready = await store.getReady();
|
||||
expect(ready.map((t) => t.id)).toContain(task.id);
|
||||
});
|
||||
|
||||
it("sorts by created date (oldest first)", async () => {
|
||||
const task1 = await store.create("Task 1");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const task2 = await store.create("Task 2");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const task3 = await store.create("Task 3");
|
||||
|
||||
const ready = await store.getReady();
|
||||
|
||||
expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]);
|
||||
});
|
||||
|
||||
describe("scoped to epic", () => {
|
||||
it("returns only ready tasks in epic dep tree", async () => {
|
||||
await store.create("Unrelated task");
|
||||
const dep = await store.create("Epic dep");
|
||||
const epic = await store.create("Epic", { deps: [dep.id] });
|
||||
|
||||
const ready = await store.getReady(epic.id);
|
||||
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0].id).toBe(dep.id);
|
||||
});
|
||||
|
||||
it("returns empty when epic has no ready deps", async () => {
|
||||
const dep = await store.create("Dep", { status: "draft" });
|
||||
const epic = await store.create("Epic", { deps: [dep.id] });
|
||||
|
||||
const ready = await store.getReady(epic.id);
|
||||
|
||||
expect(ready).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles nested deps", async () => {
|
||||
const leaf = await store.create("Leaf");
|
||||
const middle = await store.create("Middle", { deps: [leaf.id] });
|
||||
const epic = await store.create("Epic", { deps: [middle.id] });
|
||||
|
||||
// Only leaf is ready initially
|
||||
let ready = await store.getReady(epic.id);
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0].id).toBe(leaf.id);
|
||||
|
||||
// After leaf done, middle is ready
|
||||
await store.close(leaf.id);
|
||||
ready = await store.getReady(epic.id);
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0].id).toBe(middle.id);
|
||||
|
||||
// After middle done, epic itself is ready (but we're scoped, so epic not in results)
|
||||
await store.close(middle.id);
|
||||
ready = await store.getReady(epic.id);
|
||||
expect(ready).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlocked", () => {
|
||||
it("returns tasks with unresolved deps", async () => {
|
||||
const dep = await store.create("Dependency");
|
||||
const blocked = await store.create("Blocked", { deps: [dep.id] });
|
||||
|
||||
const result = await store.getBlocked();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(blocked.id);
|
||||
});
|
||||
|
||||
it("excludes tasks with no deps", async () => {
|
||||
await store.create("No deps");
|
||||
|
||||
const result = await store.getBlocked();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes tasks with all deps done", async () => {
|
||||
const dep = await store.create("Dep");
|
||||
await store.create("Task", { deps: [dep.id] });
|
||||
await store.close(dep.id);
|
||||
|
||||
const result = await store.getBlocked();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes draft tasks", async () => {
|
||||
const dep = await store.create("Dep");
|
||||
await store.create("Draft blocked", { status: "draft", deps: [dep.id] });
|
||||
|
||||
const result = await store.getBlocked();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes in_progress tasks with unresolved deps", async () => {
|
||||
const dep = await store.create("Dep");
|
||||
const task = await store.create("Task", { deps: [dep.id] });
|
||||
// Force start even with unresolved deps (edge case)
|
||||
await store.update(task.id, { status: "in_progress" });
|
||||
|
||||
const result = await store.getBlocked();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(task.id);
|
||||
});
|
||||
|
||||
describe("scoped to epic", () => {
|
||||
it("returns only blocked tasks in epic dep tree", async () => {
|
||||
const unrelatedDep = await store.create("Unrelated dep");
|
||||
await store.create("Unrelated blocked", {
|
||||
deps: [unrelatedDep.id],
|
||||
});
|
||||
|
||||
const epicDep = await store.create("Epic dep");
|
||||
const epicChild = await store.create("Epic child", {
|
||||
deps: [epicDep.id],
|
||||
});
|
||||
const epic = await store.create("Epic", { deps: [epicChild.id] });
|
||||
|
||||
const blocked = await store.getBlocked(epic.id);
|
||||
|
||||
expect(blocked).toHaveLength(1);
|
||||
expect(blocked[0].id).toBe(epicChild.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClosed", () => {
|
||||
it("returns done tasks", async () => {
|
||||
const task = await store.create("Task");
|
||||
await store.close(task.id);
|
||||
|
||||
const closed = await store.getClosed();
|
||||
|
||||
expect(closed).toHaveLength(1);
|
||||
expect(closed[0].id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("excludes non-done tasks", async () => {
|
||||
await store.create("Open task");
|
||||
await store.create("Draft task", { status: "draft" });
|
||||
const inProgress = await store.create("In progress");
|
||||
await store.start(inProgress.id);
|
||||
|
||||
const closed = await store.getClosed();
|
||||
|
||||
expect(closed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sorts by created date (most recent first)", async () => {
|
||||
const task1 = await store.create("Task 1");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const task2 = await store.create("Task 2");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const task3 = await store.create("Task 3");
|
||||
|
||||
await store.close(task1.id);
|
||||
await store.close(task2.id);
|
||||
await store.close(task3.id);
|
||||
|
||||
const closed = await store.getClosed();
|
||||
|
||||
expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]);
|
||||
});
|
||||
|
||||
describe("scoped to epic", () => {
|
||||
it("returns only closed tasks in epic dep tree", async () => {
|
||||
const unrelated = await store.create("Unrelated");
|
||||
await store.close(unrelated.id);
|
||||
|
||||
const dep = await store.create("Epic dep");
|
||||
const epic = await store.create("Epic", { deps: [dep.id] });
|
||||
await store.close(dep.id);
|
||||
|
||||
const closed = await store.getClosed(epic.id);
|
||||
|
||||
expect(closed).toHaveLength(1);
|
||||
expect(closed[0].id).toBe(dep.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDepTree", () => {
|
||||
it("returns empty for task with no deps", async () => {
|
||||
const task = await store.create("Leaf task");
|
||||
|
||||
const tree = await store.getDepTree(task.id);
|
||||
|
||||
expect(tree).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns direct deps", async () => {
|
||||
const dep1 = await store.create("Dep 1");
|
||||
const dep2 = await store.create("Dep 2");
|
||||
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
|
||||
|
||||
const tree = await store.getDepTree(task.id);
|
||||
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort());
|
||||
});
|
||||
|
||||
it("returns nested deps recursively", async () => {
|
||||
const leaf = await store.create("Leaf");
|
||||
const middle = await store.create("Middle", { deps: [leaf.id] });
|
||||
const root = await store.create("Root", { deps: [middle.id] });
|
||||
|
||||
const tree = await store.getDepTree(root.id);
|
||||
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort());
|
||||
});
|
||||
|
||||
it("handles diamond deps without duplicates", async () => {
|
||||
const shared = await store.create("Shared");
|
||||
const left = await store.create("Left", { deps: [shared.id] });
|
||||
const right = await store.create("Right", { deps: [shared.id] });
|
||||
const root = await store.create("Root", { deps: [left.id, right.id] });
|
||||
|
||||
const tree = await store.getDepTree(root.id);
|
||||
|
||||
expect(tree).toHaveLength(3);
|
||||
expect(tree.map((t) => t.id).sort()).toEqual(
|
||||
[shared.id, left.id, right.id].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("handles circular deps gracefully", async () => {
|
||||
const task1 = await store.create("Task 1");
|
||||
const task2 = await store.create("Task 2", { deps: [task1.id] });
|
||||
await store.addDep(task1.id, task2.id); // create cycle
|
||||
|
||||
// Should not infinite loop
|
||||
const tree = await store.getDepTree(task1.id);
|
||||
|
||||
expect(tree.map((t) => t.id)).toContain(task2.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent task", async () => {
|
||||
await expect(store.getDepTree("nonexistent")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("file persistence", () => {
|
||||
it("persists tasks across store instances", async () => {
|
||||
const task = await store.create("Persistent task", {
|
||||
description: "With description",
|
||||
assignee: "claude",
|
||||
});
|
||||
await store.addNote(task.id, "A note");
|
||||
|
||||
// Create new store instance pointing to same dir
|
||||
const store2 = new FileTaskStore(tempDir);
|
||||
const retrieved = await store2.get(task.id);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.title).toBe("Persistent task");
|
||||
expect(retrieved?.description).toBe("With description");
|
||||
expect(retrieved?.assignee).toBe("claude");
|
||||
expect(retrieved?.created).toBe(task.created);
|
||||
expect(retrieved?.notes).toHaveLength(1);
|
||||
expect(retrieved?.notes[0].content).toBe("A note");
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/server/src/tasks/task-store.ts
Normal file
359
packages/server/src/tasks/task-store.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type {
|
||||
Task,
|
||||
TaskStore,
|
||||
CreateTaskOptions,
|
||||
TaskStatus,
|
||||
AgentType,
|
||||
} from "./types.js";
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(4).toString("hex");
|
||||
}
|
||||
|
||||
function serializeTask(task: Task): string {
|
||||
const frontmatterLines = [
|
||||
"---",
|
||||
`id: ${task.id}`,
|
||||
`title: ${task.title}`,
|
||||
`status: ${task.status}`,
|
||||
`deps: [${task.deps.join(", ")}]`,
|
||||
`created: ${task.created}`,
|
||||
];
|
||||
|
||||
if (task.assignee) {
|
||||
frontmatterLines.push(`assignee: ${task.assignee}`);
|
||||
}
|
||||
|
||||
frontmatterLines.push("---");
|
||||
|
||||
const frontmatter = frontmatterLines.join("\n");
|
||||
|
||||
let body = "";
|
||||
if (task.description) {
|
||||
body += task.description + "\n";
|
||||
}
|
||||
|
||||
if (task.notes.length > 0) {
|
||||
body += "\n## Notes\n";
|
||||
for (const note of task.notes) {
|
||||
body += `\n**${note.timestamp}**\n\n${note.content}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter + "\n\n" + body;
|
||||
}
|
||||
|
||||
function parseTask(content: string): Task {
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!frontmatterMatch) {
|
||||
throw new Error("Invalid task file: missing frontmatter");
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const body = content.slice(frontmatterMatch[0].length);
|
||||
|
||||
const getValue = (key: string): string => {
|
||||
const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m"));
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const depsStr = getValue("deps");
|
||||
const depsMatch = depsStr.match(/\[(.*)\]/);
|
||||
const deps =
|
||||
depsMatch && depsMatch[1].trim()
|
||||
? depsMatch[1]
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Parse notes from body
|
||||
const notes: Task["notes"] = [];
|
||||
const notesSection = body.match(/## Notes\n([\s\S]*?)$/);
|
||||
if (notesSection) {
|
||||
const noteMatches = notesSection[1].matchAll(
|
||||
/\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g
|
||||
);
|
||||
for (const match of noteMatches) {
|
||||
notes.push({
|
||||
timestamp: match[1],
|
||||
content: match[2].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Description is everything before ## Notes
|
||||
let description = body;
|
||||
if (notesSection) {
|
||||
description = body.slice(0, body.indexOf("## Notes")).trim();
|
||||
}
|
||||
description = description.trim();
|
||||
|
||||
const assignee = getValue("assignee") as AgentType | "";
|
||||
|
||||
return {
|
||||
id: getValue("id"),
|
||||
title: getValue("title"),
|
||||
status: getValue("status") as TaskStatus,
|
||||
deps,
|
||||
description,
|
||||
notes,
|
||||
created: getValue("created") || new Date().toISOString(),
|
||||
assignee: assignee || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class FileTaskStore implements TaskStore {
|
||||
constructor(private readonly dir: string) {}
|
||||
|
||||
private taskPath(id: string): string {
|
||||
return join(this.dir, `${id}.md`);
|
||||
}
|
||||
|
||||
private async ensureDir(): Promise<void> {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
private async readTask(id: string): Promise<Task | null> {
|
||||
try {
|
||||
const content = await readFile(this.taskPath(id), "utf-8");
|
||||
return parseTask(content);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeTask(task: Task): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8");
|
||||
}
|
||||
|
||||
async list(): Promise<Task[]> {
|
||||
await this.ensureDir();
|
||||
try {
|
||||
const files = await readdir(this.dir);
|
||||
const tasks: Task[] = [];
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".md")) {
|
||||
const id = file.slice(0, -3);
|
||||
const task = await this.readTask(id);
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Task | null> {
|
||||
return this.readTask(id);
|
||||
}
|
||||
|
||||
async getDepTree(id: string): Promise<Task[]> {
|
||||
const root = await this.get(id);
|
||||
if (!root) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const result: Task[] = [];
|
||||
|
||||
const traverse = async (taskId: string): Promise<void> => {
|
||||
if (visited.has(taskId)) return;
|
||||
visited.add(taskId);
|
||||
|
||||
const task = await this.get(taskId);
|
||||
if (!task) return;
|
||||
|
||||
for (const depId of task.deps) {
|
||||
if (!visited.has(depId)) {
|
||||
const dep = await this.get(depId);
|
||||
if (dep) {
|
||||
result.push(dep);
|
||||
await traverse(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await traverse(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getReady(scopeId?: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const tree = await this.getDepTree(scopeId);
|
||||
candidates = tree;
|
||||
} else {
|
||||
candidates = allTasks;
|
||||
}
|
||||
|
||||
const isReady = (task: Task): boolean => {
|
||||
if (task.status !== "open") return false;
|
||||
return task.deps.every((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep?.status === "done";
|
||||
});
|
||||
};
|
||||
|
||||
// Sort by created date (oldest first) for consistent ordering
|
||||
return candidates.filter(isReady).sort((a, b) => {
|
||||
return a.created.localeCompare(b.created);
|
||||
});
|
||||
}
|
||||
|
||||
async getBlocked(scopeId?: string): Promise<Task[]> {
|
||||
const allTasks = await this.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const tree = await this.getDepTree(scopeId);
|
||||
candidates = tree;
|
||||
} else {
|
||||
candidates = allTasks;
|
||||
}
|
||||
|
||||
const isBlocked = (task: Task): boolean => {
|
||||
if (task.status === "draft" || task.status === "done") return false;
|
||||
if (task.deps.length === 0) return false;
|
||||
return task.deps.some((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep?.status !== "done";
|
||||
});
|
||||
};
|
||||
|
||||
return candidates.filter(isBlocked);
|
||||
}
|
||||
|
||||
async getClosed(scopeId?: string): Promise<Task[]> {
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const tree = await this.getDepTree(scopeId);
|
||||
candidates = tree;
|
||||
} else {
|
||||
candidates = await this.list();
|
||||
}
|
||||
|
||||
// Sort by created date (most recent first) for closed tasks
|
||||
return candidates
|
||||
.filter((t) => t.status === "done")
|
||||
.sort((a, b) => b.created.localeCompare(a.created));
|
||||
}
|
||||
|
||||
async create(title: string, opts?: CreateTaskOptions): Promise<Task> {
|
||||
const task: Task = {
|
||||
id: generateId(),
|
||||
title,
|
||||
status: opts?.status ?? "open",
|
||||
deps: opts?.deps ?? [],
|
||||
description: opts?.description ?? "",
|
||||
notes: [],
|
||||
created: new Date().toISOString(),
|
||||
assignee: opts?.assignee,
|
||||
};
|
||||
|
||||
await this.writeTask(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
changes: Partial<Omit<Task, "id" | "created">>
|
||||
): Promise<Task> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
const updated: Task = { ...task, ...changes };
|
||||
await this.writeTask(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async addDep(id: string, depId: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
const dep = await this.get(depId);
|
||||
if (!dep) {
|
||||
throw new Error(`Dependency not found: ${depId}`);
|
||||
}
|
||||
|
||||
if (!task.deps.includes(depId)) {
|
||||
task.deps.push(depId);
|
||||
await this.writeTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
async removeDep(id: string, depId: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
task.deps = task.deps.filter((d) => d !== depId);
|
||||
await this.writeTask(task);
|
||||
}
|
||||
|
||||
async addNote(id: string, content: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
task.notes.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
});
|
||||
await this.writeTask(task);
|
||||
}
|
||||
|
||||
async open(id: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
if (task.status !== "draft") {
|
||||
throw new Error(`Cannot open task with status: ${task.status}`);
|
||||
}
|
||||
await this.update(id, { status: "open" });
|
||||
}
|
||||
|
||||
async start(id: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
if (task.status !== "open") {
|
||||
throw new Error(`Cannot start task with status: ${task.status}`);
|
||||
}
|
||||
await this.update(id, { status: "in_progress" });
|
||||
}
|
||||
|
||||
async close(id: string): Promise<void> {
|
||||
const task = await this.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
await this.update(id, { status: "done" });
|
||||
}
|
||||
}
|
||||
48
packages/server/src/tasks/types.ts
Normal file
48
packages/server/src/tasks/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type TaskStatus = "draft" | "open" | "in_progress" | "done";
|
||||
|
||||
export type AgentType = "claude" | "codex";
|
||||
|
||||
export interface Note {
|
||||
timestamp: string; // ISO date
|
||||
content: string; // markdown
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string; // random hash, e.g. "a1b2c3d4"
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
deps: string[];
|
||||
description: string; // long form markdown
|
||||
notes: Note[];
|
||||
created: string; // ISO date
|
||||
assignee?: AgentType; // optional agent override
|
||||
}
|
||||
|
||||
export interface CreateTaskOptions {
|
||||
deps?: string[];
|
||||
status?: TaskStatus;
|
||||
description?: string;
|
||||
assignee?: AgentType;
|
||||
}
|
||||
|
||||
export interface TaskStore {
|
||||
// Queries
|
||||
list(): Promise<Task[]>;
|
||||
get(id: string): Promise<Task | null>;
|
||||
getDepTree(id: string): Promise<Task[]>; // all descendants in dep graph
|
||||
getReady(scopeId?: string): Promise<Task[]>; // open + all deps done, optionally scoped
|
||||
getBlocked(scopeId?: string): Promise<Task[]>; // open/in_progress but has unresolved deps
|
||||
getClosed(scopeId?: string): Promise<Task[]>; // done tasks, optionally scoped
|
||||
|
||||
// Mutations
|
||||
create(title: string, opts?: CreateTaskOptions): Promise<Task>;
|
||||
update(id: string, changes: Partial<Omit<Task, "id" | "created">>): Promise<Task>;
|
||||
addDep(id: string, depId: string): Promise<void>;
|
||||
removeDep(id: string, depId: string): Promise<void>;
|
||||
addNote(id: string, content: string): Promise<void>;
|
||||
|
||||
// Status transitions
|
||||
open(id: string): Promise<void>; // draft -> open
|
||||
start(id: string): Promise<void>; // open -> in_progress
|
||||
close(id: string): Promise<void>; // any -> done
|
||||
}
|
||||
89
packages/server/src/utils/tool-call-parsers.ts
Normal file
89
packages/server/src/utils/tool-call-parsers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ---- Principal Parameter Extraction ----
|
||||
|
||||
// Schema for file entries in arrays (e.g., apply_patch files)
|
||||
const FileEntrySchema = z.object({ path: z.string() });
|
||||
|
||||
// Schema for TodoWrite todos array
|
||||
const TodoEntrySchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
activeForm: z.string().optional(),
|
||||
});
|
||||
|
||||
const PrincipalParamSchema = z.union([
|
||||
// Direct path keys
|
||||
z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })),
|
||||
z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })),
|
||||
z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })),
|
||||
// Command as string
|
||||
z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })),
|
||||
// Command as array (Codex sends this)
|
||||
z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "text" as const, value: d.command.join(" ") })),
|
||||
// Other text params
|
||||
z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })),
|
||||
z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })),
|
||||
z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })),
|
||||
// Files array (Codex apply_patch)
|
||||
z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })),
|
||||
// TodoWrite - show in_progress item or count
|
||||
z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => {
|
||||
const inProgress = d.todos.find((t) => t.status === "in_progress");
|
||||
if (inProgress) {
|
||||
return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content };
|
||||
}
|
||||
return { type: "text" as const, value: `${d.todos.length} tasks` };
|
||||
}),
|
||||
]);
|
||||
|
||||
export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) return filePath;
|
||||
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
if (normalizedPath === normalizedCwd) {
|
||||
return ".";
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined {
|
||||
const parsed = PrincipalParamSchema.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { type, value } = parsed.data;
|
||||
return type === "path" ? stripCwdPrefix(value, cwd) : value;
|
||||
}
|
||||
|
||||
// ---- TodoWrite Extraction ----
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm?: string;
|
||||
}
|
||||
|
||||
export function extractTodos(value: unknown): TodoItem[] {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return [];
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.todos)) {
|
||||
return [];
|
||||
}
|
||||
return obj.todos.filter(
|
||||
(t): t is TodoItem =>
|
||||
typeof t === "object" &&
|
||||
t !== null &&
|
||||
typeof (t as Record<string, unknown>).content === "string" &&
|
||||
typeof (t as Record<string, unknown>).status === "string"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user