mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(cli): add --watch flag to list command for real-time updates (#1526)
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Fixes #1526 PR review comments from CodeRabbit and changeset-bot
This commit is contained in:
12
.changeset/list-watch-compact.md
Normal file
12
.changeset/list-watch-compact.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
"task-master-ai": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add watch mode and compact output to list command
|
||||||
|
|
||||||
|
- Add `-w/--watch` flag to continuously monitor task changes with real-time updates
|
||||||
|
- Add `-c/--compact` flag for minimal task output format
|
||||||
|
- Add `--no-header` flag to hide the command header
|
||||||
|
- Support file-based watching via fs.watch for local tasks.json
|
||||||
|
- Support API-based watching via Supabase Realtime for authenticated users
|
||||||
|
- Display last sync timestamp and source in watch mode
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type Task,
|
type Task,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type TmCore,
|
type TmCore,
|
||||||
|
type WatchSubscription,
|
||||||
createTmCore
|
createTmCore
|
||||||
} from '@tm/core';
|
} from '@tm/core';
|
||||||
import type { StorageType } from '@tm/core';
|
import type { StorageType } from '@tm/core';
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
displayDashboards,
|
displayDashboards,
|
||||||
displayRecommendedNextTask,
|
displayRecommendedNextTask,
|
||||||
displaySuggestedNextSteps,
|
displaySuggestedNextSteps,
|
||||||
|
displaySyncMessage,
|
||||||
|
displayWatchFooter,
|
||||||
getPriorityBreakdown,
|
getPriorityBreakdown,
|
||||||
getTaskDescription
|
getTaskDescription
|
||||||
} from '../ui/index.js';
|
} from '../ui/index.js';
|
||||||
@@ -42,8 +45,11 @@ export interface ListCommandOptions {
|
|||||||
withSubtasks?: boolean;
|
withSubtasks?: boolean;
|
||||||
format?: OutputFormat;
|
format?: OutputFormat;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
noHeader?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
project?: string;
|
project?: string;
|
||||||
|
watch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,11 +93,17 @@ export class ListTasksCommand extends Command {
|
|||||||
'text'
|
'text'
|
||||||
)
|
)
|
||||||
.option('--json', 'Output in JSON format (shorthand for --format json)')
|
.option('--json', 'Output in JSON format (shorthand for --format json)')
|
||||||
|
.option(
|
||||||
|
'-c, --compact',
|
||||||
|
'Output in compact format (shorthand for --format compact)'
|
||||||
|
)
|
||||||
|
.option('--no-header', 'Hide the command header')
|
||||||
.option('--silent', 'Suppress output (useful for programmatic usage)')
|
.option('--silent', 'Suppress output (useful for programmatic usage)')
|
||||||
.option(
|
.option(
|
||||||
'-p, --project <path>',
|
'-p, --project <path>',
|
||||||
'Project root directory (auto-detected if not provided)'
|
'Project root directory (auto-detected if not provided)'
|
||||||
)
|
)
|
||||||
|
.option('-w, --watch', 'Watch for changes and update list automatically')
|
||||||
.action(async (statusArg?: string, options?: ListCommandOptions) => {
|
.action(async (statusArg?: string, options?: ListCommandOptions) => {
|
||||||
// Handle special "all" keyword to show with subtasks
|
// Handle special "all" keyword to show with subtasks
|
||||||
let status = statusArg || options?.status;
|
let status = statusArg || options?.status;
|
||||||
@@ -127,6 +139,9 @@ export class ListTasksCommand extends Command {
|
|||||||
await this.initializeCore(getProjectRoot(options.project));
|
await this.initializeCore(getProjectRoot(options.project));
|
||||||
|
|
||||||
// Get tasks from core
|
// Get tasks from core
|
||||||
|
if (options.watch) {
|
||||||
|
await this.watchTasks(options);
|
||||||
|
} else {
|
||||||
const result = await this.getTasks(options);
|
const result = await this.getTasks(options);
|
||||||
|
|
||||||
// Store result for programmatic access
|
// Store result for programmatic access
|
||||||
@@ -136,11 +151,76 @@ export class ListTasksCommand extends Command {
|
|||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
this.displayResults(result, options);
|
this.displayResults(result, options);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
displayError(error);
|
displayError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes and update list
|
||||||
|
* Uses tm-core's unified watch API which handles both file and API storage
|
||||||
|
*/
|
||||||
|
private async watchTasks(options: ListCommandOptions): Promise<void> {
|
||||||
|
if (!this.tmCore) {
|
||||||
|
throw new Error('TmCore not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
let result = await this.getTasks(options);
|
||||||
|
let lastSync = new Date();
|
||||||
|
|
||||||
|
console.clear();
|
||||||
|
this.displayResults(result, options);
|
||||||
|
|
||||||
|
const storageType = result.storageType;
|
||||||
|
displayWatchFooter(storageType, lastSync);
|
||||||
|
|
||||||
|
let subscription: WatchSubscription | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Subscribe to task changes via tm-core
|
||||||
|
subscription = await this.tmCore.tasks.watch(
|
||||||
|
async (event) => {
|
||||||
|
if (event.type === 'change') {
|
||||||
|
try {
|
||||||
|
// Re-fetch tasks
|
||||||
|
result = await this.getTasks(options);
|
||||||
|
lastSync = new Date();
|
||||||
|
|
||||||
|
// Clear and display
|
||||||
|
console.clear();
|
||||||
|
this.displayResults(result, options);
|
||||||
|
|
||||||
|
// Show sync message with timestamp
|
||||||
|
displaySyncMessage(storageType, lastSync);
|
||||||
|
displayWatchFooter(storageType, lastSync);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during watch (e.g. partial writes)
|
||||||
|
}
|
||||||
|
} else if (event.type === 'error' && event.error) {
|
||||||
|
console.error(chalk.red(`\n⚠ Watch error: ${event.error.message}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ tag: options.tag }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup on process termination
|
||||||
|
const cleanup = () => {
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', cleanup);
|
||||||
|
process.on('SIGTERM', cleanup);
|
||||||
|
|
||||||
|
// Keep process alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(chalk.red(`Watch mode error: ${error.message}`));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate command options
|
* Validate command options
|
||||||
*/
|
*/
|
||||||
@@ -219,9 +299,13 @@ export class ListTasksCommand extends Command {
|
|||||||
result: ListTasksResult,
|
result: ListTasksResult,
|
||||||
options: ListCommandOptions
|
options: ListCommandOptions
|
||||||
): void {
|
): void {
|
||||||
// If --json flag is set, override format to 'json'
|
// Resolve format: --json and --compact flags override --format option
|
||||||
const format = (
|
const format = (
|
||||||
options.json ? 'json' : options.format || 'text'
|
options.json
|
||||||
|
? 'json'
|
||||||
|
: options.compact
|
||||||
|
? 'compact'
|
||||||
|
: options.format || 'text'
|
||||||
) as OutputFormat;
|
) as OutputFormat;
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
@@ -230,12 +314,12 @@ export class ListTasksCommand extends Command {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'compact':
|
case 'compact':
|
||||||
this.displayCompact(result.tasks, options.withSubtasks);
|
this.displayCompact(result, options);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'text':
|
case 'text':
|
||||||
default:
|
default:
|
||||||
this.displayText(result, options.withSubtasks, options.status);
|
this.displayText(result, options);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,12 +348,25 @@ export class ListTasksCommand extends Command {
|
|||||||
/**
|
/**
|
||||||
* Display in compact format
|
* Display in compact format
|
||||||
*/
|
*/
|
||||||
private displayCompact(tasks: Task[], withSubtasks?: boolean): void {
|
private displayCompact(
|
||||||
|
data: ListTasksResult,
|
||||||
|
options: ListCommandOptions
|
||||||
|
): void {
|
||||||
|
const { tasks, tag, storageType } = data;
|
||||||
|
|
||||||
|
// Display header unless --no-header is set
|
||||||
|
if (options.noHeader !== true) {
|
||||||
|
displayCommandHeader(this.tmCore, {
|
||||||
|
tag: tag || 'master',
|
||||||
|
storageType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
const icon = STATUS_ICONS[task.status];
|
const icon = STATUS_ICONS[task.status];
|
||||||
console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`);
|
console.log(`${chalk.cyan(task.id)} ${icon} ${task.title}`);
|
||||||
|
|
||||||
if (withSubtasks && task.subtasks?.length) {
|
if (options.withSubtasks && task.subtasks?.length) {
|
||||||
task.subtasks.forEach((subtask) => {
|
task.subtasks.forEach((subtask) => {
|
||||||
const subIcon = STATUS_ICONS[subtask.status];
|
const subIcon = STATUS_ICONS[subtask.status];
|
||||||
console.log(
|
console.log(
|
||||||
@@ -285,16 +382,17 @@ export class ListTasksCommand extends Command {
|
|||||||
*/
|
*/
|
||||||
private displayText(
|
private displayText(
|
||||||
data: ListTasksResult,
|
data: ListTasksResult,
|
||||||
withSubtasks?: boolean,
|
options: ListCommandOptions
|
||||||
_statusFilter?: string
|
|
||||||
): void {
|
): void {
|
||||||
const { tasks, tag, storageType } = data;
|
const { tasks, tag, storageType } = data;
|
||||||
|
|
||||||
// Display header using utility function
|
// Display header unless --no-header is set
|
||||||
|
if (options.noHeader !== true) {
|
||||||
displayCommandHeader(this.tmCore, {
|
displayCommandHeader(this.tmCore, {
|
||||||
tag: tag || 'master',
|
tag: tag || 'master',
|
||||||
storageType
|
storageType
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// No tasks message
|
// No tasks message
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
@@ -328,7 +426,7 @@ export class ListTasksCommand extends Command {
|
|||||||
// Task table
|
// Task table
|
||||||
console.log(
|
console.log(
|
||||||
ui.createTaskTable(tasks, {
|
ui.createTaskTable(tasks, {
|
||||||
showSubtasks: withSubtasks,
|
showSubtasks: options.withSubtasks,
|
||||||
showDependencies: true,
|
showDependencies: true,
|
||||||
showComplexity: true // Enable complexity column
|
showComplexity: true // Enable complexity column
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './header.component.js';
|
|||||||
export * from './next-task.component.js';
|
export * from './next-task.component.js';
|
||||||
export * from './suggested-steps.component.js';
|
export * from './suggested-steps.component.js';
|
||||||
export * from './task-detail.component.js';
|
export * from './task-detail.component.js';
|
||||||
|
export * from './watch-footer.component.js';
|
||||||
|
|||||||
41
apps/cli/src/ui/components/watch-footer.component.ts
Normal file
41
apps/cli/src/ui/components/watch-footer.component.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Watch mode footer component for real-time task updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { formatTime } from '@tm/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display label for storage source
|
||||||
|
*/
|
||||||
|
function getSourceLabel(storageType: 'api' | 'file'): string {
|
||||||
|
return storageType === 'api' ? 'Hamster Studio' : 'tasks.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display watch status footer
|
||||||
|
*/
|
||||||
|
export function displayWatchFooter(
|
||||||
|
storageType: 'api' | 'file',
|
||||||
|
lastSync: Date
|
||||||
|
): void {
|
||||||
|
const syncTime = formatTime(lastSync);
|
||||||
|
const source = getSourceLabel(storageType);
|
||||||
|
|
||||||
|
console.log(chalk.dim(`\nWatching ${source} for changes...`));
|
||||||
|
console.log(chalk.gray(`Last synced: ${syncTime}`));
|
||||||
|
console.log(chalk.dim('Press Ctrl+C to exit'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display sync notification message
|
||||||
|
*/
|
||||||
|
export function displaySyncMessage(
|
||||||
|
storageType: 'api' | 'file',
|
||||||
|
syncTime: Date
|
||||||
|
): void {
|
||||||
|
const formattedTime = formatTime(syncTime);
|
||||||
|
const source = getSourceLabel(storageType);
|
||||||
|
|
||||||
|
console.log(chalk.blue(`\nℹ ${source} updated at ${formattedTime}`));
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ sidebarTitle: "CLI Commands"
|
|||||||
|
|
||||||
# List tasks with a specific status and include subtasks
|
# List tasks with a specific status and include subtasks
|
||||||
task-master list --status=<status> --with-subtasks
|
task-master list --status=<status> --with-subtasks
|
||||||
|
|
||||||
|
# Watch for changes and auto-refresh the list
|
||||||
|
task-master list --watch
|
||||||
|
task-master list -w
|
||||||
|
|
||||||
|
# Combine watch with other options
|
||||||
|
task-master list all -w # Watch all tasks with subtasks
|
||||||
|
task-master list -w --compact # Watch in compact format
|
||||||
|
task-master list --status=pending -w # Watch pending tasks only
|
||||||
```
|
```
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ description: "A comprehensive reference of all available Task Master commands"
|
|||||||
|
|
||||||
# List tasks with a specific status and include subtasks
|
# List tasks with a specific status and include subtasks
|
||||||
task-master list --status=<status> --with-subtasks
|
task-master list --status=<status> --with-subtasks
|
||||||
|
|
||||||
|
# Watch for changes and auto-refresh
|
||||||
|
task-master list --watch
|
||||||
|
task-master list -w
|
||||||
|
|
||||||
|
# Compact output format
|
||||||
|
task-master list --compact
|
||||||
|
task-master list -c
|
||||||
|
|
||||||
|
# Watch mode with compact output
|
||||||
|
task-master list -w -c
|
||||||
|
|
||||||
|
# Hide the header
|
||||||
|
task-master list --no-header
|
||||||
```
|
```
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ This document provides a detailed reference for interacting with Taskmaster, cov
|
|||||||
* `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`)
|
* `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`)
|
||||||
* `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
* `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag <name>`)
|
||||||
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
* `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`)
|
||||||
* **Usage:** Get an overview of the project status, often used at the start of a work session.
|
* `watch`: `Watch for changes and auto-refresh the list in real-time. Works with file storage (fs.watch) and API storage (Supabase Realtime).` (CLI: `-w, --watch`)
|
||||||
|
* **Usage:** Get an overview of the project status, often used at the start of a work session. Use `--watch` to keep the list live-updating as tasks change.
|
||||||
|
|
||||||
### 4. Get Next Task (`next_task`)
|
### 4. Get Next Task (`next_task`)
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,17 @@ export interface IStorage {
|
|||||||
* @returns Promise that resolves to tags with statistics
|
* @returns Promise that resolves to tags with statistics
|
||||||
*/
|
*/
|
||||||
getTagsWithStats(): Promise<TagsWithStatsResult>;
|
getTagsWithStats(): Promise<TagsWithStatsResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes to tasks
|
||||||
|
* @param callback - Function called when tasks change
|
||||||
|
* @param options - Watch options (debounce, tag)
|
||||||
|
* @returns Subscription handle with unsubscribe method
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
callback: (event: WatchEvent) => void,
|
||||||
|
options?: WatchOptions
|
||||||
|
): Promise<WatchSubscription>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -282,6 +293,41 @@ export interface TagsWithStatsResult {
|
|||||||
totalTags: number;
|
totalTags: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch event types
|
||||||
|
*/
|
||||||
|
export type WatchEventType = 'change' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch event payload
|
||||||
|
*/
|
||||||
|
export interface WatchEvent {
|
||||||
|
/** Type of event */
|
||||||
|
type: WatchEventType;
|
||||||
|
/** Timestamp of the event */
|
||||||
|
timestamp: Date;
|
||||||
|
/** Error if type is 'error' */
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch options
|
||||||
|
*/
|
||||||
|
export interface WatchOptions {
|
||||||
|
/** Debounce time in milliseconds (default: 100) */
|
||||||
|
debounceMs?: number;
|
||||||
|
/** Tag context for file storage */
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch subscription handle
|
||||||
|
*/
|
||||||
|
export interface WatchSubscription {
|
||||||
|
/** Unsubscribe from watch events */
|
||||||
|
unsubscribe: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage statistics interface
|
* Storage statistics interface
|
||||||
*/
|
*/
|
||||||
@@ -380,6 +426,10 @@ export abstract class BaseStorage implements IStorage {
|
|||||||
abstract getStorageType(): 'file' | 'api';
|
abstract getStorageType(): 'file' | 'api';
|
||||||
abstract getCurrentBriefName(): string | null;
|
abstract getCurrentBriefName(): string | null;
|
||||||
abstract getTagsWithStats(): Promise<TagsWithStatsResult>;
|
abstract getTagsWithStats(): Promise<TagsWithStatsResult>;
|
||||||
|
abstract watch(
|
||||||
|
callback: (event: WatchEvent) => void,
|
||||||
|
options?: WatchOptions
|
||||||
|
): Promise<WatchSubscription>;
|
||||||
/**
|
/**
|
||||||
* Utility method to generate backup filename
|
* Utility method to generate backup filename
|
||||||
* @param originalPath - Original file path
|
* @param originalPath - Original file path
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ export type * from './common/types/index.js';
|
|||||||
// Common interfaces
|
// Common interfaces
|
||||||
export type * from './common/interfaces/index.js';
|
export type * from './common/interfaces/index.js';
|
||||||
|
|
||||||
// Storage interfaces - TagInfo and TagsWithStatsResult
|
// Storage interfaces - TagInfo, TagsWithStatsResult, and Watch types
|
||||||
export type {
|
export type {
|
||||||
TagInfo,
|
TagInfo,
|
||||||
TagsWithStatsResult
|
TagsWithStatsResult,
|
||||||
|
WatchEvent,
|
||||||
|
WatchEventType,
|
||||||
|
WatchOptions,
|
||||||
|
WatchSubscription
|
||||||
} from './common/interfaces/storage.interface.js';
|
} from './common/interfaces/storage.interface.js';
|
||||||
|
|
||||||
// Storage adapters - FileStorage for direct local file access
|
// Storage adapters - FileStorage for direct local file access
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import type {
|
|||||||
IStorage,
|
IStorage,
|
||||||
LoadTasksOptions,
|
LoadTasksOptions,
|
||||||
StorageStats,
|
StorageStats,
|
||||||
UpdateStatusResult
|
UpdateStatusResult,
|
||||||
|
WatchEvent,
|
||||||
|
WatchOptions,
|
||||||
|
WatchSubscription
|
||||||
} from '../../../common/interfaces/storage.interface.js';
|
} from '../../../common/interfaces/storage.interface.js';
|
||||||
import { getLogger } from '../../../common/logger/factory.js';
|
import { getLogger } from '../../../common/logger/factory.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -941,6 +944,92 @@ export class ApiStorage implements IStorage {
|
|||||||
this.tagsCache.clear();
|
this.tagsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes to tasks via Supabase Realtime
|
||||||
|
* Subscribes to postgres_changes for the tasks table filtered by brief_id
|
||||||
|
*
|
||||||
|
* Optional debouncing is supported for collaborative scenarios where multiple
|
||||||
|
* rapid changes (e.g., bulk operations, multiple users) could cause UI flicker.
|
||||||
|
* Default: 300ms (higher than FileStorage's 100ms since Realtime events are cleaner)
|
||||||
|
*/
|
||||||
|
async watch(
|
||||||
|
callback: (event: WatchEvent) => void,
|
||||||
|
options?: WatchOptions
|
||||||
|
): Promise<WatchSubscription> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const context = authManager.getContext();
|
||||||
|
|
||||||
|
if (!context?.briefId) {
|
||||||
|
throw new TaskMasterError(
|
||||||
|
'No brief context found for watching. Please connect to a brief first.',
|
||||||
|
ERROR_CODES.NO_BRIEF_SELECTED,
|
||||||
|
{ operation: 'watch' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = authManager.supabaseClient.getClient();
|
||||||
|
const channelName = `tasks-watch-${context.briefId}-${Date.now()}`;
|
||||||
|
const debounceMs = options?.debounceMs ?? 300;
|
||||||
|
|
||||||
|
let debounceTimer: NodeJS.Timeout | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const debouncedCallback = () => {
|
||||||
|
if (closed) return;
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (!closed) {
|
||||||
|
callback({
|
||||||
|
type: 'change',
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(channelName)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'tasks',
|
||||||
|
filter: `brief_id=eq.${context.briefId}`
|
||||||
|
},
|
||||||
|
debouncedCallback
|
||||||
|
)
|
||||||
|
.subscribe((status, error) => {
|
||||||
|
if (
|
||||||
|
status === 'CHANNEL_ERROR' ||
|
||||||
|
status === 'TIMED_OUT' ||
|
||||||
|
status === 'CLOSED' ||
|
||||||
|
error
|
||||||
|
) {
|
||||||
|
callback({
|
||||||
|
type: 'error',
|
||||||
|
timestamp: new Date(),
|
||||||
|
error:
|
||||||
|
error || new Error(`Channel subscription ${status.toLowerCase()}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
closed = true;
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
channel.unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure storage is initialized
|
* Ensure storage is initialized
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* @fileoverview Refactored file-based storage implementation for Task Master
|
* @fileoverview Refactored file-based storage implementation for Task Master
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
ERROR_CODES,
|
ERROR_CODES,
|
||||||
@@ -11,7 +12,10 @@ import type {
|
|||||||
IStorage,
|
IStorage,
|
||||||
LoadTasksOptions,
|
LoadTasksOptions,
|
||||||
StorageStats,
|
StorageStats,
|
||||||
UpdateStatusResult
|
UpdateStatusResult,
|
||||||
|
WatchEvent,
|
||||||
|
WatchOptions,
|
||||||
|
WatchSubscription
|
||||||
} from '../../../../common/interfaces/storage.interface.js';
|
} from '../../../../common/interfaces/storage.interface.js';
|
||||||
import type {
|
import type {
|
||||||
Task,
|
Task,
|
||||||
@@ -882,6 +886,68 @@ export class FileStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes to tasks file
|
||||||
|
* Uses fs.watch with debouncing to detect file changes
|
||||||
|
*/
|
||||||
|
async watch(
|
||||||
|
callback: (event: WatchEvent) => void,
|
||||||
|
options?: WatchOptions
|
||||||
|
): Promise<WatchSubscription> {
|
||||||
|
const tasksPath = this.pathResolver.getTasksPath();
|
||||||
|
const debounceMs = options?.debounceMs ?? 100;
|
||||||
|
|
||||||
|
// Ensure file exists before watching
|
||||||
|
const fileExists = await this.fileOps.exists(tasksPath);
|
||||||
|
if (!fileExists) {
|
||||||
|
throw new TaskMasterError(
|
||||||
|
'Tasks file not found. Initialize the project first.',
|
||||||
|
ERROR_CODES.NOT_FOUND,
|
||||||
|
{ path: tasksPath }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimer: NodeJS.Timeout | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const watcher = fs.watch(tasksPath, (eventType, filename) => {
|
||||||
|
if (closed) return;
|
||||||
|
if (filename && eventType === 'change') {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
if (!closed) {
|
||||||
|
callback({
|
||||||
|
type: 'change',
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('error', (error) => {
|
||||||
|
if (!closed) {
|
||||||
|
callback({
|
||||||
|
type: 'error',
|
||||||
|
timestamp: new Date(),
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
closed = true;
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
watcher.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enrich tasks with complexity data from the complexity report
|
* Enrich tasks with complexity data from the complexity report
|
||||||
* Private helper method called by loadTasks()
|
* Private helper method called by loadTasks()
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ function createMockStorage(tasks: Task[] = []): IStorage {
|
|||||||
getStats: vi.fn(),
|
getStats: vi.fn(),
|
||||||
getStorageType: vi.fn().mockReturnValue('file'),
|
getStorageType: vi.fn().mockReturnValue('file'),
|
||||||
getCurrentBriefName: vi.fn().mockReturnValue(null),
|
getCurrentBriefName: vi.fn().mockReturnValue(null),
|
||||||
getTagsWithStats: vi.fn()
|
getTagsWithStats: vi.fn(),
|
||||||
|
watch: vi.fn().mockResolvedValue({ unsubscribe: vi.fn() })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import type {
|
|||||||
} from './services/preflight-checker.service.js';
|
} from './services/preflight-checker.service.js';
|
||||||
import type { TaskValidationResult } from './services/task-loader.service.js';
|
import type { TaskValidationResult } from './services/task-loader.service.js';
|
||||||
import type { ExpandTaskResult } from '../integration/services/task-expansion.service.js';
|
import type { ExpandTaskResult } from '../integration/services/task-expansion.service.js';
|
||||||
|
import type {
|
||||||
|
WatchEvent,
|
||||||
|
WatchOptions,
|
||||||
|
WatchSubscription
|
||||||
|
} from '../../common/interfaces/storage.interface.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tasks Domain - Unified API for all task operations
|
* Tasks Domain - Unified API for all task operations
|
||||||
@@ -389,6 +394,25 @@ export class TasksDomain {
|
|||||||
return this.taskService.getStorageType();
|
return this.taskService.getStorageType();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Watch ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes to tasks
|
||||||
|
* For file storage: uses fs.watch on tasks.json with debouncing
|
||||||
|
* For API storage: uses Supabase Realtime subscriptions
|
||||||
|
*
|
||||||
|
* @param callback - Function called when tasks change
|
||||||
|
* @param options - Watch options (debounce, tag)
|
||||||
|
* @returns Subscription handle with unsubscribe method
|
||||||
|
*/
|
||||||
|
async watch(
|
||||||
|
callback: (event: WatchEvent) => void,
|
||||||
|
options?: WatchOptions
|
||||||
|
): Promise<WatchSubscription> {
|
||||||
|
const storage = this.taskService.getStorage();
|
||||||
|
return storage.watch(callback, options);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Task File Generation ==========
|
// ========== Task File Generation ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Time utilities for formatting relative timestamps
|
* @fileoverview Time utilities for formatting timestamps
|
||||||
* Shared across CLI, MCP, extension, and other interfaces
|
* Shared across CLI, MCP, extension, and other interfaces
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a date as relative time from now (e.g., "2 hours ago", "3 days ago")
|
* Format a date as relative time from now (e.g., "2 hours ago", "3 days ago")
|
||||||
@@ -16,3 +16,12 @@ export function formatRelativeTime(date: string | Date): string {
|
|||||||
// Use date-fns for robust formatting with proper edge case handling
|
// Use date-fns for robust formatting with proper edge case handling
|
||||||
return formatDistanceToNow(dateObj, { addSuffix: true });
|
return formatDistanceToNow(dateObj, { addSuffix: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as a time string (e.g., "02:30:45 PM")
|
||||||
|
* @param date - Date object to format
|
||||||
|
* @returns Formatted time string in 12-hour format with seconds
|
||||||
|
*/
|
||||||
|
export function formatTime(date: Date): string {
|
||||||
|
return format(date, 'hh:mm:ss a');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user