simple-task-master
Version:
A simple command-line task management tool
504 lines • 22.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TaskManager = void 0;
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const frontmatter_parser_1 = require("./frontmatter-parser");
const write_file_atomic_1 = __importDefault(require("write-file-atomic"));
const slugify_1 = __importDefault(require("slugify"));
const lock_manager_1 = require("./lock-manager");
const workspace_1 = require("./workspace");
const errors_1 = require("./errors");
const schema = __importStar(require("./schema"));
const config_1 = require("./config");
// Default configuration values
const DEFAULT_MAX_TASK_SIZE_BYTES = 1048576; // 1MB
const DEFAULT_MAX_TITLE_LENGTH = 200;
const DEFAULT_MAX_DESCRIPTION_LENGTH = 65536; // 64KB
class TaskManager {
config;
lockManager;
constructor(config, lockManager) {
this.config = config;
this.lockManager = lockManager;
}
/**
* Create a new TaskManager instance with workspace discovery
*/
static async create(config) {
const workspaceRoot = config?.workspaceRoot ?? await (0, workspace_1.getWorkspaceRoot)();
// Create ConfigManager and load configuration
const configManager = new config_1.ConfigManager(workspaceRoot);
const loadedConfig = await configManager.load();
// Use ConfigManager to get the tasks directory
const tasksDir = config?.tasksDir ?? configManager.getTasksDir();
const fullConfig = {
tasksDir,
maxTaskSizeBytes: config?.maxTaskSizeBytes ??
loadedConfig.maxTaskSizeBytes ?? DEFAULT_MAX_TASK_SIZE_BYTES,
maxTitleLength: config?.maxTitleLength ?? DEFAULT_MAX_TITLE_LENGTH,
maxDescriptionLength: config?.maxDescriptionLength ?? DEFAULT_MAX_DESCRIPTION_LENGTH
};
const lockManager = new lock_manager_1.LockManager(workspaceRoot);
return new TaskManager(fullConfig, lockManager);
}
/**
* Create a new task with auto-generated ID
*/
async create(input) {
// Validate input before acquiring lock to avoid unnecessary locking
this.validateCreateInput(input);
// Acquire lock for atomic ID generation and task creation
await this.lockManager.acquire();
try {
// Use provided content or empty string
const content = input.content || '';
// Create task with retry logic for ID conflicts
const maxRetries = 10;
let retries = 0;
while (retries < maxRetries) {
// Generate ID atomically under lock
const id = await this.generateNextIdLocked();
// Debug: ensure ID is valid
if (typeof id !== 'number' || isNaN(id) || id <= 0) {
throw new Error(`Invalid ID generated: ${id}`);
}
// Create task object
const now = new Date().toISOString();
// Extract known fields and preserve unknown fields
const { title, status, tags, dependencies, content: _content, ...unknownFields } = input;
const task = {
schema: 1,
id,
title,
status: status ?? 'pending',
created: now,
updated: now,
tags: tags ?? [],
dependencies: dependencies ?? [],
...unknownFields // Preserve any unknown fields from input
};
// Validate the task object (including field count limit)
try {
schema.validateTask(task);
}
catch (error) {
if (error instanceof schema.SchemaValidationError) {
throw new errors_1.ValidationError(error.message);
}
throw error;
}
// Create filename
const filename = this.generateFilename(id, task.title);
const filepath = path.join(this.config.tasksDir, filename);
// Serialize to markdown - exclude content from frontmatter to avoid duplication
const { content: _, ...frontmatterData } = task;
const fileContent = frontmatter_parser_1.FrontmatterParser.stringify(content, frontmatterData);
// Validate file size
await this.validateTaskSize(fileContent);
// Ensure tasks directory exists, create if needed
try {
await fs.access(this.config.tasksDir);
}
catch {
await fs.mkdir(this.config.tasksDir, { recursive: true });
}
// Before writing, check if ANY file with this ID already exists
// This is critical because different titles produce different filenames
const existingFiles = await this.findTaskFiles();
const idAlreadyExists = existingFiles.some((f) => {
const existingId = this.extractIdFromFilename(f);
return existingId === id;
});
if (idAlreadyExists) {
// ID collision detected - retry with a new ID
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to create task after ${maxRetries} retries due to ID conflicts`);
}
continue;
}
// Write file atomically - use exclusive creation as an additional safety
try {
// Try to create the file exclusively
const fd = await fs.open(filepath, 'wx'); // 'wx' fails if file exists
await fd.write(fileContent, 0, 'utf8');
await fd.sync(); // Force filesystem sync
await fd.close();
// Ensure the file is visible to other processes before we release the lock
// This prevents race conditions where the next process might not see this file
// Wait up to 100ms for the file to be visible in directory listing
let fileVisible = false;
for (let i = 0; i < 10; i++) {
const files = await fs.readdir(this.config.tasksDir);
if (files.includes(filename)) {
fileVisible = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (!fileVisible) {
throw new Error(`File ${filename} not visible in directory after creation`);
}
// Success! Return the task with content
return {
...task,
content
};
}
catch (error) {
if (error.code === 'EEXIST') {
// File already exists - this shouldn't happen after our check above
// but handle it just in case
retries++;
if (retries >= maxRetries) {
throw new Error(`Failed to create task after ${maxRetries} retries due to file conflicts`);
}
continue;
}
throw error;
}
}
throw new Error('Failed to create task: maximum retries exceeded');
}
catch (error) {
// Re-throw with appropriate error type
if (error instanceof errors_1.ValidationError) {
throw error;
}
throw new errors_1.FileSystemError(`Failed to create task: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
finally {
// Always release lock, even on error
await this.lockManager.release();
}
}
/**
* Get a single task by ID
*/
async get(id) {
const files = await this.findTaskFiles();
const file = files.find((f) => this.extractIdFromFilename(f) === id);
if (!file) {
throw new errors_1.NotFoundError(`Task not found: ${id}`);
}
const filepath = path.join(this.config.tasksDir, file);
return this.readTaskFile(filepath);
}
/**
* List all tasks with optional filtering
*/
async list(filters) {
const files = await this.findTaskFiles();
const tasks = [];
for (const file of files) {
try {
const filepath = path.join(this.config.tasksDir, file);
const task = await this.readTaskFile(filepath);
// Apply filters
if (filters?.status && task.status !== filters.status)
continue;
if (filters?.tags && !filters.tags.some((tag) => task.tags.includes(tag)))
continue;
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
const titleMatch = task.title.toLowerCase().includes(searchLower);
const contentMatch = (task.content || '').toLowerCase().includes(searchLower);
if (!titleMatch && !contentMatch)
continue;
}
tasks.push(task);
}
catch (error) {
// Skip invalid files but log warning
console.warn(`Failed to read task file ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Sort by ID
return tasks.sort((a, b) => a.id - b.id);
}
/**
* Update an existing task
*/
async update(id, updates) {
// Acquire lock for atomic update operation
await this.lockManager.acquire();
try {
// Get current task
const currentTask = await this.get(id);
// Find current file
const files = await this.findTaskFiles();
const currentFile = files.find((f) => this.extractIdFromFilename(f) === id);
if (!currentFile) {
throw new errors_1.NotFoundError(`Task not found: ${id}`);
}
// Apply updates (preserve unknown fields from currentTask and add new ones from updates)
const updatedTask = {
...currentTask, // Preserve all existing fields, including unknown ones
...updates, // Apply all updates, including arbitrary fields
// Override specific core fields to handle undefined values correctly
title: updates.title ?? currentTask.title,
status: updates.status ?? currentTask.status,
updated: new Date().toISOString(),
tags: updates.tags ?? currentTask.tags,
dependencies: updates.dependencies ?? currentTask.dependencies
};
// Validate updated task
this.validateTitle(updatedTask.title);
// Validate the updated task object (including field count limit)
try {
schema.validateTask(updatedTask);
}
catch (error) {
if (error instanceof schema.SchemaValidationError) {
throw new errors_1.ValidationError(error.message);
}
throw error;
}
// Build updated content
const updatedContent = updates.content ?? currentTask.content ?? '';
// Check if filename needs to change
const newFilename = this.generateFilename(id, updatedTask.title);
const oldFilepath = path.join(this.config.tasksDir, currentFile);
const newFilepath = path.join(this.config.tasksDir, newFilename);
// Serialize to markdown - exclude content from frontmatter to avoid duplication
const { content: _, ...frontmatterData } = updatedTask;
const fileContent = frontmatter_parser_1.FrontmatterParser.stringify(updatedContent, frontmatterData);
// Validate file size
await this.validateTaskSize(fileContent);
try {
// Write new file
await (0, write_file_atomic_1.default)(newFilepath, fileContent, { encoding: 'utf8', mode: 0o644 });
// If filename changed, delete old file
if (oldFilepath !== newFilepath) {
await fs.unlink(oldFilepath);
}
}
catch (error) {
throw new errors_1.FileSystemError(`Failed to update task: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return updatedTask;
}
finally {
// Always release lock, even on error
await this.lockManager.release();
}
}
/**
* Delete a task (optional method)
*/
async delete(id) {
const files = await this.findTaskFiles();
const file = files.find((f) => this.extractIdFromFilename(f) === id);
if (!file) {
throw new errors_1.NotFoundError(`Task not found: ${id}`);
}
const filepath = path.join(this.config.tasksDir, file);
try {
await fs.unlink(filepath);
}
catch (error) {
throw new errors_1.FileSystemError(`Failed to delete task: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Private helper methods
async generateNextIdLocked() {
// Per specification: "Implements ID generation (highest + 1)"
// Always scan filesystem to find the highest ID
return this.generateNextIdFromFilesystem();
}
async generateNextIdFromFilesystem() {
try {
const files = await fs.readdir(this.config.tasksDir);
let maxId = 0;
let maxIdFile = null;
// First pass: find the highest ID from filenames
for (const file of files) {
// Only process .md files
if (!file.endsWith('.md'))
continue;
// Extract ID from filename
const match = file.match(/^(\d+)-/);
if (match && match[1]) {
const id = parseInt(match[1], 10);
if (!isNaN(id) && id > maxId) {
maxId = id;
maxIdFile = file;
}
}
}
// If we found files, verify the highest ID file has matching frontmatter
if (maxIdFile && maxId > 0) {
try {
const filepath = path.join(this.config.tasksDir, maxIdFile);
const task = await this.readTaskFile(filepath);
if (task.id !== maxId) {
// Frontmatter doesn't match filename - this is a data integrity issue
console.warn(`Data integrity warning: File ${maxIdFile} has ID ${task.id} ` +
`in frontmatter but ${maxId} in filename`);
// Fall back to scanning all files to find the true maximum ID
return this.generateNextIdByFullScan();
}
}
catch (error) {
// If we can't read the file, still use the ID from the filename
// This prevents infinite loops when files are corrupted
console.warn(`Warning: Could not read file ${maxIdFile} for ID verification: ${error}`);
// Important: Return the next ID based on filename, not a full scan
// This ensures we don't get stuck trying to use the same ID
return maxId + 1;
}
}
return maxId + 1;
}
catch (error) {
// If directory doesn't exist, start with ID 1
if (error.code === 'ENOENT') {
return 1;
}
throw new errors_1.FileSystemError(`Failed to generate ID: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async generateNextIdByFullScan() {
// Fallback method that reads all files to find the true maximum ID
const files = await this.findTaskFiles();
let maxId = 0;
for (const file of files) {
// First try to extract ID from filename
const filenameMatch = file.match(/^(\d+)-/);
if (filenameMatch && filenameMatch[1]) {
const filenameId = parseInt(filenameMatch[1], 10);
if (!isNaN(filenameId) && filenameId > maxId) {
maxId = filenameId;
}
}
// Then try to read the file for verification
try {
const filepath = path.join(this.config.tasksDir, file);
const task = await this.readTaskFile(filepath);
if (task.id > maxId) {
maxId = task.id;
}
}
catch (error) {
// Skip files that can't be read but we already have the ID from filename
console.warn(`Warning: Could not read file ${file}: ${error}`);
}
}
return maxId + 1;
}
generateFilename(id, title) {
const slug = this.sanitizeForFilename(title);
return `${id}-${slug}.md`;
}
sanitizeForFilename(title) {
const slug = (0, slugify_1.default)(title, {
lower: true,
strict: true,
trim: true
}).substring(0, 100); // Limit slug length
return slug;
}
extractIdFromFilename(filename) {
const match = filename.match(/^(\d+)-/);
return match && match[1] ? parseInt(match[1], 10) : null;
}
async findTaskFiles() {
try {
const files = await fs.readdir(this.config.tasksDir);
return files.filter((f) => f.endsWith('.md'));
}
catch (error) {
if (error.code === 'ENOENT') {
return [];
}
throw new errors_1.FileSystemError(`Failed to list tasks: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async readTaskFile(filepath) {
try {
const fileContent = await fs.readFile(filepath, 'utf8');
const { data, content } = frontmatter_parser_1.FrontmatterParser.parse(fileContent);
// Validate required fields
frontmatter_parser_1.FrontmatterParser.validateTaskData(data);
// Content is already preserved exactly as it was in the file
// After validation, we know data is a valid Task
const task = data;
return {
...task,
content: content
};
}
catch (error) {
if (error instanceof errors_1.ValidationError)
throw error;
throw new errors_1.FileSystemError(`Failed to read task file: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
validateCreateInput(input) {
this.validateTitle(input.title);
if (input.content && input.content.length > this.config.maxDescriptionLength) {
throw new errors_1.ValidationError(`Content exceeds maximum length of ${this.config.maxDescriptionLength} characters`);
}
}
validateTitle(title) {
if (!title || title.trim().length === 0) {
throw new errors_1.ValidationError('Title is required');
}
if (title.length > this.config.maxTitleLength) {
throw new errors_1.ValidationError(`Title exceeds maximum length of ${this.config.maxTitleLength} characters`);
}
// Only check for truly dangerous characters - control characters and null bytes
// Allow quotes, apostrophes, and other symbols in titles
// The filename sanitization will handle filesystem-specific restrictions
const dangerousChars = /[\x00-\x1f]/g;
if (dangerousChars.test(title)) {
throw new errors_1.ValidationError('Title contains invalid control characters');
}
}
async validateTaskSize(content) {
const sizeInBytes = Buffer.byteLength(content, 'utf8');
if (sizeInBytes > this.config.maxTaskSizeBytes) {
throw new errors_1.ValidationError(`Task file exceeds maximum size of ${this.config.maxTaskSizeBytes} bytes. ` +
`Current size: ${sizeInBytes} bytes. Consider breaking into subtasks.`);
}
}
}
exports.TaskManager = TaskManager;
//# sourceMappingURL=task-manager.js.map